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

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

View File

@@ -1,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",
}

View File

@@ -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)

View File

@@ -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

View File

@@ -48,6 +48,11 @@ class Settings(dict):
# ESP-NOW STA channel (2.4 GHz) for LED drivers / bridge alignment; 111
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:

View File

@@ -1,4 +1,4 @@
// Device management: list, create, edit, delete (name and 6-byte address)
// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address
const HEX_BOX_COUNT = 12;
@@ -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');

View File

@@ -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;

View File

@@ -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>