feat(devices): wifi tcp registry, device API/UI, tests; bump led-tool
Made-with: Cursor
This commit is contained in:
@@ -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": []}}
|
||||
27
docs/API.md
27
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, `<id>` 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 **`<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`
|
||||
|
||||
| Method | Path | Description |
|
||||
|
||||
Submodule led-driver updated: dc19877132...7e3aca491c
2
led-tool
2
led-tool
Submodule led-tool updated: 3844aa9d6a...e86312437c
@@ -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("/<id>")
|
||||
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("/<id>")
|
||||
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",
|
||||
}
|
||||
|
||||
99
src/main.py
99
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<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="presets-btn">Presets</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">
|
||||
<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="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="presets-btn">Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||
@@ -105,6 +107,51 @@
|
||||
</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 -->
|
||||
<div id="presets-modal" class="modal">
|
||||
<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 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>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>Switch modes</strong>: use the mode button in the menu. The button label shows the mode you will switch to.</li>
|
||||
</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>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>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>
|
||||
</ul>
|
||||
|
||||
@@ -319,6 +368,7 @@
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/color_palette.js"></script>
|
||||
<script src="/static/profiles.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
<script src="/static/tab_palette.js"></script>
|
||||
<script src="/static/patterns.js"></script>
|
||||
<script src="/static/presets.js"></script>
|
||||
|
||||
@@ -5,7 +5,9 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC_PATH = PROJECT_ROOT / "src"
|
||||
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:
|
||||
sys.path.remove(p)
|
||||
sys.path.insert(0, p)
|
||||
|
||||
@@ -1,36 +1,67 @@
|
||||
from models.device import Device
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def test_device():
|
||||
"""Test Device model CRUD operations."""
|
||||
db_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "db")
|
||||
# Prefer src/models; pytest may have registered tests/models as top-level ``models``.
|
||||
_src = Path(__file__).resolve().parents[2] / "src"
|
||||
_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")
|
||||
if os.path.exists(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")
|
||||
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}")
|
||||
assert device_id is not None
|
||||
assert device_id == mac
|
||||
assert device_id in devices
|
||||
|
||||
print("\nTesting read device")
|
||||
device = devices.read(device_id)
|
||||
print(f"Read: {device}")
|
||||
assert device is not None
|
||||
assert device["id"] == mac
|
||||
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["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"})
|
||||
updated = devices.read(device_id)
|
||||
assert updated["address"] == "112233445566"
|
||||
assert updated["address"] == mac
|
||||
|
||||
print("\nTesting update device")
|
||||
print("\nTesting update device fields")
|
||||
update_data = {
|
||||
"name": "Updated Device",
|
||||
"default_pattern": "rainbow",
|
||||
@@ -46,12 +77,12 @@ def test_device():
|
||||
print("\nTesting list devices")
|
||||
device_list = devices.list()
|
||||
print(f"Device list: {device_list}")
|
||||
assert device_id in device_list
|
||||
assert mac in device_list
|
||||
|
||||
print("\nTesting delete device")
|
||||
deleted = devices.delete(device_id)
|
||||
assert deleted is True
|
||||
assert device_id not in devices
|
||||
assert mac not in devices
|
||||
|
||||
print("\nTesting read after delete")
|
||||
device = devices.read(device_id)
|
||||
@@ -60,5 +91,65 @@ def test_device():
|
||||
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__":
|
||||
test_device()
|
||||
test_upsert_wifi_tcp_client()
|
||||
test_device_can_change_address()
|
||||
test_device_duplicate_names_allowed()
|
||||
test_device_duplicate_mac_rejected()
|
||||
|
||||
@@ -4,9 +4,14 @@ Simple TCP test server for led-controller.
|
||||
|
||||
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".
|
||||
|
||||
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 contextlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -204,8 +209,6 @@ async def main():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import contextlib
|
||||
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -124,6 +124,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
import models.scene as models_scene # noqa: E402
|
||||
import models.pattern as models_pattern # noqa: E402
|
||||
import models.squence as models_sequence # noqa: E402
|
||||
import models.device as models_device # noqa: E402
|
||||
|
||||
for cls in (
|
||||
models_preset.Preset,
|
||||
@@ -134,6 +135,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
models_scene.Scene,
|
||||
models_pattern.Pattern,
|
||||
models_sequence.Sequence,
|
||||
models_device.Device,
|
||||
):
|
||||
if hasattr(cls, "_instance"):
|
||||
delattr(cls, "_instance")
|
||||
@@ -167,6 +169,7 @@ def server(monkeypatch, tmp_path_factory):
|
||||
"controllers.scene",
|
||||
"controllers.pattern",
|
||||
"controllers.settings",
|
||||
"controllers.device",
|
||||
):
|
||||
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.pattern as pattern_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.
|
||||
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(pattern_ctl.controller, "/patterns")
|
||||
app.mount(settings_ctl.controller, "/settings")
|
||||
app.mount(device_ctl.controller, "/devices")
|
||||
|
||||
@app.route("/")
|
||||
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}")
|
||||
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.
|
||||
resp = c.get(f"{base_url}/patterns/definitions")
|
||||
assert resp.status_code == 200
|
||||
|
||||
Reference in New Issue
Block a user