From e6b5bf2cf1a7d7d0b06ad5a1628467776cb0ea8e Mon Sep 17 00:00:00 2001 From: pi Date: Sun, 5 Apr 2026 21:13:07 +1200 Subject: [PATCH] feat(devices): wifi tcp registry, device API/UI, tests; bump led-tool Made-with: Cursor --- db/device.json | 2 +- docs/API.md | 27 +++- led-driver | 2 +- led-tool | 2 +- src/controllers/device.py | 79 +++++++++-- src/main.py | 99 ++++++++++++- src/models/device.py | 250 ++++++++++++++++++++++++++++++--- src/settings.py | 5 + src/static/devices.js | 115 +++++++-------- src/static/style.css | 66 +++++++++ src/templates/index.html | 50 +++++++ tests/conftest.py | 4 +- tests/models/test_device.py | 115 +++++++++++++-- tests/tcp_test_server.py | 7 +- tests/test_endpoints_pytest.py | 105 ++++++++++++++ 15 files changed, 825 insertions(+), 103 deletions(-) diff --git a/db/device.json b/db/device.json index 9e26dfe..689e6c7 100644 --- a/db/device.json +++ b/db/device.json @@ -1 +1 @@ -{} \ No newline at end of file +{"aabbccddeeff": {"id": "aabbccddeeff", "name": "one", "type": "led", "transport": "espnow", "address": "aabbccddeeff", "default_pattern": null, "tabs": []}, "f0f5bdfd78b8": {"id": "f0f5bdfd78b8", "name": "a", "type": "led", "transport": "wifi", "address": "10.1.1.215", "default_pattern": null, "tabs": []}} \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 35206a6..54576a4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -15,8 +15,8 @@ All JSON APIs use `Content-Type: application/json` for bodies and responses unle The main UI has two modes controlled by the mode toggle: -- **Run mode**: optimized for operation (tab/preset selection and profile apply). -- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, and profile management actions). +- **Run mode**: optimized for operation (tab/preset selection, profile apply, and **Devices** registry for LED driver names/MACs). +- **Edit mode**: shows editing/management controls (tabs, presets, patterns, colour palette, send presets, profile management actions, and related tools). Profiles are available in both modes, but behavior differs: @@ -70,6 +70,29 @@ Below, `` values are string identifiers used by the JSON stores (numeric str | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (1–11). Persists AP-related settings. | | GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). | +### Devices — `/devices` + +Registry in `db/device.json`: storage key **``** (string, e.g. `"1"`) maps to an object that always includes: + +| Field | Description | +|-------|-------------| +| **`id`** | Same as the storage key (stable handle for URLs). | +| **`name`** | Shown in tabs and used in `select` keys. | +| **`type`** | `led` (only value today; extensible). | +| **`transport`** | `espnow` or `wifi`. | +| **`address`** | For **`espnow`**: optional 12-character lowercase hex MAC. For **`wifi`**: optional IP or hostname string. | +| **`default_pattern`**, **`tabs`** | Optional, as before. | + +Existing records without `type` / `transport` / `id` are backfilled on load (`led`, `espnow`, and `id` = key). + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/devices` | Map of device id → device object. | +| GET | `/devices/` | One device, 404 if missing. | +| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`tabs`**. Returns `{ "": { ... } }`, 201. | +| PUT | `/devices/` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. | +| DELETE | `/devices/` | Remove device. | + ### Profiles — `/profiles` | Method | Path | Description | diff --git a/led-driver b/led-driver index dc19877..7e3aca4 160000 --- a/led-driver +++ b/led-driver @@ -1 +1 @@ -Subproject commit dc19877132e563bafd77e2b5383cf96937e73c61 +Subproject commit 7e3aca491cc0c0995628712709dd6b2d7d2deccb diff --git a/led-tool b/led-tool index 3844aa9..e863124 160000 --- a/led-tool +++ b/led-tool @@ -1 +1 @@ -Subproject commit 3844aa9d6a0b9a034c862265a2e87c3acd8db90b +Subproject commit e86312437c833ac3767c3c30cd8b77ae49d61cd4 diff --git a/src/controllers/device.py b/src/controllers/device.py index d292ae2..3aa9d39 100644 --- a/src/controllers/device.py +++ b/src/controllers/device.py @@ -1,5 +1,10 @@ from microdot import Microdot -from models.device import Device +from models.device import ( + Device, + derive_device_mac, + validate_device_transport, + validate_device_type, +) import json controller = Microdot() @@ -23,7 +28,9 @@ async def get_device(request, id): dev = devices.read(id) if dev: return json.dumps(dev), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Device not found"}), 404 + return json.dumps({"error": "Device not found"}), 404, { + "Content-Type": "application/json", + } @controller.post("") @@ -32,37 +39,91 @@ async def create_device(request): try: data = request.json or {} name = data.get("name", "").strip() + if not name: + return json.dumps({"error": "name is required"}), 400, { + "Content-Type": "application/json", + } + try: + device_type = validate_device_type(data.get("type", "led")) + transport = validate_device_transport(data.get("transport", "espnow")) + except ValueError as e: + return json.dumps({"error": str(e)}), 400, { + "Content-Type": "application/json", + } address = data.get("address") + mac = data.get("mac") + if derive_device_mac(mac=mac, address=address, transport=transport) is None: + return json.dumps( + { + "error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address" + } + ), 400, {"Content-Type": "application/json"} default_pattern = data.get("default_pattern") tabs = data.get("tabs") if isinstance(tabs, list): tabs = [str(t) for t in tabs] else: tabs = [] - dev_id = devices.create(name=name, address=address, default_pattern=default_pattern, tabs=tabs) + dev_id = devices.create( + name=name, + address=address, + mac=mac, + default_pattern=default_pattern, + tabs=tabs, + device_type=device_type, + transport=transport, + ) dev = devices.read(dev_id) return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"} + except ValueError as e: + msg = str(e) + code = 409 if "already exists" in msg.lower() else 400 + return json.dumps({"error": msg}), code, {"Content-Type": "application/json"} except Exception as e: - return json.dumps({"error": str(e)}), 400 + return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} @controller.put("/") async def update_device(request, id): """Update a device.""" try: - data = request.json or {} + raw = request.json or {} + data = dict(raw) + data.pop("id", None) + data.pop("addresses", None) + if "name" in data: + n = (data.get("name") or "").strip() + if not n: + return json.dumps({"error": "name cannot be empty"}), 400, { + "Content-Type": "application/json", + } + data["name"] = n + if "type" in data: + data["type"] = validate_device_type(data.get("type")) + if "transport" in data: + data["transport"] = validate_device_transport(data.get("transport")) if "tabs" in data and isinstance(data["tabs"], list): data["tabs"] = [str(t) for t in data["tabs"]] if devices.update(id, data): return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"} - return json.dumps({"error": "Device not found"}), 404 + return json.dumps({"error": "Device not found"}), 404, { + "Content-Type": "application/json", + } + except ValueError as e: + return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} except Exception as e: - return json.dumps({"error": str(e)}), 400 + return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"} @controller.delete("/") async def delete_device(request, id): """Delete a device.""" if devices.delete(id): - return json.dumps({"message": "Device deleted successfully"}), 200 - return json.dumps({"error": "Device not found"}), 404 + return ( + json.dumps({"message": "Device deleted successfully"}), + 200, + {"Content-Type": "application/json"}, + ) + return json.dumps({"error": "Device not found"}), 404, { + "Content-Type": "application/json", + } diff --git a/src/main.py b/src/main.py index 4386f84..c2da8bc 100644 --- a/src/main.py +++ b/src/main.py @@ -1,6 +1,8 @@ import asyncio import json import os +import threading +import traceback from microdot import Microdot, send_file from microdot.websocket import with_websocket from microdot.session import Session @@ -15,7 +17,98 @@ import controllers.palette as palette import controllers.scene as scene import controllers.pattern as pattern import controllers.settings as settings_controller -from models.transport import get_sender, set_sender +import controllers.device as device_controller +from models.transport import get_sender, set_sender, get_current_sender +from models.device import Device, normalize_mac + +_tcp_device_lock = threading.Lock() + + +def _register_tcp_device_sync(device_name: str, peer_ip: str, mac) -> None: + with _tcp_device_lock: + try: + d = Device() + did = d.upsert_wifi_tcp_client(device_name, peer_ip, mac) + if did: + print( + f"TCP device registered: mac={did} name={device_name!r} ip={peer_ip!r}" + ) + except Exception as e: + print(f"TCP device registry failed: {e}") + traceback.print_exception(type(e), e, e.__traceback__) + + +async def _handle_tcp_client(reader, writer): + """Read newline-delimited JSON from Wi-Fi LED drivers; forward to serial bridge.""" + peer = writer.get_extra_info("peername") + peer_ip = peer[0] if peer else "" + peer_label = f"{peer_ip}:{peer[1]}" if peer and len(peer) > 1 else peer_ip or "?" + print(f"[TCP] client connected {peer_label}") + sender = get_current_sender() + buf = b"" + try: + while True: + chunk = await reader.read(4096) + if not chunk: + break + buf += chunk + while b"\n" in buf: + raw_line, buf = buf.split(b"\n", 1) + line = raw_line.strip() + if not line: + continue + try: + text = line.decode("utf-8") + except UnicodeDecodeError: + print( + f"[TCP] recv {peer_label} (non-UTF-8, {len(line)} bytes): {line!r}" + ) + continue + print(f"[TCP] recv {peer_label}: {text}") + try: + parsed = json.loads(text) + except json.JSONDecodeError: + if sender: + try: + await sender.send(text) + except Exception: + pass + continue + if isinstance(parsed, dict): + dns = str(parsed.get("device_name") or "").strip() + mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get("sta_mac") + if dns and normalize_mac(mac): + _register_tcp_device_sync(dns, peer_ip, mac) + addr = parsed.pop("to", None) + payload = json.dumps(parsed) if parsed else "{}" + if sender: + try: + await sender.send(payload, addr=addr) + except Exception as e: + print(f"TCP forward to bridge failed: {e}") + elif sender: + try: + await sender.send(text) + except Exception: + pass + finally: + print(f"[TCP] client disconnected {peer_label}") + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + +async def _run_tcp_server(settings): + if not settings.get("tcp_enabled", True): + print("TCP server disabled (tcp_enabled=false)") + return + port = int(settings.get("tcp_port", 8765)) + server = await asyncio.start_server(_handle_tcp_client, "0.0.0.0", port) + print(f"TCP server listening on 0.0.0.0:{port}") + async with server: + await server.serve_forever() async def main(port=80): @@ -55,6 +148,7 @@ async def main(port=80): app.mount(scene.controller, '/scenes') app.mount(pattern.controller, '/patterns') app.mount(settings_controller.controller, '/settings') + app.mount(device_controller.controller, '/devices') # Serve index.html at root (cwd is src/ when run via pipenv run run) @app.route('/') @@ -116,6 +210,9 @@ async def main(port=80): server = asyncio.create_task(app.start_server(host="0.0.0.0", port=port)) + # Touch Device singleton early so db/device.json exists before first TCP hello. + Device() + tcp_task = asyncio.create_task(_run_tcp_server(settings)) while True: await asyncio.sleep(30) diff --git a/src/models/device.py b/src/models/device.py index d0d2368..c651e8d 100644 --- a/src/models/device.py +++ b/src/models/device.py @@ -1,49 +1,229 @@ +""" +LED driver registry persisted in ``db/device.json``. + +Storage key and **id** field are the device **MAC**: 12 lowercase hex characters +(no colons). **name** is for ``select`` / tabs (not unique). **address** is the +reachability hint: same as MAC for ESP-NOW, or IP/hostname for Wi-Fi. +""" + from models.model import Model +DEVICE_TYPES = frozenset({"led"}) +DEVICE_TRANSPORTS = frozenset({"wifi", "espnow"}) -def _normalize_address(addr): - """Normalize 6-byte ESP32 address to 12-char lowercase hex (no colons).""" - if addr is None: + +def validate_device_type(value): + t = (value or "led").strip().lower() + if t not in DEVICE_TYPES: + raise ValueError(f"type must be one of: {', '.join(sorted(DEVICE_TYPES))}") + return t + + +def validate_device_transport(value): + tr = (value or "espnow").strip().lower() + if tr not in DEVICE_TRANSPORTS: + raise ValueError( + f"transport must be one of: {', '.join(sorted(DEVICE_TRANSPORTS))}" + ) + return tr + + +def normalize_mac(mac): + """Normalise to 12-char lowercase hex or None.""" + if mac is None: return None - s = str(addr).strip().lower().replace(":", "").replace("-", "") + s = str(mac).strip().lower().replace(":", "").replace("-", "") if len(s) == 12 and all(c in "0123456789abcdef" for c in s): return s return None +def derive_device_mac(mac=None, address=None, transport="espnow"): + """ + Resolve the device MAC used as storage id. + + Explicit ``mac`` wins. For ESP-NOW, ``address`` is the peer MAC. For Wi-Fi, + ``mac`` must be supplied (``address`` is typically an IP). + """ + m = normalize_mac(mac) + if m: + return m + tr = validate_device_transport(transport) + if tr == "espnow": + return normalize_mac(address) + return None + + +def normalize_address_for_transport(addr, transport): + """ESP-NOW → 12 hex or None; Wi-Fi → trimmed string or None.""" + tr = validate_device_transport(transport) + if tr == "espnow": + return normalize_mac(addr) + if addr is None: + return None + s = str(addr).strip() + return s if s else None + + class Device(Model): def __init__(self): super().__init__() - def create(self, name="", address=None, default_pattern=None, tabs=None): - next_id = self.get_next_id() - addr = _normalize_address(address) - self[next_id] = { + def load(self): + super().load() + changed = False + for sid, doc in list(self.items()): + if not isinstance(doc, dict): + continue + if self._migrate_record(str(sid), doc): + changed = True + if self._rekey_legacy_ids(): + changed = True + if changed: + self.save() + + def _migrate_record(self, storage_id, doc): + changed = False + if doc.get("type") not in DEVICE_TYPES: + doc["type"] = "led" + changed = True + if doc.get("transport") not in DEVICE_TRANSPORTS: + doc["transport"] = "espnow" + changed = True + raw_list = doc.get("addresses") + if isinstance(raw_list, list) and raw_list: + picked = None + for item in raw_list: + n = normalize_mac(item) + if n: + picked = n + break + if picked: + doc["address"] = picked + del doc["addresses"] + changed = True + elif "addresses" in doc: + del doc["addresses"] + changed = True + tr = doc["transport"] + norm = normalize_address_for_transport(doc.get("address"), tr) + if doc.get("address") != norm: + doc["address"] = norm + changed = True + mac_key = normalize_mac(storage_id) + if mac_key and mac_key == storage_id and str(doc.get("id") or "") != mac_key: + doc["id"] = mac_key + changed = True + elif str(doc.get("id") or "").strip() != storage_id: + doc["id"] = storage_id + changed = True + doc.pop("mac", None) + return changed + + def _rekey_legacy_ids(self): + """Move numeric-keyed rows to MAC keys when ESP-NOW MAC is known.""" + changed = False + moves = [] + for sid in list(self.keys()): + doc = self.get(sid) + if not isinstance(doc, dict): + continue + if normalize_mac(sid) == sid: + continue + if not str(sid).isdigit(): + continue + tr = doc.get("transport", "espnow") + cand = None + if tr == "espnow": + cand = normalize_mac(doc.get("address")) + if not cand: + continue + moves.append((sid, cand)) + for old, mac in moves: + if old not in self: + continue + doc = self.pop(old) + if mac in self: + existing = dict(self[mac]) + for k, v in doc.items(): + if k not in existing or existing[k] in (None, "", []): + existing[k] = v + doc = existing + doc["id"] = mac + self[mac] = doc + changed = True + return changed + + def create( + self, + name="", + address=None, + mac=None, + default_pattern=None, + tabs=None, + device_type="led", + transport="espnow", + ): + dt = validate_device_type(device_type) + tr = validate_device_transport(transport) + mac_hex = derive_device_mac(mac=mac, address=address, transport=tr) + if not mac_hex: + raise ValueError( + "mac is required (12 hex characters); for Wi-Fi pass mac separately from IP address" + ) + if mac_hex in self: + raise ValueError("device with this mac already exists") + addr = normalize_address_for_transport(address, tr) + if tr == "espnow": + addr = mac_hex + self[mac_hex] = { + "id": mac_hex, "name": name, + "type": dt, + "transport": tr, "address": addr, "default_pattern": default_pattern if default_pattern else None, "tabs": list(tabs) if tabs else [], } self.save() - return next_id + return mac_hex def read(self, id): - id_str = str(id) - return self.get(id_str, None) + m = normalize_mac(id) + if m is not None and m in self: + return self.get(m) + return self.get(str(id), None) def update(self, id, data): - id_str = str(id) + id_str = normalize_mac(id) + if id_str is None: + id_str = str(id) if id_str not in self: return False - if "address" in data and data["address"] is not None: - data = dict(data) - data["address"] = _normalize_address(data["address"]) - self[id_str].update(data) + incoming = dict(data) + incoming.pop("id", None) + incoming.pop("addresses", None) + in_mac = normalize_mac(incoming.get("mac")) + if in_mac is not None and in_mac != id_str: + raise ValueError("cannot change device mac; delete and re-add") + incoming.pop("mac", None) + merged = dict(self[id_str]) + merged.update(incoming) + merged["type"] = validate_device_type(merged.get("type")) + merged["transport"] = validate_device_transport(merged.get("transport")) + tr = merged["transport"] + merged["address"] = normalize_address_for_transport(merged.get("address"), tr) + if tr == "espnow": + merged["address"] = id_str + merged["id"] = id_str + self[id_str] = merged self.save() return True def delete(self, id): - id_str = str(id) + id_str = normalize_mac(id) + if id_str is None: + id_str = str(id) if id_str not in self: return False self.pop(id_str) @@ -52,3 +232,39 @@ class Device(Model): def list(self): return list(self.keys()) + + def upsert_wifi_tcp_client(self, device_name, peer_ip, mac): + """ + Register or update a Wi-Fi client by **MAC** (storage id). Updates **name** + and **address** (peer IP) on each connect. + """ + mac_hex = normalize_mac(mac) + if not mac_hex: + return None + name = (device_name or "").strip() + if not name: + return None + ip = normalize_address_for_transport(peer_ip, "wifi") + if not ip: + return None + if mac_hex in self: + merged = dict(self[mac_hex]) + merged["name"] = name + merged["type"] = validate_device_type(merged.get("type")) + merged["transport"] = "wifi" + merged["address"] = ip + merged["id"] = mac_hex + self[mac_hex] = merged + self.save() + return mac_hex + self[mac_hex] = { + "id": mac_hex, + "name": name, + "type": "led", + "transport": "wifi", + "address": ip, + "default_pattern": None, + "tabs": [], + } + self.save() + return mac_hex diff --git a/src/settings.py b/src/settings.py index 89127d6..7bfa290 100644 --- a/src/settings.py +++ b/src/settings.py @@ -48,6 +48,11 @@ class Settings(dict): # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11 if 'wifi_channel' not in self: self['wifi_channel'] = 6 + # Wi-Fi LED drivers: newline-delimited JSON over TCP (see led-driver WiFi transport) + if 'tcp_enabled' not in self: + self['tcp_enabled'] = True + if 'tcp_port' not in self: + self['tcp_port'] = 8765 def save(self): try: diff --git a/src/static/devices.js b/src/static/devices.js index 567f420..15e06cb 100644 --- a/src/static/devices.js +++ b/src/static/devices.js @@ -1,4 +1,4 @@ -// Device management: list, create, edit, delete (name and 6-byte address) +// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address const HEX_BOX_COUNT = 12; @@ -42,12 +42,6 @@ function makeHexAddressBoxes(container) { } } -function getAddressFromBoxes(container) { - if (!container) return ''; - const boxes = container.querySelectorAll('.hex-addr-box'); - return Array.from(boxes).map((b) => b.value).join('').toLowerCase(); -} - function setAddressToBoxes(container, addrStr) { if (!container) return; const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); @@ -57,6 +51,27 @@ function setAddressToBoxes(container, addrStr) { }); } +function applyTransportVisibility(transport) { + const isWifi = transport === 'wifi'; + const esp = document.getElementById('edit-device-address-espnow'); + const wifiWrap = document.getElementById('edit-device-address-wifi-wrap'); + if (esp) esp.hidden = isWifi; + if (wifiWrap) wifiWrap.hidden = !isWifi; +} + +function getAddressForPayload(transport) { + if (transport === 'wifi') { + const el = document.getElementById('edit-device-address-wifi'); + const v = (el && el.value.trim()) || ''; + return v || null; + } + const boxEl = document.getElementById('edit-device-address-boxes'); + if (!boxEl) return null; + const boxes = boxEl.querySelectorAll('.hex-addr-box'); + const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase(); + return hex || null; +} + async function loadDevicesModal() { const container = document.getElementById('devices-list-modal'); if (!container) return; @@ -80,7 +95,7 @@ function renderDevicesList(devices) { if (ids.length === 0) { const p = document.createElement('p'); p.className = 'muted-text'; - p.textContent = 'No devices. Create one above.'; + p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.'; container.appendChild(p); return; } @@ -101,8 +116,10 @@ function renderDevicesList(devices) { const meta = document.createElement('span'); meta.className = 'muted-text'; meta.style.fontSize = '0.85em'; + const t = (dev && dev.type) || 'led'; + const tr = (dev && dev.transport) || 'espnow'; const addr = (dev && dev.address) ? dev.address : '—'; - meta.textContent = `Address: ${addr}`; + meta.textContent = `${t} · ${tr} · ${addr}`; const editBtn = document.createElement('button'); editBtn.className = 'btn btn-secondary btn-small'; @@ -115,7 +132,7 @@ function renderDevicesList(devices) { deleteBtn.addEventListener('click', async () => { if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return; try { - const res = await fetch(`/devices/${devId}`, { method: 'DELETE' }); + const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'DELETE' }); if (res.ok) await loadDevicesModal(); else { const data = await res.json().catch(() => ({})); @@ -138,42 +155,36 @@ function renderDevicesList(devices) { function openEditDeviceModal(devId, dev) { const modal = document.getElementById('edit-device-modal'); const idInput = document.getElementById('edit-device-id'); + const storageLabel = document.getElementById('edit-device-storage-id'); const nameInput = document.getElementById('edit-device-name'); + const typeSel = document.getElementById('edit-device-type'); + const transportSel = document.getElementById('edit-device-transport'); const addressBoxes = document.getElementById('edit-device-address-boxes'); + const wifiInput = document.getElementById('edit-device-address-wifi'); if (!modal || !idInput) return; idInput.value = devId; + if (storageLabel) storageLabel.textContent = devId; if (nameInput) nameInput.value = (dev && dev.name) || ''; - setAddressToBoxes(addressBoxes, (dev && dev.address) || ''); + if (typeSel) typeSel.value = (dev && dev.type) || 'led'; + const tr = (dev && dev.transport) || 'espnow'; + if (transportSel) transportSel.value = tr; + applyTransportVisibility(tr); + setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : ''); + if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : ''; modal.classList.add('active'); } -async function createDevice(name, address) { +async function updateDevice(devId, name, type, transport, address) { try { - const res = await fetch('/devices', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, address: address || null }), - }); - const data = await res.json().catch(() => ({})); - if (res.ok) { - await loadDevicesModal(); - return true; - } - alert(data.error || 'Failed to create device'); - return false; - } catch (e) { - console.error('createDevice:', e); - alert('Failed to create device'); - return false; - } -} - -async function updateDevice(devId, name, address) { - try { - const res = await fetch(`/devices/${devId}`, { + const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, address: address || null }), + body: JSON.stringify({ + name, + type: type || 'led', + transport: transport || 'espnow', + address, + }), }); const data = await res.json().catch(() => ({})); if (res.ok) { @@ -190,14 +201,18 @@ async function updateDevice(devId, name, address) { } document.addEventListener('DOMContentLoaded', () => { - makeHexAddressBoxes(document.getElementById('new-device-address-boxes')); makeHexAddressBoxes(document.getElementById('edit-device-address-boxes')); + const transportEdit = document.getElementById('edit-device-transport'); + if (transportEdit) { + transportEdit.addEventListener('change', () => { + applyTransportVisibility(transportEdit.value); + }); + } + const devicesBtn = document.getElementById('devices-btn'); const devicesModal = document.getElementById('devices-modal'); const devicesCloseBtn = document.getElementById('devices-close-btn'); - const newName = document.getElementById('new-device-name'); - const createBtn = document.getElementById('create-device-btn'); const editForm = document.getElementById('edit-device-form'); const editCloseBtn = document.getElementById('edit-device-close-btn'); const editDeviceModal = document.getElementById('edit-device-modal'); @@ -211,35 +226,23 @@ document.addEventListener('DOMContentLoaded', () => { if (devicesCloseBtn) { devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active')); } - const newAddressBoxes = document.getElementById('new-device-address-boxes'); - const doCreate = async () => { - const name = (newName && newName.value.trim()) || ''; - if (!name) { - alert('Device name is required.'); - return; - } - const address = newAddressBoxes ? getAddressFromBoxes(newAddressBoxes) : ''; - const ok = await createDevice(name, address); - if (ok && newName) { - newName.value = ''; - setAddressToBoxes(newAddressBoxes, ''); - } - }; - if (createBtn) createBtn.addEventListener('click', doCreate); - if (newName) newName.addEventListener('keypress', (e) => { if (e.key === 'Enter') doCreate(); }); if (editForm) { editForm.addEventListener('submit', async (e) => { e.preventDefault(); const idInput = document.getElementById('edit-device-id'); const nameInput = document.getElementById('edit-device-name'); - const addressBoxes = document.getElementById('edit-device-address-boxes'); + const typeSel = document.getElementById('edit-device-type'); + const transportSel = document.getElementById('edit-device-transport'); const devId = idInput && idInput.value; if (!devId) return; - const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : ''; + const transport = (transportSel && transportSel.value) || 'espnow'; + const address = getAddressForPayload(transport); const ok = await updateDevice( devId, nameInput ? nameInput.value.trim() : '', + (typeSel && typeSel.value) || 'led', + transport, address ); if (ok) editDeviceModal.classList.remove('active'); diff --git a/src/static/style.css b/src/static/style.css index 1e13a6f..540ad84 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -12,6 +12,72 @@ body { overflow: hidden; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.hex-address-row { + display: flex; + flex-wrap: wrap; + gap: 0.2rem; + align-items: center; +} + +input.hex-addr-box { + width: 1.35rem; + padding: 0.25rem 0.1rem; + text-align: center; + font-family: ui-monospace, monospace; + font-size: 0.85rem; +} + +.device-form-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: 0.75rem; + align-items: end; +} + +.device-field-label { + display: block; + font-size: 0.8rem; + color: #aaa; + margin-bottom: 0.25rem; +} + +.device-form-actions { + display: flex; + align-items: flex-end; +} + +#devices-modal select { + width: 100%; + max-width: 16rem; + padding: 0.35rem; + background-color: #2e2e2e; + border: 1px solid #4a4a4a; + border-radius: 4px; + color: white; +} + +#edit-device-modal select { + width: 100%; + max-width: 20rem; + padding: 0.35rem; + background-color: #2e2e2e; + border: 1px solid #4a4a4a; + border-radius: 4px; + color: white; +} + .app-container { display: flex; flex-direction: column; diff --git a/src/templates/index.html b/src/templates/index.html index b984799..01444cc 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -16,6 +16,7 @@
+ @@ -29,6 +30,7 @@
+ + + + +