feat(devices): wifi tcp registry, device API/UI, tests; bump led-tool

Made-with: Cursor
This commit is contained in:
pi
2026-04-05 21:13:07 +12:00
parent fbae75b957
commit e6b5bf2cf1
15 changed files with 825 additions and 103 deletions

View File

@@ -1 +1 @@
{} {"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": []}}

View File

@@ -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: The main UI has two modes controlled by the mode toggle:
- **Run mode**: optimized for operation (tab/preset selection and profile apply). - **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, and profile management actions). - **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: Profiles are available in both modes, but behavior differs:
@@ -70,6 +70,29 @@ Below, `<id>` values are string identifiers used by the JSON stores (numeric str
| POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. | | POST | `/settings/wifi/ap` | Body: `ssid` (required), `password`, `channel` (111). Persists AP-related settings. |
| GET | `/settings/page` | Serves `templates/settings.html` (same page as `GET /settings` from the root app, for convenience). | | 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 **`<id>`** (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/<id>` | One device, 404 if missing. |
| POST | `/devices` | Create. Body: **`name`** (required), **`type`** (default `led`), **`transport`** (default `espnow`), optional **`address`**, **`default_pattern`**, **`tabs`**. Returns `{ "<id>": { ... } }`, 201. |
| PUT | `/devices/<id>` | Partial update. **`name`** cannot be cleared. **`id`** in the body is ignored. **`type`** / **`transport`** validated; **`address`** normalised for the resulting transport. |
| DELETE | `/devices/<id>` | Remove device. |
### Profiles — `/profiles` ### Profiles — `/profiles`
| Method | Path | Description | | Method | Path | Description |

View File

@@ -1,5 +1,10 @@
from microdot import Microdot 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 import json
controller = Microdot() controller = Microdot()
@@ -23,7 +28,9 @@ async def get_device(request, id):
dev = devices.read(id) dev = devices.read(id)
if dev: if dev:
return json.dumps(dev), 200, {"Content-Type": "application/json"} 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("") @controller.post("")
@@ -32,37 +39,91 @@ async def create_device(request):
try: try:
data = request.json or {} data = request.json or {}
name = data.get("name", "").strip() 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") 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") default_pattern = data.get("default_pattern")
tabs = data.get("tabs") tabs = data.get("tabs")
if isinstance(tabs, list): if isinstance(tabs, list):
tabs = [str(t) for t in tabs] tabs = [str(t) for t in tabs]
else: else:
tabs = [] 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) dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"} 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: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.put("/<id>") @controller.put("/<id>")
async def update_device(request, id): async def update_device(request, id):
"""Update a device.""" """Update a device."""
try: 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): if "tabs" in data and isinstance(data["tabs"], list):
data["tabs"] = [str(t) for t in data["tabs"]] data["tabs"] = [str(t) for t in data["tabs"]]
if devices.update(id, data): if devices.update(id, data):
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"} 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: except Exception as e:
return json.dumps({"error": str(e)}), 400 return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
@controller.delete("/<id>") @controller.delete("/<id>")
async def delete_device(request, id): async def delete_device(request, id):
"""Delete a device.""" """Delete a device."""
if devices.delete(id): if devices.delete(id):
return json.dumps({"message": "Device deleted successfully"}), 200 return (
return json.dumps({"error": "Device not found"}), 404 json.dumps({"message": "Device deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}

View File

@@ -1,6 +1,8 @@
import asyncio import asyncio
import json import json
import os import os
import threading
import traceback
from microdot import Microdot, send_file from microdot import Microdot, send_file
from microdot.websocket import with_websocket from microdot.websocket import with_websocket
from microdot.session import Session from microdot.session import Session
@@ -15,7 +17,98 @@ import controllers.palette as palette
import controllers.scene as scene import controllers.scene as scene
import controllers.pattern as pattern import controllers.pattern as pattern
import controllers.settings as settings_controller 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): async def main(port=80):
@@ -55,6 +148,7 @@ async def main(port=80):
app.mount(scene.controller, '/scenes') app.mount(scene.controller, '/scenes')
app.mount(pattern.controller, '/patterns') app.mount(pattern.controller, '/patterns')
app.mount(settings_controller.controller, '/settings') 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) # Serve index.html at root (cwd is src/ when run via pipenv run run)
@app.route('/') @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)) 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: while True:
await asyncio.sleep(30) await asyncio.sleep(30)

View File

@@ -1,48 +1,228 @@
"""
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 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).""" def validate_device_type(value):
if addr is None: 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 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): if len(s) == 12 and all(c in "0123456789abcdef" for c in s):
return s return s
return None 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): class Device(Model):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
def create(self, name="", address=None, default_pattern=None, tabs=None): def load(self):
next_id = self.get_next_id() super().load()
addr = _normalize_address(address) changed = False
self[next_id] = { 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, "name": name,
"type": dt,
"transport": tr,
"address": addr, "address": addr,
"default_pattern": default_pattern if default_pattern else None, "default_pattern": default_pattern if default_pattern else None,
"tabs": list(tabs) if tabs else [], "tabs": list(tabs) if tabs else [],
} }
self.save() self.save()
return next_id return mac_hex
def read(self, id): def read(self, id):
id_str = str(id) m = normalize_mac(id)
return self.get(id_str, None) if m is not None and m in self:
return self.get(m)
return self.get(str(id), None)
def update(self, id, data): def update(self, id, data):
id_str = normalize_mac(id)
if id_str is None:
id_str = str(id) id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
if "address" in data and data["address"] is not None: incoming = dict(data)
data = dict(data) incoming.pop("id", None)
data["address"] = _normalize_address(data["address"]) incoming.pop("addresses", None)
self[id_str].update(data) 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() self.save()
return True return True
def delete(self, id): def delete(self, id):
id_str = normalize_mac(id)
if id_str is None:
id_str = str(id) id_str = str(id)
if id_str not in self: if id_str not in self:
return False return False
@@ -52,3 +232,39 @@ class Device(Model):
def list(self): def list(self):
return list(self.keys()) 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

View File

@@ -48,6 +48,11 @@ class Settings(dict):
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111 # ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
if 'wifi_channel' not in self: if 'wifi_channel' not in self:
self['wifi_channel'] = 6 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): def save(self):
try: try:

View File

@@ -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; 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) { function setAddressToBoxes(container, addrStr) {
if (!container) return; if (!container) return;
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT); 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() { async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal'); const container = document.getElementById('devices-list-modal');
if (!container) return; if (!container) return;
@@ -80,7 +95,7 @@ function renderDevicesList(devices) {
if (ids.length === 0) { if (ids.length === 0) {
const p = document.createElement('p'); const p = document.createElement('p');
p.className = 'muted-text'; 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); container.appendChild(p);
return; return;
} }
@@ -101,8 +116,10 @@ function renderDevicesList(devices) {
const meta = document.createElement('span'); const meta = document.createElement('span');
meta.className = 'muted-text'; meta.className = 'muted-text';
meta.style.fontSize = '0.85em'; meta.style.fontSize = '0.85em';
const t = (dev && dev.type) || 'led';
const tr = (dev && dev.transport) || 'espnow';
const addr = (dev && dev.address) ? dev.address : '—'; const addr = (dev && dev.address) ? dev.address : '—';
meta.textContent = `Address: ${addr}`; meta.textContent = `${t} · ${tr} · ${addr}`;
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
editBtn.className = 'btn btn-secondary btn-small'; editBtn.className = 'btn btn-secondary btn-small';
@@ -115,7 +132,7 @@ function renderDevicesList(devices) {
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return; if (!confirm(`Delete device "${(dev && dev.name) || devId}"?`)) return;
try { try {
const res = await fetch(`/devices/${devId}`, { method: 'DELETE' }); const res = await fetch(`/devices/${encodeURIComponent(devId)}`, { method: 'DELETE' });
if (res.ok) await loadDevicesModal(); if (res.ok) await loadDevicesModal();
else { else {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -138,42 +155,36 @@ function renderDevicesList(devices) {
function openEditDeviceModal(devId, dev) { function openEditDeviceModal(devId, dev) {
const modal = document.getElementById('edit-device-modal'); const modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id'); const idInput = document.getElementById('edit-device-id');
const storageLabel = document.getElementById('edit-device-storage-id');
const nameInput = document.getElementById('edit-device-name'); 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 addressBoxes = document.getElementById('edit-device-address-boxes');
const wifiInput = document.getElementById('edit-device-address-wifi');
if (!modal || !idInput) return; if (!modal || !idInput) return;
idInput.value = devId; idInput.value = devId;
if (storageLabel) storageLabel.textContent = devId;
if (nameInput) nameInput.value = (dev && dev.name) || ''; 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'); modal.classList.add('active');
} }
async function createDevice(name, address) { async function updateDevice(devId, name, type, transport, address) {
try { try {
const res = await fetch('/devices', { const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
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}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, 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(() => ({})); const data = await res.json().catch(() => ({}));
if (res.ok) { if (res.ok) {
@@ -190,14 +201,18 @@ async function updateDevice(devId, name, address) {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
makeHexAddressBoxes(document.getElementById('new-device-address-boxes'));
makeHexAddressBoxes(document.getElementById('edit-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 devicesBtn = document.getElementById('devices-btn');
const devicesModal = document.getElementById('devices-modal'); const devicesModal = document.getElementById('devices-modal');
const devicesCloseBtn = document.getElementById('devices-close-btn'); 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 editForm = document.getElementById('edit-device-form');
const editCloseBtn = document.getElementById('edit-device-close-btn'); const editCloseBtn = document.getElementById('edit-device-close-btn');
const editDeviceModal = document.getElementById('edit-device-modal'); const editDeviceModal = document.getElementById('edit-device-modal');
@@ -211,35 +226,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (devicesCloseBtn) { if (devicesCloseBtn) {
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active')); 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) { if (editForm) {
editForm.addEventListener('submit', async (e) => { editForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const idInput = document.getElementById('edit-device-id'); const idInput = document.getElementById('edit-device-id');
const nameInput = document.getElementById('edit-device-name'); 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; const devId = idInput && idInput.value;
if (!devId) return; if (!devId) return;
const address = addressBoxes ? getAddressFromBoxes(addressBoxes) : ''; const transport = (transportSel && transportSel.value) || 'espnow';
const address = getAddressForPayload(transport);
const ok = await updateDevice( const ok = await updateDevice(
devId, devId,
nameInput ? nameInput.value.trim() : '', nameInput ? nameInput.value.trim() : '',
(typeSel && typeSel.value) || 'led',
transport,
address address
); );
if (ok) editDeviceModal.classList.remove('active'); if (ok) editDeviceModal.classList.remove('active');

View File

@@ -12,6 +12,72 @@ body {
overflow: hidden; 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 { .app-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -16,6 +16,7 @@
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="devices-btn">Devices</button>
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button> <button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button> <button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button> <button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
@@ -29,6 +30,7 @@
<div id="main-menu-dropdown" class="main-menu-dropdown"> <div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button> <button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
<button type="button" data-target="profiles-btn">Profiles</button> <button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="devices-btn">Devices</button>
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button> <button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button> <button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button> <button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
@@ -105,6 +107,51 @@
</div> </div>
</div> </div>
<!-- Devices Modal (registry: Wi-Fi drivers appear when they connect over TCP) -->
<div id="devices-modal" class="modal">
<div class="modal-content">
<h2>Devices</h2>
<p class="muted-text">Wi-Fi LED drivers register over TCP when each hello line includes <code>device_name</code> and <code>mac</code> (12 hex). The registry key is the <strong>MAC</strong>; <strong>name</strong> is used in tabs and <code>select</code> (several devices may share the same name).</p>
<div id="devices-list-modal" class="profiles-list"></div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
</div>
</div>
</div>
<div id="edit-device-modal" class="modal">
<div class="modal-content">
<h2>Edit device</h2>
<form id="edit-device-form">
<input type="hidden" id="edit-device-id">
<p class="muted-text" style="margin-bottom:0.75rem;">MAC (id): <code id="edit-device-storage-id"></code></p>
<label for="edit-device-name">Name</label>
<input type="text" id="edit-device-name" required autocomplete="off">
<label for="edit-device-type" style="margin-top:0.75rem;display:block;">Type</label>
<select id="edit-device-type">
<option value="led">LED</option>
</select>
<label for="edit-device-transport" style="margin-top:0.75rem;display:block;">Transport</label>
<select id="edit-device-transport">
<option value="espnow">ESP-NOW</option>
<option value="wifi">WiFi</option>
</select>
<div id="edit-device-address-espnow" style="margin-top:0.75rem;">
<label class="device-field-label">MAC (12 hex, optional)</label>
<div id="edit-device-address-boxes" class="hex-address-row" aria-label="MAC address"></div>
</div>
<div id="edit-device-address-wifi-wrap" style="margin-top:0.75rem;" hidden>
<label for="edit-device-address-wifi">Address (IP or hostname)</label>
<input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" id="edit-device-close-btn">Cancel</button>
</div>
</form>
</div>
</div>
<!-- Presets Modal --> <!-- Presets Modal -->
<div id="presets-modal" class="modal"> <div id="presets-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
@@ -226,6 +273,7 @@
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li> <li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
<li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li> <li><strong>Select preset</strong>: left-click a preset tile to send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li> <li><strong>Profiles</strong>: open <strong>Profiles</strong> to apply a profile. Profile editing actions are hidden in Run mode.</li>
<li><strong>Devices</strong>: open <strong>Devices</strong> to see drivers (Wi-Fi clients appear when they connect); edit or remove rows as needed.</li>
<li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li> <li><strong>Send all presets</strong>: this action is available in <strong>Edit mode</strong> and pushes every preset used in the current tab to all tab devices.</li>
<li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li> <li><strong>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
</ul> </ul>
@@ -237,6 +285,7 @@
<li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li> <li><strong>Preset tiles</strong>: each tile shows <strong>Edit</strong> and <strong>Remove</strong> controls in Edit mode.</li>
<li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li> <li><strong>Reorder presets</strong>: drag and drop preset tiles to save tab order.</li>
<li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li> <li><strong>Profiles</strong>: create/clone/delete profiles. New profiles get a populated <strong>default</strong> tab and can optionally seed a <strong>DJ tab</strong>.</li>
<li><strong>Devices</strong>: view, edit, or remove registry entries (tabs use <strong>names</strong>; each row is keyed by <strong>MAC</strong>).</li>
<li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li> <li><strong>Colour Palette</strong>: build profile colours and use <strong>From Palette</strong> in preset editor to add linked colours (badge <strong>P</strong>) that update when palette colours change.</li>
</ul> </ul>
@@ -319,6 +368,7 @@
<script src="/static/help.js"></script> <script src="/static/help.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/devices.js"></script>
<script src="/static/tab_palette.js"></script> <script src="/static/tab_palette.js"></script>
<script src="/static/patterns.js"></script> <script src="/static/patterns.js"></script>
<script src="/static/presets.js"></script> <script src="/static/presets.js"></script>

View File

@@ -5,7 +5,9 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src" SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib" LIB_PATH = PROJECT_ROOT / "lib"
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)): # Last insert(0) wins: order must be (root, lib, src) so src/models wins over
# tests/models (same package name "models" on sys.path when pytest imports tests).
for p in (str(PROJECT_ROOT), str(LIB_PATH), str(SRC_PATH)):
if p in sys.path: if p in sys.path:
sys.path.remove(p) sys.path.remove(p)
sys.path.insert(0, p) sys.path.insert(0, p)

View File

@@ -1,36 +1,67 @@
from models.device import Device
import os import os
import sys
from pathlib import Path
def test_device(): # Prefer src/models; pytest may have registered tests/models as top-level ``models``.
"""Test Device model CRUD operations.""" _src = Path(__file__).resolve().parents[2] / "src"
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db") _sp = str(_src)
if _sp in sys.path:
sys.path.remove(_sp)
sys.path.insert(0, _sp)
_m = sys.modules.get("models")
if _m is not None:
mf = (getattr(_m, "__file__", "") or "").replace("\\", "/")
if "/tests/models" in mf:
del sys.modules["models"]
from models.device import Device
def _fresh_device():
"""New empty device DB and new Device singleton (tests only)."""
db_dir = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db"
)
device_file = os.path.join(db_dir, "device.json") device_file = os.path.join(db_dir, "device.json")
if os.path.exists(device_file): if os.path.exists(device_file):
os.remove(device_file) os.remove(device_file)
if hasattr(Device, "_instance"):
del Device._instance
return Device()
devices = Device()
def test_device():
"""Test Device model CRUD operations (id = MAC)."""
devices = _fresh_device()
mac = "aabbccddeeff"
print("Testing create device") print("Testing create device")
device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"]) device_id = devices.create("Test Device", address="aa:bb:cc:dd:ee:ff", default_pattern="on", tabs=["1", "2"])
print(f"Created device with ID: {device_id}") print(f"Created device with ID: {device_id}")
assert device_id is not None assert device_id == mac
assert device_id in devices assert device_id in devices
print("\nTesting read device") print("\nTesting read device")
device = devices.read(device_id) device = devices.read(device_id)
print(f"Read: {device}") print(f"Read: {device}")
assert device is not None assert device is not None
assert device["id"] == mac
assert device["name"] == "Test Device" assert device["name"] == "Test Device"
assert device["address"] == "aabbccddeeff" assert device["type"] == "led"
assert device["transport"] == "espnow"
assert device["address"] == mac
assert device["default_pattern"] == "on" assert device["default_pattern"] == "on"
assert device["tabs"] == ["1", "2"] assert device["tabs"] == ["1", "2"]
print("\nTesting address normalization") print("\nTesting read by colon MAC")
assert devices.read("aa:bb:cc:dd:ee:ff")["id"] == mac
print("\nTesting address normalization on update (espnow keeps MAC as address)")
devices.update(device_id, {"address": "11:22:33:44:55:66"}) devices.update(device_id, {"address": "11:22:33:44:55:66"})
updated = devices.read(device_id) updated = devices.read(device_id)
assert updated["address"] == "112233445566" assert updated["address"] == mac
print("\nTesting update device") print("\nTesting update device fields")
update_data = { update_data = {
"name": "Updated Device", "name": "Updated Device",
"default_pattern": "rainbow", "default_pattern": "rainbow",
@@ -46,12 +77,12 @@ def test_device():
print("\nTesting list devices") print("\nTesting list devices")
device_list = devices.list() device_list = devices.list()
print(f"Device list: {device_list}") print(f"Device list: {device_list}")
assert device_id in device_list assert mac in device_list
print("\nTesting delete device") print("\nTesting delete device")
deleted = devices.delete(device_id) deleted = devices.delete(device_id)
assert deleted is True assert deleted is True
assert device_id not in devices assert mac not in devices
print("\nTesting read after delete") print("\nTesting read after delete")
device = devices.read(device_id) device = devices.read(device_id)
@@ -60,5 +91,65 @@ def test_device():
print("\nAll device tests passed!") print("\nAll device tests passed!")
def test_upsert_wifi_tcp_client():
devices = _fresh_device()
assert devices.upsert_wifi_tcp_client("", "192.168.1.10", None) is None
assert devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", "bad") is None
m1 = "001122334455"
m2 = "001122334466"
i1 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.20", m1)
assert i1 == m1
d = devices.read(i1)
assert d["name"] == "kitchen"
assert d["transport"] == "wifi"
assert d["address"] == "192.168.1.20"
i2 = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.21", m2)
assert i2 == m2
assert devices.read(m1)["address"] == "192.168.1.20"
assert devices.read(m2)["address"] == "192.168.1.21"
assert devices.read(m1)["name"] == devices.read(m2)["name"] == "kitchen"
again = devices.upsert_wifi_tcp_client("kitchen", "192.168.1.99", m1)
assert again == m1
assert devices.read(m1)["address"] == "192.168.1.99"
i3 = devices.upsert_wifi_tcp_client("hall", "10.0.0.5", "deadbeefcafe")
assert i3 == "deadbeefcafe"
assert len(devices.list()) == 3
def test_device_can_change_address():
devices = _fresh_device()
m = "feedfacec0de"
did = devices.create("mover", mac=m, address="192.168.1.1", transport="wifi")
assert did == m
devices.update(did, {"address": "10.0.0.99"})
assert devices.read(did)["address"] == "10.0.0.99"
def test_device_duplicate_names_allowed():
devices = _fresh_device()
a1 = devices.create("alpha", address="aa:bb:cc:dd:ee:ff")
a2 = devices.create("alpha", address="11:22:33:44:55:66")
assert a1 != a2
assert devices.read(a1)["name"] == devices.read(a2)["name"] == "alpha"
def test_device_duplicate_mac_rejected():
devices = _fresh_device()
devices.create("one", address="aa:bb:cc:dd:ee:ff")
try:
devices.create("two", address="aa-bb-cc-dd-ee-ff")
assert False, "expected ValueError"
except ValueError as e:
assert "already exists" in str(e).lower()
if __name__ == "__main__": if __name__ == "__main__":
test_device() test_device()
test_upsert_wifi_tcp_client()
test_device_can_change_address()
test_device_duplicate_names_allowed()
test_device_duplicate_mac_rejected()

View File

@@ -4,9 +4,14 @@ Simple TCP test server for led-controller.
Listens on the same TCP port used by led-driver WiFi transport and Listens on the same TCP port used by led-driver WiFi transport and
every 5 seconds sends a newline-delimited JSON message with v="1". every 5 seconds sends a newline-delimited JSON message with v="1".
Clients talking to the real Pi registry should send a first line JSON object
that includes device_name and mac (12 hex) so the controller can register
the device by MAC.
""" """
import asyncio import asyncio
import contextlib
import json import json
import os import os
import sys import sys
@@ -204,8 +209,6 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
import contextlib
try: try:
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -124,6 +124,7 @@ def server(monkeypatch, tmp_path_factory):
import models.scene as models_scene # noqa: E402 import models.scene as models_scene # noqa: E402
import models.pattern as models_pattern # noqa: E402 import models.pattern as models_pattern # noqa: E402
import models.squence as models_sequence # noqa: E402 import models.squence as models_sequence # noqa: E402
import models.device as models_device # noqa: E402
for cls in ( for cls in (
models_preset.Preset, models_preset.Preset,
@@ -134,6 +135,7 @@ def server(monkeypatch, tmp_path_factory):
models_scene.Scene, models_scene.Scene,
models_pattern.Pattern, models_pattern.Pattern,
models_sequence.Sequence, models_sequence.Sequence,
models_device.Device,
): ):
if hasattr(cls, "_instance"): if hasattr(cls, "_instance"):
delattr(cls, "_instance") delattr(cls, "_instance")
@@ -167,6 +169,7 @@ def server(monkeypatch, tmp_path_factory):
"controllers.scene", "controllers.scene",
"controllers.pattern", "controllers.pattern",
"controllers.settings", "controllers.settings",
"controllers.device",
): ):
sys.modules.pop(mod_name, None) sys.modules.pop(mod_name, None)
@@ -180,6 +183,7 @@ def server(monkeypatch, tmp_path_factory):
import controllers.scene as scene_ctl # noqa: E402 import controllers.scene as scene_ctl # noqa: E402
import controllers.pattern as pattern_ctl # noqa: E402 import controllers.pattern as pattern_ctl # noqa: E402
import controllers.settings as settings_ctl # noqa: E402 import controllers.settings as settings_ctl # noqa: E402
import controllers.device as device_ctl # noqa: E402
# Configure transport sender used by /presets/send. # Configure transport sender used by /presets/send.
from models.transport import set_sender # noqa: E402 from models.transport import set_sender # noqa: E402
@@ -206,6 +210,7 @@ def server(monkeypatch, tmp_path_factory):
app.mount(scene_ctl.controller, "/scenes") app.mount(scene_ctl.controller, "/scenes")
app.mount(pattern_ctl.controller, "/patterns") app.mount(pattern_ctl.controller, "/patterns")
app.mount(settings_ctl.controller, "/settings") app.mount(settings_ctl.controller, "/settings")
app.mount(device_ctl.controller, "/devices")
@app.route("/") @app.route("/")
def index(request): def index(request):
@@ -562,6 +567,106 @@ def test_groups_sequences_scenes_palettes_patterns_endpoints(server):
resp = c.delete(f"{base_url}/palettes/{palette_id}") resp = c.delete(f"{base_url}/palettes/{palette_id}")
assert resp.status_code == 200 assert resp.status_code == 200
# Devices (LED driver registry).
resp = c.get(f"{base_url}/devices")
assert resp.status_code == 200
assert resp.json() == {}
resp = c.post(f"{base_url}/devices", json={})
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "pytest-dev", "address": "aa:bb:cc:dd:ee:ff"},
)
assert resp.status_code == 201
dev_map = resp.json()
dev_id = next(iter(dev_map.keys()))
assert dev_id == "aabbccddeeff"
assert dev_map[dev_id]["name"] == "pytest-dev"
assert dev_map[dev_id]["id"] == dev_id
assert dev_map[dev_id]["type"] == "led"
assert dev_map[dev_id]["transport"] == "espnow"
assert dev_map[dev_id]["address"] == "aabbccddeeff"
resp = c.get(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
assert resp.json()["name"] == "pytest-dev"
assert resp.json()["type"] == "led"
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"type": "led",
"transport": "wifi",
"address": "192.168.50.10",
"mac": "102030405060",
},
)
assert resp.status_code == 201
wid = "102030405060"
assert wid in resp.json()
assert resp.json()[wid]["transport"] == "wifi"
assert resp.json()[wid]["address"] == "192.168.50.10"
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi",
"transport": "wifi",
"address": "192.168.50.11",
"mac": "102030405061",
},
)
assert resp.status_code == 201
wid2 = "102030405061"
assert wid2 in resp.json()
assert resp.json()[wid2]["name"] == "pytest-wifi"
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi-dupmac",
"transport": "wifi",
"address": "192.168.50.99",
"mac": "102030405060",
},
)
assert resp.status_code == 409
resp = c.post(
f"{base_url}/devices",
json={"name": "no-mac-wifi", "transport": "wifi", "address": "192.168.50.12"},
)
assert resp.status_code == 400
resp = c.post(
f"{base_url}/devices",
json={"name": "bad-tr", "transport": "serial"},
)
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": " "})
assert resp.status_code == 400
resp = c.put(f"{base_url}/devices/{dev_id}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.put(f"{base_url}/devices/{wid}", json={"name": "renamed"})
assert resp.status_code == 200
assert resp.json()["name"] == "renamed"
resp = c.delete(f"{base_url}/devices/{wid2}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{wid}")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/devices/{dev_id}")
assert resp.status_code == 200
# Patterns. # Patterns.
resp = c.get(f"{base_url}/patterns/definitions") resp = c.get(f"{base_url}/patterns/definitions")
assert resp.status_code == 200 assert resp.status_code == 200