refactor(led-driver): simplify websocket runtime and test layout
This commit is contained in:
218
src/controller_messages.py
Normal file
218
src/controller_messages.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Parse controller JSON (v1) and apply brightness, presets, OTA patterns, etc."""
|
||||
|
||||
import json
|
||||
import socket
|
||||
|
||||
from utils import convert_and_reorder_colors
|
||||
|
||||
try:
|
||||
import uos as os
|
||||
except ImportError:
|
||||
import os
|
||||
|
||||
|
||||
def process_data(payload, settings, presets, controller_ip=None):
|
||||
"""Read one controller message; json.loads (bytes or str), then apply fields."""
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
print(payload)
|
||||
if data.get("v", "") != "1":
|
||||
return
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if "b" in data:
|
||||
apply_brightness(data, settings, presets)
|
||||
if "presets" in data:
|
||||
apply_presets(data, settings, presets)
|
||||
if "select" in data:
|
||||
apply_select(data, settings, presets)
|
||||
if "default" in data:
|
||||
apply_default(data, settings, presets)
|
||||
if "manifest" in data:
|
||||
apply_patterns_ota(data, presets, controller_ip=controller_ip)
|
||||
if "save" in data and ("presets" in data or "default" in data):
|
||||
presets.save()
|
||||
|
||||
|
||||
def apply_brightness(data, settings, presets):
|
||||
try:
|
||||
presets.b = max(0, min(255, int(data["b"])))
|
||||
settings["brightness"] = presets.b
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def apply_presets(data, settings, presets):
|
||||
presets_map = data["presets"]
|
||||
for id, preset_data in presets_map.items():
|
||||
if not preset_data:
|
||||
continue
|
||||
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
|
||||
if color_key is not None:
|
||||
try:
|
||||
preset_data[color_key] = convert_and_reorder_colors(
|
||||
preset_data[color_key], settings
|
||||
)
|
||||
except (TypeError, ValueError, KeyError):
|
||||
continue
|
||||
presets.edit(id, preset_data)
|
||||
print(f"Edited preset {id}: {preset_data.get('name', '')}")
|
||||
|
||||
|
||||
def apply_select(data, settings, presets):
|
||||
select_map = data["select"]
|
||||
device_name = settings["name"]
|
||||
select_list = select_map.get(device_name, [])
|
||||
if not select_list:
|
||||
return
|
||||
preset_name = select_list[0]
|
||||
step = select_list[1] if len(select_list) > 1 else None
|
||||
presets.select(preset_name, step=step)
|
||||
|
||||
|
||||
def apply_default(data, settings, presets):
|
||||
targets = data.get("targets") or []
|
||||
default_name = data["default"]
|
||||
if (
|
||||
settings["name"] in targets
|
||||
and isinstance(default_name, str)
|
||||
and default_name in presets.presets
|
||||
):
|
||||
settings["default"] = default_name
|
||||
|
||||
|
||||
def _parse_http_url(url):
|
||||
"""Parse http://host[:port]/path into (host, port, path)."""
|
||||
if not isinstance(url, str):
|
||||
raise ValueError("url must be a string")
|
||||
if not url.startswith("http://"):
|
||||
raise ValueError("only http:// URLs are supported")
|
||||
remainder = url[7:]
|
||||
slash_idx = remainder.find("/")
|
||||
if slash_idx == -1:
|
||||
host_port = remainder
|
||||
path = "/"
|
||||
else:
|
||||
host_port = remainder[:slash_idx]
|
||||
path = remainder[slash_idx:]
|
||||
if ":" in host_port:
|
||||
host, port_s = host_port.rsplit(":", 1)
|
||||
port = int(port_s)
|
||||
else:
|
||||
host = host_port
|
||||
port = 80
|
||||
if not host:
|
||||
raise ValueError("missing host")
|
||||
return host, port, path
|
||||
|
||||
|
||||
def _http_get_raw(url, timeout_s=10.0):
|
||||
host, port, path = _parse_http_url(url)
|
||||
req = (
|
||||
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % (path, host)
|
||||
).encode("utf-8")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout_s)
|
||||
sock.connect((host, int(port)))
|
||||
sock.send(req)
|
||||
data = b""
|
||||
while True:
|
||||
chunk = sock.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
sep = b"\r\n\r\n"
|
||||
if sep not in data:
|
||||
raise OSError("invalid HTTP response")
|
||||
head, body = data.split(sep, 1)
|
||||
status_line = head.split(b"\r\n", 1)[0]
|
||||
if b" 200 " not in status_line:
|
||||
raise OSError("HTTP status not OK: %s" % status_line.decode("utf-8"))
|
||||
return body
|
||||
|
||||
|
||||
def _http_get_json(url, timeout_s=10.0):
|
||||
body = _http_get_raw(url, timeout_s=timeout_s)
|
||||
return json.loads(body.decode("utf-8"))
|
||||
|
||||
|
||||
def _http_get_text(url, timeout_s=10.0, controller_ip=None):
|
||||
# Support relative URLs from controller messages.
|
||||
if isinstance(url, str) and url.startswith("/"):
|
||||
if not controller_ip:
|
||||
raise OSError("controller IP unavailable for relative URL")
|
||||
url = "http://%s%s" % (controller_ip, url)
|
||||
try:
|
||||
body = _http_get_raw(url, timeout_s=timeout_s)
|
||||
return body.decode("utf-8")
|
||||
except Exception:
|
||||
# Fallback for mDNS/unresolvable host: retry against current controller IP.
|
||||
if not controller_ip or not isinstance(url, str) or not url.startswith("http://"):
|
||||
raise
|
||||
_host, _port, path = _parse_http_url(url)
|
||||
fallback = "http://%s:%d%s" % (controller_ip, _port, path)
|
||||
body = _http_get_raw(fallback, timeout_s=timeout_s)
|
||||
return body.decode("utf-8")
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
if not name.endswith(".py"):
|
||||
return False
|
||||
if "/" in name or "\\" in name or ".." in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def apply_patterns_ota(data, presets, controller_ip=None):
|
||||
manifest_payload = data.get("manifest")
|
||||
if not manifest_payload:
|
||||
return
|
||||
try:
|
||||
if isinstance(manifest_payload, dict):
|
||||
manifest = manifest_payload
|
||||
elif isinstance(manifest_payload, str):
|
||||
manifest = _http_get_json(manifest_payload, timeout_s=20.0)
|
||||
else:
|
||||
print("patterns_ota: invalid manifest payload type")
|
||||
return
|
||||
files = manifest.get("files", [])
|
||||
if not isinstance(files, list) or not files:
|
||||
print("patterns_ota: no files in manifest")
|
||||
return
|
||||
try:
|
||||
os.mkdir("patterns")
|
||||
except OSError:
|
||||
pass
|
||||
updated = 0
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
url = item.get("url")
|
||||
inline_code = item.get("code")
|
||||
if not _safe_pattern_filename(name):
|
||||
continue
|
||||
if isinstance(inline_code, str):
|
||||
code = inline_code
|
||||
elif isinstance(url, str):
|
||||
code = _http_get_text(url, timeout_s=20.0, controller_ip=controller_ip)
|
||||
else:
|
||||
continue
|
||||
with open("patterns/" + name, "w") as f:
|
||||
f.write(code)
|
||||
updated += 1
|
||||
if updated > 0:
|
||||
presets.reload_patterns()
|
||||
print("patterns_ota: updated", updated, "pattern file(s)")
|
||||
else:
|
||||
print("patterns_ota: no valid files downloaded")
|
||||
except Exception as e:
|
||||
print("patterns_ota failed:", e)
|
||||
37
src/hello.py
37
src/hello.py
@@ -1,4 +1,8 @@
|
||||
"""LED hello payload and UDP broadcast discovery (controller IP via echo on port 8766).
|
||||
"""LED hello JSON line and UDP broadcast on port 8766.
|
||||
|
||||
Used so led-controller can register the device (name, MAC, IP) when ``wait_reply`` is
|
||||
false; the controller may then connect to the device's WebSocket. With
|
||||
``wait_reply`` true, blocks for an echo and returns the controller IP (legacy discovery).
|
||||
|
||||
Wi-Fi must already be connected; this module does not use Settings or call connect().
|
||||
"""
|
||||
@@ -40,7 +44,13 @@ def ipv4_broadcast(ip, netmask):
|
||||
im = [int(x) for x in netmask.split(".")]
|
||||
if len(ia) != 4 or len(im) != 4:
|
||||
return None
|
||||
return ".".join(str(ia[i] | (255 - im[i])) for i in range(4))
|
||||
# STA often reports 255.255.255.255; "broadcast" would equal the host IP — useless for LAN.
|
||||
if netmask == "255.255.255.255":
|
||||
return None
|
||||
bcast = ".".join(str(ia[i] | (255 - im[i])) for i in range(4))
|
||||
if bcast == ip:
|
||||
return None
|
||||
return bcast
|
||||
|
||||
|
||||
def udp_discovery_targets(ip, mask):
|
||||
@@ -52,6 +62,14 @@ def udp_discovery_targets(ip, mask):
|
||||
return out
|
||||
|
||||
|
||||
def _udp_discovery_targets_single(ip, mask):
|
||||
"""One destination: subnet broadcast if known, else limited broadcast."""
|
||||
b = ipv4_broadcast(ip, mask)
|
||||
if b:
|
||||
return [(b, DISCOVERY_UDP_PORT)]
|
||||
return [("255.255.255.255", DISCOVERY_UDP_PORT)]
|
||||
|
||||
|
||||
def broadcast_hello_udp(
|
||||
sta,
|
||||
device_name="",
|
||||
@@ -59,11 +77,17 @@ def broadcast_hello_udp(
|
||||
wait_reply=True,
|
||||
recv_timeout_s=DEFAULT_RECV_TIMEOUT_S,
|
||||
wdt=None,
|
||||
dual_destinations=True,
|
||||
):
|
||||
"""
|
||||
Send pack_hello_line via directed then 255.255.255.255 on DISCOVERY_UDP_PORT.
|
||||
Send pack_hello_line on DISCOVERY_UDP_PORT.
|
||||
STA must already be connected with a valid IPv4 (caller brings up Wi-Fi).
|
||||
|
||||
If dual_destinations (default), send subnet broadcast then 255.255.255.255 so
|
||||
discovery works on awkward APs — the controller may receive two packets.
|
||||
If dual_destinations is False, send only one (subnet broadcast or limited),
|
||||
e.g. after TCP connect so the Pi does not run duplicate resync handlers.
|
||||
|
||||
If wait_reply, wait for first UDP echo. Returns controller IP string or None.
|
||||
"""
|
||||
ip, mask, _gw, _dns = sta.ifconfig()
|
||||
@@ -89,7 +113,12 @@ def broadcast_hello_udp(
|
||||
pass
|
||||
|
||||
discovered = None
|
||||
for dest_ip, dest_port in udp_discovery_targets(ip, mask):
|
||||
targets = (
|
||||
udp_discovery_targets(ip, mask)
|
||||
if dual_destinations
|
||||
else _udp_discovery_targets_single(ip, mask)
|
||||
)
|
||||
for dest_ip, dest_port in targets:
|
||||
if wdt is not None:
|
||||
wdt.feed()
|
||||
label = "%s:%s" % (dest_ip, dest_port)
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Minimal HTTP/1.1 POST JSON client for driver long-poll (MicroPython)."""
|
||||
|
||||
import json
|
||||
import socket
|
||||
|
||||
|
||||
def _send_all(sock, data):
|
||||
n = 0
|
||||
while n < len(data):
|
||||
m = sock.send(data[n:])
|
||||
if m <= 0:
|
||||
raise OSError("socket send failed")
|
||||
n += m
|
||||
|
||||
|
||||
def _read_http_json_body(sock, max_headers=8192):
|
||||
buf = b""
|
||||
while b"\r\n\r\n" not in buf:
|
||||
chunk = sock.recv(256)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
if len(buf) > max_headers:
|
||||
raise OSError("response headers too large")
|
||||
if b"\r\n\r\n" not in buf:
|
||||
raise OSError("incomplete response headers")
|
||||
head, rest = buf.split(b"\r\n\r\n", 1)
|
||||
cl = None
|
||||
for line in head.split(b"\r\n"):
|
||||
if line.lower().startswith(b"content-length:"):
|
||||
try:
|
||||
cl = int(line.split(b":", 1)[1].strip())
|
||||
except (ValueError, IndexError):
|
||||
cl = None
|
||||
if cl is None:
|
||||
body = rest
|
||||
else:
|
||||
body = rest
|
||||
while len(body) < cl:
|
||||
chunk = sock.recv(min(2048, cl - len(body)))
|
||||
if not chunk:
|
||||
break
|
||||
body += chunk
|
||||
return json.loads(body.decode("utf-8"))
|
||||
|
||||
|
||||
def http_driver_poll(host, port, payload_dict, timeout_s=40.0):
|
||||
"""
|
||||
POST ``/driver/v1/poll`` with JSON body; return parsed JSON (expects ``{"lines": [...]}``).
|
||||
"""
|
||||
path = "/driver/v1/poll"
|
||||
body_bytes = json.dumps(payload_dict).encode("utf-8")
|
||||
host_s = str(host)
|
||||
req_head = (
|
||||
"POST %s HTTP/1.1\r\nHost: %s\r\nContent-Type: application/json\r\nContent-Length: %d\r\nConnection: close\r\n\r\n"
|
||||
% (path, host_s, len(body_bytes))
|
||||
).encode("utf-8")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout_s)
|
||||
sock.connect((host_s, int(port)))
|
||||
_send_all(sock, req_head + body_bytes)
|
||||
return _read_http_json_body(sock)
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
414
src/main.py
414
src/main.py
@@ -1,23 +1,13 @@
|
||||
from settings import Settings
|
||||
from machine import WDT
|
||||
import utime
|
||||
import network
|
||||
import utime
|
||||
import asyncio
|
||||
from microdot import Microdot
|
||||
from microdot.websocket import WebSocketError, with_websocket
|
||||
from presets import Presets
|
||||
from utils import convert_and_reorder_colors
|
||||
import json
|
||||
import time
|
||||
import select
|
||||
import socket
|
||||
import ubinascii
|
||||
from hello import discover_controller_udp
|
||||
try:
|
||||
import uos as os
|
||||
except ImportError:
|
||||
import os
|
||||
|
||||
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
|
||||
CONTROLLER_TCP_PORT = 8765
|
||||
controller_ip = None
|
||||
from controller_messages import process_data
|
||||
from hello import broadcast_hello_udp
|
||||
|
||||
settings = Settings()
|
||||
print(settings)
|
||||
@@ -27,364 +17,74 @@ presets.load(settings)
|
||||
presets.b = settings.get("brightness", 255)
|
||||
default_preset = settings.get("default", "")
|
||||
if default_preset and default_preset in presets.presets:
|
||||
presets.select(default_preset)
|
||||
print(f"Selected startup preset: {default_preset}")
|
||||
if presets.select(default_preset):
|
||||
print(f"Selected startup preset: {default_preset}")
|
||||
else:
|
||||
print("Startup preset failed (invalid pattern?):", default_preset)
|
||||
|
||||
wdt = WDT(timeout=10000)
|
||||
wdt.feed()
|
||||
|
||||
|
||||
# --- Controller JSON (bytes or str): parse v1, then apply -------------------------
|
||||
|
||||
|
||||
def process_data(payload):
|
||||
"""Read one controller message; json.loads (bytes or str), then apply fields."""
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
print(payload)
|
||||
if data.get("v", "") != "1":
|
||||
return
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if "b" in data:
|
||||
apply_brightness(data)
|
||||
if "presets" in data:
|
||||
apply_presets(data)
|
||||
if "select" in data:
|
||||
apply_select(data)
|
||||
if "default" in data:
|
||||
apply_default(data)
|
||||
if "manifest" in data:
|
||||
apply_patterns_ota(data)
|
||||
if "save" in data and ("presets" in data or "default" in data):
|
||||
presets.save()
|
||||
|
||||
|
||||
def apply_brightness(data):
|
||||
try:
|
||||
presets.b = max(0, min(255, int(data["b"])))
|
||||
settings["brightness"] = presets.b
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def apply_presets(data):
|
||||
presets_map = data["presets"]
|
||||
for id, preset_data in presets_map.items():
|
||||
if not preset_data:
|
||||
continue
|
||||
color_key = "c" if "c" in preset_data else ("colors" if "colors" in preset_data else None)
|
||||
if color_key is not None:
|
||||
try:
|
||||
preset_data[color_key] = convert_and_reorder_colors(
|
||||
preset_data[color_key], settings
|
||||
)
|
||||
except (TypeError, ValueError, KeyError):
|
||||
continue
|
||||
presets.edit(id, preset_data)
|
||||
print(f"Edited preset {id}: {preset_data.get('name', '')}")
|
||||
|
||||
|
||||
def apply_select(data):
|
||||
select_map = data["select"]
|
||||
device_name = settings["name"]
|
||||
select_list = select_map.get(device_name, [])
|
||||
if not select_list:
|
||||
return
|
||||
preset_name = select_list[0]
|
||||
step = select_list[1] if len(select_list) > 1 else None
|
||||
presets.select(preset_name, step=step)
|
||||
|
||||
|
||||
def apply_default(data):
|
||||
targets = data.get("targets") or []
|
||||
default_name = data["default"]
|
||||
if (
|
||||
settings["name"] in targets
|
||||
and isinstance(default_name, str)
|
||||
and default_name in presets.presets
|
||||
):
|
||||
settings["default"] = default_name
|
||||
|
||||
|
||||
def _parse_http_url(url):
|
||||
"""Parse http://host[:port]/path into (host, port, path)."""
|
||||
if not isinstance(url, str):
|
||||
raise ValueError("url must be a string")
|
||||
if not url.startswith("http://"):
|
||||
raise ValueError("only http:// URLs are supported")
|
||||
remainder = url[7:]
|
||||
slash_idx = remainder.find("/")
|
||||
if slash_idx == -1:
|
||||
host_port = remainder
|
||||
path = "/"
|
||||
else:
|
||||
host_port = remainder[:slash_idx]
|
||||
path = remainder[slash_idx:]
|
||||
if ":" in host_port:
|
||||
host, port_s = host_port.rsplit(":", 1)
|
||||
port = int(port_s)
|
||||
else:
|
||||
host = host_port
|
||||
port = 80
|
||||
if not host:
|
||||
raise ValueError("missing host")
|
||||
return host, port, path
|
||||
|
||||
|
||||
def _http_get_raw(url, timeout_s=10.0):
|
||||
host, port, path = _parse_http_url(url)
|
||||
req = (
|
||||
"GET %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % (path, host)
|
||||
).encode("utf-8")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.settimeout(timeout_s)
|
||||
sock.connect((host, int(port)))
|
||||
sock.send(req)
|
||||
data = b""
|
||||
while True:
|
||||
chunk = sock.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
finally:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
sep = b"\r\n\r\n"
|
||||
if sep not in data:
|
||||
raise OSError("invalid HTTP response")
|
||||
head, body = data.split(sep, 1)
|
||||
status_line = head.split(b"\r\n", 1)[0]
|
||||
if b" 200 " not in status_line:
|
||||
raise OSError("HTTP status not OK: %s" % status_line.decode("utf-8"))
|
||||
return body
|
||||
|
||||
|
||||
def _http_get_json(url, timeout_s=10.0):
|
||||
body = _http_get_raw(url, timeout_s=timeout_s)
|
||||
return json.loads(body.decode("utf-8"))
|
||||
|
||||
|
||||
def _http_get_text(url, timeout_s=10.0):
|
||||
global controller_ip
|
||||
# Support relative URLs from controller messages.
|
||||
if isinstance(url, str) and url.startswith("/"):
|
||||
if not controller_ip:
|
||||
raise OSError("controller IP unavailable for relative URL")
|
||||
url = "http://%s%s" % (controller_ip, url)
|
||||
try:
|
||||
body = _http_get_raw(url, timeout_s=timeout_s)
|
||||
return body.decode("utf-8")
|
||||
except Exception:
|
||||
# Fallback for mDNS/unresolvable host: retry against current controller IP.
|
||||
if not controller_ip or not isinstance(url, str) or not url.startswith("http://"):
|
||||
raise
|
||||
_host, _port, path = _parse_http_url(url)
|
||||
fallback = "http://%s:%d%s" % (controller_ip, _port, path)
|
||||
body = _http_get_raw(fallback, timeout_s=timeout_s)
|
||||
return body.decode("utf-8")
|
||||
|
||||
|
||||
def _safe_pattern_filename(name):
|
||||
if not isinstance(name, str):
|
||||
return False
|
||||
if not name.endswith(".py"):
|
||||
return False
|
||||
if "/" in name or "\\" in name or ".." in name:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def apply_patterns_ota(data):
|
||||
manifest_payload = data.get("manifest")
|
||||
if not manifest_payload:
|
||||
return
|
||||
try:
|
||||
if isinstance(manifest_payload, dict):
|
||||
manifest = manifest_payload
|
||||
elif isinstance(manifest_payload, str):
|
||||
manifest = _http_get_json(manifest_payload, timeout_s=20.0)
|
||||
else:
|
||||
print("patterns_ota: invalid manifest payload type")
|
||||
return
|
||||
files = manifest.get("files", [])
|
||||
if not isinstance(files, list) or not files:
|
||||
print("patterns_ota: no files in manifest")
|
||||
return
|
||||
try:
|
||||
os.mkdir("patterns")
|
||||
except OSError:
|
||||
pass
|
||||
updated = 0
|
||||
for item in files:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = item.get("name")
|
||||
url = item.get("url")
|
||||
inline_code = item.get("code")
|
||||
if not _safe_pattern_filename(name):
|
||||
continue
|
||||
if isinstance(inline_code, str):
|
||||
code = inline_code
|
||||
elif isinstance(url, str):
|
||||
code = _http_get_text(url, timeout_s=20.0)
|
||||
else:
|
||||
continue
|
||||
with open("patterns/" + name, "w") as f:
|
||||
f.write(code)
|
||||
updated += 1
|
||||
if updated > 0:
|
||||
presets.reload_patterns()
|
||||
print("patterns_ota: updated", updated, "pattern file(s)")
|
||||
else:
|
||||
print("patterns_ota: no valid files downloaded")
|
||||
except Exception as e:
|
||||
print("patterns_ota failed:", e)
|
||||
|
||||
|
||||
# --- TCP framing (bytes) → process_data -------------------------------------------
|
||||
|
||||
|
||||
def tcp_append_and_drain_lines(buf, chunk):
|
||||
"""Return (new_buf, list of non-empty stripped line byte strings)."""
|
||||
buf += chunk
|
||||
lines = []
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
line = line.strip()
|
||||
if line:
|
||||
lines.append(line)
|
||||
return buf, lines
|
||||
|
||||
|
||||
# --- Network + hello --------------------------------------------------------------
|
||||
|
||||
sta_if = network.WLAN(network.STA_IF)
|
||||
sta_if.active(True)
|
||||
sta_if.config(pm=network.WLAN.PM_NONE)
|
||||
sta_if.connect(settings["ssid"], settings["password"])
|
||||
while not sta_if.isconnected():
|
||||
utime.sleep(1)
|
||||
wdt.feed()
|
||||
|
||||
mac = sta_if.config("mac")
|
||||
hello_payload = {
|
||||
"v": "1",
|
||||
"device_name": settings.get("name", ""),
|
||||
"mac": ubinascii.hexlify(mac).decode().lower(),
|
||||
"type": "led",
|
||||
}
|
||||
hello_bytes = json.dumps(hello_payload).encode("utf-8")
|
||||
print(sta_if.ifconfig())
|
||||
|
||||
if settings["transport_type"] == "espnow":
|
||||
from espnow import ESPNow # import only in this branch (avoids load when using Wi-Fi)
|
||||
app = Microdot()
|
||||
|
||||
sta_if.disconnect()
|
||||
sta_if.config(channel=settings.get("wifi_channel", 1))
|
||||
e = ESPNow()
|
||||
e.active(True)
|
||||
e.add_peer(BROADCAST_MAC)
|
||||
e.add_peer(mac)
|
||||
e.send(BROADCAST_MAC, hello_bytes)
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws_handler(request, ws):
|
||||
print("WS client connected")
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
if not data:
|
||||
print("WS client disconnected (closed)")
|
||||
break
|
||||
print(data)
|
||||
process_data(data, settings, presets)
|
||||
except WebSocketError as e:
|
||||
print("WS client disconnected:", e)
|
||||
except OSError as e:
|
||||
print("WS client dropped (OSError):", e)
|
||||
|
||||
|
||||
async def presets_loop():
|
||||
while True:
|
||||
if e.any():
|
||||
_peer, msg = e.recv()
|
||||
if msg:
|
||||
process_data(msg)
|
||||
presets.tick()
|
||||
await presets.tick()
|
||||
wdt.feed()
|
||||
# tick() does not await; yield so UDP hello and HTTP/WebSocket can run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
elif settings["transport_type"] == "wifi":
|
||||
sta_if.connect(settings["ssid"], settings["password"])
|
||||
while not sta_if.isconnected():
|
||||
time.sleep(1)
|
||||
print(f"WiFi connected {sta_if.ifconfig()[0]}")
|
||||
controller_ip = discover_controller_udp(
|
||||
device_name=settings.get("name", ""),
|
||||
wdt=wdt,
|
||||
)
|
||||
if not controller_ip:
|
||||
raise SystemExit("No controller IP discovered for Wi-Fi transport")
|
||||
|
||||
def pick_controller_ip(current):
|
||||
ip = discover_controller_udp(
|
||||
device_name=settings.get("name", ""),
|
||||
async def _udp_hello_after_http_ready():
|
||||
"""Hello must run after the HTTP server binds, or discovery clients time out on /ws."""
|
||||
await asyncio.sleep(1)
|
||||
print("UDP hello: broadcasting…")
|
||||
try:
|
||||
broadcast_hello_udp(
|
||||
sta_if,
|
||||
settings.get("name", ""),
|
||||
wait_reply=False,
|
||||
wdt=wdt,
|
||||
dual_destinations=True,
|
||||
)
|
||||
if ip and ip != current:
|
||||
print("Controller IP updated to", ip)
|
||||
return ip if ip else current
|
||||
except Exception as ex:
|
||||
print("UDP hello broadcast failed:", ex)
|
||||
|
||||
reconnect_ms = 1000
|
||||
next_connect_at = 0
|
||||
client = None
|
||||
poller = None
|
||||
buf = b""
|
||||
|
||||
while True:
|
||||
now = utime.ticks_ms()
|
||||
async def main(port=80):
|
||||
asyncio.create_task(presets_loop())
|
||||
asyncio.create_task(_udp_hello_after_http_ready())
|
||||
await app.start_server(host="0.0.0.0", port=port)
|
||||
|
||||
if client is None and utime.ticks_diff(now, next_connect_at) >= 0:
|
||||
c = None
|
||||
try:
|
||||
c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
c.connect((controller_ip, CONTROLLER_TCP_PORT))
|
||||
c.setblocking(False)
|
||||
p = select.poll()
|
||||
p.register(c, select.POLLIN)
|
||||
client = c
|
||||
poller = p
|
||||
buf = b""
|
||||
print("TCP connected")
|
||||
except Exception:
|
||||
if c is not None:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
controller_ip = pick_controller_ip(controller_ip)
|
||||
next_connect_at = utime.ticks_add(now, reconnect_ms)
|
||||
|
||||
if client is not None and poller is not None:
|
||||
try:
|
||||
events = poller.poll(0)
|
||||
except Exception:
|
||||
events = []
|
||||
|
||||
reconnect_needed = False
|
||||
for fd, event in events:
|
||||
if (event & select.POLLHUP) or (event & select.POLLERR):
|
||||
reconnect_needed = True
|
||||
break
|
||||
if event & select.POLLIN:
|
||||
try:
|
||||
chunk = client.recv(512)
|
||||
except OSError:
|
||||
reconnect_needed = True
|
||||
break
|
||||
|
||||
if not chunk:
|
||||
reconnect_needed = True
|
||||
break
|
||||
|
||||
buf, lines = tcp_append_and_drain_lines(buf, chunk)
|
||||
for raw_line in lines:
|
||||
process_data(raw_line)
|
||||
|
||||
if reconnect_needed:
|
||||
print("TCP disconnected, reconnecting...")
|
||||
try:
|
||||
poller.unregister(client)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
client = None
|
||||
poller = None
|
||||
buf = b""
|
||||
controller_ip = pick_controller_ip(controller_ip)
|
||||
next_connect_at = utime.ticks_add(now, reconnect_ms)
|
||||
|
||||
presets.tick()
|
||||
wdt.feed()
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main(port=80))
|
||||
|
||||
136
src/patterns/main.py
Normal file
136
src/patterns/main.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from settings import Settings
|
||||
from machine import WDT
|
||||
import network
|
||||
import utime
|
||||
import asyncio
|
||||
from microdot import Microdot
|
||||
from microdot.websocket import WebSocketError, with_websocket
|
||||
from presets import Presets
|
||||
from controller_messages import process_data
|
||||
from hello import broadcast_hello_udp
|
||||
|
||||
settings = Settings()
|
||||
print(settings)
|
||||
|
||||
presets = Presets(settings["led_pin"], settings["num_leds"])
|
||||
presets.load(settings)
|
||||
presets.b = settings.get("brightness", 255)
|
||||
default_preset = settings.get("default", "")
|
||||
if default_preset and default_preset in presets.presets:
|
||||
if presets.select(default_preset):
|
||||
print(f"Selected startup preset: {default_preset}")
|
||||
else:
|
||||
print("Startup preset failed (invalid pattern?):", default_preset)
|
||||
|
||||
wdt = WDT(timeout=10000)
|
||||
wdt.feed()
|
||||
|
||||
sta_if = network.WLAN(network.STA_IF)
|
||||
sta_if.active(True)
|
||||
sta_if.config(pm=network.WLAN.PM_NONE)
|
||||
sta_if.connect(settings["ssid"], settings["password"])
|
||||
while not sta_if.isconnected():
|
||||
utime.sleep(1)
|
||||
wdt.feed()
|
||||
|
||||
app = Microdot()
|
||||
|
||||
|
||||
def _simulator_register_microdot_app():
|
||||
"""led-simulator sets LED_SIM_ROOT so Stop can shutdown() the same Microdot instance."""
|
||||
root = os.environ.get("LED_SIM_ROOT")
|
||||
if not root:
|
||||
return
|
||||
sys.path.insert(0, root)
|
||||
try:
|
||||
import led_driver_sim_hook as _sim_hook
|
||||
except ImportError:
|
||||
return
|
||||
_sim_hook.register_app(app)
|
||||
|
||||
|
||||
_simulator_register_microdot_app()
|
||||
|
||||
|
||||
@app.route("/ws")
|
||||
@with_websocket
|
||||
async def ws_handler(request, ws):
|
||||
print("WS client connected")
|
||||
try:
|
||||
while True:
|
||||
data = await ws.receive()
|
||||
if not data:
|
||||
print("WS client disconnected (closed)")
|
||||
break
|
||||
print(data)
|
||||
process_data(data, settings, presets)
|
||||
except WebSocketError as e:
|
||||
print("WS client disconnected:", e)
|
||||
except OSError as e:
|
||||
print("WS client dropped (OSError):", e)
|
||||
|
||||
|
||||
async def presets_loop():
|
||||
while True:
|
||||
await presets.tick()
|
||||
wdt.feed()
|
||||
# tick() does not await; yield so UDP hello and HTTP/WebSocket can run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
async def _udp_hello_after_http_ready():
|
||||
"""Hello must run after the HTTP server binds, or discovery clients time out on /ws."""
|
||||
await asyncio.sleep(1)
|
||||
print("UDP hello: broadcasting…")
|
||||
try:
|
||||
broadcast_hello_udp(
|
||||
sta_if,
|
||||
settings.get("name", ""),
|
||||
wait_reply=False,
|
||||
wdt=wdt,
|
||||
dual_destinations=True,
|
||||
)
|
||||
except Exception as ex:
|
||||
print("UDP hello broadcast failed:", ex)
|
||||
|
||||
|
||||
async def main(port=80):
|
||||
t_presets = asyncio.create_task(presets_loop())
|
||||
t_hello = asyncio.create_task(_udp_hello_after_http_ready())
|
||||
try:
|
||||
await app.start_server(host="0.0.0.0", port=port)
|
||||
finally:
|
||||
for t in (t_presets, t_hello):
|
||||
t.cancel()
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
def _simulator_apply_pattern_from_env():
|
||||
"""led-simulator sets LED_SIM_PATTERN to a patterns/ module name (no .py)."""
|
||||
mod = os.environ.get("LED_SIM_PATTERN", "").strip()
|
||||
if not mod:
|
||||
return
|
||||
presets.reload_patterns()
|
||||
presets.edit(
|
||||
"_sim",
|
||||
{
|
||||
"p": mod,
|
||||
"d": 200,
|
||||
"b": 255,
|
||||
"c": [(255, 0, 0), (0, 255, 0), (0, 0, 255)],
|
||||
},
|
||||
)
|
||||
if not presets.select("_sim"):
|
||||
print("LED_SIM_PATTERN: could not select pattern:", mod)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_simulator_apply_pattern_from_env()
|
||||
_port = int(os.environ.get("LED_SIM_PORT", "80"))
|
||||
asyncio.run(main(port=_port))
|
||||
@@ -21,10 +21,9 @@ class Settings(dict):
|
||||
self["debug"] = False
|
||||
self["default"] = "on"
|
||||
self["brightness"] = 32
|
||||
self["transport_type"] = "wifi"
|
||||
self["transport_type"] = "espnow"
|
||||
self["wifi_channel"] = 1
|
||||
# Wi-Fi + TCP to controller: set ssid and password. Use transport_type "espnow"
|
||||
# for ESP-NOW (requires espnow firmware).
|
||||
# ESP-NOW transport (requires espnow firmware; uses wifi_channel).
|
||||
self["ssid"] = ""
|
||||
self["password"] = ""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user