From 2a768376d05573b7865113123a1b7ecc1c602b78 Mon Sep 17 00:00:00 2001 From: Jimmy Date: Sun, 10 May 2026 16:13:59 +1200 Subject: [PATCH] chore(release): beta-1.03 Co-authored-by: Cursor --- src/background_tasks.py | 24 ++++++++-- src/controller_messages.py | 87 ++++++++++++++++++++++++++++++++++- src/main.py | 43 +++++++----------- src/settings.py | 14 ++++++ src/startup.py | 6 +-- src/wifi_sta.py | 93 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 232 insertions(+), 35 deletions(-) create mode 100644 src/wifi_sta.py diff --git a/src/background_tasks.py b/src/background_tasks.py index 64e7397..1bafbf0 100644 --- a/src/background_tasks.py +++ b/src/background_tasks.py @@ -3,6 +3,9 @@ import gc import utime from hello import broadcast_hello_udp +from wifi_sta import try_reconnect + +_UDP_HELLO_ATTEMPT = 0 async def presets_loop(presets, wdt): @@ -21,11 +24,26 @@ async def presets_loop(presets, wdt): async def udp_hello_loop_after_http_ready(sta_if, settings, wdt, runtime_state): - """Broadcast hello at startup-fast cadence, then slower cadence.""" + """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: - if runtime_state.hello: + 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, @@ -37,5 +55,5 @@ async def udp_hello_loop_after_http_ready(sta_if, settings, wdt, runtime_state): except Exception as ex: print("UDP hello broadcast failed:", ex) elapsed_ms = utime.ticks_diff(utime.ticks_ms(), started_ms) - interval_s = 5 if elapsed_ms < 60000 else 60 + 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 3f5fd04..8d0946f 100644 --- a/src/controller_messages.py +++ b/src/controller_messages.py @@ -58,6 +58,8 @@ def process_data(payload, settings, presets, controller_ip=None): return if data.get("v", "") != "1": return + if "device_config" in data: + apply_device_config(data, settings, presets) if "b" in data: apply_brightness(data, settings, presets) if "presets" in data: @@ -76,6 +78,88 @@ def process_data(payload, settings, presets, controller_ip=None): presets.save() if "save" in data and "b" in data: settings.save() + if "save" in data and "device_config" in data: + settings.save() + + +_VALID_DEVICE_COLOR_ORDERS = frozenset({"rgb", "rbg", "grb", "gbr", "brg", "bgr"}) +_STARTUP_MODES = frozenset({"default", "last", "off"}) +_MAX_DEVICE_LEDS = 2048 + + +def apply_startup_pattern(settings, presets): + """Apply power-on behaviour from ``startup_mode`` (default / last / off).""" + mode = str(settings.get("startup_mode", "default")).lower().strip() + if mode not in _STARTUP_MODES: + mode = "default" + if mode == "off": + if presets.select("off"): + return + presets.fill((0, 0, 0)) + return + if mode == "last": + lp = settings.get("last_preset") or "" + if isinstance(lp, str) and lp.strip() and lp.strip() in presets.presets: + if presets.select(lp.strip()): + return + dp = settings.get("default", "") + if dp and dp in presets.presets: + if not presets.select(dp): + print("Startup preset failed (invalid pattern?):", dp) + + +def apply_device_config(data, settings, presets): + """Apply fields from v1 ``device_config``; reload presets when strip length or colour order changes.""" + dc = data.get("device_config") + if not isinstance(dc, dict): + return + strip_changed = False + meta_changed = False + if "name" in dc: + n = dc["name"] + if isinstance(n, str) and n.strip(): + settings["name"] = n.strip() + meta_changed = True + if "num_leds" in dc: + try: + n = int(dc["num_leds"]) + if 1 <= n <= _MAX_DEVICE_LEDS: + settings["num_leds"] = n + presets.update_num_leds(settings["led_pin"], n) + strip_changed = True + except (TypeError, ValueError): + pass + if "color_order" in dc: + co = str(dc["color_order"]).lower().strip() + if co in _VALID_DEVICE_COLOR_ORDERS: + settings["color_order"] = co + settings.color_order = settings.get_color_order(co) + strip_changed = True + if "startup_mode" in dc: + sm = str(dc["startup_mode"]).lower().strip() + if sm in _STARTUP_MODES: + settings["startup_mode"] = sm + meta_changed = True + if not strip_changed and not meta_changed: + return + if strip_changed: + prev = presets.selected + try: + presets.load(settings) + except Exception as e: + print("device_config: presets.load failed:", e) + if prev and prev in presets.presets: + presets.select(prev) + elif settings.get("default") and settings["default"] in presets.presets: + presets.select(settings["default"]) + + +def record_last_preset(settings, preset_name): + """Persist the last selected preset id (single entry in flash).""" + if not isinstance(preset_name, str) or not preset_name: + return + settings["last_preset"] = preset_name.strip() + settings.save() def apply_brightness(data, settings, presets): @@ -117,7 +201,8 @@ def apply_select(data, settings, presets): return preset_name = select_list[0] step = select_list[1] if len(select_list) > 1 else None - presets.select(preset_name, step=step) + if presets.select(preset_name, step=step): + record_last_preset(settings, preset_name) def apply_clear_presets(data, presets): diff --git a/src/main.py b/src/main.py index ba2f02a..d244453 100644 --- a/src/main.py +++ b/src/main.py @@ -8,8 +8,10 @@ import gc 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 +from controller_messages import apply_startup_pattern, process_data +from runtime_state import RuntimeState +from background_tasks import udp_hello_loop_after_http_ready +from wifi_sta import connect_until_up try: import uos as os except ImportError: @@ -33,10 +35,7 @@ presets.b = settings.get("brightness", 255) presets.debug = bool(settings.get("debug", False)) gc.collect() -default_preset = settings.get("default", "") -if default_preset and default_preset in presets.presets: - if not presets.select(default_preset): - print("Startup preset failed (invalid pattern?):", default_preset) +apply_startup_pattern(settings, presets) # On ESP32-C3, soft reboots can leave Wi-Fi driver state allocated. # Reset both interfaces and collect before bringing STA up. @@ -49,11 +48,9 @@ utime.sleep_ms(100) gc.collect() sta_if.active(True) sta_if.config(pm=network.WLAN.PM_NONE) -sta_if.connect(settings["ssid"], settings["password"]) -while not sta_if.isconnected(): - print("Waiting for network connection...") - utime.sleep(1) - wdt.feed() +_boot_ssid = settings.get("ssid") or "" +if _boot_ssid: + connect_until_up(sta_if, _boot_ssid, settings.get("password") or "", wdt) def _print_network_ips(controller_ip=None): @@ -68,6 +65,8 @@ def _print_network_ips(controller_ip=None): _print_network_ips() +runtime_state = RuntimeState() + app = Microdot() @@ -84,6 +83,7 @@ def _safe_pattern_filename(name): @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) @@ -104,6 +104,8 @@ async def ws_handler(request, ws): print("WS client disconnected:", e) except OSError as e: print("WS client dropped (OSError):", e) + finally: + runtime_state.ws_disconnected() @app.post("/patterns/upload") @@ -173,24 +175,11 @@ async def presets_loop(): 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) - 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): asyncio.create_task(presets_loop()) - asyncio.create_task(_udp_hello_after_http_ready()) + 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) diff --git a/src/settings.py b/src/settings.py index 8724d9c..63ef712 100644 --- a/src/settings.py +++ b/src/settings.py @@ -27,6 +27,9 @@ class Settings(dict): self["debug"] = False self["default"] = "on" + self["last_preset"] = "" + # Power-on: "default" | "last" | "off" + self["startup_mode"] = "default" self["brightness"] = 32 self["transport_type"] = "espnow" self["wifi_channel"] = 1 @@ -47,6 +50,17 @@ class Settings(dict): with open(self.SETTINGS_FILE, 'r') as file: loaded_settings = json.load(file) self.update(loaded_settings) + old_recent = self.pop("recent_presets", None) + if isinstance(old_recent, list) and old_recent and not self.get("last_preset"): + for x in reversed(old_recent): + if isinstance(x, str) and x.strip(): + self["last_preset"] = x.strip() + break + if x is not None: + s = str(x).strip() + if s: + self["last_preset"] = s + break except Exception as e: print(f"Error loading settings") self.set_defaults() diff --git a/src/startup.py b/src/startup.py index 62243f4..f3cf85c 100644 --- a/src/startup.py +++ b/src/startup.py @@ -5,6 +5,7 @@ import utime from presets import Presets from settings import Settings +from controller_messages import apply_startup_pattern def initialize_runtime(): @@ -23,10 +24,7 @@ def initialize_runtime(): presets.debug = bool(settings.get("debug", False)) gc.collect() - default_preset = settings.get("default", "") - if default_preset and default_preset in presets.presets: - if not presets.select(default_preset): - print("Startup preset failed (invalid pattern?):", default_preset) + apply_startup_pattern(settings, presets) # On ESP32-C3, soft reboots can leave Wi-Fi driver state allocated. # Reset both interfaces and collect before bringing STA up. diff --git a/src/wifi_sta.py b/src/wifi_sta.py new file mode 100644 index 0000000..b4733e6 --- /dev/null +++ b/src/wifi_sta.py @@ -0,0 +1,93 @@ +"""STA connect helpers aligned with tests/test_wifi.py (status polling, fatal codes).""" + +import utime +import network + +_CONNECT_TIMEOUT_S = 45 +_RETRY_DELAY_S = 2 + + +def _wifi_status_label(code): + names = { + getattr(network, "STAT_IDLE", 0): "idle", + getattr(network, "STAT_CONNECTING", 1): "connecting", + getattr(network, "STAT_WRONG_PASSWORD", -3): "wrong_password", + getattr(network, "STAT_NO_AP_FOUND", -2): "no_ap_found", + getattr(network, "STAT_CONNECT_FAIL", -1): "connect_fail", + getattr(network, "STAT_GOT_IP", 3): "got_ip", + } + return names.get(code, str(code)) + + +# Only abort the wait loop immediately on wrong password. NO_AP_FOUND / CONNECT_FAIL are often +# transient while the radio is still scanning (ESP32-C3 may report them before the AP appears). +_ABORT_WAIT_IMMEDIATE = ( + getattr(network, "STAT_WRONG_PASSWORD", -3), +) + + +def _one_association_campaign(sta_if, ssid, password, wdt): + """disconnect → connect → wait until connected, wrong password, or timeout. Returns True if connected.""" + try: + sta_if.disconnect() + except Exception: + pass + utime.sleep_ms(200) + try: + sta_if.connect(ssid, password) + except Exception as ex: + print("wifi_sta: connect raised:", ex) + return False + + start = utime.time() + last_status = None + while not sta_if.isconnected(): + status = sta_if.status() + if status != last_status: + print("wifi_sta: status", status, _wifi_status_label(status)) + last_status = status + if status in _ABORT_WAIT_IMMEDIATE: + return False + if utime.time() - start >= _CONNECT_TIMEOUT_S: + print("wifi_sta: association timeout") + return False + utime.sleep(1) + if wdt is not None: + wdt.feed() + return True + + +def connect_until_up(sta_if, ssid, password, wdt): + """Boot: repeat campaigns until STA has a route (same strategy as tests/test_wifi.py).""" + if not ssid: + print("wifi_sta: no ssid in settings") + return False + attempt = 0 + while True: + attempt += 1 + print("wifi_sta: boot attempt", attempt, "ssid=", repr(ssid)) + if _one_association_campaign(sta_if, ssid, password, wdt): + try: + print("wifi_sta: connected", sta_if.ifconfig()[0]) + except Exception: + print("wifi_sta: connected") + return True + print("wifi_sta: retry in", _RETRY_DELAY_S, "s") + for _ in range(_RETRY_DELAY_S): + utime.sleep(1) + if wdt is not None: + wdt.feed() + + +def try_reconnect(sta_if, ssid, password, wdt): + """Runtime: single association campaign after link loss; non-looping.""" + if not ssid: + return False + print("wifi_sta: reconnect") + ok = _one_association_campaign(sta_if, ssid, password, wdt) + if ok: + try: + print("wifi_sta: connected", sta_if.ifconfig()[0]) + except Exception: + print("wifi_sta: connected") + return ok