Files
led-driver/src/main.py

391 lines
12 KiB
Python

from settings import Settings
from machine import WDT
import utime
import network
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
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:
presets.select(default_preset)
print(f"Selected startup preset: {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)
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")
if settings["transport_type"] == "espnow":
from espnow import ESPNow # import only in this branch (avoids load when using Wi-Fi)
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)
while True:
if e.any():
_peer, msg = e.recv()
if msg:
process_data(msg)
presets.tick()
wdt.feed()
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", ""),
wdt=wdt,
)
if ip and ip != current:
print("Controller IP updated to", ip)
return ip if ip else current
reconnect_ms = 1000
next_connect_at = 0
client = None
poller = None
buf = b""
while True:
now = utime.ticks_ms()
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()