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:
|
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` (1–11). Persists AP-related settings. |
|
| 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). |
|
| 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 |
|
||||||
|
|||||||
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 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",
|
||||||
|
}
|
||||||
|
|||||||
99
src/main.py
99
src/main.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ class Settings(dict):
|
|||||||
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 1–11
|
||||||
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:
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user