feat(driver): add HTTP routes, startup split, and binary envelope support
Wire controller messages through new modules (background tasks, runtime state, startup) and add binary envelope handling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
42
src/background_tasks.py
Normal file
42
src/background_tasks.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import asyncio
|
||||||
|
import gc
|
||||||
|
import utime
|
||||||
|
|
||||||
|
from hello import broadcast_hello_udp
|
||||||
|
|
||||||
|
|
||||||
|
async def presets_loop(presets, wdt):
|
||||||
|
last_mem_log = utime.ticks_ms()
|
||||||
|
while True:
|
||||||
|
presets.tick()
|
||||||
|
wdt.feed()
|
||||||
|
if bool(getattr(presets, "debug", False)):
|
||||||
|
now = utime.ticks_ms()
|
||||||
|
if utime.ticks_diff(now, last_mem_log) >= 5000:
|
||||||
|
gc.collect()
|
||||||
|
print("mem runtime:", {"free": gc.mem_free(), "alloc": gc.mem_alloc()})
|
||||||
|
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):
|
||||||
|
"""Broadcast hello at startup-fast cadence, then slower cadence."""
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
started_ms = utime.ticks_ms()
|
||||||
|
while True:
|
||||||
|
if runtime_state.hello:
|
||||||
|
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)
|
||||||
|
elapsed_ms = utime.ticks_diff(utime.ticks_ms(), started_ms)
|
||||||
|
interval_s = 5 if elapsed_ms < 60000 else 60
|
||||||
|
await asyncio.sleep(interval_s)
|
||||||
209
src/binary_envelope.py
Normal file
209
src/binary_envelope.py
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
"""Decode compact binary controller envelopes — v2 native binary, v1 legacy JSON blobs."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
|
||||||
|
BINARY_ENVELOPE_VERSION_1 = 1
|
||||||
|
BINARY_ENVELOPE_VERSION_2 = 2
|
||||||
|
HEADER_LEN = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _brightness_0_255_from_wire(wire):
|
||||||
|
w = max(0, min(127, int(wire)))
|
||||||
|
return min(255, (w * 255) // 127)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_preset_record(buf, off):
|
||||||
|
nl = buf[off]
|
||||||
|
off += 1
|
||||||
|
name = buf[off : off + nl].decode("utf-8")
|
||||||
|
off += nl
|
||||||
|
pl = buf[off]
|
||||||
|
off += 1
|
||||||
|
pattern = buf[off : off + pl].decode("utf-8")
|
||||||
|
off += pl
|
||||||
|
nc = buf[off]
|
||||||
|
off += 1
|
||||||
|
colors = []
|
||||||
|
for _ in range(nc):
|
||||||
|
r, g, b = buf[off], buf[off + 1], buf[off + 2]
|
||||||
|
off += 3
|
||||||
|
colors.append("#%02x%02x%02x" % (r, g, b))
|
||||||
|
if off + 16 > len(buf):
|
||||||
|
raise ValueError("truncated")
|
||||||
|
delay, br, auto, n1, n2, n3, n4, n5, n6 = struct.unpack_from(
|
||||||
|
"<HBBhhhhhh", buf, off
|
||||||
|
)
|
||||||
|
off += 16
|
||||||
|
preset = {
|
||||||
|
"p": pattern,
|
||||||
|
"c": colors,
|
||||||
|
"d": delay,
|
||||||
|
"b": br,
|
||||||
|
"a": bool(auto),
|
||||||
|
"n1": n1,
|
||||||
|
"n2": n2,
|
||||||
|
"n3": n3,
|
||||||
|
"n4": n4,
|
||||||
|
"n5": n5,
|
||||||
|
"n6": n6,
|
||||||
|
}
|
||||||
|
return name, preset, off
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_presets_blob(chunk):
|
||||||
|
if not chunk:
|
||||||
|
return {}
|
||||||
|
off = 0
|
||||||
|
count = chunk[off]
|
||||||
|
off += 1
|
||||||
|
out = {}
|
||||||
|
for _ in range(count):
|
||||||
|
name, preset, off = _decode_preset_record(chunk, off)
|
||||||
|
out[name] = preset
|
||||||
|
if off != len(chunk):
|
||||||
|
raise ValueError("presets blob mismatch")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_select_blob(chunk):
|
||||||
|
if not chunk:
|
||||||
|
return {}
|
||||||
|
off = 0
|
||||||
|
count = chunk[off]
|
||||||
|
off += 1
|
||||||
|
out = {}
|
||||||
|
for _ in range(count):
|
||||||
|
dl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
device = chunk[off : off + dl].decode("utf-8")
|
||||||
|
off += dl
|
||||||
|
pl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
pname = chunk[off : off + pl].decode("utf-8")
|
||||||
|
off += pl
|
||||||
|
has_step = chunk[off]
|
||||||
|
off += 1
|
||||||
|
if has_step:
|
||||||
|
step = struct.unpack_from("<H", chunk, off)[0]
|
||||||
|
off += 2
|
||||||
|
out[device] = [pname, step]
|
||||||
|
else:
|
||||||
|
out[device] = [pname]
|
||||||
|
if off != len(chunk):
|
||||||
|
raise ValueError("select blob mismatch")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_default_blob(chunk):
|
||||||
|
if not chunk:
|
||||||
|
return "", []
|
||||||
|
off = 0
|
||||||
|
nl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
default_name = chunk[off : off + nl].decode("utf-8") if nl else ""
|
||||||
|
off += nl
|
||||||
|
nt = chunk[off]
|
||||||
|
off += 1
|
||||||
|
targets = []
|
||||||
|
for _ in range(nt):
|
||||||
|
tl = chunk[off]
|
||||||
|
off += 1
|
||||||
|
targets.append(chunk[off : off + tl].decode("utf-8"))
|
||||||
|
off += tl
|
||||||
|
if off != len(chunk):
|
||||||
|
raise ValueError("default blob mismatch")
|
||||||
|
return default_name, targets
|
||||||
|
|
||||||
|
|
||||||
|
def parse_binary_envelope_v2(buf):
|
||||||
|
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
|
||||||
|
return None
|
||||||
|
if buf[0] != BINARY_ENVELOPE_VERSION_2:
|
||||||
|
return None
|
||||||
|
lp = buf[2]
|
||||||
|
ls = buf[3]
|
||||||
|
ld = buf[4]
|
||||||
|
need = HEADER_LEN + lp + ls + ld
|
||||||
|
if len(buf) != need:
|
||||||
|
return None
|
||||||
|
|
||||||
|
off = HEADER_LEN
|
||||||
|
presets_chunk = buf[off : off + lp]
|
||||||
|
off += lp
|
||||||
|
select_chunk = buf[off : off + ls]
|
||||||
|
off += ls
|
||||||
|
default_chunk = buf[off : off + ld]
|
||||||
|
|
||||||
|
data = {"v": "1"}
|
||||||
|
br = buf[1]
|
||||||
|
if br < 128:
|
||||||
|
data["b"] = _brightness_0_255_from_wire(br)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if lp:
|
||||||
|
data["presets"] = _decode_presets_blob(presets_chunk)
|
||||||
|
if ls:
|
||||||
|
data["select"] = _decode_select_blob(select_chunk)
|
||||||
|
if ld:
|
||||||
|
dname, targets = _decode_default_blob(default_chunk)
|
||||||
|
data["default"] = dname
|
||||||
|
data["targets"] = targets
|
||||||
|
except (ValueError, UnicodeError, TypeError, struct.error):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_binary_envelope_v1(buf):
|
||||||
|
if not isinstance(buf, (bytes, bytearray)) or len(buf) < HEADER_LEN:
|
||||||
|
return None
|
||||||
|
if buf[0] != BINARY_ENVELOPE_VERSION_1:
|
||||||
|
return None
|
||||||
|
lp = buf[2]
|
||||||
|
ls = buf[3]
|
||||||
|
ld = buf[4]
|
||||||
|
need = HEADER_LEN + lp + ls + ld
|
||||||
|
if len(buf) != need:
|
||||||
|
return None
|
||||||
|
|
||||||
|
off = HEADER_LEN
|
||||||
|
presets_chunk = buf[off : off + lp]
|
||||||
|
off += lp
|
||||||
|
select_chunk = buf[off : off + ls]
|
||||||
|
off += ls
|
||||||
|
default_chunk = buf[off : off + ld]
|
||||||
|
|
||||||
|
data = {"v": "1"}
|
||||||
|
|
||||||
|
br = buf[1]
|
||||||
|
if br < 128:
|
||||||
|
data["b"] = _brightness_0_255_from_wire(br)
|
||||||
|
|
||||||
|
if lp:
|
||||||
|
try:
|
||||||
|
data["presets"] = json.loads(presets_chunk.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
if ls:
|
||||||
|
try:
|
||||||
|
data["select"] = json.loads(select_chunk.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
if ld:
|
||||||
|
try:
|
||||||
|
extra = json.loads(default_chunk.decode("utf-8"))
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
for k, v in extra.items():
|
||||||
|
data[k] = v
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def parse_binary_envelope(buf):
|
||||||
|
d = parse_binary_envelope_v2(buf)
|
||||||
|
if d is not None:
|
||||||
|
return d
|
||||||
|
return parse_binary_envelope_v1(buf)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
|
from binary_envelope import parse_binary_envelope
|
||||||
from utils import convert_and_reorder_colors
|
from utils import convert_and_reorder_colors
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,14 +13,23 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
def process_data(payload, settings, presets, controller_ip=None):
|
def process_data(payload, settings, presets, controller_ip=None):
|
||||||
"""Read one controller message; json.loads (bytes or str), then apply fields."""
|
"""Read one controller message; binary v1 envelope or JSON v1, then apply fields."""
|
||||||
|
data = None
|
||||||
|
if isinstance(payload, (bytes, bytearray)):
|
||||||
|
data = parse_binary_envelope(payload)
|
||||||
|
if data is None:
|
||||||
try:
|
try:
|
||||||
data = json.loads(payload)
|
data = json.loads(payload)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
data = json.loads(payload)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return
|
||||||
print(payload)
|
print(payload)
|
||||||
if data.get("v", "") != "1":
|
if data.get("v", "") != "1":
|
||||||
return
|
return
|
||||||
except (ValueError, TypeError):
|
|
||||||
return
|
|
||||||
if "b" in data:
|
if "b" in data:
|
||||||
apply_brightness(data, settings, presets)
|
apply_brightness(data, settings, presets)
|
||||||
if "presets" in data:
|
if "presets" in data:
|
||||||
|
|||||||
125
src/http_routes.py
Normal file
125
src/http_routes.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from controller_messages import process_data
|
||||||
|
from microdot.websocket import WebSocketError, with_websocket
|
||||||
|
|
||||||
|
try:
|
||||||
|
import uos as os
|
||||||
|
except ImportError:
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
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 register_routes(app, settings, presets, runtime_state):
|
||||||
|
@app.route("/ws")
|
||||||
|
@with_websocket
|
||||||
|
async def ws_handler(request, ws):
|
||||||
|
print("WS client connected")
|
||||||
|
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("WS controller_ip:", controller_ip)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = await ws.receive()
|
||||||
|
if not data:
|
||||||
|
print("WS client disconnected (closed)")
|
||||||
|
break
|
||||||
|
print("WS recv bytes:", len(data) if isinstance(data, (bytes, bytearray)) else len(str(data)))
|
||||||
|
print(data)
|
||||||
|
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()
|
||||||
|
print(
|
||||||
|
"WS client disconnected: hello=",
|
||||||
|
runtime_state.hello,
|
||||||
|
"ws_client_count=",
|
||||||
|
runtime_state.ws_client_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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")
|
||||||
|
print("patterns/upload request:", {"name": raw_name, "reload": reload_patterns})
|
||||||
|
|
||||||
|
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:
|
||||||
|
print("patterns/upload rejected: empty body")
|
||||||
|
return json.dumps({"error": "code is required"}), 400, {"Content-Type": "application/json"}
|
||||||
|
print("patterns/upload body_bytes:", len(body))
|
||||||
|
try:
|
||||||
|
code = body.decode("utf-8")
|
||||||
|
except UnicodeError:
|
||||||
|
print("patterns/upload rejected: body not utf-8")
|
||||||
|
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:
|
||||||
|
print("patterns/upload writing:", path)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(code)
|
||||||
|
if reload_patterns:
|
||||||
|
print("patterns/upload reloading patterns")
|
||||||
|
presets.reload_patterns()
|
||||||
|
except OSError as e:
|
||||||
|
print("patterns/upload failed:", e)
|
||||||
|
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
|
||||||
|
print("patterns/upload success:", {"name": name, "reloaded": reload_patterns})
|
||||||
|
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"message": "pattern uploaded",
|
||||||
|
"name": name,
|
||||||
|
"reloaded": reload_patterns,
|
||||||
|
}
|
||||||
|
), 201, {"Content-Type": "application/json"}
|
||||||
|
|
||||||
|
@app.post("/presets/upload")
|
||||||
|
async def upload_presets(request):
|
||||||
|
"""Receive v1 JSON with ``presets`` and apply/save on the driver."""
|
||||||
|
body = request.body
|
||||||
|
if not isinstance(body, (bytes, bytearray)) or not body:
|
||||||
|
return json.dumps({"error": "body is required"}), 400, {"Content-Type": "application/json"}
|
||||||
|
try:
|
||||||
|
process_data(body, settings, presets)
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||||
|
return json.dumps({"message": "presets applied"}), 200, {"Content-Type": "application/json"}
|
||||||
12
src/runtime_state.py
Normal file
12
src/runtime_state.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
class RuntimeState:
|
||||||
|
def __init__(self):
|
||||||
|
self.hello = True
|
||||||
|
self.ws_client_count = 0
|
||||||
|
|
||||||
|
def ws_connected(self):
|
||||||
|
self.ws_client_count += 1
|
||||||
|
self.hello = False
|
||||||
|
|
||||||
|
def ws_disconnected(self):
|
||||||
|
self.ws_client_count = max(0, self.ws_client_count - 1)
|
||||||
|
self.hello = self.ws_client_count == 0
|
||||||
53
src/startup.py
Normal file
53
src/startup.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import gc
|
||||||
|
import machine
|
||||||
|
import network
|
||||||
|
import utime
|
||||||
|
|
||||||
|
from presets import Presets
|
||||||
|
from settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_runtime():
|
||||||
|
machine.freq(160000000)
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
print(settings)
|
||||||
|
|
||||||
|
wdt = machine.WDT(timeout=10000)
|
||||||
|
wdt.feed()
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
print("mem before presets:", {"free": gc.mem_free(), "alloc": gc.mem_alloc()})
|
||||||
|
|
||||||
|
presets = Presets(settings["led_pin"], settings["num_leds"])
|
||||||
|
presets.load(settings)
|
||||||
|
presets.b = settings.get("brightness", 255)
|
||||||
|
presets.debug = bool(settings.get("debug", False))
|
||||||
|
gc.collect()
|
||||||
|
print("mem after presets:", {"free": gc.mem_free(), "alloc": gc.mem_alloc()})
|
||||||
|
|
||||||
|
default_preset = settings.get("default", "")
|
||||||
|
if default_preset and default_preset in presets.presets:
|
||||||
|
if presets.select(default_preset):
|
||||||
|
print("Selected startup preset:", default_preset)
|
||||||
|
else:
|
||||||
|
print("Startup preset failed (invalid pattern?):", default_preset)
|
||||||
|
|
||||||
|
# On ESP32-C3, soft reboots can leave Wi-Fi driver state allocated.
|
||||||
|
# Reset both interfaces and collect before bringing STA up.
|
||||||
|
ap_if = network.WLAN(network.AP_IF)
|
||||||
|
ap_if.active(False)
|
||||||
|
sta_if = network.WLAN(network.STA_IF)
|
||||||
|
if sta_if.active():
|
||||||
|
sta_if.active(False)
|
||||||
|
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():
|
||||||
|
utime.sleep(1)
|
||||||
|
wdt.feed()
|
||||||
|
|
||||||
|
print(sta_if.ifconfig())
|
||||||
|
return settings, presets, wdt, sta_if
|
||||||
Reference in New Issue
Block a user