diff --git a/src/background_tasks.py b/src/background_tasks.py index cbb64b1..a816139 100644 --- a/src/background_tasks.py +++ b/src/background_tasks.py @@ -1,11 +1,7 @@ import asyncio import utime -from hello import broadcast_hello_udp from mem_stats import print_mem -from wifi_sta import try_reconnect - -_UDP_HELLO_ATTEMPT = 0 async def presets_loop(presets, wdt): @@ -18,41 +14,4 @@ async def presets_loop(presets, wdt): if utime.ticks_diff(now, last_mem_log) >= 5000: print_mem("runtime") last_mem_log = now - # tick() does not await; yield so UDP hello and HTTP/WebSocket can run. await asyncio.sleep(0) - - -async def udp_hello_loop_after_http_ready(sta_if, settings, wdt, runtime_state): - """UDP hello on cadence; if STA drops, one reconnect campaign per iteration.""" - global _UDP_HELLO_ATTEMPT - await asyncio.sleep(1) - started_ms = utime.ticks_ms() - while True: - try: - wifi_ok = sta_if.isconnected() - except Exception: - wifi_ok = False - if not wifi_ok: - ssid = settings.get("ssid") or "" - if ssid: - try_reconnect(sta_if, ssid, settings.get("password") or "", wdt) - try: - wifi_ok = sta_if.isconnected() - except Exception: - wifi_ok = False - if wifi_ok and runtime_state.hello: - _UDP_HELLO_ATTEMPT += 1 - print("UDP hello broadcast attempt", _UDP_HELLO_ATTEMPT) - 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) - elapsed_ms = utime.ticks_diff(utime.ticks_ms(), started_ms) - interval_s = 10 if elapsed_ms < 120000 else 30 - await asyncio.sleep(interval_s) diff --git a/src/controller_messages.py b/src/controller_messages.py index 8d0946f..0e19993 100644 --- a/src/controller_messages.py +++ b/src/controller_messages.py @@ -40,8 +40,8 @@ def _log_rx(payload) -> None: print("rx (logging failed)") -def process_data(payload, settings, presets, controller_ip=None): - """Read one controller message; binary v1 envelope or JSON v1, then apply fields.""" +def process_data(payload, settings, presets, controller_ip=None, save=False): + """Read one controller message; binary v2 envelope or JSON v1, then apply fields.""" _log_rx(payload) data = None if isinstance(payload, (bytes, bytearray)): @@ -58,6 +58,8 @@ def process_data(payload, settings, presets, controller_ip=None): return if data.get("v", "") != "1": return + if save: + data["save"] = True if "device_config" in data: apply_device_config(data, settings, presets) if "b" in data: diff --git a/src/device_groups.py b/src/device_groups.py new file mode 100644 index 0000000..3f5a975 --- /dev/null +++ b/src/device_groups.py @@ -0,0 +1,16 @@ +"""In-memory group membership for GROUP_CMD filtering.""" + +_groups = [] + + +def groups_replace(group_ids): + global _groups + _groups = [str(g) for g in group_ids] + + +def in_group(group_id): + return str(group_id) in _groups + + +def list_groups(): + return list(_groups) diff --git a/src/espnow_transport.py b/src/espnow_transport.py new file mode 100644 index 0000000..297cbf8 --- /dev/null +++ b/src/espnow_transport.py @@ -0,0 +1,119 @@ +"""ESP-NOW receive loop and boot announce.""" + +import asyncio + +import espnow +import network + +import device_groups as dg +from espnow_wire import ( + BROADCAST_MAC, + MSG_ANNOUNCE, + MSG_CMD, + MSG_GROUP_CMD, + MSG_GROUPS, + cmd_envelope, + pack_announce, + parse_group_cmd, + parse_groups, + wire_msg_type, +) +from controller_messages import process_data + +_esp = None +_groups_received = False + + +def init_espnow(settings): + global _esp + ch = 6 + try: + ch = int(settings.get("wifi_channel", 6)) + except (TypeError, ValueError): + pass + ch = max(1, min(11, ch)) + sta = network.WLAN(network.STA_IF) + sta.active(True) + sta.config(channel=ch) + _esp = espnow.ESPNow() + _esp.active(True) + try: + _esp.add_peer(BROADCAST_MAC) + except Exception: + pass + return _esp + + +def send_boot_announce(settings): + if _esp is None: + return + pkt = pack_announce( + settings.get("name", "led"), + settings.get("num_leds", 1), + color_order=settings.get("color_order", "rgb"), + startup_mode=settings.get("startup_mode", "default"), + brightness=settings.get("brightness", 32), + ) + try: + _esp.send(BROADCAST_MAC, pkt) + print("espnow announce", len(pkt), "B") + except Exception as e: + print("espnow announce failed:", e) + + +def _handle_packet(pkt, settings, presets): + global _groups_received + mt = wire_msg_type(pkt) + if mt == MSG_GROUPS: + ids = parse_groups(pkt) + if ids is not None: + dg.groups_replace(ids) + _groups_received = True + print("groups", ids) + return + if mt == MSG_GROUP_CMD: + parsed = parse_group_cmd(pkt) + if parsed is None: + return + gid, env = parsed + if not dg.in_group(gid): + return + from espnow_wire import _envelope_size + + need = _envelope_size(env) + save = len(env) > need and env[need] == 1 + body = env[:need] if save else env + if body: + process_data(body, settings, presets, save=save) + return + if mt == MSG_CMD: + env, save = cmd_envelope(pkt) + if env: + process_data(env, settings, presets, save=save) + return + if mt == MSG_ANNOUNCE: + return + + +async def espnow_receive_loop(settings, presets, wdt=None): + global _groups_received + while True: + if _esp is None: + await asyncio.sleep(0.1) + continue + host, msg = _esp.recv(0) + if not host: + if not _groups_received: + await asyncio.sleep(5) + send_boot_announce(settings) + else: + await asyncio.sleep(0.02) + if wdt: + wdt.feed() + continue + try: + _handle_packet(msg, settings, presets) + except Exception as e: + print("espnow rx error:", e) + if wdt: + wdt.feed() diff --git a/src/espnow_wire.py b/src/espnow_wire.py new file mode 100644 index 0000000..0d4cd70 --- /dev/null +++ b/src/espnow_wire.py @@ -0,0 +1,110 @@ +"""ESP-NOW wire format (MicroPython). See docs/espnow-binary-protocol.md in led-controller.""" + +import struct + +WIRE_MAGIC = 0x4C +MAX_ESPNOW_PAYLOAD = 250 + +MSG_ANNOUNCE = 0x01 +MSG_GROUPS = 0x02 +MSG_CMD = 0x03 +MSG_GROUP_CMD = 0x04 + +BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff" + +COLOR_ORDER_TO_ENUM = { + "rgb": 0, + "rbg": 1, + "grb": 2, + "gbr": 3, + "brg": 4, + "bgr": 5, +} +STARTUP_MODE_TO_ENUM = {"default": 0, "last": 1, "off": 2} + + +def _pack_header(msg_type, body): + pkt = bytes([WIRE_MAGIC, msg_type]) + body + if len(pkt) > MAX_ESPNOW_PAYLOAD: + raise ValueError("packet too large") + return pkt + + +def pack_announce( + name, + num_leds, + color_order="rgb", + startup_mode="default", + brightness=32, + device_type=0, +): + name_b = name.encode("utf-8") + co = COLOR_ORDER_TO_ENUM.get(str(color_order).lower(), 0) + sm = STARTUP_MODE_TO_ENUM.get(str(startup_mode).lower(), 0) + body = ( + bytes([len(name_b)]) + + name_b + + struct.pack("= 2 and payload[0] == WIRE_MAGIC: + if payload[1] != MSG_GROUPS: + return None + body = payload[2:] + else: + body = payload + if not body: + return [] + off = 0 + count = body[off] + off += 1 + out = [] + for _ in range(count): + gl = body[off] + off += 1 + out.append(body[off : off + gl].decode("utf-8")) + off += gl + return out + + +def parse_group_cmd(payload): + if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_GROUP_CMD: + return None + body = payload[2:] + gl = body[0] + gid = body[1 : 1 + gl].decode("utf-8") + env = body[1 + gl :] + return gid, env + + +HEADER_LEN = 5 + + +def _envelope_size(env): + if len(env) < HEADER_LEN: + return len(env) + lp, ls, ld = env[2], env[3], env[4] + return HEADER_LEN + lp + ls + ld + + +def cmd_envelope(payload): + if len(payload) < 2 or payload[0] != WIRE_MAGIC or payload[1] != MSG_CMD: + return None, False + env = payload[2:] + if not env: + return None, False + need = _envelope_size(env) + if need > len(env): + return None, False + save = len(env) > need and env[need] == 1 + return env[:need], save + + +def wire_msg_type(payload): + if len(payload) >= 2 and payload[0] == WIRE_MAGIC: + return payload[1] + return None diff --git a/src/main.py b/src/main.py index c934d13..269bb75 100644 --- a/src/main.py +++ b/src/main.py @@ -1,33 +1,24 @@ -import print_timestamp # noqa: F401 — prefixes every print with [ticks_ms] +import print_timestamp # noqa: F401 from settings import Settings import machine -import utime import asyncio -import json import gc -from microdot import Microdot -from microdot.websocket import WebSocketError, with_websocket +import network +import espnow from presets import Presets -from controller_messages import apply_startup_pattern, process_data -from runtime_state import RuntimeState -from background_tasks import presets_loop, udp_hello_loop_after_http_ready +from controller_messages import apply_startup_pattern +from background_tasks import presets_loop +from espnow_transport import espnow_receive_loop, init_espnow, send_boot_announce from mem_stats import print_mem -from wifi_sta import boot_sta -try: - import uos as os -except ImportError: - import os +import json wdt = machine.WDT(timeout=10000) wdt.feed() machine.freq(160000000) - settings = Settings() - gc.collect() -sta_if = boot_sta(settings, wdt) presets = Presets(settings["led_pin"], settings["num_leds"]) presets.load(settings) @@ -37,130 +28,32 @@ gc.collect() apply_startup_pattern(settings, presets) +sta_if = network.WLAN(network.STA_IF) +sta_if.active(True) +print(sta_if.ifconfig()) +print(sta_if.config("channel")) -def _print_network_ips(controller_ip=None): - """Always log STA address and led-controller (WS client) address when known.""" - try: - led_ip = sta_if.ifconfig()[0] - except Exception: - led_ip = "?" - ctrl = controller_ip if controller_ip else "(not connected)" - print("led-driver IP:", led_ip, " led-controller IP:", ctrl) - - -_print_network_ips() -print_mem("startup") - -runtime_state = RuntimeState() - -app = Microdot() - - -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 - - -@app.route("/ws") -@with_websocket -async def ws_handler(request, ws): - runtime_state.ws_connected() - controller_ip = None - try: - client_addr = getattr(request, "client_addr", None) - if isinstance(client_addr, (tuple, list)) and client_addr: - controller_ip = client_addr[0] - elif isinstance(client_addr, str): - controller_ip = client_addr - except Exception: - controller_ip = None - _print_network_ips(controller_ip) - print_mem("ws connect") - try: - while True: - data = await ws.receive() - if not data: - break - process_data(data, settings, presets, controller_ip=controller_ip) - except WebSocketError as e: - print("WS client disconnected:", e) - except OSError as e: - print("WS client dropped (OSError):", e) - finally: - runtime_state.ws_disconnected() - - -@app.post("/patterns/upload") -async def upload_pattern(request): - """Receive one pattern file body from led-controller and reload patterns.""" - raw_name = request.args.get("name") - reload_raw = request.args.get("reload", "1") - reload_patterns = str(reload_raw).strip().lower() not in ("0", "false", "no", "off") - - if not isinstance(raw_name, str) or not raw_name.strip(): - return json.dumps({"error": "name is required"}), 400, { - "Content-Type": "application/json" - } - body = request.body - if not isinstance(body, (bytes, bytearray)) or not body: - return json.dumps({"error": "code is required"}), 400, { - "Content-Type": "application/json" - } - try: - code = body.decode("utf-8") - except UnicodeError: - return json.dumps({"error": "body must be utf-8 text"}), 400, { - "Content-Type": "application/json" - } - if not code.strip(): - return json.dumps({"error": "code is required"}), 400, { - "Content-Type": "application/json" - } - - name = raw_name.strip() - if not name.endswith(".py"): - name += ".py" - if not _safe_pattern_filename(name) or name in ("__init__.py", "main.py"): - return json.dumps({"error": "invalid pattern filename"}), 400, { - "Content-Type": "application/json" - } - - try: - os.mkdir("patterns") - except OSError: - pass - - path = "patterns/" + name - try: - with open(path, "w") as f: - f.write(code) - if reload_patterns: - presets.reload_patterns() - except OSError as e: - print("patterns/upload failed:", e) - return json.dumps({"error": str(e)}), 500, { - "Content-Type": "application/json" - } - - return json.dumps({ - "message": "pattern uploaded", - "name": name, - "reloaded": reload_patterns, - }), 201, {"Content-Type": "application/json"} - - -async def main(port=80): - asyncio.create_task(presets_loop(presets, wdt)) - asyncio.create_task( - udp_hello_loop_after_http_ready(sta_if, settings, wdt, runtime_state) - ) - await app.start_server(host="0.0.0.0", port=port) +esp = espnow.ESPNow() +esp.active(True) +esp.add_peer(b"\xff\xff\xff\xff\xff\xff") + +hello = json.dumps({ + "v": "1", + "settings": settings, + "type": "led", +}) +esp.send(b"\xff\xff\xff\xff\xff\xff", hello) +print(hello) +async def main(): + while True: + presets.tick() + wdt.feed() + if esp.any(): + host, msg = esp.recv(0) + print(host, msg) + await asyncio.sleep(0) + if __name__ == "__main__": - asyncio.run(main(port=80)) + asyncio.run(main()) diff --git a/src/settings.py b/src/settings.py index 63ef712..3015259 100644 --- a/src/settings.py +++ b/src/settings.py @@ -31,11 +31,9 @@ class Settings(dict): # Power-on: "default" | "last" | "off" self["startup_mode"] = "default" self["brightness"] = 32 - self["transport_type"] = "espnow" + self["wifi_channel"] = 1 - # ESP-NOW transport (requires espnow firmware; uses wifi_channel). - self["ssid"] = "" - self["password"] = "" + def save(self): try: