Compare commits
1 Commits
170a0e05ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a768376d0 |
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
43
src/main.py
43
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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
93
src/wifi_sta.py
Normal file
93
src/wifi_sta.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user