feat(devices): wifi tcp registry, device API/UI, tests; bump led-tool
Made-with: Cursor
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user