refactor(api): complete fastapi migration and related features

Finish native FastAPI controllers, drop vendored microdot, and add
Wi-Fi driver runtime, beat SSE, simulated BPM, sequence playback
improvements, bridge ESP-NOW sources, UI updates, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 22:55:28 +12:00
parent cb9758b97b
commit ace5770b3a
73 changed files with 4540 additions and 4487 deletions

View File

@@ -1,16 +1,16 @@
"""Application factory: Microdot routes and shared runtime startup."""
"""Application factory: FastAPI routes and shared runtime startup."""
from __future__ import annotations
import asyncio
import hashlib
import json
import os
import secrets
from typing import Any, Optional
from microdot import Microdot, send_file
from microdot.session import Session
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from fastapi.staticfiles import StaticFiles
from settings import WIFI_CHANNEL_DEFAULT, get_settings
import controllers.preset as preset
@@ -25,6 +25,8 @@ import controllers.settings as settings_controller
import controllers.device as device_controller
import controllers.led_tool as led_tool_controller
import controllers.wifi_bridge as wifi_bridge_controller
from http_responses import send_file, send_html_file
from http_session import SessionMiddleware
from models.transport import (
BridgeSerialTransport,
BridgeWsTransport,
@@ -37,6 +39,9 @@ from models.bridge_ws_client import init_bridge_client
from util.espnow_registry import handle_bridge_uplink
from util.bridge_runtime import set_bridge_uplink_handler
from util.audio_detector import AudioBeatDetector
from util.wifi_driver_runtime import start_wifi_driver_runtime, stop_wifi_driver_runtime
_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
def live_reload_enabled() -> bool:
@@ -60,7 +65,7 @@ def dev_client_revision() -> Optional[str]:
"""Revision of static/template assets (changes when UI files are saved)."""
if not live_reload_enabled():
return None
base = os.path.dirname(os.path.abspath(__file__))
base = _SRC_DIR
parts: list[str] = []
for sub in ("static", "templates"):
root = os.path.join(base, sub)
@@ -82,71 +87,42 @@ def dev_client_revision() -> Optional[str]:
return digest[:16]
def create_microdot_app(*, inject_live_reload: bool = False) -> Microdot:
"""Build the Microdot app with mounted controllers and static routes."""
settings = get_settings()
app = Microdot()
secret_key = settings.get(
"session_secret_key", "led-controller-secret-key-change-in-production"
def mount_controller_routers(app: FastAPI) -> None:
"""Register all controller API routers."""
app.include_router(preset.router, prefix="/presets", tags=["presets"])
app.include_router(profile.router, prefix="/profiles", tags=["profiles"])
app.include_router(group.router, prefix="/groups", tags=["groups"])
app.include_router(sequence.router, prefix="/sequences", tags=["sequences"])
app.include_router(zone.router, prefix="/zones", tags=["zones"])
app.include_router(palette.router, prefix="/palettes", tags=["palettes"])
app.include_router(scene.router, prefix="/scenes", tags=["scenes"])
app.include_router(pattern.router, prefix="/patterns", tags=["patterns"])
app.include_router(settings_controller.router, prefix="/settings", tags=["settings"])
app.include_router(
wifi_bridge_controller.router, prefix="/settings/wifi", tags=["wifi"]
)
Session(app, secret_key=secret_key)
app.include_router(device_controller.router, prefix="/devices", tags=["devices"])
app.include_router(led_tool_controller.router, prefix="/led-tool", tags=["led-tool"])
app.mount(preset.controller, "/presets")
app.mount(profile.controller, "/profiles")
app.mount(group.controller, "/groups")
app.mount(sequence.controller, "/sequences")
app.mount(zone.controller, "/zones")
app.mount(palette.controller, "/palettes")
app.mount(scene.controller, "/scenes")
app.mount(pattern.controller, "/patterns")
app.mount(settings_controller.controller, "/settings")
app.mount(wifi_bridge_controller.controller, "/settings/wifi")
app.mount(device_controller.controller, "/devices")
app.mount(led_tool_controller.controller, "/led-tool")
def mount_static_routes(app: FastAPI, *, inject_live_reload: bool = False) -> None:
"""Index page, favicon, and static assets."""
build_id = dev_build_id() if inject_live_reload else None
if build_id:
live_tag = '<script src="/static/dev-live-reload.js" defer></script>'
@app.route("/__dev/build-id")
def dev_build_id_route(request):
_ = request
return (
build_id,
200,
{
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
},
)
@app.route("/")
def index(request):
_ = request
@app.get("/")
async def index():
if build_id:
try:
with open("templates/index.html", encoding="utf-8") as f:
html = f.read()
tag = '<script src="/static/dev-live-reload.js" defer></script>'
if "</body>" in html:
html = html.replace("</body>", tag + "\n</body>", 1)
return html, 200, {"Content-Type": "text/html; charset=utf-8"}
except OSError:
pass
return send_html_file("templates/index.html", inject=live_tag)
return send_file("templates/index.html")
@app.route("/favicon.ico")
def favicon(request):
_ = request
return "", 204
@app.get("/favicon.ico")
async def favicon():
return PlainTextResponse("", status_code=204)
@app.route("/static/<path:path>")
def static_handler(request, path):
if ".." in path:
return "Not found", 404
return send_file("static/" + path)
return app
static_dir = os.path.join(_SRC_DIR, "static")
if os.path.isdir(static_dir):
app.mount("/static", StaticFiles(directory=static_dir), name="static")
class AppRuntime:
@@ -226,24 +202,43 @@ class AppRuntime:
from util import beat_driver_route
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop())
from util import beat_status_broadcaster as beat_sse
from util import sequence_playback as seq_pb
loop = asyncio.get_running_loop()
beat_sse.configure(
loop=loop,
status_builder=lambda: audio_status_payload(
self.audio_detector, self.settings
),
)
seq_pb.ensure_beat_consumer_started()
Device()
if not test_mode:
await start_wifi_driver_runtime(self.settings)
async def shutdown(self) -> None:
try:
await stop_wifi_driver_runtime()
except Exception:
pass
try:
self.audio_detector.stop()
except Exception:
pass
try:
from util import beat_status_broadcaster as beat_sse
await beat_sse.shutdown()
except Exception:
pass
try:
from util import sequence_playback as seq_pb
seq_pb.stop()
for attr in ("_pending_beat_task", "_sim_beat_task"):
t = getattr(seq_pb, attr, None)
if t is not None and not t.done():
t.cancel()
t = getattr(seq_pb, "_background_beat_task", None)
if t is not None and not t.done():
t.cancel()
except Exception:
pass
@@ -259,10 +254,15 @@ def audio_status_payload(
st["sequence"] = sequence_playback.playback_status()
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
seq = st.get("sequence")
running = bool(st.get("running"))
beat_readout = ""
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
beat_readout = str(seq.get("beat_readout") or "").strip()
elif st.get("running"):
if not beat_readout:
tail = sequence_playback.last_completed_beat_readout()
if tail:
beat_readout = tail
if not beat_readout and st.get("running"):
mb = st.get("manual_beat_stride")
if isinstance(mb, dict) and mb.get("active"):
try:
@@ -293,6 +293,28 @@ def audio_status_payload(
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
if seq_wait not in ("beat", "downbeat"):
seq_wait = "beat"
st["sequence_switch_wait"] = seq_wait
st["sequence_switch_wait_saved"] = seq_wait
from util.sequence_playback import effective_sequence_switch_wait
st["sequence_switch_wait"] = effective_sequence_switch_wait()
st["audio_run"] = read_audio_run_state()
from util.bpm_limits import clamp_bpm
sim_bpm = int(clamp_bpm(settings.get("audio_simulated_bpm")))
st["audio_simulated_bpm"] = sim_bpm
st["sequence_pending"] = sequence_playback.pending_play_status()
from util import audio_detector as audio_detector_module
st["bpm_simulated"] = not audio_detector_module.shared_beat_detector_timing_sequences()
if running and st.get("bpm") is not None:
st["bpm"] = float(clamp_bpm(st["bpm"]))
if not running:
st["bpm"] = float(sim_bpm)
st["simulated_beat_tick"] = sequence_playback.simulated_beat_tick()
if not running:
phase = sequence_playback.simulated_beat_phase_snapshot()
st["bar_beat"] = phase.get("bar_beat")
st["is_downbeat"] = bool(phase.get("is_downbeat"))
st["bar_phase_readout"] = str(phase.get("bar_phase_readout") or "")
st["phase_confidence"] = 0.0
return st

View File

@@ -1,4 +1,7 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.device import (
Device,
derive_device_mac,
@@ -46,7 +49,7 @@ def _compact_v1_json(*, presets=None, select=None, save=False):
body["save"] = True
if select is not None:
body["select"] = select
return json.dumps(body, separators=(",", ":"))
return J(body, separators=(",", ":"))
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
IDENTIFY_OFF_DELAY_S = 2.0
@@ -69,7 +72,7 @@ def _brightness_save_message_json(b_val: int) -> str:
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
controller = Microdot()
router = APIRouter()
devices = Device()
_group_registry = Group()
_pi_settings = get_settings()
@@ -246,38 +249,34 @@ async def send_identify_to_group_devices(
return len(seen), errors
@controller.get("")
async def list_devices(request):
@router.get("/")
async def list_devices(request: Request):
"""List all devices (includes ``connected`` for live Wi-Fi WebSocket presence)."""
devices_data = {}
for dev_id in devices.list():
d = devices.read(dev_id)
if d:
devices_data[dev_id] = _device_json_with_live_status(d)
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
@controller.post("/resolve-brightness")
async def resolve_brightness_batch(request):
return J(devices_data, 200)
@router.post("/resolve-brightness")
async def resolve_brightness_batch(request: Request):
"""
POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0255 }``.
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
"""
try:
data = request.json or {}
data = await read_json(request)
except Exception:
data = {}
macs = data.get("macs")
if not isinstance(macs, list):
return json.dumps({"error": "macs must be an array"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "macs must be an array"}, 400)
zb = None
if isinstance(data, dict) and data.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(data.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
values = {}
for raw in macs:
m = normalize_mac(str(raw))
@@ -290,47 +289,37 @@ async def resolve_brightness_batch(request):
m,
zone_brightness=zb,
)
return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}
return J({"values": values}, 200)
@controller.get("/<id>")
async def get_device(request, id):
@router.get("/{id}")
async def get_device(request: Request, id):
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
dev = devices.read(id)
if dev:
return json.dumps(_device_json_with_live_status(dev)), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
return J(_device_json_with_live_status(dev), 200)
return J({"error": "Device not found"}, 404)
@controller.post("")
async def create_device(request):
@router.post("/")
async def create_device(request: Request):
"""Create a new device."""
try:
data = request.json or {}
data = await read_json(request)
name = data.get("name", "").strip()
if not name:
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "name is required"}, 400)
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",
}
return J({"error": str(e)}, 400)
address = data.get("address")
mac = data.get("mac")
if derive_device_mac(mac=mac, address=address, transport=transport) is None:
return json.dumps(
{
return J({
"error": "mac is required (12 hex digits); for Wi-Fi include mac plus IP in address"
}
), 400, {"Content-Type": "application/json"}
}, 400)
default_pattern = data.get("default_pattern")
zl = data.get("zones")
if isinstance(zl, list):
@@ -347,20 +336,20 @@ async def create_device(request):
transport=transport,
)
dev = devices.read(dev_id)
return json.dumps({dev_id: dev}), 201, {"Content-Type": "application/json"}
return J({dev_id: dev}, 201)
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"}
return J({"error": msg}, code)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
@controller.put("/<id>")
async def update_device(request, id):
@router.put("/{id}")
async def update_device(request: Request, id):
"""Update a device."""
try:
raw = request.json or {}
raw = await read_json(request)
data = dict(raw)
data.pop("id", None)
data.pop("addresses", None)
@@ -368,9 +357,7 @@ async def update_device(request, id):
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",
}
return J({"error": "name cannot be empty"}, 400)
data["name"] = n
if "type" in data:
data["type"] = validate_device_type(data.get("type"))
@@ -389,32 +376,24 @@ async def update_device(request, id):
from util.beat_driver_route import remap_beat_route_device_name
remap_beat_route_device_name(on, nn)
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
return J(devices.read(id), 200)
return J({"error": "Device not found"}, 404)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
@controller.delete("/<id>")
async def delete_device(request, id):
@router.delete("/{id}")
async def delete_device(request: Request, id):
"""Delete a device."""
if devices.delete(id):
return (
json.dumps({"message": "Device deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
return J({"message": "Device deleted successfully"}, 200)
return J({"error": "Device not found"}, 404)
@controller.post("/groups")
async def update_device_groups(request):
@router.post("/groups")
async def update_device_groups(request: Request):
"""Push current group membership to all ESP-NOW drivers in the registry."""
_ = request
from util.espnow_registry import push_groups_all_espnow_devices
@@ -422,16 +401,12 @@ async def update_device_groups(request):
result = await push_groups_all_espnow_devices()
status = 200 if result.get("ok") else 503
if not result.get("total"):
return (
json.dumps({"ok": False, "error": "No ESP-NOW devices in registry"}),
400,
{"Content-Type": "application/json"},
)
return json.dumps(result), status, {"Content-Type": "application/json"}
return J({"ok": False, "error": "No ESP-NOW devices in registry"}, 400)
return J(result, status)
@controller.post("/ping")
async def ping_devices(request):
@router.post("/ping")
async def ping_devices(request: Request):
"""
Broadcast ESP-NOW PING_REQ; collect PING_RSP until timeout (default 3 s).
JSON body: ``{"timeout_s": 3.0}`` (optional).
@@ -440,21 +415,19 @@ async def ping_devices(request):
timeout_s = 3.0
try:
body = request.json or {}
body = await read_json(request)
if isinstance(body, dict) and body.get("timeout_s") is not None:
timeout_s = float(body["timeout_s"])
except (TypeError, ValueError):
return json.dumps({"error": "Invalid timeout_s"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "Invalid timeout_s"}, 400)
timeout_s = max(0.5, min(30.0, timeout_s))
result = await run_ping(timeout_s=timeout_s)
status = 200 if result.get("ok") else 503
return json.dumps(result), status, {"Content-Type": "application/json"}
return J(result, status)
@controller.post("/<id>/identify")
async def identify_device(request, id):
@router.post("/{id}/identify")
async def identify_device(request: Request, id):
"""
One v1 JSON object: ``presets.__identify`` (``d``=50 ms → 10 Hz blink) plus ``select`` for
this device name — same combined shape as profile sends the driver already accepts over TCP
@@ -462,30 +435,26 @@ async def identify_device(request, id):
"""
status, err = await send_identify_to_device(id)
if status == 200:
return json.dumps({"message": "Identify sent"}), 200, {
"Content-Type": "application/json",
}
return json.dumps({"error": err}), status, {"Content-Type": "application/json"}
return J({"message": "Identify sent"}, 200)
return J({"error": err}, status)
@controller.post("/<id>/brightness")
async def push_device_output_brightness(request, id):
@router.post("/{id}/brightness")
async def push_device_output_brightness(request: Request, id):
"""
Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
in JSON body — single ``b`` (``v``/``b``/``save``). WiFi or ESPNOW.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
body = request.json or {}
return J({"error": "Device not found"}, 404)
body = await read_json(request)
zb = None
if isinstance(body, dict) and body.get("zone_brightness") is not None:
try:
zb = _validate_output_brightness(body.get("zone_brightness"))
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
b_val = effective_brightness_for_mac(
_pi_settings,
_group_registry,
@@ -496,40 +465,30 @@ async def push_device_output_brightness(request, id):
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
return J({"error": "Transport not configured"}, 503)
try:
ok = await bridge.send({"v": "1", "b": b_val, "save": True}, addr=id)
if not ok:
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",
}
return J({"error": "Send failed"}, 503)
except Exception as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return J({"error": str(e)}, 503)
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
"Content-Type": "application/json",
}
return J({"message": "brightness sent", "brightness": b_val}, 200)
@controller.post("/<id>/driver-config")
async def push_driver_config(request, id):
@router.post("/{id}/driver-config")
async def push_driver_config(request: Request, id):
"""
Push ``device_config`` to an ESP-NOW LED driver.
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
return J({"error": "Device not found"}, 404)
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {
"Content-Type": "application/json",
}
body = request.json or {}
return J({"error": "Transport not configured"}, 503)
body = await read_json(request)
dc = {}
if isinstance(body.get("name"), str) and body["name"].strip():
dc["name"] = body["name"].strip()
@@ -549,31 +508,21 @@ async def push_driver_config(request, id):
if sm in ("default", "last", "off"):
dc["startup_mode"] = sm
if not dc:
return json.dumps(
{
return J({
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
}
), 400, {"Content-Type": "application/json"}
}, 400)
ok = await bridge.send({"v": "1", "device_config": dc, "save": True}, addr=id)
if not ok:
return json.dumps({"error": "Send failed"}), 503, {
"Content-Type": "application/json",
}
return json.dumps({"message": "driver-config sent"}), 200, {
"Content-Type": "application/json",
}
return J({"error": "Send failed"}, 503)
return J({"message": "driver-config sent"}, 200)
@controller.post("/<id>/patterns/push")
async def push_patterns_ota(request, id):
@router.post("/{id}/patterns/push")
async def push_patterns_ota(request: Request, id):
"""
Pattern OTA over HTTP is not available for ESP-NOW drivers.
"""
dev = devices.read(id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json",
}
return json.dumps(
{"error": "Pattern OTA push is not supported for ESP-NOW devices"}
), 400, {"Content-Type": "application/json"}
return J({"error": "Device not found"}, 404)
return J({"error": "Pattern OTA push is not supported for ESP-NOW devices"}, 400)

View File

@@ -1,5 +1,7 @@
from microdot import Microdot
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import asyncio
from models.group import Group
from models.device import Device
@@ -9,7 +11,7 @@ from settings import get_settings
from util.brightness_combine import effective_brightness_for_mac
import json
controller = Microdot()
router = APIRouter()
groups = Group()
devices = Device()
_pi_settings = get_settings()
@@ -41,27 +43,25 @@ def _filtered_groups_dict(session):
return out
@controller.get("")
@router.get("/")
@with_session
async def list_groups(request, session):
async def list_groups(request: Request, session):
"""List groups visible for the current profile (shared + profile-scoped)."""
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
return J(_filtered_groups_dict(session), 200)
@controller.get("/<id>")
@router.get("/{id}")
@with_session
async def get_group(request, session, id):
async def get_group(request: Request, session, id):
"""Get a specific group by ID (404 if scoped to another profile)."""
group = groups.read(id)
if not group or not isinstance(group, dict):
return json.dumps({"error": "Group not found"}), 404
return J({"error": "Group not found"}, 404)
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
return json.dumps(group), 200, {"Content-Type": "application/json"}
return J({"error": "Group not found"}, 404)
return J(group, 200)
def _sanitize_group_bridge_id_write(data):
"""Per-group bridge assignment is disabled; ignore writes."""
if isinstance(data, dict) and "bridge_id" in data:
@@ -89,12 +89,12 @@ def _sanitize_group_profile_id_write(data, session):
data.pop("profile_id", None)
@controller.post("")
@router.post("/")
@with_session
async def create_group(request, session):
async def create_group(request: Request, session):
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
try:
data = dict(request.json or {})
data = dict(await read_json(request))
name = data.get("name", "")
profile_scoped = bool(data.pop("profile_scoped", False))
_sanitize_group_profile_id_write(data, session)
@@ -111,19 +111,17 @@ async def create_group(request, session):
g = groups.read(group_id)
if g:
await push_groups_for_group_devices(g)
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
return J(groups.read(group_id), 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put("/<id>")
return J({"error": str(e)}, 400)
@router.put("/{id}")
@with_session
async def update_group(request, session, id):
async def update_group(request: Request, session, id):
"""Update an existing group."""
try:
data = request.json
data = await read_json(request)
if not isinstance(data, dict):
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
return J({"error": "Invalid JSON"}, 400)
data = dict(data)
_sanitize_group_profile_id_write(data, session)
_sanitize_group_bridge_id_write(data)
@@ -131,29 +129,26 @@ async def update_group(request, session, id):
g = groups.read(id)
if g:
await push_groups_for_group_devices(g)
return json.dumps(g), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Group not found"}), 404
return J(g, 200)
return J({"error": "Group not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete("/<id>")
return J({"error": str(e)}, 400)
@router.delete("/{id}")
@with_session
async def delete_group(request, session, id):
async def delete_group(request: Request, session, id):
"""Delete a group (not allowed for another profile's scoped group)."""
g = groups.read(id)
if not g or not isinstance(g, dict):
return json.dumps({"error": "Group not found"}), 404
return J({"error": "Group not found"}, 404)
from controllers.zone import get_current_profile_id
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
return json.dumps({"error": "Group not found"}), 404
return J({"error": "Group not found"}, 404)
macs = list(g.get("devices") or []) if isinstance(g, dict) else []
if groups.delete(id):
await push_groups_for_group_devices({"devices": macs})
return json.dumps({"message": "Group deleted successfully"}), 200
return json.dumps({"error": "Group not found"}), 404
return J({"message": "Group deleted successfully"}, 200)
return J({"error": "Group not found"}, 404)
def _group_driver_config_payload(doc):
"""Build ``device_config`` dict from stored group WiFi defaults (non-empty only)."""
dc = {}
@@ -194,18 +189,17 @@ def _read_group_for_session(session, id):
return g
@controller.post("/<id>/driver-config")
@router.post("/{id}/driver-config")
@with_session
async def push_group_driver_config(request, session, id):
async def push_group_driver_config(request: Request, session, id):
"""
Push group driver defaults to every ESP-NOW device listed in the group.
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
"""
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
body = request.json or {}
return J({"error": "Group not found"}, 404)
body = await read_json(request)
merged = dict(gdoc)
if isinstance(body, dict):
for k in (
@@ -218,16 +212,19 @@ async def push_group_driver_config(request, session, id):
merged[k] = body[k]
dc = _group_driver_config_payload(merged)
if not dc:
return json.dumps(
{"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"}
), 400, {"Content-Type": "application/json"}
return J(
{
"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"
},
400,
)
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503
return J({"error": "Transport not configured"}, 503)
payload = {"v": "1", "device_config": dc, "save": True}
for mac in mac_list:
m = str(mac).strip().lower().replace(":", "").replace("-", "")
@@ -245,9 +242,7 @@ async def push_group_driver_config(request, session, id):
except Exception as e:
errors.append({"mac": m, "error": str(e)})
return json.dumps(
{"message": "driver-config sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}
return J({"message": "driver-config sent", "sent": sent, "errors": errors}, 200)
def _brightness_save_message_json(b_val: int) -> str:
@@ -255,16 +250,15 @@ def _brightness_save_message_json(b_val: int) -> str:
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
@controller.post("/<id>/brightness")
@router.post("/{id}/brightness")
@with_session
async def push_group_output_brightness(request, session, id):
async def push_group_output_brightness(request: Request, session, id):
"""
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
"""
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404
return J({"error": "Group not found"}, 404)
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
sent = 0
errors = []
@@ -309,14 +303,12 @@ async def push_group_output_brightness(request, session, id):
elif err:
errors.append({"mac": m, "error": err})
return json.dumps(
{"message": "brightness sent", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}
return J({"message": "brightness sent", "sent": sent, "errors": errors}, 200)
@controller.post("/<id>/identify")
@router.post("/{id}/identify")
@with_session
async def identify_group_devices(request, session, id):
async def identify_group_devices(request: Request, session, id):
"""
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
in parallel so all drivers in the group blink together.
@@ -324,11 +316,11 @@ async def identify_group_devices(request, session, id):
_ = request
gdoc = _read_group_for_session(session, id)
if not gdoc:
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
return J({"error": "Group not found"}, 404)
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
if not mac_list:
return json.dumps({"error": "Group has no devices"}), 400, {"Content-Type": "application/json"}
return J({"error": "Group has no devices"}, 400)
from controllers.device import send_identify_to_group_devices
@@ -342,15 +334,11 @@ async def identify_group_devices(request, session, id):
normalized.append(m)
if not normalized:
return json.dumps(
{"message": "identify group done", "sent": 0, "errors": errors}
), 200, {"Content-Type": "application/json"}
return J({"message": "identify group done", "sent": 0, "errors": errors}, 200)
sent, batch_errors = await send_identify_to_group_devices(
normalized, group_ids=[str(id)]
)
errors.extend(batch_errors)
return json.dumps(
{"message": "identify group done", "sent": sent, "errors": errors}
), 200, {"Content-Type": "application/json"}
return J({"message": "identify group done", "sent": sent, "errors": errors}, 200)

View File

@@ -1,12 +1,15 @@
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import json
import os
import subprocess
import sys
from microdot import Microdot, send_file
from serial.tools import list_ports
controller = Microdot()
router = APIRouter()
_STATIC_ALLOWED = frozenset(
{"settings_editor.html", "settings_editor.js", "web_serial.js"}
@@ -74,31 +77,17 @@ def _run_led_cli_command(cmd, cli_path: str, timeout_s=180):
cwd=os.path.dirname(cli_path),
)
except subprocess.TimeoutExpired:
return (
json.dumps({"error": "led-tool command timed out after 180 seconds"}),
504,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool command timed out after 180 seconds"}, 504)
except Exception as exc:
return (
json.dumps({"error": str(exc)}),
500,
{"Content-Type": "application/json"},
)
return J({"error": str(exc)}, 500)
return (
json.dumps(
{
return J({
"ok": result.returncode == 0,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"command": cmd,
}
),
200,
{"Content-Type": "application/json"},
)
}, 200)
def _extract_settings_from_stdout(stdout: str):
@@ -112,31 +101,27 @@ def _extract_settings_from_stdout(stdout: str):
return None
@controller.get("/editor")
async def settings_editor_page(request):
@router.get("/editor")
async def settings_editor_page(request: Request):
"""led-tool settings UI (Web Serial + host serial via led-cli)."""
path = os.path.join(_led_tool_static_dir(), "settings_editor.html")
if not os.path.isfile(path):
return (
json.dumps({"error": "led-tool/static/settings_editor.html not found"}),
404,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool/static/settings_editor.html not found"}, 404)
return send_file(path)
@controller.get("/static/<path:filename>")
async def led_tool_static(request, filename):
@router.get("/static/<path:filename>")
async def led_tool_static(request: Request, filename):
if filename not in _STATIC_ALLOWED:
return "Not found", 404
return plain("Not found", 404)
path = os.path.join(_led_tool_static_dir(), filename)
if not os.path.isfile(path):
return "Not found", 404
return plain("Not found", 404)
return send_file(path)
@controller.get("/ports")
async def list_serial_ports(request):
@router.get("/ports")
async def list_serial_ports(request: Request):
ports = _filter_host_serial_ports(
[
{
@@ -147,87 +132,57 @@ async def list_serial_ports(request):
for info in list_ports.comports()
]
)
return (
json.dumps(
{
return J({
"ports": ports,
"led_cli_exists": os.path.exists(_led_cli_path()),
}
),
200,
{"Content-Type": "application/json"},
)
}, 200)
@controller.post("/settings")
async def apply_settings(request):
data = request.json or {}
@router.post("/settings")
async def apply_settings(request: Request):
data = await read_json(request)
port = str(data.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "port is required"}, 400)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool/cli.py not found"}, 500)
cmd = _build_led_cli_command(port, data) + ["--follow"]
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
@controller.post("/reset")
@controller.post("/reset/")
async def reset_device(request):
data = request.json or {}
@router.post("/reset")
@router.post("/reset/")
async def reset_device(request: Request):
data = await read_json(request)
port = str(data.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "port is required"}, 400)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool/cli.py not found"}, 500)
cmd = [sys.executable, cli_path, "--port", port, "--reset", "--follow"]
return _run_led_cli_command(cmd, cli_path, timeout_s=None)
@controller.get("/settings")
async def read_settings(request):
port = str(request.args.get("port") or "").strip()
@router.get("/settings")
async def read_settings(request: Request):
port = str(request.query_params.get("port") or "").strip()
if not port:
return (
json.dumps({"error": "port is required"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "port is required"}, 400)
cli_path = _led_cli_path()
if not os.path.exists(cli_path):
return (
json.dumps({"error": "led-tool/cli.py not found"}),
500,
{"Content-Type": "application/json"},
)
return J({"error": "led-tool/cli.py not found"}, 500)
cmd = [sys.executable, cli_path, "--port", port, "--show"]
body, status, headers = _run_led_cli_command(cmd, cli_path)
if status != 200:
return body, status, headers
data = json.loads(body)
result = _run_led_cli_command(cmd, cli_path)
if result.status_code != 200:
return result
data = json.loads(result.body.decode())
data["settings"] = _extract_settings_from_stdout(data.get("stdout") or "")
return json.dumps(data), status, headers
return J(data, 200)

View File

@@ -1,58 +1,58 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.pallet import Palette
import json
controller = Microdot()
router = APIRouter()
palettes = Palette()
@controller.get('')
async def list_palettes(request):
@router.get("/")
async def list_palettes(request: Request):
"""List all palettes."""
data = {}
for pid in palettes.list():
colors = palettes.read(pid)
data[pid] = colors
return json.dumps(data), 200, {'Content-Type': 'application/json'}
return J(data, 200)
@controller.get('/<id>')
async def get_palette(request, id):
@router.get("/{id}")
async def get_palette(request: Request, id):
"""Get a specific palette by ID."""
if str(id) in palettes:
palette = palettes.read(id)
return json.dumps({"colors": palette or [], "id": str(id)}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404
@controller.post('')
async def create_palette(request):
return J({"colors": palette or [], "id": str(id)}, 200)
return J({"error": "Palette not found"}, 404)
@router.post("/")
async def create_palette(request: Request):
"""Create a new palette."""
try:
data = request.json or {}
data = await read_json(request)
colors = data.get("colors", None)
# Palette no longer needs a name; only colors are stored.
palette_id = palettes.create("", colors)
created_colors = palettes.read(palette_id) or []
return json.dumps({"id": str(palette_id), "colors": created_colors}), 201, {'Content-Type': 'application/json'}
return J({"id": str(palette_id), "colors": created_colors}, 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_palette(request, id):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_palette(request: Request, id):
"""Update an existing palette."""
try:
data = request.json or {}
data = await read_json(request)
# Ignore any name field; only colors are relevant.
if "name" in data:
data.pop("name", None)
if palettes.update(id, data):
colors = palettes.read(id) or []
return json.dumps({"id": str(id), "colors": colors}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Palette not found"}), 404
return J({"id": str(id), "colors": colors}, 200)
return J({"error": "Palette not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_palette(request, id):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_palette(request: Request, id):
"""Delete a palette."""
if palettes.delete(id):
return json.dumps({"message": "Palette deleted successfully"}), 200
return json.dumps({"error": "Palette not found"}), 404
return J({"message": "Palette deleted successfully"}, 200)
return J({"error": "Palette not found"}, 404)

View File

@@ -1,4 +1,7 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.pattern import Pattern
from models.device import Device
from util.driver_patterns import (
@@ -12,7 +15,7 @@ import os
import socket
from urllib.parse import quote
controller = Microdot()
router = APIRouter()
patterns = Pattern()
@@ -147,26 +150,24 @@ def build_runtime_pattern_map():
result[name] = {}
return result
@controller.get('/definitions')
async def get_pattern_definitions(request):
@router.get("/definitions")
async def get_pattern_definitions(request: Request):
"""Get definitions for patterns currently available on the driver."""
definitions = build_runtime_pattern_map()
return json.dumps(definitions), 200, {'Content-Type': 'application/json'}
return J(definitions, 200)
@controller.get('/ota/manifest')
async def ota_manifest(request):
@router.get("/ota/manifest")
async def ota_manifest(request: Request):
"""Manifest of driver pattern source files for OTA pulls."""
base_dir = driver_patterns_dir()
host = request.headers.get("Host", "")
if not host:
return json.dumps({"error": "Missing Host header"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Missing Host header"}, 400)
try:
names = sorted(os.listdir(base_dir))
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
files = []
for name in names:
@@ -177,97 +178,69 @@ async def ota_manifest(request):
"url": "http://%s/patterns/ota/file/%s" % (host, name),
})
return json.dumps({"files": files}), 200, {"Content-Type": "application/json"}
return J({"files": files}, 200)
@controller.get('/ota/file/<name>')
async def ota_pattern_file(request, name):
@router.get("/ota/file/{name}")
async def ota_pattern_file(request: Request, name):
"""Serve one driver pattern source file for OTA pulls."""
fname = normalize_pattern_py_filename(name)
if not fname or not _safe_pattern_filename(fname) or fname == "__init__.py":
return json.dumps({"error": "Invalid filename"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Invalid filename"}, 400)
if is_firmware_builtin_pattern_module(fname):
return json.dumps(
{
return J({
"error": "on and off are built into the driver firmware; there is no module file to serve.",
}
), 400, {
"Content-Type": "application/json"
}
}, 400)
base = driver_patterns_dir()
path = os.path.join(base, fname)
try:
with open(path, "r") as f:
content = f.read()
except OSError:
return json.dumps(
{
return J({
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json"
}
return content, 200, {"Content-Type": "text/plain; charset=utf-8"}
@controller.post('/<name>/send')
async def send_pattern_to_device(request, name):
}, 404)
return plain(content, 200)
@router.post("/{name}/send")
async def send_pattern_to_device(request: Request, name):
"""Push one pattern source file directly to Wi-Fi driver(s) over HTTP."""
if not isinstance(name, str):
return json.dumps({"error": "Invalid pattern name"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Invalid pattern name"}, 400)
filename = normalize_pattern_py_filename(name)
if not filename or not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "Invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Invalid pattern filename"}, 400)
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{
return J({
"error": "on and off are built into the driver firmware; send does not apply.",
}
), 400, {
"Content-Type": "application/json"
}
}, 400)
devices = Device()
body = request.json or {}
body = await read_json(request)
requested_device_id = str(body.get("device_id") or "").strip()
base = driver_patterns_dir()
path = os.path.join(base, filename)
if not os.path.exists(path):
return json.dumps(
{
return J({
"error": "Pattern file not found",
"path": path,
"hint": "Ensure led-driver is present or set LED_CONTROLLER_PATTERNS_DIR.",
}
), 404, {
"Content-Type": "application/json"
}
}, 404)
try:
with open(path, "r") as f:
source = f.read()
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
target_ids = []
if requested_device_id:
dev = devices.read(requested_device_id)
if not dev:
return json.dumps({"error": "Device not found"}), 404, {
"Content-Type": "application/json"
}
return J({"error": "Device not found"}, 404)
if (dev.get("transport") or "").lower() != "wifi":
return json.dumps({"error": "Pattern send is only supported for Wi-Fi devices"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "Pattern send is only supported for Wi-Fi devices"}, 400)
target_ids = [requested_device_id]
else:
for did in devices.list():
@@ -275,9 +248,7 @@ async def send_pattern_to_device(request, name):
if (dev.get("transport") or "").lower() == "wifi":
target_ids.append(str(did))
if not target_ids:
return json.dumps({"error": "No Wi-Fi devices found"}), 404, {
"Content-Type": "application/json"
}
return J({"error": "No Wi-Fi devices found"}, 404)
sent_ids = []
for did in target_ids:
@@ -290,16 +261,12 @@ async def send_pattern_to_device(request, name):
sent_ids.append(did)
if not sent_ids:
return json.dumps({"error": "No Wi-Fi drivers accepted pattern upload"}), 503, {
"Content-Type": "application/json"
}
return json.dumps({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}), 200, {
"Content-Type": "application/json"
}
return J({"error": "No Wi-Fi drivers accepted pattern upload"}, 503)
return J({"message": "Pattern sent", "pattern": filename, "device_ids": sent_ids, "sent_count": len(sent_ids)}, 200)
@controller.post('/upload')
async def upload_pattern_file(request):
@router.post("/upload")
async def upload_pattern_file(request: Request):
"""
Upload a pattern source file to led-controller local storage.
@@ -310,56 +277,44 @@ async def upload_pattern_file(request):
"overwrite": true | false # optional, default true
}
"""
data = request.json or {}
data = await read_json(request)
raw_name = data.get("name") or data.get("filename")
code = data.get("code")
overwrite = data.get("overwrite", True)
overwrite = bool(overwrite)
if not isinstance(raw_name, str) or not raw_name.strip():
return json.dumps({"error": "name is required"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "name is required"}, 400)
filename = raw_name.strip()
if not filename.endswith(".py"):
filename += ".py"
if not _safe_pattern_filename(filename) or filename == "__init__.py":
return json.dumps({"error": "invalid pattern filename"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "invalid pattern filename"}, 400)
if is_firmware_builtin_pattern_module(filename):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400)
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "code is required"}, 400)
path = os.path.join(driver_patterns_dir(), filename)
exists = os.path.exists(path)
if exists and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
return J({"error": "pattern file already exists", "name": filename}, 409)
try:
with open(path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
return json.dumps({
return J({
"message": "Pattern uploaded",
"name": filename,
"overwrote": bool(exists),
}), 201, {"Content-Type": "application/json"}
}, 201)
@controller.post('/driver')
async def create_driver_pattern(request):
@router.post("/driver")
async def create_driver_pattern(request: Request):
"""
Create a driver pattern: save ``.py`` under led-driver/src/patterns and
metadata in db/pattern.json (Pattern model).
@@ -372,33 +327,25 @@ async def create_driver_pattern(request):
n1..n8 (optional string labels),
overwrite (optional, default true).
"""
data = request.json or {}
data = await read_json(request)
key = _normalize_pattern_key(data.get("name") or "")
if not _valid_pattern_key(key):
return json.dumps({
return J({
"error": "name must be a valid Python identifier (e.g. sparkle, my_pattern)",
}), 400, {"Content-Type": "application/json"}
}, 400)
if is_firmware_builtin_pattern_module(key):
return json.dumps(
{"error": "on and off are built into the driver firmware; use a different pattern name."}
), 400, {
"Content-Type": "application/json"
}
return J({"error": "on and off are built into the driver firmware; use a different pattern name."}, 400)
code = data.get("code")
if not isinstance(code, str) or not code.strip():
return json.dumps({"error": "code is required (upload a .py file or paste source)"}), 400, {
"Content-Type": "application/json"
}
return J({"error": "code is required (upload a .py file or paste source)"}, 400)
overwrite = bool(data.get("overwrite", True))
filename = key + ".py"
py_path = os.path.join(driver_patterns_dir(), filename)
if os.path.exists(py_path) and not overwrite:
return json.dumps({"error": "pattern file already exists", "name": filename}), 409, {
"Content-Type": "application/json"
}
return J({"error": "pattern file already exists", "name": filename}, 409)
meta = {}
for fld in ("min_delay", "max_delay", "max_colors"):
@@ -407,9 +354,7 @@ async def create_driver_pattern(request):
try:
meta[fld] = int(data[fld])
except (TypeError, ValueError):
return json.dumps({"error": "%s must be an integer" % fld}), 400, {
"Content-Type": "application/json"
}
return J({"error": "%s must be an integer" % fld}, 400)
if "has_background" in data:
meta["has_background"] = bool(data.get("has_background"))
@@ -432,41 +377,39 @@ async def create_driver_pattern(request):
with open(py_path, "w") as f:
f.write(code)
except OSError as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
if patterns.read(key):
patterns.update(key, meta)
else:
patterns.create(key, meta)
return json.dumps({
return J({
"message": "Pattern created",
"name": key,
"file": filename,
"metadata": patterns.read(key),
}), 201, {"Content-Type": "application/json"}
}, 201)
@controller.get('')
async def list_patterns(request):
@router.get("/")
async def list_patterns(request: Request):
"""List patterns for UI (DB metadata + local driver additions)."""
return json.dumps(build_runtime_pattern_map()), 200, {'Content-Type': 'application/json'}
return J(build_runtime_pattern_map(), 200)
@controller.get('/<id>')
async def get_pattern(request, id):
@router.get("/{id}")
async def get_pattern(request: Request, id):
"""Get a specific pattern by ID."""
pattern = patterns.read(id)
if pattern is not None:
return json.dumps(pattern), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Pattern not found"}), 404
@controller.post('')
async def create_pattern(request):
return J(pattern, 200)
return J({"error": "Pattern not found"}, 404)
@router.post("/")
async def create_pattern(request: Request):
"""Create a new pattern."""
try:
payload = request.json or {}
payload = await read_json(request)
name = payload.get("name", "")
pattern_data = payload.get("data", {})
@@ -483,26 +426,22 @@ async def create_pattern(request):
extra.pop("data", None)
if extra:
patterns.update(pattern_id, extra)
return json.dumps(patterns.read(pattern_id)), 201, {'Content-Type': 'application/json'}
return J(patterns.read(pattern_id), 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_pattern(request, id):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_pattern(request: Request, id):
"""Update an existing pattern."""
try:
data = request.json
data = await read_json(request)
if patterns.update(id, data):
return json.dumps(patterns.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Pattern not found"}), 404
return J(patterns.read(id), 200)
return J({"error": "Pattern not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_pattern(request, id):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_pattern(request: Request, id):
"""Delete a pattern."""
if patterns.delete(id):
return json.dumps({"message": "Pattern deleted successfully"}), 200
return json.dumps({"error": "Pattern not found"}), 404
return J({"message": "Pattern deleted successfully"}, 200)
return J({"error": "Pattern not found"}, 404)

View File

@@ -1,5 +1,7 @@
from microdot import Microdot
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.preset import Preset
from models.profile import Profile
from models.pallet import Palette
@@ -13,7 +15,7 @@ from util.espnow_message import build_message, build_preset_dict
from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json
controller = Microdot()
router = APIRouter()
presets = Preset()
profiles = Profile()
@@ -41,76 +43,75 @@ def get_current_profile_id(session=None):
return profile_list[0]
return None
@controller.get('')
@router.get("/")
@with_session
async def list_presets(request, session):
async def list_presets(request: Request, session):
"""List presets for the current profile."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {'Content-Type': 'application/json'}
return J({}, 200)
scoped = {
pid: pdata for pid, pdata in presets.items()
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {'Content-Type': 'application/json'}
return J(scoped, 200)
@controller.get('/<preset_id>/export')
@router.get("/{preset_id}/export")
@with_session
async def export_preset(request, session, preset_id):
async def export_preset(request: Request, session, preset_id):
"""Export one preset as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
preset = presets.read(preset_id)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404, {'Content-Type': 'application/json'}
return J({"error": "Preset not found"}, 404)
try:
bundle = export_preset_bundle(preset_id, presets)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
return J(bundle, 200)
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 404)
@controller.post('/import')
@router.post("/import")
@with_session
async def import_preset(request, session):
async def import_preset(request: Request, session):
"""Import a preset bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'}
body = request.json or {}
return J({"error": "No profile available"}, 404)
body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Expected JSON bundle"}, 400)
new_id, preset_data = import_preset_bundle(bundle, presets, current_profile_id)
return json.dumps({new_id: preset_data}), 201, {'Content-Type': 'application/json'}
return J({new_id: preset_data}, 201)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 400)
@controller.get('/<preset_id>')
@router.get("/{preset_id}")
@with_session
async def get_preset(request, session, preset_id):
async def get_preset(request: Request, session, preset_id):
"""Get a specific preset by ID (current profile only)."""
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
@controller.post('')
return J(preset, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/")
@with_session
async def create_preset(request, session):
async def create_preset(request: Request, session):
"""Create a new preset for the current profile."""
try:
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404
return J({"error": "No profile available"}, 404)
preset_id = presets.create(current_profile_id)
if not isinstance(data, dict):
data = {}
@@ -118,65 +119,46 @@ async def create_preset(request, session):
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data):
preset_data = presets.read(preset_id)
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to create preset"}), 400
return J({preset_id: preset_data}, 201)
return J({"error": "Failed to create preset"}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<preset_id>')
return J({"error": str(e)}, 400)
@router.put("/{preset_id}")
@with_session
async def update_preset(request, session, preset_id):
async def update_preset(request: Request, session, preset_id):
"""Update an existing preset (current profile only)."""
try:
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
return J({"error": "Preset not found"}, 404)
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
if not isinstance(data, dict):
data = {}
data = dict(data)
data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data):
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Preset not found"}), 404
return J(presets.read(preset_id), 200)
return J({"error": "Preset not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<preset_id>')
return J({"error": str(e)}, 400)
@router.delete("/{preset_id}")
@with_session
async def delete_preset(request, *args, **kwargs):
async def delete_preset(request: Request, session, preset_id):
"""Delete a preset (current profile only)."""
# Be tolerant of wrapper/arg-order variations.
session = None
preset_id = None
if len(args) > 0:
session = args[0]
if len(args) > 1:
preset_id = args[1]
if 'session' in kwargs and kwargs.get('session') is not None:
session = kwargs.get('session')
if 'preset_id' in kwargs and kwargs.get('preset_id') is not None:
preset_id = kwargs.get('preset_id')
if 'id' in kwargs and kwargs.get('id') is not None and preset_id is None:
preset_id = kwargs.get('id')
if preset_id is None:
return json.dumps({"error": "Preset ID is required"}), 400
preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Preset not found"}), 404
return J({"error": "Preset not found"}, 404)
if presets.delete(preset_id):
return json.dumps({"message": "Preset deleted successfully"}), 200
return json.dumps({"error": "Preset not found"}), 404
@controller.post('/send')
return J({"message": "Preset deleted successfully"}, 200)
return J({"error": "Preset not found"}, 404)
@router.post("/send")
@with_session
async def send_presets(request, session):
async def send_presets(request: Request, session):
"""
Send one or more presets to LED drivers (serial/ESP-NOW and/or TCP Wi-Fi clients).
@@ -191,13 +173,12 @@ async def send_presets(request, session):
Optional "destination_mac" / "to": single MAC when targets is omitted.
"""
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
preset_ids = data.get('preset_ids') or data.get('ids')
if not isinstance(preset_ids, list) or not preset_ids:
return json.dumps({"error": "preset_ids must be a non-empty list"}), 400, {'Content-Type': 'application/json'}
return J({"error": "preset_ids must be a non-empty list"}, 400)
save_flag = data.get('save', True)
save_flag = bool(save_flag)
default_id = data.get('default')
@@ -219,14 +200,14 @@ async def send_presets(request, session):
presets_by_name[preset_key] = preset_payload
if not presets_by_name:
return json.dumps({"error": "No matching presets found"}), 404, {'Content-Type': 'application/json'}
return J({"error": "No matching presets found"}, 404)
if default_id is not None and str(default_id) not in presets_by_name:
default_id = None
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Transport not configured"}, 503)
send_delay_s = 0.1
total_presets = len(presets_by_name)
@@ -300,18 +281,18 @@ async def send_presets(request, session):
delay_s=send_delay_s,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Send failed"}, 503)
return json.dumps({
return J({
"message": "Presets sent",
"presets_sent": total_presets,
"messages_sent": deliveries,
}), 200, {'Content-Type': 'application/json'}
}, 200)
@controller.post('/push')
@router.post("/push")
@with_session
async def push_driver_messages(request, session):
async def push_driver_messages(request: Request, session):
"""
Deliver one or more raw v1 JSON objects to devices (ESP-NOW and/or TCP).
@@ -320,15 +301,15 @@ async def push_driver_messages(request, session):
or a single {"payload": {...}, "targets": [...]}.
"""
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Invalid JSON"}, 400)
seq = data.get("sequence")
if not seq and data.get("payload") is not None:
seq = [data["payload"]]
if not isinstance(seq, list) or not seq:
return json.dumps({"error": "sequence or payload required"}), 400, {'Content-Type': 'application/json'}
return J({"error": "sequence or payload required"}, 400)
raw_targets = data.get("targets")
target_list = None
@@ -344,7 +325,7 @@ async def push_driver_messages(request, session):
bridge = get_current_bridge()
if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Transport not configured"}, 503)
messages = []
i = 0
@@ -355,7 +336,7 @@ async def push_driver_messages(request, session):
messages.append(item)
i += 1
continue
return json.dumps({"error": "sequence items must be objects or strings"}), 400, {'Content-Type': 'application/json'}
return J({"error": "sequence items must be objects or strings"}, 400)
nxt = seq[i + 1] if i + 1 < len(seq) else None
if (
isinstance(nxt, dict)
@@ -392,7 +373,7 @@ async def push_driver_messages(request, session):
unicast=unicast,
)
except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
return J({"error": "Send failed"}, 503)
try:
from util import sequence_playback as seq_pb
@@ -405,8 +386,8 @@ async def push_driver_messages(request, session):
except Exception:
pass
return json.dumps({
return J({
"message": "Delivered",
"deliveries": deliveries,
}), 200, {'Content-Type': 'application/json'}
}, 200)

View File

@@ -1,5 +1,7 @@
from microdot import Microdot
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.profile import Profile
from models.zone import Zone
from models.preset import Preset
@@ -7,15 +9,15 @@ from models.sequence import Sequence
from util.profile_bundle import export_profile_bundle, import_profile_bundle
import json
controller = Microdot()
router = APIRouter()
profiles = Profile()
zones = Zone()
presets = Preset()
sequences = Sequence()
@controller.get('')
@router.get("/")
@with_session
async def list_profiles(request, session):
async def list_profiles(request: Request, session):
"""List all profiles with current profile info."""
profile_list = profiles.list()
current_id = session.get('current_profile')
@@ -35,14 +37,14 @@ async def list_profiles(request, session):
if profile_data:
profiles_data[profile_id] = profile_data
return json.dumps({
return J({
"profiles": profiles_data,
"current_profile_id": current_id
}), 200, {'Content-Type': 'application/json'}
}, 200)
@controller.get('/current')
@router.get("/current")
@with_session
async def get_current_profile(request, session):
async def get_current_profile(request: Request, session):
"""Get the current profile ID from session (or fallback)."""
profile_list = profiles.list()
current_id = session.get('current_profile')
@@ -54,19 +56,17 @@ async def get_current_profile(request, session):
session.save()
if current_id:
profile = profiles.read(current_id)
return json.dumps({"id": current_id, "profile": profile}), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "No profile available"}), 404
@controller.post('/import')
return J({"id": current_id, "profile": profile}, 200)
return J({"error": "No profile available"}, 404)
@router.post("/import")
@with_session
async def import_profile(request, session):
async def import_profile(request: Request, session):
"""Import a profile bundle (optionally apply as current profile)."""
try:
body = request.json or {}
body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return json.dumps({"error": "Expected JSON bundle"}), 400, {'Content-Type': 'application/json'}
return J({"error": "Expected JSON bundle"}, 400)
name = body.get("name") if isinstance(body, dict) else None
apply_raw = body.get("apply", True) if isinstance(body, dict) else True
if isinstance(apply_raw, str):
@@ -86,19 +86,15 @@ async def import_profile(request, session):
if apply:
session['current_profile'] = str(new_profile_id)
session.save()
return (
json.dumps({new_profile_id: profile_data, "id": new_profile_id}),
201,
{'Content-Type': 'application/json'},
)
return J({new_profile_id: profile_data, "id": new_profile_id}, 201)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 400)
@controller.get('/<id>/export')
async def export_profile(request, id):
@router.get("/{id}/export")
async def export_profile(request: Request, id):
"""Export profile, zones, presets, sequences, and palette as a JSON bundle."""
try:
bundle = export_profile_bundle(
@@ -109,33 +105,32 @@ async def export_profile(request, id):
sequences,
profiles._palette_model,
)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'}
return J(bundle, 200)
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {'Content-Type': 'application/json'}
return J({"error": str(e)}, 400)
@controller.post('/<id>/apply')
@router.post("/{id}/apply")
@with_session
async def apply_profile(request, session, id):
async def apply_profile(request: Request, session, id):
"""Apply a profile by saving it to session."""
if not profiles.read(id):
return json.dumps({"error": "Profile not found"}), 404
return J({"error": "Profile not found"}, 404)
session['current_profile'] = str(id)
session.save()
return json.dumps({"message": "Profile applied", "id": str(id)}), 200, {'Content-Type': 'application/json'}
return J({"message": "Profile applied", "id": str(id)}, 200)
@controller.post('/<id>/clone')
async def clone_profile(request, id):
@router.post("/{id}/clone")
async def clone_profile(request: Request, id):
"""Clone an existing profile along with its tabs and palette."""
try:
source = profiles.read(id)
if not source:
return json.dumps({"error": "Profile not found"}), 404
data = request.json or {}
return J({"error": "Profile not found"}, 404)
data = await read_json(request)
source_name = source.get("name") or f"Profile {id}"
new_name = data.get("name") or source_name
profile_type = source.get("type", "zones")
@@ -235,14 +230,12 @@ async def clone_profile(request, id):
zones.save()
profiles.save()
return json.dumps({new_profile_id: new_profile_data}), 201, {'Content-Type': 'application/json'}
return J({new_profile_id: new_profile_data}, 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.get('/<id>')
return J({"error": str(e)}, 400)
@router.get("/{id}")
@with_session
async def get_profile(request, id, session):
async def get_profile(request: Request, id, session):
"""Get a specific profile by ID."""
# Handle 'current' as a special case
if id == 'current':
@@ -250,14 +243,13 @@ async def get_profile(request, id, session):
profile = profiles.read(id)
if profile:
return json.dumps(profile), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
@controller.post('')
async def create_profile(request):
return J(profile, 200)
return J({"error": "Profile not found"}, 404)
@router.post("/")
async def create_profile(request: Request):
"""Create a new profile."""
try:
data = dict(request.json or {})
data = dict(await read_json(request))
name = data.get("name", "")
seed_raw = data.get("seed_dj_zone", False)
if isinstance(seed_raw, str):
@@ -413,16 +405,15 @@ async def create_profile(request):
profiles.update(profile_id, {"zones": profile_tabs})
profile_data = profiles.read(profile_id)
return json.dumps({profile_id: profile_data}), 201, {'Content-Type': 'application/json'}
return J({profile_id: profile_data}, 201)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/current')
return J({"error": str(e)}, 400)
@router.put("/current")
@with_session
async def update_current_profile(request, session):
async def update_current_profile(request: Request, session):
"""Update the current profile using session (or fallback)."""
try:
data = request.json or {}
data = await read_json(request)
profile_list = profiles.list()
current_id = session.get('current_profile')
if not current_id and profile_list:
@@ -430,27 +421,25 @@ async def update_current_profile(request, session):
session['current_profile'] = str(current_id)
session.save()
if not current_id:
return json.dumps({"error": "No profile available"}), 404
return J({"error": "No profile available"}, 404)
if profiles.update(current_id, data):
return json.dumps(profiles.read(current_id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
return J(profiles.read(current_id), 200)
return J({"error": "Profile not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_profile(request, id):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_profile(request: Request, id):
"""Update an existing profile."""
try:
data = request.json
data = await read_json(request)
if profiles.update(id, data):
return json.dumps(profiles.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Profile not found"}), 404
return J(profiles.read(id), 200)
return J({"error": "Profile not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_profile(request, id):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_profile(request: Request, id):
"""Delete a profile."""
if profiles.delete(id):
return json.dumps({"message": "Profile deleted successfully"}), 200
return json.dumps({"error": "Profile not found"}), 404
return J({"message": "Profile deleted successfully"}, 200)
return J({"error": "Profile not found"}, 404)

View File

@@ -1,49 +1,49 @@
from microdot import Microdot
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.scene import Scene
import json
controller = Microdot()
router = APIRouter()
scenes = Scene()
@controller.get('')
async def list_scenes(request):
@router.get("/")
async def list_scenes(request: Request):
"""List all scenes."""
return json.dumps(scenes), 200, {'Content-Type': 'application/json'}
return J(scenes, 200)
@controller.get('/<id>')
async def get_scene(request, id):
@router.get("/{id}")
async def get_scene(request: Request, id):
"""Get a specific scene by ID."""
scene = scenes.read(id)
if scene:
return json.dumps(scene), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Scene not found"}), 404
@controller.post('')
async def create_scene(request):
return J(scene, 200)
return J({"error": "Scene not found"}, 404)
@router.post("/")
async def create_scene(request: Request):
"""Create a new scene."""
try:
data = request.json
data = await read_json(request)
scene_id = scenes.create()
if scenes.update(scene_id, data):
return json.dumps(scenes.read(scene_id)), 201, {'Content-Type': 'application/json'}
return json.dumps({"error": "Failed to create scene"}), 400
return J(scenes.read(scene_id), 201)
return J({"error": "Failed to create scene"}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.put('/<id>')
async def update_scene(request, id):
return J({"error": str(e)}, 400)
@router.put("/{id}")
async def update_scene(request: Request, id):
"""Update an existing scene."""
try:
data = request.json
data = await read_json(request)
if scenes.update(id, data):
return json.dumps(scenes.read(id)), 200, {'Content-Type': 'application/json'}
return json.dumps({"error": "Scene not found"}), 404
return J(scenes.read(id), 200)
return J({"error": "Scene not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete('/<id>')
async def delete_scene(request, id):
return J({"error": str(e)}, 400)
@router.delete("/{id}")
async def delete_scene(request: Request, id):
"""Delete a scene."""
if scenes.delete(id):
return json.dumps({"message": "Scene deleted successfully"}), 200
return json.dumps({"error": "Scene not found"}), 404
return J({"message": "Scene deleted successfully"}, 200)
return J({"error": "Scene not found"}, 404)

View File

@@ -1,5 +1,7 @@
from microdot import Microdot
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
from models.sequence import Sequence
from models.profile import Profile
from models.transport import get_current_bridge
@@ -7,7 +9,7 @@ from models.preset import Preset
from util.profile_bundle import export_sequence_bundle, import_sequence_bundle
import json
controller = Microdot()
router = APIRouter()
sequences = Sequence()
profiles = Profile()
presets = Preset()
@@ -26,30 +28,30 @@ def get_current_profile_id(session=None):
return None
@controller.get("")
@router.get("/")
@with_session
async def list_sequences(request, session):
async def list_sequences(request: Request, session):
"""List sequences for the current profile."""
sequences.load()
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({}), 200, {"Content-Type": "application/json"}
return J({}, 200)
scoped = {
sid: sdata
for sid, sdata in sequences.items()
if isinstance(sdata, dict)
and str(sdata.get("profile_id")) == str(current_profile_id)
}
return json.dumps(scoped), 200, {"Content-Type": "application/json"}
return J(scoped, 200)
@controller.get("/<id>/export")
@router.get("/{id}/export")
@with_session
async def export_sequence(request, session, id):
async def export_sequence(request: Request, session, id):
"""Export a sequence and referenced presets as a JSON bundle."""
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {"Content-Type": "application/json"}
return J({"error": "No profile available"}, 404)
try:
bundle = export_sequence_bundle(
id,
@@ -57,46 +59,34 @@ async def export_sequence(request, session, id):
presets,
profile_id=current_profile_id,
)
return json.dumps(bundle), 200, {"Content-Type": "application/json"}
return J(bundle, 200)
except ValueError as e:
return json.dumps({"error": str(e)}), 404, {"Content-Type": "application/json"}
return J({"error": str(e)}, 404)
@controller.post("/import")
@router.post("/import")
@with_session
async def import_sequence(request, session):
async def import_sequence(request: Request, session):
"""Import a sequence bundle into the current profile."""
try:
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
body = request.json or {}
return J({"error": "No profile available"}, 404)
body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict):
return (
json.dumps({"error": "Expected JSON bundle"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "Expected JSON bundle"}, 400)
new_id, seq_data = import_sequence_bundle(bundle, sequences, presets, current_profile_id)
return (
json.dumps({new_id: seq_data}),
201,
{"Content-Type": "application/json"},
)
return J({new_id: seq_data}, 201)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
@controller.get("/<id>")
@router.get("/{id}")
@with_session
async def get_sequence(request, session, id):
async def get_sequence(request: Request, session, id):
"""Get a specific sequence by ID (current profile only)."""
sequences.load()
current_profile_id = get_current_profile_id(session)
@@ -106,30 +96,20 @@ async def get_sequence(request, session, id):
and current_profile_id
and str(seq.get("profile_id")) == str(current_profile_id)
):
return json.dumps(seq), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Sequence not found"}), 404
@controller.post("")
return J(seq, 200)
return J({"error": "Sequence not found"}, 404)
@router.post("/")
@with_session
async def create_sequence(request, session):
async def create_sequence(request: Request, session):
"""Create a new sequence for the current profile."""
try:
try:
data = request.json or {}
data = await read_json(request)
except Exception:
return (
json.dumps({"error": "Invalid JSON"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "Invalid JSON"}, 400)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
return J({"error": "No profile available"}, 404)
sequence_id = sequences.create(current_profile_id)
if not isinstance(data, dict):
data = {}
@@ -137,36 +117,24 @@ async def create_sequence(request, session):
data["profile_id"] = str(current_profile_id)
if sequences.update(sequence_id, data):
seq_data = sequences.read(sequence_id)
return (
json.dumps({sequence_id: seq_data}),
201,
{"Content-Type": "application/json"},
)
return (
json.dumps({"error": "Failed to create sequence"}),
400,
{"Content-Type": "application/json"},
)
return J({sequence_id: seq_data}, 201)
return J({"error": "Failed to create sequence"}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
@controller.put("/<id>")
@router.put("/{id}")
@with_session
async def update_sequence(request, session, id):
async def update_sequence(request: Request, session, id):
"""Update an existing sequence (current profile only)."""
try:
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Sequence not found"}), 404
data = request.json
return J({"error": "Sequence not found"}, 404)
data = await read_json(request)
if not isinstance(data, dict):
return (
json.dumps({"error": "Invalid JSON"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "Invalid JSON"}, 400)
data = dict(data)
data["profile_id"] = str(current_profile_id)
if sequences.update(id, data):
@@ -176,20 +144,20 @@ async def update_sequence(request, session, id):
stop_if_playing_sequence(str(id))
except Exception:
pass
return json.dumps(sequences.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Sequence not found"}), 404
return J(sequences.read(id), 200)
return J({"error": "Sequence not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
@controller.delete("/<id>")
@router.delete("/{id}")
@with_session
async def delete_sequence(request, session, id):
async def delete_sequence(request: Request, session, id):
"""Delete a sequence (current profile only)."""
current_profile_id = get_current_profile_id(session)
seq = sequences.read(id)
if not seq or str(seq.get("profile_id")) != str(current_profile_id):
return json.dumps({"error": "Sequence not found"}), 404
return J({"error": "Sequence not found"}, 404)
try:
from util.sequence_playback import stop_if_playing_sequence
@@ -197,21 +165,15 @@ async def delete_sequence(request, session, id):
except Exception:
pass
if sequences.delete(id):
return (
json.dumps({"message": "Sequence deleted successfully"}),
200,
{"Content-Type": "application/json"},
)
return json.dumps({"error": "Sequence not found"}), 404
@controller.post("/sync-phase")
return J({"message": "Sequence deleted successfully"}, 200)
return J({"error": "Sequence not found"}, 404)
@router.post("/sync-phase")
@with_session
async def sync_sequence_beat_phase(request, session):
async def sync_sequence_beat_phase(request: Request, session):
"""Align beat counters while a sequence is playing (body: {\"mode\": \"step\"|\"pass\"})."""
_ = session
try:
data = request.json or {}
data = await read_json(request)
except Exception:
data = {}
if not isinstance(data, dict):
@@ -221,65 +183,47 @@ async def sync_sequence_beat_phase(request, session):
from util.sequence_playback import sync_beat_phase
if not await sync_beat_phase(str(mode)):
return (
json.dumps({"error": "No sequence is playing"}),
409,
{"Content-Type": "application/json"},
)
return J({"error": "No sequence is playing"}, 409)
from util.audio_detector import anchor_shared_bar_phase
anchor_shared_bar_phase()
return json.dumps({"ok": True, "mode": str(mode).strip().lower()}), 200, {
"Content-Type": "application/json"
}
return J({"ok": True, "mode": str(mode).strip().lower()}, 200)
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
@controller.post("/stop")
@router.post("/stop")
@with_session
async def stop_sequence_playback(request, session):
async def stop_sequence_playback(request: Request, session):
"""Stop server-driven zone sequence playback."""
_ = request
try:
from util.sequence_playback import stop_playback
await stop_playback(clear_devices=True)
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
return J({"ok": True}, 200)
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
@controller.post("/<id>/play")
@router.post("/{id}/play")
@with_session
async def play_sequence(request, session, id):
async def play_sequence(request: Request, session, id):
"""Start server-driven playback for a sequence in a zone (body: {\"zone_id\": \"...\"})."""
if not get_current_bridge():
return (
json.dumps({"error": "Transport not configured"}),
503,
{"Content-Type": "application/json"},
)
return J({"error": "Transport not configured"}, 503)
current_profile_id = get_current_profile_id(session)
if not current_profile_id:
return (
json.dumps({"error": "No profile available"}),
404,
{"Content-Type": "application/json"},
)
return J({"error": "No profile available"}, 404)
try:
data = request.json or {}
data = await read_json(request)
except Exception:
data = {}
if not isinstance(data, dict):
data = {}
zone_id = data.get("zone_id") or data.get("zoneId")
if zone_id is None or str(zone_id).strip() == "":
return (
json.dumps({"error": "zone_id required"}),
400,
{"Content-Type": "application/json"},
)
return J({"error": "zone_id required"}, 400)
zone_id = str(zone_id).strip()
try:
from util.sequence_playback import start
@@ -289,10 +233,10 @@ async def play_sequence(request, session, id):
from util.sequence_playback import pending_play_status
body = {"ok": True, **pending_play_status()}
return json.dumps(body), 200, {"Content-Type": "application/json"}
return J(body, 200)
except ValueError as e:
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
return J({"error": str(e)}, 400)
except RuntimeError as e:
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
return J({"error": str(e)}, 503)
except Exception as e:
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)

View File

@@ -1,22 +1,25 @@
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import asyncio
import json
from microdot import Microdot, send_file
from settings import get_settings
controller = Microdot()
router = APIRouter()
settings = get_settings()
@controller.get('')
async def get_settings(request):
@router.get("/")
async def get_settings(request: Request):
"""Get all settings."""
# Settings is already a dict subclass; avoid dict() wrapper which can
# trigger MicroPython's "dict update sequence has wrong length" quirk.
return json.dumps(settings), 200, {'Content-Type': 'application/json'}
return J(settings, 200)
@controller.get('/wifi/ap')
async def get_ap_config(request):
@router.get("/wifi/ap")
async def get_ap_config(request: Request):
"""Get saved AP configuration (Pi: no in-device AP)."""
config = {
'saved_ssid': settings.get('wifi_ap_ssid'),
@@ -24,40 +27,37 @@ async def get_ap_config(request):
'saved_channel': settings.get('wifi_ap_channel'),
'active': False,
}
return json.dumps(config), 200, {'Content-Type': 'application/json'}
return J(config, 200)
@controller.post('/wifi/ap')
async def configure_ap(request):
@router.post("/wifi/ap")
async def configure_ap(request: Request):
"""Save AP configuration to settings (Pi: no in-device AP)."""
try:
data = request.json
data = await read_json(request)
ssid = data.get('ssid')
password = data.get('password', '')
channel = data.get('channel')
if not ssid:
return json.dumps({"error": "SSID is required"}), 400
return J({"error": "SSID is required"}, 400)
# Validate channel (1-11 for 2.4GHz)
if channel is not None:
channel = int(channel)
if channel < 1 or channel > 11:
return json.dumps({"error": "Channel must be between 1 and 11"}), 400
return J({"error": "Channel must be between 1 and 11"}, 400)
settings['wifi_ap_ssid'] = ssid
settings['wifi_ap_password'] = password
if channel is not None:
settings['wifi_ap_channel'] = channel
settings.save()
return json.dumps({
return J({
"message": "AP settings saved",
"ssid": ssid,
"channel": channel
}), 200, {'Content-Type': 'application/json'}
"channel": channel,
}, 200)
except Exception as e:
return json.dumps({"error": str(e)}), 500
return J({"error": str(e)}, 500)
def _validate_wifi_channel(value):
"""Return int 111 or raise ValueError."""
ch = int(value)
@@ -95,11 +95,17 @@ def _validate_audio_input_volume(value):
return v
@controller.put('')
async def update_settings(request):
def _validate_audio_simulated_bpm(value):
from util.bpm_limits import clamp_bpm
return int(clamp_bpm(value))
@router.put("/")
async def update_settings(request: Request):
"""Update general settings."""
try:
data = request.json
data = await read_json(request)
global_brightness_changed = False
for key, value in data.items():
if key == 'wifi_channel' and value is not None:
@@ -113,17 +119,18 @@ async def update_settings(request):
settings[key] = _validate_audio_beat_phase_ms(value)
elif key == 'audio_input_volume' and value is not None:
settings[key] = _validate_audio_input_volume(value)
elif key == 'audio_simulated_bpm' and value is not None:
settings[key] = _validate_audio_simulated_bpm(value)
else:
settings[key] = value
settings.save()
return json.dumps({"message": "Settings updated successfully"}), 200, {'Content-Type': 'application/json'}
return J({"message": "Settings updated successfully"}, 200)
except ValueError as e:
return json.dumps({"error": str(e)}), 400
return J({"error": str(e)}, 400)
except Exception as e:
return json.dumps({"error": str(e)}), 500
@controller.get('/page')
async def settings_page(request):
return J({"error": str(e)}, 500)
@router.get("/page")
async def settings_page(request: Request):
"""Serve the settings page."""
return send_file('templates/settings.html')

View File

@@ -2,10 +2,14 @@
from __future__ import annotations
from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
import json
import secrets
from microdot import Microdot
from settings import get_settings
from util.bridge_profiles import find_bridge_profile, normalise_bridges
@@ -20,7 +24,7 @@ from util.bridge_runtime import (
)
from util.pi_wifi import list_wifi_interfaces, nmcli_available, scan_wifi
controller = Microdot()
router = APIRouter()
def _bridge_transport(settings) -> str:
@@ -44,55 +48,39 @@ def _bridges_payload(settings) -> dict:
}
@controller.get("/interfaces")
async def wifi_interfaces(request):
@router.get("/interfaces")
async def wifi_interfaces(request: Request):
_ = request
if not nmcli_available():
return (
json.dumps({"ok": False, "error": "nmcli not found (install NetworkManager)"}),
503,
{"Content-Type": "application/json"},
)
return (
json.dumps({"ok": True, "interfaces": list_wifi_interfaces()}),
200,
{"Content-Type": "application/json"},
)
return J({"ok": False, "error": "nmcli not found (install NetworkManager)"}, 503)
return J({"ok": True, "interfaces": list_wifi_interfaces()}, 200)
@controller.get("/scan")
async def wifi_scan(request):
device = (request.args.get("device") or "").strip()
@router.get("/scan")
async def wifi_scan(request: Request):
device = (request.query_params.get("device") or "").strip()
if not device:
return json.dumps({"error": "device query param required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "device query param required"}, 400)
if not nmcli_available():
return json.dumps({"ok": False, "error": "nmcli not found"}), 503, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": "nmcli not found"}, 503)
try:
networks = await scan_wifi(device)
return json.dumps({"ok": True, "device": device, "networks": networks}), 200, {
"Content-Type": "application/json",
}
return J({"ok": True, "device": device, "networks": networks}, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)
@controller.get("/bridges")
async def get_bridges(request):
@router.get("/bridges")
async def get_bridges(request: Request):
_ = request
settings = get_settings()
return json.dumps(_bridges_payload(settings)), 200, {"Content-Type": "application/json"}
return J(_bridges_payload(settings), 200)
@controller.put("/bridges")
async def put_bridges(request):
@router.put("/bridges")
async def put_bridges(request: Request):
try:
data = request.json or {}
data = await read_json(request)
settings = get_settings()
if "wifi_interface" in data:
settings["wifi_interface"] = str(data.get("wifi_interface") or "").strip()
@@ -109,62 +97,50 @@ async def put_bridges(request):
if "bridges" in data:
settings["bridges"] = normalise_bridges(data.get("bridges"))
settings.save()
return json.dumps({"ok": True, "message": "Bridge profiles saved"}), 200, {
"Content-Type": "application/json",
}
return J({"ok": True, "message": "Bridge profiles saved"}, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 400, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 400)
@controller.delete("/bridges/<bridge_id>")
async def delete_bridge_profile(request, bridge_id):
@router.delete("/bridges/{bridge_id}")
async def delete_bridge_profile(request: Request, bridge_id):
_ = request
settings = get_settings()
bid = str(bridge_id or "").strip()
bridges = normalise_bridges(settings.get("bridges"))
kept = [b for b in bridges if str(b.get("id") or "") != bid]
if len(kept) == len(bridges):
return json.dumps({"ok": False, "error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": "Bridge profile not found"}, 404)
settings["bridges"] = kept
settings.save()
payload = _bridges_payload(settings)
payload["message"] = "Bridge profile deleted"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
@controller.post("/bridges/<bridge_id>/connect")
async def connect_saved_bridge(request, bridge_id):
@router.post("/bridges/{bridge_id}/connect")
async def connect_saved_bridge(request: Request, bridge_id):
_ = request
settings = get_settings()
profile = find_bridge_profile(settings, bridge_id)
if not profile:
return json.dumps({"error": "Bridge profile not found"}), 404, {
"Content-Type": "application/json",
}
return J({"error": "Bridge profile not found"}, 404)
try:
ok, err = await connect_bridge_profile(profile, settings)
if not ok:
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": err or "Connect failed"}, 400)
payload = _bridges_payload(settings)
payload["message"] = f"Connected to {profile.get('label')}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)
@controller.post("/connect")
async def wifi_connect_bridge(request):
@router.post("/connect")
async def wifi_connect_bridge(request: Request):
"""Join a bridge AP and open its WebSocket."""
try:
data = request.json or {}
data = await read_json(request)
settings = get_settings()
device = str(data.get("device") or settings.get("wifi_interface") or "").strip()
ssid = str(data.get("ssid") or "").strip()
@@ -177,13 +153,9 @@ async def wifi_connect_bridge(request):
label = str(data.get("label") or ssid).strip() or ssid
save_profile = bool(data.get("save_profile", True))
if not device:
return json.dumps({"error": "WiFi interface (device) is required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "WiFi interface (device) is required"}, 400)
if not ssid:
return json.dumps({"error": "ssid is required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "ssid is required"}, 400)
settings["wifi_interface"] = device
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
@@ -217,23 +189,19 @@ async def wifi_connect_bridge(request):
}
ok, err = await connect_bridge_wifi(profile, settings)
if not ok:
return json.dumps({"ok": False, "error": err or "Connect failed"}), 400, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": err or "Connect failed"}, 400)
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected to {ssid}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)
@controller.post("/serial/connect")
async def serial_connect_bridge(request):
@router.post("/serial/connect")
async def serial_connect_bridge(request: Request):
try:
data = request.json or {}
data = await read_json(request)
port = str(data.get("port") or data.get("serial_port") or "").strip()
save_profile = bool(data.get("save_profile", True))
label = str(data.get("label") or port).strip() or port
@@ -242,9 +210,7 @@ async def serial_connect_bridge(request):
except (TypeError, ValueError):
baud = 921600
if not port:
return json.dumps({"error": "port is required"}), 400, {
"Content-Type": "application/json",
}
return J({"error": "port is required"}, 400)
settings = get_settings()
bridges = normalise_bridges(settings.get("bridges"))
profile_id = None
@@ -269,14 +235,10 @@ async def serial_connect_bridge(request):
profile = {"transport": "serial", "serial_port": port, "serial_baudrate": baud}
ok, err = await connect_bridge_serial(profile, settings)
if not ok:
return json.dumps({"ok": False, "error": err}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": err}, 500)
payload = _bridges_payload(settings)
payload["profile_id"] = profile_id
payload["message"] = f"Connected on {port}"
return json.dumps(payload), 200, {"Content-Type": "application/json"}
return J(payload, 200)
except Exception as e:
return json.dumps({"ok": False, "error": str(e)}), 500, {
"Content-Type": "application/json",
}
return J({"ok": False, "error": str(e)}, 500)

View File

@@ -1,10 +1,12 @@
from microdot import Microdot, send_file
from microdot.session import with_session
from fastapi import APIRouter, Request
from http_responses import J, J_cookie, html_response, plain, read_json, send_file
from http_session import with_session
from models.zone import Zone
from models.profile import Profile
import json
controller = Microdot()
router = APIRouter()
zones = Zone()
profiles = Profile()
@@ -69,11 +71,7 @@ def _render_zones_list_fragment(request, session):
"""Render zone strip HTML for HTMX / JS."""
profile_id = get_current_profile_id(session)
if not profile_id:
return (
'<div class="zones-list">No profile selected</div>',
200,
{"Content-Type": "text/html"},
)
return html_response('<div class="zones-list">No profile selected</div>', 200)
zone_order = get_profile_zone_order(profile_id)
current_zone_id = get_current_zone_id(request, session)
@@ -96,9 +94,7 @@ def _render_zones_list_fragment(request, session):
+ "</button>"
)
html += "</div>"
return html, 200, {"Content-Type": "text/html"}
return html_response(html, 200)
def _render_zone_content_fragment(request, session, id):
if id == "current":
current_zone_id = get_current_zone_id(request, session)
@@ -106,18 +102,13 @@ def _render_zone_content_fragment(request, session, id):
accept_header = request.headers.get("Accept", "")
wants_html = "text/html" in accept_header
if wants_html:
return (
'<div class="error">No current zone set</div>',
404,
{"Content-Type": "text/html"},
)
return json.dumps({"error": "No current zone set"}), 404
return html_response('<div class="error">No current zone set</div>', 404)
return J({"error": "No current zone set"}, 404)
id = current_zone_id
z = zones.read(id)
if not z:
return '<div>Zone not found</div>', 404, {"Content-Type": "text/html"}
return html_response('<div>Zone not found</div>', 404)
session["current_zone"] = str(id)
session.save()
@@ -133,18 +124,16 @@ def _render_zone_content_fragment(request, session, id):
"</div>"
"</div>"
)
return html, 200, {"Content-Type": "text/html"}
@controller.get("/<id>/content-fragment")
return html_response(html, 200)
@router.get("/{id}/content-fragment")
@with_session
async def zone_content_fragment(request, session, id):
async def zone_content_fragment(request: Request, session, id):
return _render_zone_content_fragment(request, session, id)
@controller.get("")
@router.get("/")
@with_session
async def list_zones(request, session):
async def list_zones(request: Request, session):
zones.load()
profile_id = get_current_profile_id(session)
current_zone_id = get_current_zone_id(request, session)
@@ -156,93 +145,66 @@ async def list_zones(request, session):
if zdata:
zones_data[zid] = zdata
return (
json.dumps(
{
return J({
"zones": zones_data,
"zone_order": zone_order,
"current_zone_id": current_zone_id,
"profile_id": profile_id,
}
),
200,
{"Content-Type": "application/json"},
)
}, 200)
@controller.get("/current")
@router.get("/current")
@with_session
async def get_current_zone(request, session):
async def get_current_zone(request: Request, session):
current_zone_id = get_current_zone_id(request, session)
if not current_zone_id:
return (
json.dumps({"error": "No current zone set", "zone": None, "zone_id": None}),
404,
)
return J({"error": "No current zone set", "zone": None, "zone_id": None}, 404)
z = zones.read(current_zone_id)
if z:
return (
json.dumps({"zone": z, "zone_id": current_zone_id}),
200,
{"Content-Type": "application/json"},
)
return (
json.dumps({"error": "Zone not found", "zone": None, "zone_id": None}),
404,
)
return J({"zone": z, "zone_id": current_zone_id}, 200)
return J({"error": "Zone not found", "zone": None, "zone_id": None}, 404)
@controller.post("/<id>/set-current")
async def set_current_zone(request, id):
@router.post("/{id}/set-current")
async def set_current_zone(request: Request, id):
z = zones.read(id)
if not z:
return json.dumps({"error": "Zone not found"}), 404
response_data = json.dumps({"message": "Current zone set", "zone_id": id})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
f"current_zone={id}; Path=/; Max-Age=31536000; SameSite=Lax"
),
},
return J({"error": "Zone not found"}, 404)
return J_cookie(
{"message": "Current zone set", "zone_id": id},
name="current_zone",
value=str(id),
max_age=31536000,
)
@controller.get("/<id>")
async def get_zone(request, id):
@router.get("/{id}")
async def get_zone(request: Request, id):
zones.load()
z = zones.read(id)
if z:
return json.dumps(z), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
@controller.put("/<id>")
async def update_zone(request, id):
return J(z, 200)
return J({"error": "Zone not found"}, 404)
@router.put("/{id}")
async def update_zone(request: Request, id):
try:
data = request.json
data = await read_json(request)
if zones.update(id, data):
return json.dumps(zones.read(id)), 200, {"Content-Type": "application/json"}
return json.dumps({"error": "Zone not found"}), 404
return J(zones.read(id), 200)
return J({"error": "Zone not found"}, 404)
except Exception as e:
return json.dumps({"error": str(e)}), 400
@controller.delete("/<id>")
return J({"error": str(e)}, 400)
@router.delete("/{id}")
@with_session
async def delete_zone(request, session, id):
async def delete_zone(request: Request, session, id):
try:
if id == "current":
current_zone_id = get_current_zone_id(request, session)
if current_zone_id:
id = current_zone_id
else:
return json.dumps({"error": "No current zone to delete"}), 404
return J({"error": "No current zone to delete"}, 404)
if zones.delete(id):
profile_id = get_current_profile_id(session)
if profile_id:
@@ -256,23 +218,15 @@ async def delete_zone(request, session, id):
current_zone_id = get_current_zone_id(request, session)
if current_zone_id == id:
response_data = json.dumps({"message": "Zone deleted successfully"})
return (
response_data,
200,
{
"Content-Type": "application/json",
"Set-Cookie": (
"current_zone=; Path=/; Max-Age=0; SameSite=Lax"
),
},
return J_cookie(
{"message": "Zone deleted successfully"},
name="current_zone",
value="",
max_age=0,
)
return json.dumps({"message": "Zone deleted successfully"}), 200, {
"Content-Type": "application/json"
}
return json.dumps({"error": "Zone not found"}), 404
return J({"message": "Zone deleted successfully"}, 200)
return J({"error": "Zone not found"}, 404)
except Exception as e:
import sys
@@ -280,22 +234,24 @@ async def delete_zone(request, session, id):
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 500, {"Content-Type": "application/json"}
return J({"error": str(e)}, 500)
@controller.post("")
@router.post("/")
@with_session
async def create_zone(request, session):
async def create_zone(request: Request, session):
try:
if request.form:
name = request.form.get("name", "").strip()
ids_str = request.form.get("ids", "1").strip()
ct = (request.headers.get("content-type") or "").split(";")[0].strip().lower()
if ct in ("application/x-www-form-urlencoded", "multipart/form-data"):
form = await request.form()
name = (form.get("name") or "").strip()
ids_str = (form.get("ids") or "1").strip()
names = [i.strip() for i in ids_str.split(",") if i.strip()]
preset_ids = None
group_ids = []
content_kind = None
else:
data = request.json or {}
data = await read_json(request)
name = data.get("name", "")
names = data.get("names")
if names is None:
@@ -312,8 +268,7 @@ async def create_zone(request, session):
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
if not name:
return json.dumps({"error": "Zone name cannot be empty"}), 400
return J({"error": "Zone name cannot be empty"}, 400)
zid = zones.create(name, names, preset_ids, group_ids, content_kind)
profile_id = get_current_profile_id(session)
@@ -327,23 +282,20 @@ async def create_zone(request, session):
profiles.update(profile_id, profile)
zdata = zones.read(zid)
return json.dumps({zid: zdata}), 201, {"Content-Type": "application/json"}
return J({zid: zdata}, 201)
except Exception as e:
import sys
sys.print_exception(e)
return json.dumps({"error": str(e)}), 400
@controller.post("/<id>/clone")
return J({"error": str(e)}, 400)
@router.post("/{id}/clone")
@with_session
async def clone_zone(request, session, id):
async def clone_zone(request: Request, session, id):
try:
source = zones.read(id)
if not source:
return json.dumps({"error": "Zone not found"}), 404
data = request.json or {}
return J({"error": "Zone not found"}, 404)
data = await read_json(request)
source_name = source.get("name") or f"Zone {id}"
new_name = data.get("name") or f"{source_name} Copy"
clone_id = zones.create(
@@ -368,7 +320,7 @@ async def clone_zone(request, session, id):
profiles.update(profile_id, profile)
zdata = zones.read(clone_id)
return json.dumps({clone_id: zdata}), 201, {"Content-Type": "application/json"}
return J({clone_id: zdata}, 201)
except Exception as e:
import sys
@@ -376,5 +328,4 @@ async def clone_zone(request, session, id):
sys.print_exception(e)
except Exception:
pass
return json.dumps({"error": str(e)}), 400
return J({"error": str(e)}, 400)

View File

@@ -1,4 +1,4 @@
"""FastAPI entrypoint; Microdot controllers run behind an ASGI bridge."""
"""FastAPI application entrypoint."""
from __future__ import annotations
@@ -6,23 +6,26 @@ import json
import logging
import os
from contextlib import asynccontextmanager
from typing import Any, Optional
from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, PlainTextResponse
import asyncio
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from app_factory import (
AppRuntime,
audio_status_payload,
create_microdot_app,
dev_build_id,
dev_client_revision,
live_reload_enabled,
mount_controller_routers,
mount_static_routes,
)
from microdot_asgi import MicrodotASGI
from http_session import SessionMiddleware
from models.transport import get_current_bridge
_runtime: Optional[AppRuntime] = None
_microdot_app = None
_test_mode = False
@@ -38,6 +41,15 @@ def _bridge():
return get_current_bridge()
def _notify_audio_status_sse() -> None:
try:
from util.beat_status_broadcaster import request_status_broadcast
request_status_broadcast()
except Exception:
pass
@asynccontextmanager
async def _lifespan(app: FastAPI):
global _runtime
@@ -51,14 +63,19 @@ async def _lifespan(app: FastAPI):
await _runtime.shutdown()
def _create_fastapi() -> FastAPI:
def create_application(*, test_mode: bool = False) -> FastAPI:
global _test_mode
_test_mode = test_mode
api = FastAPI(title="LED Controller", lifespan=_lifespan)
api.add_middleware(SessionMiddleware)
mount_controller_routers(api)
mount_static_routes(api, inject_live_reload=live_reload_enabled())
@api.get("/__dev/build-id", response_class=PlainTextResponse)
async def dev_build_id_route():
from app_factory import dev_build_id as current_build_id
bid = current_build_id()
bid = dev_build_id()
if not bid:
return PlainTextResponse("", status_code=404)
return PlainTextResponse(
@@ -68,8 +85,6 @@ def _create_fastapi() -> FastAPI:
@api.get("/__dev/client-rev", response_class=PlainTextResponse)
async def dev_client_rev_route():
from app_factory import dev_client_revision
rev = dev_client_revision()
if not rev:
return PlainTextResponse("", status_code=404)
@@ -114,6 +129,7 @@ def _create_fastapi() -> FastAPI:
device_override=str(body.get("device_override") or ""),
device_select=device_select,
)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@@ -146,6 +162,7 @@ def _create_fastapi() -> FastAPI:
from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=False)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/reset")
@@ -158,6 +175,7 @@ def _create_fastapi() -> FastAPI:
{"ok": False, "error": "Audio detector is not running"},
status_code=409,
)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/anchor-bar")
@@ -170,6 +188,7 @@ def _create_fastapi() -> FastAPI:
{"ok": False, "error": "Audio detector is not running"},
status_code=409,
)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()}
@api.get("/api/audio/status")
@@ -178,9 +197,54 @@ def _create_fastapi() -> FastAPI:
return JSONResponse({"error": "not ready"}, status_code=503)
return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)}
@api.get("/api/audio/events")
async def audio_events(request: Request):
if _runtime is None:
return JSONResponse({"error": "not ready"}, status_code=503)
from util.beat_status_broadcaster import (
initial_sse_line,
register_sse_client,
unregister_sse_client,
)
async def stream():
queue: asyncio.Queue[str] = asyncio.Queue(maxsize=8)
await register_sse_client(queue)
try:
yield await initial_sse_line()
while True:
if await request.is_disconnected():
break
try:
line = await asyncio.wait_for(queue.get(), timeout=30.0)
except asyncio.TimeoutError:
yield ": keepalive\n\n"
continue
yield line
finally:
await unregister_sse_client(queue)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@api.websocket("/ws")
async def ws_endpoint(websocket: WebSocket):
from util.device_status_broadcaster import (
broadcast_device_tcp_snapshot_to,
register_device_status_ws,
unregister_device_status_ws,
)
await websocket.accept()
await register_device_status_ws(websocket)
await broadcast_device_tcp_snapshot_to(websocket)
bridge = _bridge()
try:
while True:
@@ -208,44 +272,10 @@ def _create_fastapi() -> FastAPI:
pass
except Exception:
pass
finally:
await unregister_device_status_ws(websocket)
return api
class CombinedASGI:
"""Route FastAPI-only paths first; delegate the rest to Microdot."""
_FASTAPI_PREFIXES = ("/api/", "/__dev/")
def __init__(self, fastapi_app: FastAPI, microdot_asgi: MicrodotASGI):
self.fastapi_app = fastapi_app
self.microdot_asgi = microdot_asgi
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
stype = scope.get("type")
if stype == "lifespan":
await self.fastapi_app(scope, receive, send)
return
if stype == "websocket":
if scope.get("path") == "/ws":
await self.fastapi_app(scope, receive, send)
return
await send({"type": "websocket.close", "code": 1000})
return
if stype == "http":
path = scope.get("path") or ""
if path.startswith(self._FASTAPI_PREFIXES):
await self.fastapi_app(scope, receive, send)
return
await self.microdot_asgi(scope, receive, send)
def create_application(*, test_mode: bool = False) -> CombinedASGI:
global _microdot_app, _test_mode
_test_mode = test_mode
_microdot_app = create_microdot_app(inject_live_reload=live_reload_enabled())
fastapi_app = _create_fastapi()
return CombinedASGI(fastapi_app, MicrodotASGI(_microdot_app))
app = create_application()

84
src/http_responses.py Normal file
View File

@@ -0,0 +1,84 @@
"""Response helpers for FastAPI controllers."""
from __future__ import annotations
import os
from typing import Any
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
_SRC_DIR = os.path.dirname(os.path.abspath(__file__))
def J(
data: Any,
status_code: int = 200,
*,
headers: dict[str, str] | None = None,
) -> Response:
"""JSON response (accepts dict or JSON string)."""
if isinstance(data, str):
return Response(
content=data,
status_code=status_code,
media_type="application/json",
headers=headers,
)
return JSONResponse(content=data, status_code=status_code, headers=headers)
async def read_json(request) -> dict:
try:
body = await request.json()
except Exception:
return {}
return body if isinstance(body, dict) else {}
def send_file(relative_path: str) -> FileResponse:
path = os.path.join(_SRC_DIR, relative_path)
return FileResponse(path)
def send_html_file(relative_path: str, *, inject: str | None = None) -> HTMLResponse:
path = os.path.join(_SRC_DIR, relative_path)
with open(path, encoding="utf-8") as f:
html = f.read()
if inject and "</body>" in html:
html = html.replace("</body>", inject + "\n</body>", 1)
return HTMLResponse(content=html)
def html_response(content: str, status_code: int = 200) -> HTMLResponse:
return HTMLResponse(content=content, status_code=status_code)
def plain(content: str, status_code: int = 200) -> Response:
return Response(
content=content,
status_code=status_code,
media_type="text/plain; charset=utf-8",
)
def empty(status_code: int = 204) -> Response:
return Response(status_code=status_code)
def J_cookie(
data: Any,
status_code: int = 200,
*,
name: str,
value: str,
max_age: int | None = None,
path: str = "/",
samesite: str = "lax",
) -> Response:
resp = J(data, status_code)
kwargs: dict[str, Any] = {"path": path, "samesite": samesite}
if max_age is not None:
kwargs["max_age"] = max_age
resp.set_cookie(name, value, **kwargs)
return resp

117
src/http_session.py Normal file
View File

@@ -0,0 +1,117 @@
"""Signed-cookie sessions for the web UI."""
from __future__ import annotations
import inspect
from typing import Any, Callable
import jwt
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from settings import get_settings
_COOKIE = "session"
_ALGORITHM = "HS256"
class SessionDict(dict):
"""Session mapping with ``save()`` / ``delete()`` for cookie persistence."""
def __init__(self, request: Request, data: dict | None = None):
super().__init__(data or {})
self._request = request
self._save = False
self._delete = False
def save(self) -> None:
self._save = True
self._delete = False
def delete(self) -> None:
self._delete = True
self._save = False
def _secret_key() -> str:
return str(
get_settings().get(
"session_secret_key",
"led-controller-secret-key-change-in-production",
)
)
def encode_session(payload: dict) -> str:
return jwt.encode(payload, _secret_key(), algorithm=_ALGORITHM)
def decode_session(token: str) -> dict:
try:
data = jwt.decode(token, _secret_key(), algorithms=[_ALGORITHM])
return data if isinstance(data, dict) else {}
except jwt.PyJWTError:
return {}
def get_session(request: Request) -> SessionDict:
session = getattr(request.state, "session", None)
if session is None:
cookie = request.cookies.get(_COOKIE)
data = decode_session(cookie) if cookie else {}
session = SessionDict(request, data)
request.state.session = session
return session
def with_session(handler: Callable) -> Callable:
sig = inspect.signature(handler)
public_params = [
p
for name, p in sig.parameters.items()
if name not in ("request", "session")
]
if "request" in sig.parameters:
req_param = sig.parameters["request"].replace(annotation=Request)
else:
req_param = inspect.Parameter(
"request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request
)
wrapper_sig = inspect.Signature([req_param, *public_params])
async def wrapper(request: Request, *args: Any, **kwargs: Any):
session = get_session(request)
return await handler(request, session, *args, **kwargs)
wrapper.__name__ = handler.__name__
wrapper.__doc__ = handler.__doc__
wrapper.__module__ = handler.__module__
wrapper.__qualname__ = handler.__qualname__
wrapper.__signature__ = wrapper_sig # type: ignore[attr-defined]
return wrapper
def _apply_session_cookie(request: Request, response: Response) -> Response:
session: SessionDict | None = getattr(request.state, "session", None)
if session is None:
return response
if session._delete:
response.delete_cookie(_COOKIE, path="/", httponly=True)
elif session._save:
response.set_cookie(
_COOKIE,
encode_session(dict(session)),
path="/",
httponly=True,
)
return response
class SessionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
cookie = request.cookies.get(_COOKIE)
data = decode_session(cookie) if cookie else {}
request.state.session = SessionDict(request, data)
response = await call_next(request)
return _apply_session_cookie(request, response)

View File

@@ -1,84 +0,0 @@
"""ASGI bridge for existing Microdot route handlers."""
from __future__ import annotations
from typing import Any
from microdot.microdot import Microdot, NoCaseDict, Request, Response
class MicrodotASGI:
"""Dispatch HTTP requests to a :class:`Microdot` application."""
def __init__(self, microdot_app: Microdot):
self.app = microdot_app
async def __call__(self, scope: dict, receive: Any, send: Any) -> None:
if scope.get("type") != "http":
return
body = b""
while True:
message = await receive()
if message["type"] != "http.request":
continue
body += message.get("body", b"")
if not message.get("more_body"):
break
headers = NoCaseDict()
for key, value in scope.get("headers", ()):
headers[key.decode("latin-1")] = value.decode("latin-1")
path = scope.get("path", "/") or "/"
query = scope.get("query_string", b"").decode("latin-1")
url = path + (f"?{query}" if query else "")
client = scope.get("client") or ("127.0.0.1", 0)
req = Request(
self.app,
client,
scope.get("method", "GET"),
url,
"1.1",
headers,
body=body,
)
res = await self.app.dispatch_request(req)
if res is Response.already_handled:
return
await _send_microdot_response(res, send)
async def _send_microdot_response(res: Response, send: Any) -> None:
res.complete()
headers: list[tuple[bytes, bytes]] = []
for header, value in res.headers.items():
values = value if isinstance(value, list) else [value]
for item in values:
headers.append(
(header.lower().encode("latin-1"), str(item).encode("latin-1"))
)
body = res.body
if isinstance(body, str):
payload = body.encode()
elif isinstance(body, bytes):
payload = body
else:
parts: list[bytes] = []
async for chunk in res.body_iter():
if isinstance(chunk, str):
chunk = chunk.encode()
parts.append(chunk)
payload = b"".join(parts)
await send(
{
"type": "http.response.start",
"status": res.status_code,
"headers": headers,
}
)
await send({"type": "http.response.body", "body": payload})

View File

@@ -57,16 +57,6 @@ class Sequence(Model):
if doc.get("advance_mode") != "beats":
doc["advance_mode"] = "beats"
changed = True
if "simulated_bpm" not in doc:
doc["simulated_bpm"] = 120
changed = True
else:
try:
sb = int(float(doc["simulated_bpm"]))
doc["simulated_bpm"] = max(30, min(300, sb))
except (TypeError, ValueError):
doc["simulated_bpm"] = 120
changed = True
if "sequence_transition" not in doc:
doc["sequence_transition"] = 500
changed = True
@@ -115,7 +105,6 @@ class Sequence(Model):
"advance_mode": "beats",
"steps": [],
"step_duration_ms": 3000,
"simulated_bpm": 120,
"sequence_transition": 500,
"loop": True,
}

View File

@@ -67,6 +67,21 @@ class Settings(dict):
self['bridge_serial_port'] = ''
if 'bridge_serial_baudrate' not in self:
self['bridge_serial_baudrate'] = 115200
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
if 'wifi_driver_ws_port' not in self:
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
if 'wifi_driver_connect_stagger_max_s' not in self:
self['wifi_driver_connect_stagger_max_s'] = 2.5
if 'wifi_driver_ws_open_timeout' not in self:
self['wifi_driver_ws_open_timeout'] = 45.0
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self:
self['global_brightness'] = 255
@@ -81,6 +96,13 @@ class Settings(dict):
# Input gain for beat detection (percent, 0200).
if 'audio_input_volume' not in self:
self['audio_input_volume'] = 100
# BPM used for sequences when the audio detector is not running.
from util.bpm_limits import clamp_bpm
if 'audio_simulated_bpm' not in self:
self['audio_simulated_bpm'] = int(clamp_bpm(120))
else:
self['audio_simulated_bpm'] = int(clamp_bpm(self['audio_simulated_bpm']))
def save(self):
try:

View File

@@ -1382,7 +1382,7 @@ class LightingController {
const presetNames = Object.keys(this.state.presets);
if (presetNames.length === 0) {
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found. Create one to get started.</p>';
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found.</p>';
} else {
presetNames.forEach(presetName => {
const preset = this.state.presets[presetName];

View File

@@ -1,7 +1,9 @@
(() => {
let pollTimer = null;
let beatEventSource = null;
let beatEventsReconnectTimer = null;
let audioDetectorRunning = false;
let lastBeatSeq = 0;
let lastSimulatedBeatTick = 0;
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false;
/**
@@ -14,26 +16,51 @@
let cachedBeatPhaseMs = 0;
/** @type {{ device: string|number|null, device_override: string, device_select: string }} */
let cachedAudioRun = { device: null, device_override: "", device_select: "" };
/** True after client starts sequence playback until server reports stop. */
let clientSequenceUiActive = false;
/** Last pass readout (e.g. ``6/6``) kept visible briefly after playback ends. */
let stickySequenceBeatReadout = "";
function el(id) {
return document.getElementById(id);
}
/** @param {Record<string, unknown>} status */
function resolveBeatReadoutText(status) {
let text = String((status && status.beat_readout) || "").trim();
if (text) return text;
const seq = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence
);
if (seq && seq.active) {
text = String(seq.beat_readout || "").trim();
if (text) return text;
}
if (stickySequenceBeatReadout) {
return stickySequenceBeatReadout;
}
return "";
}
/** @param {Record<string, unknown>} status */
function updateBeatReadoutDisplays(status) {
const text = String((status && status.beat_readout) || "").trim();
const text = resolveBeatReadoutText(status);
for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
const n = el(id);
if (n) n.textContent = text;
}
}
function updateBpmDisplay(bpm) {
function updateBpmDisplay(bpm, simulated = false) {
const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) {
const node = el(id);
if (node) node.textContent = text;
}
for (const id of ["audio-top-indicator", "audio-modal-beat-sync"]) {
const node = el(id);
if (node) node.classList.toggle("audio-simulated", !!simulated);
}
}
/** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
@@ -44,6 +71,43 @@
return !!(seq && seq.active);
}
/** Sequence playing or waiting on beat/downbeat before start (simulated beats still run). */
function sequenceBeatUiActiveFromStatus(status) {
if (sequencePlaybackActiveFromStatus(status)) return true;
const pending = /** @type {Record<string, unknown>|undefined} */ (
status && status.sequence_pending
);
return !!(pending && pending.pending);
}
function resolveSeqUiActive(status) {
return sequenceBeatUiActiveFromStatus(status) || clientSequenceUiActive;
}
/** @param {Record<string, unknown>} status */
function updateTopIndicatorFromStatus(status) {
const running = !!(status && status.running);
const bpmSimulated = !!(status && status.bpm_simulated);
const seqUiActive = resolveSeqUiActive(status);
const show = running || seqUiActive || bpmSimulated;
setTopBpmVisible(show);
if (!show || running) return;
const simBpm =
status && status.audio_simulated_bpm != null
? Number(status.audio_simulated_bpm)
: getSimulatedBpmPercent();
updateBpmDisplay(Number.isFinite(simBpm) ? simBpm : null, true);
}
/** @param {Record<string, unknown>} status */
function shouldKeepStatusPolling(status) {
return (
!!(status && status.running) ||
resolveSeqUiActive(status) ||
!!(status && status.bpm_simulated)
);
}
function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value");
if (!node) return;
@@ -57,6 +121,8 @@
const readout = String((status && status.bar_phase_readout) || "").trim();
const phaseConf = Number((status && status.phase_confidence) || 0);
const downbeat = !!(status && status.is_downbeat);
const simulated = !!(status && status.bpm_simulated);
const showPhase = !!(status && status.running) || simulated;
let text = readout || "--";
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
text = `${text} (${Math.round(phaseConf * 100)}%)`;
@@ -64,8 +130,8 @@
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
const node = el(id);
if (!node) continue;
node.textContent = status && status.running ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout);
node.textContent = showPhase ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout && showPhase);
}
}
@@ -75,6 +141,50 @@
top.classList.toggle("audio-running", !!on);
}
function closeBeatEvents() {
if (beatEventsReconnectTimer != null) {
clearTimeout(beatEventsReconnectTimer);
beatEventsReconnectTimer = null;
}
if (beatEventSource) {
beatEventSource.close();
beatEventSource = null;
}
}
function scheduleBeatEventsReconnect() {
if (beatEventsReconnectTimer != null) return;
beatEventsReconnectTimer = setTimeout(() => {
beatEventsReconnectTimer = null;
void fetchAudioStatusOnce()
.then((status) => {
applyAudioStatus(status);
if (shouldKeepStatusPolling(status)) ensureBeatEvents();
})
.catch((e) => {
console.warn("audio status reconnect fetch failed", e);
});
}, 2000);
}
function ensureBeatEvents() {
if (beatEventSource) return;
const es = new EventSource("/api/audio/events");
beatEventSource = es;
es.onmessage = (ev) => {
try {
const data = JSON.parse(String(ev.data || ""));
if (data && data.status) applyAudioStatus(data.status);
} catch (e) {
console.warn("audio beat event parse failed", e);
}
};
es.onerror = () => {
closeBeatEvents();
scheduleBeatEventsReconnect();
};
}
function setResetDetectorEnabled(on) {
const btn = el("audio-reset-btn");
if (btn) btn.disabled = !on;
@@ -150,21 +260,25 @@
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
}
function flashBeatSyncButton(btn) {
function flashBeatSyncButton(btn, simulated = false) {
if (!btn) return;
btn.classList.add("flash");
setTimeout(() => btn.classList.remove("flash"), 90);
btn.classList.add(simulated ? "flash-simulated" : "flash");
setTimeout(() => btn.classList.remove(simulated ? "flash-simulated" : "flash"), 90);
}
function flashBeat() {
function flashBeat(simulated = false) {
const top = el("audio-top-indicator");
const topSync = el("audio-top-beat-sync");
if (topSync && top && top.classList.contains("audio-running")) {
flashBeatSyncButton(topSync);
if (
topSync &&
top &&
(top.classList.contains("audio-running") || simulated)
) {
flashBeatSyncButton(topSync, simulated);
}
const modalSync = el("audio-modal-beat-sync");
if (modalSync && audioDetectorRunning) {
flashBeatSyncButton(modalSync);
if (modalSync && (audioDetectorRunning || simulated)) {
flashBeatSyncButton(modalSync, simulated);
}
}
@@ -214,6 +328,38 @@
}
}
const SIMULATED_BPM_MIN = 60;
const SIMULATED_BPM_MAX = 200;
function clampSimulatedBpm(n) {
if (!Number.isFinite(n)) return 120;
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, Math.round(n)));
}
function clampLiveBpm(n) {
if (!Number.isFinite(n)) return null;
return Math.min(SIMULATED_BPM_MAX, Math.max(SIMULATED_BPM_MIN, n));
}
function getSimulatedBpmPercent() {
const inp = el("audio-simulated-bpm");
if (!inp) return 120;
return clampSimulatedBpm(parseInt(String(inp.value).trim(), 10));
}
async function persistSimulatedBpm() {
const bpm = getSimulatedBpmPercent();
try {
await fetch("/settings", {
method: "PUT",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ audio_simulated_bpm: bpm }),
});
} catch (e) {
console.warn("simulated bpm save failed", e);
}
}
async function persistInputVolume() {
const vol = getInputVolumePercent();
updateInputVolumeReadout();
@@ -228,11 +374,11 @@
}
}
function scheduleBeatPhaseFire(seq, delayMs) {
function scheduleBeatPhaseFire(seq, delayMs, simulated = false) {
let tid = null;
const run = () => {
if (tid != null) pendingBeatPhaseTimers.delete(tid);
flashBeat();
flashBeat(simulated);
try {
window.dispatchEvent(
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
@@ -252,23 +398,23 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() {
audioDetectorRunning = false;
setTopBpmVisible(false);
setResetDetectorEnabled(false);
clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0;
lastSimulatedBeatTick = 0;
prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false;
updateBeatReadoutDisplays({});
updateInputLevelDisplay(0);
setTopBpmVisible(true);
updateBpmDisplay(getSimulatedBpmPercent(), true);
try {
await fetch("/api/audio/stop", { method: "POST" });
} catch (e) {
console.warn("audio stop failed", e);
}
ensureBeatEvents();
await pollStatus();
}
/** User-initiated stop (run intent cleared on server). */
@@ -276,11 +422,24 @@
await stopAudioOnly();
}
async function fetchAudioStatusOnce() {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
return data?.status || {};
}
async function pollStatus() {
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
const status = await fetchAudioStatusOnce();
applyAudioStatus(status);
} catch (e) {
console.warn("audio status fetch failed", e);
}
}
/** @param {Record<string, unknown>} status */
function applyAudioStatus(status) {
try {
if (status.error && String(status.error).trim()) {
const node = el("audio-hit-type-value");
if (node) {
@@ -288,28 +447,41 @@
}
updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running;
updateBpmDisplay(null);
updateInputLevelDisplay(0);
setTopBpmVisible(!!status.running);
updateTopIndicatorFromStatus(status);
setResetDetectorEnabled(!!status.running);
if (!status.running && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (!shouldKeepStatusPolling(status)) closeBeatEvents();
return;
}
audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status);
setTopBpmVisible(!!status.running || zoneSeqActive);
const seqUiActive = resolveSeqUiActive(status);
const bpmSimulated = !!status.bpm_simulated;
if (sequenceBeatUiActiveFromStatus(status)) {
clientSequenceUiActive = false;
}
updateTopIndicatorFromStatus(status);
setResetDetectorEnabled(!!status.running);
updateSequenceSyncControls(zoneSeqActive);
updateBpmDisplay(status.bpm);
updateSequenceSyncControls(zoneSeqActive || clientSequenceUiActive);
const displayBpm =
bpmSimulated && status.audio_simulated_bpm != null
? clampSimulatedBpm(Number(status.audio_simulated_bpm))
: status.bpm != null
? clampLiveBpm(Number(status.bpm))
: null;
updateBpmDisplay(
Number.isFinite(displayBpm) ? displayBpm : null,
bpmSimulated,
);
updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status);
updateInputLevelDisplay(
status.running ? Number(status.input_level) : 0,
);
applyServerAudioUiFields(status);
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
window.setSequenceSwitchSimulatedMode(bpmSimulated);
}
if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
}
@@ -319,30 +491,56 @@
* `sequence` on each poll.
*/
const beatSeq = Number(status.beat_seq || 0);
const simTick = Number(status.simulated_beat_tick || 0);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) {
headerBeatStickyIdleAfterSeq = false;
stickySequenceBeatReadout = "";
if (bpmSimulated) {
lastSimulatedBeatTick = Math.max(0, simTick - 1);
}
}
if (zoneSeqActive) {
const liveReadout = String((status.beat_readout || "") || "").trim()
|| String((status.sequence && status.sequence.beat_readout) || "").trim();
if (liveReadout) {
stickySequenceBeatReadout = liveReadout;
}
}
if (endedSeq) {
clientSequenceUiActive = false;
headerBeatStickyIdleAfterSeq = true;
clearBeatPhaseTimers();
lastBeatSeq = beatSeq;
lastSimulatedBeatTick = simTick;
if (!stickySequenceBeatReadout) {
const tail = String((status.beat_readout || "") || "").trim();
if (tail) stickySequenceBeatReadout = tail;
}
}
if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (bpmSimulated && simTick > lastSimulatedBeatTick) {
lastSimulatedBeatTick = simTick;
scheduleBeatPhaseFire(simTick, getBeatPhaseDelayMs(), true);
} else if (!zoneSeqActive && headerBeatStickyIdleAfterSeq) {
if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
headerBeatStickyIdleAfterSeq = false;
}
} else if (beatSeq > lastBeatSeq) {
} else if (!bpmSimulated && beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs());
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
}
updateBeatReadoutDisplays(status);
if (shouldKeepStatusPolling(status)) {
ensureBeatEvents();
} else {
closeBeatEvents();
}
} catch (e) {
console.warn("audio status poll failed", e);
console.warn("audio status apply failed", e);
}
}
@@ -479,7 +677,7 @@
setSelectedDeviceId(selected);
updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250);
ensureBeatEvents();
await pollStatus();
}
@@ -594,6 +792,17 @@
});
updateInputVolumeReadout();
}
const simBpmInp = el("audio-simulated-bpm");
if (simBpmInp) {
const onSimBpmChange = () => {
void persistSimulatedBpm();
if (!audioDetectorRunning) {
updateBpmDisplay(getSimulatedBpmPercent(), true);
}
};
simBpmInp.addEventListener("input", onSimBpmChange);
simBpmInp.addEventListener("change", onSimBpmChange);
}
for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id);
@@ -614,22 +823,24 @@
});
}
async function resumePollingIfDetectorRunning() {
async function resumeBeatEventsIfNeeded() {
try {
const res = await fetch("/api/audio/status", { cache: "no-store" });
const data = await res.json();
const status = data?.status || {};
const status = await fetchAudioStatusOnce();
audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) {
pollTimer = setInterval(pollStatus, 250);
updateTopIndicatorFromStatus(status);
if (shouldKeepStatusPolling(status)) {
lastBeatSeq = Number(status.beat_seq || 0);
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus();
applyAudioStatus(status);
ensureBeatEvents();
} else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status));
updateSequenceSyncControls(
sequencePlaybackActiveFromStatus(status) || clientSequenceUiActive,
);
}
} catch (e) {
console.warn("audio resume poll check failed", e);
console.warn("audio resume status check failed", e);
}
}
@@ -662,6 +873,17 @@
updateInputVolumeReadout();
}
}
const simBpmInp = el("audio-simulated-bpm");
if (
simBpmInp &&
status.audio_simulated_bpm != null &&
document.activeElement !== simBpmInp
) {
const bpm = parseInt(String(status.audio_simulated_bpm), 10);
if (Number.isFinite(bpm)) {
simBpmInp.value = String(clampSimulatedBpm(bpm));
}
}
}
async function loadServerAudioUiFields() {
@@ -677,27 +899,38 @@
setSelectedDeviceId(saved);
}
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0);
updateTopIndicatorFromStatus(status);
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
window.setSequenceSwitchSimulatedMode(!!status.bpm_simulated);
}
if (!status.running) {
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
applyAudioStatus(status);
}
} catch (e) {
console.warn("audio status load failed", e);
}
}
/** Called from sequences.js when server playback starts/stops without audio polling. */
/** Called from sequences.js when server playback starts/stops. */
window.ledControllerSequencePlaybackChanged = (active) => {
clientSequenceUiActive = !!active;
updateSequenceSyncControls(!!active);
if (active) {
setTopBpmVisible(true);
if (!audioDetectorRunning) {
updateBpmDisplay(getSimulatedBpmPercent(), true);
}
ensureBeatEvents();
void pollStatus();
return;
}
if (!pollTimer) {
setTopBpmVisible(false);
updateSequenceSyncControls(false);
}
void pollStatus();
};
document.addEventListener("DOMContentLoaded", async () => {
bind();
await loadServerAudioUiFields();
await resumePollingIfDetectorRunning();
await resumeBeatEventsIfNeeded();
});
})();

View File

@@ -1,6 +1,5 @@
// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address
const HEX_BOX_COUNT = 12;
/** Last TCP snapshot from WebSocket (so we can apply after async list render). */
let lastTcpSnapshotIps = null;
@@ -290,75 +289,51 @@ function mergeTcpSnapshotPresence(ip, connected) {
lastTcpSnapshotIps = Array.from(set);
}
function makeHexAddressBoxes(container) {
if (!container || container.querySelector('.hex-addr-box')) return;
container.innerHTML = '';
for (let i = 0; i < HEX_BOX_COUNT; i++) {
const input = document.createElement('input');
input.type = 'text';
input.className = 'hex-addr-box';
input.maxLength = 1;
input.autocomplete = 'off';
input.setAttribute('data-index', i);
input.setAttribute('inputmode', 'numeric');
input.setAttribute('aria-label', `Hex digit ${i + 1}`);
input.addEventListener('input', (e) => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
e.target.value = v;
if (v && e.target.nextElementSibling && e.target.nextElementSibling.classList.contains('hex-addr-box')) {
e.target.nextElementSibling.focus();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !e.target.value && e.target.previousElementSibling) {
e.target.previousElementSibling.focus();
}
});
input.addEventListener('paste', (e) => {
e.preventDefault();
const pasted = (e.clipboardData.getData('text') || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
for (let j = 0; j < pasted.length && j < boxes.length; j++) {
boxes[j].value = pasted[j];
}
if (pasted.length > 0) {
const nextIdx = Math.min(pasted.length, boxes.length - 1);
boxes[nextIdx].focus();
}
});
container.appendChild(input);
}
}
function setAddressToBoxes(container, addrStr) {
if (!container) return;
const s = (addrStr || '').replace(/[^0-9a-fA-F]/g, '').slice(0, HEX_BOX_COUNT);
const boxes = container.querySelectorAll('.hex-addr-box');
boxes.forEach((b, i) => {
b.value = s[i] || '';
});
}
function applyTransportVisibility(transport) {
const isWifi = transport === 'wifi';
const esp = document.getElementById('edit-device-address-espnow');
const espDrv = document.getElementById('edit-device-espnow-driver-wrap');
const wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
const drvWrap = document.getElementById('edit-device-wifi-driver-wrap');
if (esp) esp.hidden = isWifi;
if (espDrv) espDrv.hidden = isWifi;
if (wifiWrap) wifiWrap.hidden = !isWifi;
if (drvWrap) drvWrap.hidden = !isWifi;
}
function getDriverConfigPushFields(transport, registryName) {
const push = {};
if (registryName) push.name = registryName;
if (transport === 'wifi') {
const nl = document.getElementById('edit-device-wifi-num-leds');
const co = document.getElementById('edit-device-wifi-color-order');
const ws = document.getElementById('edit-device-wifi-startup-mode');
if (nl && nl.value !== '') {
const n = parseInt(nl.value, 10);
if (!Number.isNaN(n) && n >= 1) push.num_leds = n;
}
if (co && co.value) push.color_order = co.value;
if (ws && ws.value) push.startup_mode = ws.value;
} else {
const nl = document.getElementById('edit-device-espnow-num-leds');
const co = document.getElementById('edit-device-espnow-color-order');
if (nl && nl.value !== '') {
const n = parseInt(nl.value, 10);
if (!Number.isNaN(n) && n >= 1) push.num_leds = n;
}
if (co && co.value) push.color_order = co.value;
}
return push;
}
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();
const macEl = document.getElementById('edit-device-address-mac');
const hex = normalizeMacInput(macEl && macEl.value);
return hex || null;
}
@@ -395,30 +370,18 @@ function collectDeviceEditPayload() {
}
if (co && co.value) payload.wifi_color_order = co.value;
if (ws && ws.value) payload.wifi_startup_mode = ws.value;
} else {
const nl = document.getElementById('edit-device-espnow-num-leds');
const co = document.getElementById('edit-device-espnow-color-order');
if (nl && nl.value !== '') {
const n = parseInt(nl.value, 10);
if (!Number.isNaN(n) && n >= 1) payload.num_leds = n;
}
if (co && co.value) payload.color_order = co.value;
}
return { devId, payload };
}
function refreshEditDeviceDebug() {
const ta = document.getElementById('edit-device-debug');
if (!ta) return;
try {
const { devId, payload } = collectDeviceEditPayload();
const loaded = window.__editDeviceLoadedSnapshot;
ta.value = JSON.stringify(
{
device_id: devId || null,
loaded_from_server: loaded != null ? loaded : null,
save_payload_preview: payload,
},
null,
2,
);
} catch (e) {
ta.value = String(e);
}
}
async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal');
if (!container) return;
@@ -508,7 +471,7 @@ function renderDevicesList(devices) {
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.';
p.textContent = 'No devices yet.';
container.appendChild(p);
return;
}
@@ -622,18 +585,13 @@ function renderDevicesList(devices) {
}
function openEditDeviceModal(devId, dev) {
try {
window.__editDeviceLoadedSnapshot = dev ? JSON.parse(JSON.stringify(dev)) : null;
} catch (e) {
window.__editDeviceLoadedSnapshot = dev || null;
}
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 macInput = document.getElementById('edit-device-address-mac');
const wifiInput = document.getElementById('edit-device-address-wifi');
if (!modal || !idInput) return;
idInput.value = devId;
@@ -643,8 +601,22 @@ function openEditDeviceModal(devId, dev) {
const tr = (dev && dev.transport) || 'espnow';
if (transportSel) transportSel.value = tr;
applyTransportVisibility(tr);
setAddressToBoxes(addressBoxes, tr === 'espnow' ? ((dev && dev.address) || '') : '');
if (macInput) macInput.value = tr === 'espnow' ? ((dev && dev.address) || '') : '';
if (wifiInput) wifiInput.value = tr === 'wifi' ? ((dev && dev.address) || '') : '';
const eLeds = document.getElementById('edit-device-espnow-num-leds');
const eCo = document.getElementById('edit-device-espnow-color-order');
if (eLeds) {
eLeds.value =
tr === 'espnow' && dev && dev.num_leds != null && dev.num_leds !== ''
? String(dev.num_leds)
: '';
}
if (eCo) {
const co = (dev && dev.color_order) || 'rgb';
eCo.value = ['rgb', 'rbg', 'grb', 'gbr', 'brg', 'bgr'].includes(String(co).toLowerCase())
? String(co).toLowerCase()
: 'rgb';
}
const wName = document.getElementById('edit-device-wifi-driver-name');
const wLeds = document.getElementById('edit-device-wifi-num-leds');
const wCo = document.getElementById('edit-device-wifi-color-order');
@@ -689,35 +661,11 @@ function openEditDeviceModal(devId, dev) {
obr.value = String(bv);
if (obv) obv.textContent = String(bv);
}
refreshEditDeviceDebug();
modal.classList.add('active');
}
async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) {
async function updateDevice(devId, payload) {
try {
const payload = {
name,
type: type || 'led',
transport: transport || 'espnow',
address,
};
if (typeof outputBrightness === 'number') {
payload.output_brightness = Math.max(0, Math.min(255, Math.round(outputBrightness)));
}
if (transport === 'wifi' && wifiDriverFields && typeof wifiDriverFields === 'object') {
if (wifiDriverFields.wifi_driver_display_name != null) {
payload.wifi_driver_display_name = wifiDriverFields.wifi_driver_display_name;
}
if (wifiDriverFields.wifi_driver_num_leds != null) {
payload.wifi_driver_num_leds = wifiDriverFields.wifi_driver_num_leds;
}
if (wifiDriverFields.wifi_color_order != null) {
payload.wifi_color_order = wifiDriverFields.wifi_color_order;
}
if (wifiDriverFields.wifi_startup_mode != null) {
payload.wifi_startup_mode = wifiDriverFields.wifi_startup_mode;
}
}
const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -796,8 +744,6 @@ document.addEventListener('DOMContentLoaded', () => {
refreshDevicesListQuiet();
});
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
const devOutBr = document.getElementById('edit-device-output-brightness');
const devOutBrVal = document.getElementById('edit-device-output-brightness-value');
if (devOutBr && devOutBrVal) {
@@ -810,7 +756,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (transportEdit) {
transportEdit.addEventListener('change', () => {
applyTransportVisibility(transportEdit.value);
refreshEditDeviceDebug();
});
}
@@ -878,38 +823,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (editForm) {
editForm.addEventListener('input', () => refreshEditDeviceDebug());
editForm.addEventListener('change', () => refreshEditDeviceDebug());
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const { devId, payload } = collectDeviceEditPayload();
if (!devId) return;
const transport = payload.transport || 'espnow';
let wifiDriverFields = null;
if (transport === 'wifi') {
wifiDriverFields = {};
if (payload.wifi_driver_display_name != null) {
wifiDriverFields.wifi_driver_display_name = payload.wifi_driver_display_name;
}
if (payload.wifi_driver_num_leds != null) {
wifiDriverFields.wifi_driver_num_leds = payload.wifi_driver_num_leds;
}
if (payload.wifi_color_order != null) {
wifiDriverFields.wifi_color_order = payload.wifi_color_order;
}
if (payload.wifi_startup_mode != null) {
wifiDriverFields.wifi_startup_mode = payload.wifi_startup_mode;
}
}
const ok = await updateDevice(
devId,
payload.name,
payload.type,
transport,
payload.address,
wifiDriverFields,
payload.output_brightness,
);
const ok = await updateDevice(devId, payload);
if (!ok) return;
try {
const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, {
@@ -925,21 +843,12 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (e) {
console.warn('brightness push failed', e);
}
if (transport === 'wifi' && wifiDriverFields) {
const dn = document.getElementById('edit-device-wifi-driver-name');
const nl = document.getElementById('edit-device-wifi-num-leds');
const co = document.getElementById('edit-device-wifi-color-order');
const ws = document.getElementById('edit-device-wifi-startup-mode');
const pushRes = await pushWifiDriverConfig(devId, {
name: dn ? dn.value : '',
num_leds: nl ? nl.value : '',
color_order: co ? co.value : '',
startup_mode: ws ? ws.value : '',
});
if (!pushRes.ok) return;
}
const pushRes = await pushWifiDriverConfig(
devId,
getDriverConfigPushFields(payload.transport || 'espnow', payload.name),
);
if (!pushRes.ok) return;
await loadDevicesModal();
refreshEditDeviceDebug();
});
}
if (editCloseBtn) {

View File

@@ -117,7 +117,6 @@ function renderGroupDevicesEditor(containerEl, macRows, devicesMap) {
} else {
containerEl.appendChild(addWrap);
}
refreshEditGroupDebug();
}
function collectGroupEditPayload() {
@@ -153,26 +152,6 @@ function collectGroupEditPayload() {
return { gid, payload };
}
function refreshEditGroupDebug() {
const ta = document.getElementById('edit-group-debug');
if (!ta) return;
try {
const { gid, payload } = collectGroupEditPayload();
const loaded = window.__editGroupLoadedSnapshot;
ta.value = JSON.stringify(
{
group_id: gid || null,
loaded_from_server: loaded != null ? loaded : null,
save_payload_preview: payload,
},
null,
2,
);
} catch (e) {
ta.value = String(e);
}
}
function syncGroupShareCheckboxFromDoc(g) {
const cb = document.getElementById('edit-group-share-all-profiles');
if (!cb) return;
@@ -243,11 +222,6 @@ async function openEditGroupModal(groupId, groupDoc) {
}
}
g = g || {};
try {
window.__editGroupLoadedSnapshot = JSON.parse(JSON.stringify(g));
} catch (e) {
window.__editGroupLoadedSnapshot = g;
}
if (idInput) idInput.value = groupId;
if (nameInput) nameInput.value = g.name || '';
@@ -265,7 +239,6 @@ async function openEditGroupModal(groupId, groupDoc) {
renderGroupDevicesEditor(editor, window.__editGroupDeviceRows, dm);
loadWifiFieldsFromGroup(g);
syncGroupShareCheckboxFromDoc(g);
refreshEditGroupDebug();
if (modal) modal.classList.add('active');
}
@@ -290,7 +263,7 @@ function renderGroupsList(groups) {
if (ids.length === 0) {
const p = document.createElement('p');
p.className = 'muted-text';
p.textContent = 'No groups yet. Create one to assign devices and WiFi defaults.';
p.textContent = 'No groups yet.';
container.appendChild(p);
return;
}
@@ -510,8 +483,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
if (editForm) {
editForm.addEventListener('input', () => refreshEditGroupDebug());
editForm.addEventListener('change', () => refreshEditGroupDebug());
editForm.addEventListener('submit', async (e) => {
e.preventDefault();
const { gid, payload } = collectGroupEditPayload();
@@ -548,7 +519,6 @@ document.addEventListener('DOMContentLoaded', () => {
/* ignore push errors after save */
}
await loadGroupsModal();
refreshEditGroupDebug();
} catch (err) {
console.error(err);
alert('Save failed');

View File

@@ -404,13 +404,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label);
if (isFirmwareBuiltinPattern(patternName)) {
const note = document.createElement('span');
note.className = 'muted-text';
note.style.fontSize = '0.85em';
note.textContent = 'Built-in (no OTA module)';
row.appendChild(note);
} else {
if (!isFirmwareBuiltinPattern(patternName)) {
const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary btn-small';
sendBtn.textContent = 'Send';

View File

@@ -269,7 +269,6 @@ document.addEventListener('DOMContentLoaded', () => {
const presetBackgroundInput = document.getElementById('preset-background-input');
const presetBackgroundButton = document.getElementById('preset-background-btn');
const presetManualModeInput = document.getElementById('preset-manual-mode-input');
const presetManualModeHint = document.getElementById('preset-manual-mode-hint');
const presetManualModeLabel = document.getElementById('preset-manual-mode-label');
const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap');
const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input');
@@ -447,16 +446,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualModeLabel) {
presetManualModeLabel.style.opacity = ok ? '' : '0.55';
}
if (presetManualModeHint) {
if (!patternName || ok) {
presetManualModeHint.style.display = 'none';
presetManualModeHint.textContent = '';
} else {
presetManualModeHint.style.display = '';
presetManualModeHint.textContent =
'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.';
}
}
if (!ok) {
presetManualModeInput.checked = false;
}
@@ -521,12 +510,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Get max colors for current pattern
const maxColors = getMaxColors();
const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : '';
if (currentPresetColors.length === 0) {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`;
empty.textContent = 'No colours yet.';
presetColorsContainer.appendChild(empty);
return;
}
@@ -536,7 +524,7 @@ document.addEventListener('DOMContentLoaded', () => {
const info = document.createElement('p');
info.className = 'muted-text';
info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;';
info.textContent = `Maximum ${maxColors} color${maxColors !== 1 ? 's' : ''} reached for this pattern.`;
info.textContent = 'Maximum colours reached.';
presetColorsContainer.appendChild(info);
}
@@ -1443,7 +1431,7 @@ document.addEventListener('DOMContentLoaded', () => {
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
if (availableToAdd.length === 0) {
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this zone, or create a preset first.</p>';
listContainer.innerHTML = '<p class="muted-text">No presets to add.</p>';
} else {
availableToAdd.forEach(presetId => {
const preset = allPresets[presetId];
@@ -2421,8 +2409,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1';
empty.textContent =
"No presets or sequences on this zone yet. Open Edit to add presets or sequences.";
empty.textContent = 'No presets on this zone yet.';
presetsList.appendChild(empty);
}
} else {

View File

@@ -1,4 +1,4 @@
// Sequences: lanes (parallel preset chains); advance is always by audio beats or simulated BPM.
// Sequences: lanes (parallel preset chains); advance by audio beats or global simulated BPM.
// Debug: in the browser console run setSequenceDebug(true) — session only, not persisted.
/** @type {'beat'|'downbeat'} */
@@ -6,6 +6,8 @@ let sequenceSwitchWaitFor = 'beat';
let sequenceDebugEnabled = false;
let sequenceSwitchSaveInFlight = false;
/** When true (simulated BPM / audio off), downbeat is disabled and switch is beat-only. */
let sequenceSwitchSimulatedMode = false;
async function loadSequenceSwitchWaitForFromServer() {
try {
@@ -49,32 +51,82 @@ function getSequenceSwitchWaitFor() {
}
async function setSequenceSwitchWaitFor(waitFor) {
if (sequenceSwitchSimulatedMode) {
sequenceSwitchWaitFor = 'beat';
updateSequenceSwitchToggleUI();
return;
}
sequenceSwitchWaitFor = waitFor === 'downbeat' ? 'downbeat' : 'beat';
updateSequenceSwitchToggleUI();
await persistSequenceSwitchWaitFor();
}
function updateSequenceSwitchToggleUI() {
const mode = getSequenceSwitchWaitFor();
const mode = sequenceSwitchSimulatedMode ? 'beat' : getSequenceSwitchWaitFor();
const ariaLabels = {
beat: 'Switch sequence on beat',
downbeat: 'Switch sequence on downbeat',
};
document.documentElement.classList.toggle(
'simulated-bpm-mode',
sequenceSwitchSimulatedMode,
);
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
wrap.hidden = sequenceSwitchSimulatedMode;
wrap.setAttribute('aria-hidden', sequenceSwitchSimulatedMode ? 'true' : 'false');
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
});
if (sequenceSwitchSimulatedMode) {
return;
}
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.disabled = false;
btn.removeAttribute('aria-disabled');
btn.setAttribute('aria-pressed', mode === 'beat' ? 'false' : 'true');
btn.setAttribute('aria-label', ariaLabels[mode] || ariaLabels.beat);
btn.classList.toggle('seq-switch-toggle--downbeat', mode === 'downbeat');
});
document.querySelectorAll('.seq-switch-toggle-wrap').forEach((wrap) => {
wrap.classList.toggle('nav-slide-toggle-wrap--downbeat', mode === 'downbeat');
btn.title =
mode === 'downbeat'
? 'When starting a sequence: wait for downbeat'
: 'When starting a sequence: wait for beat';
});
}
/** @param {boolean} simulated */
function setSequenceSwitchSimulatedMode(simulated) {
const next = !!simulated;
if (next === sequenceSwitchSimulatedMode) {
if (next) updateSequenceSwitchToggleUI();
return;
}
sequenceSwitchSimulatedMode = next;
if (next) {
sequenceSwitchWaitFor = 'beat';
updateSequenceSwitchToggleUI();
void persistSequenceSwitchWaitFor();
return;
}
void loadSequenceSwitchWaitForFromServer().then(() => updateSequenceSwitchToggleUI());
}
async function syncSequenceSwitchSimulatedModeFromStatus() {
try {
const res = await fetch('/api/audio/status', { cache: 'no-store' });
const data = await res.json();
const simulated = !!(data && data.status && data.status.bpm_simulated);
setSequenceSwitchSimulatedMode(simulated);
} catch {
setSequenceSwitchSimulatedMode(true);
}
}
async function initSequenceSwitchToggle() {
await syncSequenceSwitchSimulatedModeFromStatus();
await loadSequenceSwitchWaitForFromServer();
updateSequenceSwitchToggleUI();
document.querySelectorAll('.seq-switch-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
if (sequenceSwitchSimulatedMode) return;
void setSequenceSwitchWaitFor(getSequenceSwitchWaitFor() === 'beat' ? 'downbeat' : 'beat');
});
});
@@ -82,7 +134,7 @@ async function initSequenceSwitchToggle() {
/** Sync toggle when settings changed elsewhere (e.g. another tab via audio status poll). */
function applySequenceSwitchWaitFromServer(raw) {
if (sequenceSwitchSaveInFlight) return;
if (sequenceSwitchSaveInFlight || sequenceSwitchSimulatedMode) return;
let mode = 'beat';
if (raw === 'downbeat') mode = 'downbeat';
else if (raw !== 'beat' && raw !== 'phrase') return;
@@ -95,49 +147,6 @@ function seqDebugEnabled() {
return sequenceDebugEnabled;
}
/** @type {ReturnType<typeof setInterval> | null} */
let sequenceBpmPollTimer = null;
function stopSequenceEditorBpmPoll() {
if (sequenceBpmPollTimer) {
clearInterval(sequenceBpmPollTimer);
sequenceBpmPollTimer = null;
}
}
async function refreshSequenceEditorBpmDisplay() {
const live = document.getElementById('sequence-editor-bpm-live');
const panel = document.getElementById('sequence-editor-beats-panel');
if (!live || !panel) return;
try {
const res = await fetch('/api/audio/status', { headers: { Accept: 'application/json' } });
const j = res.ok ? await res.json() : {};
const st = j && j.status ? j.status : {};
const running = !!st.running;
const bpmRaw = st.bpm;
const bpm =
typeof bpmRaw === 'number' && Number.isFinite(bpmRaw)
? bpmRaw
: typeof bpmRaw === 'string' && bpmRaw.trim()
? parseFloat(bpmRaw)
: NaN;
if (!running) {
live.textContent =
'Audio detector is stopped — the sequence uses simulated beats at the BPM you set above.';
return;
}
if (!Number.isFinite(bpm) || bpm <= 0) {
live.textContent = 'Audio detector running; BPM will appear after a few beats.';
return;
}
const msPer = Math.round(60000 / bpm);
const rounded = Math.round(bpm * 10) / 10;
live.textContent = `Current estimate: ${rounded} BPM (~${msPer} ms per beat).`;
} catch (_) {
live.textContent = 'Could not read audio status.';
}
}
/** @param {boolean} [clearSequenceTileSelection] When false, leaves the active highlight on sequence tiles (used when restarting playback so the click handlers selection is not cleared). */
async function stopZoneSequencePlayback(clearSequenceTileSelection = true) {
// Clear selection **before** awaiting fetch so overlapping stop() calls cannot finish out of
@@ -157,6 +166,9 @@ async function stopZoneSequencePlayback(clearSequenceTileSelection = true) {
} catch (e) {
console.warn('Sequence stop:', e);
}
if (typeof window.ledControllerSequencePlaybackChanged === 'function') {
window.ledControllerSequencePlaybackChanged(false);
}
}
function normalizeSequenceLanes(doc) {
@@ -261,12 +273,6 @@ function renderLaneGroupCheckboxes(groupsMap, selectedIds, zoneGroupIds) {
const wrap = document.createElement('div');
wrap.className = 'sequence-lane-groups-wrap';
wrap.style.cssText = 'margin-bottom:0.6rem;';
const hint = document.createElement('div');
hint.className = 'muted-text';
hint.style.fontSize = '0.85em';
hint.style.marginBottom = '0.35rem';
hint.textContent = 'Only checked groups are used on this lane';
wrap.appendChild(hint);
const row = document.createElement('div');
row.className = 'sequence-lane-groups';
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:0.5rem;align-items:center;';
@@ -343,13 +349,7 @@ async function resolveSequenceSendDeviceNames(zoneId, zoneDoc, groupIds) {
async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) {
// Do not call stop here: server start() already stops any prior run. A fire-and-forget
// client stop can reorder after play and clear the new session (same tile re-click bug).
let bodyBpm;
if (sequenceDoc && typeof sequenceDoc === 'object' && sequenceDoc.simulated_bpm != null) {
const n = parseInt(String(sequenceDoc.simulated_bpm), 10);
if (Number.isFinite(n)) bodyBpm = Math.min(300, Math.max(30, n));
}
const body = { zone_id: String(zoneId) };
if (bodyBpm != null) body.simulated_bpm = bodyBpm;
const res = await fetch(`/sequences/${encodeURIComponent(sequenceId)}/play`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
@@ -360,7 +360,9 @@ async function requestBackendSequencePlay(sequenceId, zoneId, sequenceDoc) {
const err = await res.json().catch(() => ({}));
throw new Error((err && err.error) || res.statusText);
}
console.log(Number(sequenceId));
if (typeof window.ledControllerSequencePlaybackChanged === 'function') {
window.ledControllerSequencePlaybackChanged(true);
}
}
async function fetchSequencesMap() {
@@ -606,7 +608,7 @@ async function refreshEditTabSequencesUi(zoneId) {
const available = allIds.filter((id) => !onSet.has(String(id)));
if (!available.length) {
addEl.innerHTML =
'<span class="muted-text">No sequences to add. Create one in Sequences or all are already on this zone.</span>';
'<span class="muted-text">No sequences to add.</span>';
} else {
const wrap = document.createElement('div');
wrap.className = 'zone-devices-add profiles-actions';
@@ -908,20 +910,10 @@ function collectLanesFromEditor() {
return { lanes, lanes_group_ids };
}
function syncSequenceBeatsPanel() {
const panel = document.getElementById('sequence-editor-beats-panel');
stopSequenceEditorBpmPoll();
if (panel) {
void refreshSequenceEditorBpmDisplay();
sequenceBpmPollTimer = setInterval(() => void refreshSequenceEditorBpmDisplay(), 1500);
}
}
async function openSequenceEditor(sequenceId, existing) {
sequenceEditorId = sequenceId != null && String(sequenceId).length ? String(sequenceId) : null;
const modal = document.getElementById('sequence-editor-modal');
const nameInput = document.getElementById('sequence-editor-name');
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const lanesHost = document.getElementById('sequence-editor-lanes');
if (!modal || !nameInput || !lanesHost) return;
@@ -969,12 +961,6 @@ async function openSequenceEditor(sequenceId, existing) {
doc = {};
}
nameInput.value = doc.name || '';
if (simBpmInput) {
const v = parseInt(String(doc.simulated_bpm != null ? doc.simulated_bpm : 120), 10);
const clamped = Number.isFinite(v) ? Math.min(300, Math.max(30, v)) : 120;
simBpmInput.value = String(clamped);
}
syncSequenceBeatsPanel();
const lanes = normalizeSequenceLanes(doc);
lanesHost.innerHTML = '';
@@ -1012,7 +998,6 @@ function resolveZoneIdForPresetStripRefresh() {
async function saveSequenceEditor() {
const nameInput = document.getElementById('sequence-editor-name');
const simBpmInput = document.getElementById('sequence-editor-simulated-bpm');
const { lanes, lanes_group_ids } = collectLanesFromEditor();
const idxs = [];
lanes.forEach((l, i) => {
@@ -1024,18 +1009,12 @@ async function saveSequenceEditor() {
}
const nonEmpty = idxs.map((i) => lanes[i].filter((s) => s && s.preset_id));
const nonEmptyLg = idxs.map((i) => (lanes_group_ids[i] ? [...lanes_group_ids[i]] : []));
let simulated_bpm = 120;
if (simBpmInput && simBpmInput.value) {
const n = parseInt(String(simBpmInput.value).trim(), 10);
if (Number.isFinite(n)) simulated_bpm = Math.min(300, Math.max(30, n));
}
const payload = {
name: nameInput ? nameInput.value.trim() : '',
lanes: nonEmpty,
lanes_group_ids: nonEmptyLg,
group_ids: nonEmptyLg[0] ? [...nonEmptyLg[0]] : [],
advance_mode: 'beats',
simulated_bpm,
loop: true,
steps: nonEmpty.length === 1 ? nonEmpty[0] : [],
};
@@ -1094,7 +1073,6 @@ async function deleteCurrentSequence() {
if (!res.ok) throw new Error('Delete failed');
const edModal = document.getElementById('sequence-editor-modal');
if (edModal) edModal.classList.remove('active');
stopSequenceEditorBpmPoll();
sequenceEditorId = null;
await loadSequencesModalList();
const zid = resolveZoneIdForPresetStripRefresh();
@@ -1138,7 +1116,7 @@ async function loadSequencesModalList() {
});
listEl.innerHTML = '';
if (!ids.length) {
listEl.innerHTML = '<p class="muted-text">No sequences yet. Click Add.</p>';
listEl.innerHTML = '<p class="muted-text">No sequences yet.</p>';
return;
}
ids.forEach((id) => {
@@ -1163,6 +1141,7 @@ async function loadSequencesModalList() {
}
window.applySequenceSwitchWaitFromServer = applySequenceSwitchWaitFromServer;
window.setSequenceSwitchSimulatedMode = setSequenceSwitchSimulatedMode;
window.stopZoneSequencePlayback = stopZoneSequencePlayback;
/** @param {boolean} on */
window.setSequenceDebug = function setSequenceDebug(on) {
@@ -1209,7 +1188,6 @@ document.addEventListener('DOMContentLoaded', () => {
const edDel = document.getElementById('sequence-editor-delete-btn');
if (edClose) {
edClose.addEventListener('click', () => {
stopSequenceEditorBpmPoll();
document.getElementById('sequence-editor-modal') && document.getElementById('sequence-editor-modal').classList.remove('active');
});
}

View File

@@ -24,21 +24,6 @@ body {
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));
@@ -46,13 +31,6 @@ input.hex-addr-box {
align-items: end;
}
.device-field-label {
display: block;
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.25rem;
}
.device-row-mac {
font-size: 0.82em;
color: #b0b0b0;
@@ -291,6 +269,32 @@ header h1 {
text-align: left;
}
.audio-top-indicator.audio-simulated .audio-top-bpm-value,
#audio-modal-beat-sync.audio-simulated .audio-top-indicator-value {
color: #e6c200;
}
.audio-beat-sync-btn.flash-simulated,
.audio-top-beat-sync.flash-simulated {
background-color: #5a4a00;
border-color: #e6c200;
}
.audio-beat-sync-btn.flash-simulated .audio-top-indicator-value,
.audio-beat-sync-btn.flash-simulated .audio-top-indicator-label,
.audio-beat-sync-btn.flash-simulated .audio-top-beat-readout,
.audio-beat-sync-btn.flash-simulated .audio-top-beat-readout::before,
.audio-beat-sync-btn.flash-simulated .audio-top-bar-phase,
.audio-beat-sync-btn.flash-simulated .audio-top-bar-phase::before,
.audio-top-beat-sync.flash-simulated .audio-top-indicator-value,
.audio-top-beat-sync.flash-simulated .audio-top-indicator-label,
.audio-top-beat-sync.flash-simulated .audio-top-beat-readout,
.audio-top-beat-sync.flash-simulated .audio-top-beat-readout::before,
.audio-top-beat-sync.flash-simulated .audio-top-bar-phase,
.audio-top-beat-sync.flash-simulated .audio-top-bar-phase::before {
color: #fff9c4;
}
.audio-beat-sync-btn:disabled,
.audio-top-beat-sync:disabled {
cursor: default;
@@ -1236,6 +1240,10 @@ body.preset-ui-run .edit-mode-only {
flex-shrink: 0;
}
html.simulated-bpm-mode .seq-switch-toggle-wrap {
display: none !important;
}
.nav-slide-toggle-side-label {
font-size: 0.82rem;
color: #888;
@@ -2068,9 +2076,6 @@ body.preset-ui-run .edit-mode-only {
#help-modal .modal-head {
margin-bottom: 0.75rem;
}
#help-modal .help-modal-intro {
margin-bottom: 0.25rem;
}
#help-modal .help-tabs {
display: flex;
flex-wrap: wrap;
@@ -2412,10 +2417,6 @@ body.preset-ui-run .edit-mode-only {
display: none;
}
#settings-modal .settings-led-tool-intro {
margin: 0 0 0.75rem;
}
#settings-modal .settings-led-tool-iframe {
width: 100%;
height: min(75vh, 720px);

View File

@@ -1023,7 +1023,7 @@ async function refreshEditTabPresetsUi(zoneId) {
addEl.innerHTML = "";
if (availableToAdd.length === 0) {
addEl.innerHTML =
'<span class="muted-text">No presets to add. All presets are already on this zone.</span>';
'<span class="muted-text">No presets to add.</span>';
} else {
const addWrap = document.createElement("div");
addWrap.className = "zone-devices-add profiles-actions";

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="simulated-bpm-mode">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -209,7 +209,6 @@
<button class="btn btn-secondary" id="groups-close-btn">Close</button>
</div>
</div>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name">
<label class="muted-text" style="display:inline-flex;align-items:center;gap:0.35rem;white-space:nowrap;">
@@ -236,14 +235,13 @@
<input type="text" id="edit-group-name" required autocomplete="off">
<label class="muted-text" style="display:flex;align-items:flex-start;gap:0.5rem;margin-top:0.5rem;">
<input type="checkbox" id="edit-group-share-all-profiles" style="margin-top:0.2rem;">
<span>Share with all profiles (untick to keep this group on the <strong>current profile only</strong>)</span>
<span>Share with all profiles</span>
</label>
<label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;">
<button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
</div>
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;">
<input type="range" id="edit-group-output-brightness" min="0" max="255" value="255" style="flex:1;">
@@ -277,15 +275,27 @@
<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>
<label for="edit-device-address-mac">MAC (12 hex, optional)</label>
<input type="text" id="edit-device-address-mac" placeholder="MAC (12 hex)" autocomplete="off">
</div>
<div id="edit-device-espnow-driver-wrap">
<label for="edit-device-espnow-num-leds" style="margin-top:0.75rem;display:block;">Number of LEDs</label>
<input type="number" id="edit-device-espnow-num-leds" min="1" max="2048" step="1" placeholder="119">
<label for="edit-device-espnow-color-order" style="margin-top:0.5rem;display:block;">Colour order</label>
<select id="edit-device-espnow-color-order">
<option value="rgb">RGB</option>
<option value="rbg">RBG</option>
<option value="grb">GRB</option>
<option value="gbr">GBR</option>
<option value="brg">BRG</option>
<option value="bgr">BGR</option>
</select>
</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 id="edit-device-wifi-driver-wrap" hidden>
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">On-device settings (sent over WiFi when connected). For shared defaults across several drivers, use <strong>Groups</strong>.</p>
<label for="edit-device-wifi-driver-name">Display name</label>
<input type="text" id="edit-device-wifi-driver-name" placeholder="hello / discovery name" autocomplete="off">
<label for="edit-device-wifi-num-leds" style="margin-top:0.5rem;display:block;">Number of LEDs</label>
@@ -311,10 +321,6 @@
<input type="range" id="edit-device-output-brightness" min="0" max="255" value="255" style="flex:1;">
<span id="edit-device-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div>
<small class="muted-text" style="display:block;margin-top:0.25rem;">Saved on the device; use <strong>Save</strong> to push to the driver (when connected).</small>
<label for="edit-device-debug" style="margin-top:1rem;display:block;">Debug</label>
<small class="muted-text" style="display:block;margin-bottom:0.35rem;">Stored registry row and the JSON preview for <strong>Save</strong> (updates as you edit).</small>
<textarea id="edit-device-debug" rows="8" readonly spellcheck="false" style="width:100%;font-family:monospace;resize:vertical;"></textarea>
</form>
</div>
</div>
@@ -369,17 +375,9 @@
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
</div>
<div id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;">
<p class="muted-text" style="font-size:0.85em;margin:0 0 0.5rem 0;">
Each step runs for the number of <strong>beats</strong> you set on that step.
When the header <strong>Audio</strong> detector is running, real beats advance the sequence.
When it is stopped, the server uses <strong>simulated</strong> beats at the BPM below.
</p>
<label for="sequence-editor-simulated-bpm" style="display:block;margin-bottom:0.25rem;">Simulated BPM (when audio is off)</label>
<input type="number" id="sequence-editor-simulated-bpm" min="30" max="300" value="120" style="width:6rem;" title="Used only while the audio detector is stopped">
<p id="sequence-editor-bpm-live" class="muted-text" style="font-size:0.85em;margin:0.5rem 0 0 0;"></p>
<label style="display:block;margin-top:0.65rem;">
<label style="display:block;">
<input type="checkbox" id="sequence-editor-loop" checked>
Loop sequence (restart from the first step after the last)
Loop
</label>
</div>
<div id="sequence-editor-lanes"></div>
@@ -433,19 +431,18 @@
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;">
<label id="preset-manual-mode-label" style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0;">
<input type="checkbox" id="preset-manual-mode-input">
Manual mode (single-shot where supported)
Manual mode
</label>
<p id="preset-manual-mode-hint" class="muted-text" style="display: none; margin-top: 0.35rem; font-size: 0.85em;"></p>
<div id="preset-manual-beat-n-wrap" class="preset-editor-field" style="display: none; margin-top: 0.5rem;">
<label for="preset-manual-beat-n-input">Audio beat: every</label>
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" title="Controller only; not sent to pattern logic" autocomplete="off">
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span>
<input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" autocomplete="off">
<span>beats</span>
</div>
</div>
<div class="preset-editor-field" id="preset-reverse-group" hidden>
<label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
<input type="checkbox" id="preset-reverse-input">
Reverse direction (strip installed upside down)
Reverse direction
</label>
</div>
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden>
@@ -521,14 +518,13 @@
<button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
</div>
</div>
<p class="muted-text" style="margin: 0 0 0.75rem 0;">Add a driver <code>.py</code> file and editor metadata (stored in the pattern database).</p>
<div class="profiles-row" style="flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem;">
<label for="pattern-create-name" style="min-width: 7rem;">Name</label>
<input type="text" id="pattern-create-name" class="preset-name-like" placeholder="e.g. sparkle" pattern="[a-zA-Z_][a-zA-Z0-9_]*" style="flex: 1; min-width: 12rem;" autocomplete="off">
</div>
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
<h3 class="muted-text">Readable parameter names</h3>
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names are stored for this pattern.</p>
<p id="pattern-create-n-empty" class="muted-text" style="display: none; margin: 0 0 0.5rem 0;">No parameter names.</p>
<div class="n-params-grid">
<div class="n-param-group">
<label for="pattern-create-n1"></label>
@@ -575,7 +571,7 @@
<div class="profiles-row" style="flex-direction: column; align-items: stretch; gap: 0.35rem; margin-bottom: 0.5rem;">
<label for="pattern-create-file">Pattern file</label>
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY">
<label for="pattern-create-code" class="muted-text" style="font-size: 0.85em;">Or paste Python source (if no file chosen)</label>
<label for="pattern-create-code">Or paste source</label>
<textarea id="pattern-create-code" rows="5" style="width: 100%; font-family: monospace; font-size: 0.85rem;" placeholder="# class MyPattern: ..."></textarea>
</div>
<div class="modal-actions">
@@ -613,8 +609,6 @@
<button class="btn btn-secondary" id="help-close-btn">Close</button>
</div>
</div>
<p class="muted-text help-modal-intro">How to use the LED controller UI. Previews use the same styles as the live interface.</p>
<div class="help-tabs" role="tablist" aria-label="Help sections">
<button type="button" class="help-tab-btn active" role="tab" id="help-tab-overview" data-help-tab="overview" aria-selected="true" aria-controls="help-panel-overview">Overview</button>
<button type="button" class="help-tab-btn" role="tab" id="help-tab-profiles" data-help-tab="profiles" aria-selected="false" aria-controls="help-panel-profiles">Profiles</button>
@@ -1114,7 +1108,10 @@
</select>
<button type="button" class="btn btn-secondary btn-small" id="audio-refresh-btn" title="Refresh device list">Refresh</button>
</div>
<small class="muted-text">Same sources as PulseAudio volume control. Pick a <strong>monitor</strong> source to follow playback.</small>
</div>
<div class="form-group">
<label for="audio-simulated-bpm">Simulated BPM</label>
<input type="number" id="audio-simulated-bpm" min="60" max="200" step="1" value="120" style="width:6rem;" title="Used for sequences when beat detection is stopped">
</div>
<div class="form-group">
<label>Beat indicators</label>
@@ -1124,7 +1121,6 @@
<span id="audio-modal-beat-readout" class="audio-top-beat-readout" aria-live="polite"></span>
<span id="audio-bar-phase-value" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
</button>
<small class="muted-text">Flashes on each beat (same as the header). Tap on a downbeat while a sequence is playing to sync (<kbd>S</kbd>).</small>
</div>
<div class="form-group">
<label>Detected hit type</label>
@@ -1145,7 +1141,6 @@
<div class="audio-input-level-meter" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Live input level">
<div id="audio-input-level-bar" class="audio-input-level-bar"></div>
</div>
<small class="muted-text">Gain before beat detection (saved on the controller). The bar shows live input level while running.</small>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button>
@@ -1177,7 +1172,6 @@
<ul id="bridge-connection-details" class="settings-bridge-connection-details muted-text" aria-live="polite"></ul>
<h3 class="settings-subheading">USB serial</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi ↔ bridge over USB/UART. The bridge still uses WiFi radio for ESP-NOW only.</p>
<div class="form-group">
<label for="bridge-serial-label">Profile label</label>
<input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off">
@@ -1199,7 +1193,6 @@
</div>
<h3 class="settings-subheading">WiFi</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://&lt;bridge-ip&gt;/ws</code>.</p>
<div class="form-group">
<label for="bridge-wifi-interface">WiFi adapter</label>
<select id="bridge-wifi-interface">
@@ -1246,7 +1239,6 @@
</div>
<div id="settings-panel-led-tool" class="settings-tab-panel" data-settings-panel="led-tool" role="tabpanel" aria-labelledby="settings-tab-led-tool" hidden>
<p class="muted-text settings-led-tool-intro">USB serial setup for drivers and bridges: device settings, deploy, and firmware.</p>
<iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
</div>

View File

@@ -7,8 +7,6 @@ import time
from typing import Any
_HOLDOVER_BPM_MIN = 30.0
_HOLDOVER_BPM_MAX = 300.0
_HOLDOVER_MAX_S = 300.0
# After this many seconds without a detected beat, re-prime aubio and start BPM holdover
# (same window as status() uses to hide stale BPM).
@@ -257,6 +255,10 @@ class AudioBeatDetector:
st["bpm"] = None
except (TypeError, ValueError):
pass
if st.get("bpm") is not None:
from util.bpm_limits import clamp_bpm_optional
st["bpm"] = clamp_bpm_optional(st["bpm"])
return st
def _apply_tracking_reset_status(self) -> None:
@@ -275,16 +277,14 @@ class AudioBeatDetector:
)
def _clamp_holdover_bpm(self, bpm: Any) -> float | None:
try:
v = float(bpm)
except (TypeError, ValueError):
return None
if not (_HOLDOVER_BPM_MIN <= v <= _HOLDOVER_BPM_MAX):
return None
return v
from util.bpm_limits import clamp_bpm_optional
return clamp_bpm_optional(bpm)
def _holdover_interval_s(self, bpm: float) -> float:
return 60.0 / max(_HOLDOVER_BPM_MIN, min(_HOLDOVER_BPM_MAX, float(bpm)))
from util.bpm_limits import clamp_bpm
return 60.0 / clamp_bpm(bpm)
def _stop_bpm_holdover(self) -> None:
with self._lock:
@@ -353,6 +353,12 @@ class AudioBeatDetector:
return
self._emit_holdover_beat(bpm)
def prime_bpm_holdover(self, bpm: float) -> None:
"""Public: tick at *bpm* until the next detected beat (e.g. pending sequence switch)."""
if not self._running:
return
self._start_bpm_holdover(bpm)
def _start_bpm_holdover(self, bpm: float) -> None:
bpm_v = self._clamp_holdover_bpm(bpm)
if bpm_v is None:
@@ -434,8 +440,12 @@ class AudioBeatDetector:
bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
holdover = self._holdover_active
last_reset = self._last_gap_tempo_reset_ts
if last_real is None or bpm is None:
if last_real is None:
return
if bpm is None:
from util.bpm_limits import clamp_bpm
bpm = clamp_bpm(120)
try:
gap = now - float(last_real)
except (TypeError, ValueError):
@@ -460,10 +470,15 @@ class AudioBeatDetector:
self._last_gap_tempo_reset_ts = now
def _record_beat(self, bpm, beat_type="unknown", beat_type_confidence=0.0, **phase_fields):
from util.bpm_limits import clamp_bpm_optional
self._stop_bpm_holdover()
now = time.time()
self._last_real_beat_ts = now
bpm = clamp_bpm_optional(bpm)
with self._lock:
if bpm is None:
bpm = clamp_bpm_optional(self._status.get("bpm"))
self._last_gap_tempo_reset_ts = 0.0
self._status["last_beat_ts"] = now
self._status["bpm"] = bpm
@@ -486,6 +501,12 @@ class AudioBeatDetector:
seq_pb.push_thread_beat()
except Exception as e:
print(f"[audio] sequence beat queue: {e}")
holdover_bpm = None
with self._lock:
if self._running:
holdover_bpm = self._clamp_holdover_bpm(self._status.get("bpm"))
if holdover_bpm is not None:
self._start_bpm_holdover(holdover_bpm)
def _run_loop(self, device):
try:
@@ -525,6 +546,8 @@ class AudioBeatDetector:
dev_info = sd.query_devices(device, "input")
sample_rate = int(dev_info["default_samplerate"])
from util.bpm_limits import max_beat_min_ioi_ms
args = argparse.Namespace(
mode="aubio",
device=device,
@@ -537,7 +560,7 @@ class AudioBeatDetector:
flux_weight=0.3,
threshold_multiplier=1.35,
ema_alpha=0.08,
min_ioi_ms=100.0,
min_ioi_ms=max_beat_min_ioi_ms(),
bpm_window=8,
post_url="",
aubio_method="default",
@@ -645,6 +668,39 @@ def shared_beat_detector_running():
return False
def shared_beat_detector_timing_sequences() -> bool:
"""True when live audio is running and has clocked a beat recently enough to drive sequences."""
d = _shared_beat_detector
if d is None:
return False
try:
st = dict(d.status())
except Exception:
return False
if not st.get("running"):
return False
with d._lock:
last = d._last_real_beat_ts
holdover = d._holdover_active
if holdover:
return True
if last is None:
return False
try:
gap = time.time() - float(last)
except (TypeError, ValueError):
return False
from util.bpm_limits import clamp_bpm
bpm_raw = st.get("bpm")
try:
bpm_v = clamp_bpm(bpm_raw) if bpm_raw is not None else 120.0
except (TypeError, ValueError):
bpm_v = 120.0
max_gap = (60.0 / bpm_v) * 2.0
return gap < max_gap
def shared_beat_status_snapshot() -> dict:
"""Thread-safe copy of live detector status, or {} if audio is off."""
d = _shared_beat_detector
@@ -656,6 +712,17 @@ def shared_beat_status_snapshot() -> dict:
return {}
def prime_bpm_holdover(bpm: float) -> None:
"""Start BPM holdover on the shared detector when audio is on but not clocking."""
d = _shared_beat_detector
if d is None:
return
try:
d.prime_bpm_holdover(bpm)
except Exception:
pass
def anchor_shared_bar_phase() -> bool:
"""Anchor bar phase on the shared detector (no-op if audio is off)."""
d = _shared_beat_detector

View File

@@ -0,0 +1,156 @@
"""Push beat and audio status updates to browser SSE clients."""
from __future__ import annotations
import asyncio
import json
import threading
from typing import Any, Callable, Dict, List, Optional, Set
_main_loop: Optional[asyncio.AbstractEventLoop] = None
_status_builder: Optional[Callable[[], Dict[str, Any]]] = None
_clients_lock = threading.Lock()
_client_queues: Set[asyncio.Queue[str]] = set()
_heartbeat_task: Optional[asyncio.Task] = None
_HEARTBEAT_INTERVAL_S = 0.25
def configure(
*,
loop: asyncio.AbstractEventLoop,
status_builder: Callable[[], Dict[str, Any]],
) -> None:
global _main_loop, _status_builder
_main_loop = loop
_status_builder = status_builder
def request_beat_status_broadcast() -> None:
"""Thread-safe: schedule a beat push after the consumer processes a tick."""
loop = _main_loop
if loop is None:
return
loop.call_soon_threadsafe(_schedule_broadcast, "beat")
def request_status_broadcast() -> None:
"""Thread-safe: schedule a non-beat status push (e.g. audio start/stop)."""
loop = _main_loop
if loop is None:
return
loop.call_soon_threadsafe(_schedule_broadcast, "status")
def _schedule_broadcast(event_type: str) -> None:
try:
asyncio.create_task(_broadcast(event_type))
except RuntimeError:
pass
def _build_sse_line(event_type: str) -> str:
if _status_builder is None:
return ""
st = _status_builder()
payload = {"type": event_type, "status": st}
return f"data: {json.dumps(payload)}\n\n"
def _enqueue(queue: asyncio.Queue[str], line: str) -> None:
if not line:
return
try:
queue.put_nowait(line)
except asyncio.QueueFull:
try:
queue.get_nowait()
except asyncio.QueueEmpty:
pass
try:
queue.put_nowait(line)
except asyncio.QueueFull:
pass
async def initial_sse_line() -> str:
return _build_sse_line("status")
async def register_sse_client(queue: asyncio.Queue[str]) -> None:
with _clients_lock:
_client_queues.add(queue)
await _ensure_heartbeat()
async def unregister_sse_client(queue: asyncio.Queue[str]) -> None:
with _clients_lock:
_client_queues.discard(queue)
await _maybe_stop_heartbeat()
async def _ensure_heartbeat() -> None:
global _heartbeat_task
if _heartbeat_task is not None and not _heartbeat_task.done():
return
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
async def _maybe_stop_heartbeat() -> None:
global _heartbeat_task
with _clients_lock:
if _client_queues:
return
if _heartbeat_task is not None:
_heartbeat_task.cancel()
try:
await _heartbeat_task
except asyncio.CancelledError:
pass
_heartbeat_task = None
def _should_heartbeat() -> bool:
if _status_builder is None:
return False
try:
st = _status_builder()
return bool(st.get("running"))
except Exception:
return False
async def _heartbeat_loop() -> None:
try:
while True:
await asyncio.sleep(_HEARTBEAT_INTERVAL_S)
with _clients_lock:
if not _client_queues:
break
if _should_heartbeat():
await _broadcast("heartbeat")
except asyncio.CancelledError:
pass
async def _broadcast(event_type: str) -> None:
line = _build_sse_line(event_type)
if not line:
return
with _clients_lock:
targets: List[asyncio.Queue[str]] = list(_client_queues)
dead: List[asyncio.Queue[str]] = []
for queue in targets:
try:
_enqueue(queue, line)
except Exception:
dead.append(queue)
if dead:
with _clients_lock:
for queue in dead:
_client_queues.discard(queue)
async def shutdown() -> None:
await _maybe_stop_heartbeat()
with _clients_lock:
_client_queues.clear()

40
src/util/bpm_limits.py Normal file
View File

@@ -0,0 +1,40 @@
"""Shared BPM bounds for simulated tempo, live detection, and UI."""
BPM_MIN = 60
BPM_MAX = 200
def clamp_bpm(value, *, default: float = 120.0) -> float:
try:
v = float(value)
except (TypeError, ValueError):
v = float(default)
return max(float(BPM_MIN), min(float(BPM_MAX), v))
def clamp_bpm_optional(value) -> float | None:
"""Clamp when *value* is a positive number; otherwise return ``None``."""
if value is None:
return None
try:
v = float(value)
except (TypeError, ValueError):
return None
if v <= 0:
return None
return clamp_bpm(v)
def min_beat_interval_s() -> float:
"""Shortest allowed time between counted beats (``BPM_MAX``)."""
return 60.0 / float(BPM_MAX)
def max_beat_interval_s() -> float:
"""Longest IOI used for BPM estimation (``BPM_MIN``)."""
return 60.0 / float(BPM_MIN)
def max_beat_min_ioi_ms() -> float:
"""Minimum inter-onset interval (ms) allowed — matches ``BPM_MAX``."""
return 60_000.0 / float(BPM_MAX)

View File

@@ -1,22 +1,60 @@
"""Device status WebSocket broadcasts (ESP-NOW has no live TCP session)."""
"""Push Wi-Fi driver connect/disconnect updates to browser WebSocket clients."""
import asyncio
import json
import threading
from typing import Any, Set
_ws_clients: set = set()
_clients_lock = threading.Lock()
_clients: Set[Any] = set()
async def register_device_status_ws(ws):
_ws_clients.add(ws)
async def _ws_send_text(ws: Any, msg: str) -> None:
"""Starlette/FastAPI WebSocket uses send_text; Microdot uses send."""
send_text = getattr(ws, "send_text", None)
if callable(send_text):
await send_text(msg)
return
await ws.send(msg)
async def unregister_device_status_ws(ws):
_ws_clients.discard(ws)
async def register_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.add(ws)
async def broadcast_device_tcp_snapshot_to(ws):
await ws.send(json.dumps({"type": "device_tcp_snapshot", "devices": {}}))
async def unregister_device_status_ws(ws: Any) -> None:
with _clients_lock:
_clients.discard(ws)
async def broadcast_device_tcp_status(mac: str, connected: bool):
pass
async def broadcast_device_tcp_status(ip: str, connected: bool) -> None:
from models.wifi_ws_clients import normalize_tcp_peer_ip
ip = normalize_tcp_peer_ip(ip)
if not ip:
return
msg = json.dumps({"type": "device_tcp", "ip": ip, "connected": bool(connected)})
with _clients_lock:
targets = list(_clients)
dead = []
for ws in targets:
try:
await _ws_send_text(ws, msg)
except Exception as exc:
dead.append(ws)
print(f"[device_status_broadcaster] ws.send failed: {exc!r}")
if dead:
with _clients_lock:
for ws in dead:
_clients.discard(ws)
async def broadcast_device_tcp_snapshot_to(ws: Any) -> None:
from models import wifi_ws_clients as tcp
ips = tcp.list_connected_ips()
msg = json.dumps({"type": "device_tcp_snapshot", "connected_ips": ips})
try:
await _ws_send_text(ws, msg)
except Exception as exc:
print(f"[device_status_broadcaster] snapshot send failed: {exc!r}")

View File

@@ -1,9 +1,11 @@
"""Deliver v1 JSON to drivers via bridge devices envelope."""
"""Deliver v1 JSON via ESP-NOW bridge and/or outbound Wi-Fi driver WebSockets."""
import asyncio
import json
from typing import Any, Dict, List, Optional, Set, Union
from typing import Any, Dict, List, Optional, Union
from models.device import normalize_mac
from models.wifi_ws_clients import send_json_line_to_ip
from util.bridge_envelope import (
BROADCAST_MAC,
build_devices_envelope,
@@ -12,6 +14,7 @@ from util.bridge_envelope import (
split_v1_body_for_espnow,
)
from util.espnow_message import build_message
_MAX_JSON_ESPNOW = 240
@@ -38,6 +41,19 @@ def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Opt
return None
def _message_text(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Optional[str]:
if isinstance(msg, str):
return msg
if isinstance(msg, dict):
return json.dumps(msg, separators=(",", ":"))
if isinstance(msg, (bytes, bytearray)):
try:
return bytes(msg).decode("utf-8")
except UnicodeError:
return None
return None
async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
deliveries = 0
try:
@@ -53,6 +69,91 @@ async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s:
return deliveries
def _wifi_message_for_device(msg: str, device_name: str) -> str:
if not device_name:
return msg
try:
body = json.loads(msg)
except (ValueError, TypeError):
return msg
if not isinstance(body, dict):
return msg
select = body.get("select")
if not isinstance(select, dict) or device_name not in select:
return msg
body["select"] = {device_name: select[device_name]}
return json.dumps(body, separators=(",", ":"))
def _combine_preset_chunks_for_wifi(chunk_messages: List[str]) -> str:
merged_presets: Dict[str, Any] = {}
save_flag = False
default_id = None
for msg in chunk_messages:
body = _body_from_message(msg)
if not body:
continue
presets = body.get("presets")
if isinstance(presets, dict):
merged_presets.update(presets)
if body.get("save"):
save_flag = True
if body.get("default") is not None:
default_id = body.get("default")
out: Dict[str, Any] = {"v": "1", "presets": merged_presets}
if save_flag:
out["save"] = True
if default_id is not None:
out["default"] = default_id
return json.dumps(out, separators=(",", ":"))
def _ordered_target_macs(target_macs: Optional[List[str]]) -> List[str]:
if not target_macs:
return []
seen: set[str] = set()
ordered: List[str] = []
for raw in target_macs:
m = normalize_mac(str(raw)) if raw else None
if not m or m in seen:
continue
seen.add(m)
ordered.append(m)
return ordered
def _wifi_targets(
devices_model,
target_macs: Optional[List[str]],
) -> List[tuple[str, str]]:
"""Return (ip, device_name) pairs for Wi-Fi drivers in scope."""
ordered = _ordered_target_macs(target_macs)
out: List[tuple[str, str]] = []
seen_ips: set[str] = set()
if ordered:
for mac in ordered:
doc = devices_model.read(mac)
if not doc or doc.get("transport") != "wifi":
continue
ip = str(doc.get("address") or "").strip()
if not ip or ip in seen_ips:
continue
seen_ips.add(ip)
name = str(doc.get("name") or "").strip() or mac
out.append((ip, name))
return out
for _sid, doc in devices_model.items():
if not isinstance(doc, dict) or doc.get("transport") != "wifi":
continue
ip = str(doc.get("address") or "").strip()
if not ip or ip in seen_ips:
continue
seen_ips.add(ip)
name = str(doc.get("name") or "").strip() or str(doc.get("id") or _sid)
out.append((ip, name))
return out
def build_preset_json_chunks(
presets_by_name: Dict[str, Any],
*,
@@ -96,11 +197,10 @@ def build_preset_json_chunks(
def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
"""One formatted MAC per target; empty list means broadcast."""
if not target_macs:
return [BROADCAST_MAC]
keys: List[str] = []
seen: set = set()
seen: set[str] = set()
for raw in target_macs:
h = normalize_mac_key(raw)
if h and h not in seen:
@@ -109,6 +209,69 @@ def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
return keys if keys else [BROADCAST_MAC]
async def deliver_preset_broadcast_then_per_device(
bridge,
chunk_messages,
target_macs,
devices_model,
default_id,
delay_s=0.1,
):
"""ESP-NOW preset chunks via bridge broadcast; one combined preset per Wi-Fi driver."""
if not chunk_messages:
return 0
from models.transport import get_current_bridge
active = get_current_bridge() or bridge
if active is None:
raise RuntimeError("Transport not configured")
ordered = _ordered_target_macs(target_macs)
wifi_targets = _wifi_targets(devices_model, ordered or None)
deliveries = 0
for msg in chunk_messages:
body = _body_from_message(msg)
if body:
deliveries += await _deliver_v1_body(active, BROADCAST_MAC, body, 0)
await asyncio.sleep(delay_s)
if wifi_targets:
combined = _combine_preset_chunks_for_wifi(chunk_messages)
for ip, _name in wifi_targets:
try:
if await send_json_line_to_ip(ip, combined):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi preset send failed: {e!r}")
await asyncio.sleep(delay_s)
if default_id:
did = str(default_id)
macs = ordered or [
sid for sid, doc in devices_model.items()
if isinstance(doc, dict)
]
for mac in macs:
doc = devices_model.read(mac) or {}
name = str(doc.get("name") or "").strip() or mac
body = {"v": "1", "default": did, "save": True, "targets": [name]}
out = json.dumps(body, separators=(",", ":"))
if doc.get("transport") == "wifi" and doc.get("address"):
ip = str(doc["address"]).strip()
try:
if await send_json_line_to_ip(ip, out):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] default Wi-Fi send failed: {e!r}")
else:
deliveries += await _deliver_v1_body(active, mac, body, 0)
await asyncio.sleep(delay_s)
return deliveries
async def deliver_json_messages(
bridge,
messages,
@@ -119,30 +282,110 @@ async def deliver_json_messages(
unicast: bool = False,
):
"""
Deliver v1 JSON to drivers. Default: ESP-NOW broadcast (``ff:ff:…``); drivers
filter on ``groups`` in the body. Set ``unicast=True`` only for per-device settings
or single-device identify.
Deliver v1 JSON to ESP-NOW (bridge) and Wi-Fi (outbound WebSocket) drivers.
Uses the current bridge connection only (per-group bridge assignment is disabled).
Broadcast (no targets): ESP-NOW via bridge plus all registered Wi-Fi drivers.
Targeted: route each MAC by transport. ``unicast=True`` forces per-MAC ESP-NOW
delivery instead of broadcast.
"""
del devices_model
from models.transport import get_current_bridge
active = get_current_bridge() or bridge
if active is None:
raise RuntimeError("Transport not configured")
if unicast and target_macs:
mac_keys = _unicast_mac_keys(target_macs)
else:
mac_keys = [BROADCAST_MAC]
if not messages:
return 0, 0
ordered_macs = _ordered_target_macs(target_macs)
deliveries = 0
for mac_key in mac_keys:
if not ordered_macs:
wifi_targets = _wifi_targets(devices_model, None)
for msg in messages:
text = _message_text(msg)
body = _body_from_message(msg)
if not body:
continue
deliveries += await _deliver_v1_body(active, mac_key, body, delay_s)
if body:
deliveries += await _deliver_v1_body(active, BROADCAST_MAC, body, 0)
if text and wifi_targets:
for ip, name in wifi_targets:
wifi_msg = _wifi_message_for_device(text, name)
try:
if await send_json_line_to_ip(ip, wifi_msg):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi delivery failed: {e!r}")
if delay_s > 0:
await asyncio.sleep(delay_s)
return deliveries, len(messages)
if unicast:
mac_keys = _unicast_mac_keys(target_macs)
for mac_key in mac_keys:
mac_hex = normalize_mac_key(mac_key) or mac_key.replace(":", "")
doc = devices_model.read(mac_hex) if mac_hex else None
for msg in messages:
text = _message_text(msg)
body = _body_from_message(msg)
if doc and doc.get("transport") == "wifi" and doc.get("address"):
ip = str(doc["address"]).strip()
name = str(doc.get("name") or "").strip()
wifi_msg = _wifi_message_for_device(text or "", name) if text else ""
if wifi_msg:
try:
if await send_json_line_to_ip(ip, wifi_msg):
deliveries += 1
except Exception as e:
print(f"[driver_delivery] Wi-Fi delivery failed: {e!r}")
elif body:
deliveries += await _deliver_v1_body(active, mac_key, body, 0)
if delay_s > 0:
await asyncio.sleep(delay_s)
return deliveries, len(messages)
for msg in messages:
text = _message_text(msg)
body = _body_from_message(msg)
wifi_tasks = []
espnow_macs: List[str] = []
for mac in ordered_macs:
doc = devices_model.read(mac)
if doc and doc.get("transport") == "wifi":
ip = str(doc.get("address") or "").strip()
if ip and text:
name = str(doc.get("name") or "").strip()
wifi_tasks.append(send_json_line_to_ip(ip, _wifi_message_for_device(text, name)))
else:
espnow_macs.append(mac)
tasks = []
espnow_peer_count = 0
if body and len(espnow_macs) > 1:
for mac in espnow_macs:
tasks.append(_deliver_v1_body(active, format_mac_key(normalize_mac_key(mac)), body, 0))
espnow_peer_count = len(espnow_macs)
elif body and len(espnow_macs) == 1:
mac_key = format_mac_key(normalize_mac_key(espnow_macs[0]))
tasks.append(_deliver_v1_body(active, mac_key, body, 0))
espnow_peer_count = 1
tasks.extend(wifi_tasks)
if tasks:
results = await asyncio.gather(*tasks, return_exceptions=True)
n_serial = len(tasks) - len(wifi_tasks)
for i, r in enumerate(results):
if i < n_serial:
if isinstance(r, int) and r > 0:
deliveries += r
elif isinstance(r, Exception):
print(f"[driver_delivery] ESP-NOW delivery failed: {r!r}")
else:
if r is True:
deliveries += 1
elif isinstance(r, Exception):
print(f"[driver_delivery] Wi-Fi delivery failed: {r!r}")
if delay_s > 0:
await asyncio.sleep(delay_s)
return deliveries, len(messages)

View File

@@ -1,7 +1,8 @@
"""Server-side zone sequence playback (audio beats or simulated BPM).
Steps advance on each beat from the audio detector when it is running; otherwise the server
emits beats at the sequence ``simulated_bpm`` rate until playback stops or live audio starts.
A background clock at ``audio_simulated_bpm`` ticks continuously. When the audio detector is
running, live (and holdover) beats drive sequences; otherwise the background clock does.
The consumer dedupes so both sources cannot exceed the configured BPM limit.
"""
from __future__ import annotations
@@ -10,6 +11,7 @@ import asyncio
import json
import queue
import threading
import time
from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import resolve_preset_background_hex
@@ -17,22 +19,23 @@ from util.espnow_message import resolve_preset_background_hex
_thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256)
_beat_consumer_started = False
_beat_consumer_lock = threading.Lock()
_last_beat_processed_ts = 0.0
_beat_dedupe_lock = threading.Lock()
_sim_beat_task: Optional[asyncio.Task] = None
_sim_beat_token = 0
_background_beat_task: Optional[asyncio.Task] = None
_background_beat_token = 0
_beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock()
_pending_play: Optional[Dict[str, Any]] = None
_pending_play_lock = threading.Lock()
_pending_beat_task: Optional[asyncio.Task] = None
_pending_beat_token = 0
_last_thread_beat_phase: Dict[str, Any] = {
"is_downbeat": True,
"bar_beat": 1,
}
_sim_beat_counter = 0
_last_completed_beat_readout = ""
def _norm_mac(raw: Any) -> Optional[str]:
@@ -933,6 +936,62 @@ def _build_ctx(
}
def _beat_readout_for_ctx(ctx: Dict[str, Any]) -> str:
"""Pass position readout (e.g. ``3/6`` or ``6/6``) from an active run context."""
lanes: List[List[Dict[str, Any]]] = ctx.get("lanes") or []
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
lane0_steps = len(lanes[0]) if lanes else 0
lane0 = lanes[0] if lanes else []
sequence_beats_per_pass = 0
for step in lane0:
sequence_beats_per_pass += max(1, int((step or {}).get("beats") or 1))
sequence_beat_at = 0
if lane_states and lane0_steps > 0:
st0 = lane_states[0]
idx = int(st0.get("stepIdx", 0))
if st0.get("done"):
sequence_beat_at = sequence_beats_per_pass
else:
beats_per_step = 1
if 0 <= idx < len(lanes[0]):
step = lanes[0][idx]
beats_per_step = max(1, int(step.get("beats") or 1))
beat_count_raw = int(st0.get("beatCount", 0))
bt = max(1, int(beats_per_step))
beat_count = min(bt, max(0, beat_count_raw))
for j in range(min(idx, len(lane0))):
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
sequence_beat_at += beat_count
if sequence_beats_per_pass > 0 and lane_states and lane0_steps > 0 and lane_states[0]:
tot = max(1, int(sequence_beats_per_pass))
st0_done = bool(lane_states[0].get("done"))
if st0_done:
return f"{tot}/{tot}"
at = int(sequence_beat_at)
if at > 0:
# On the last beat of the pass, show n/n (not n-1/n) for the whole beat interval.
sp = tot if at == tot - 1 else min(tot, at)
return f"{sp}/{tot}"
return ""
def last_completed_beat_readout() -> str:
"""Final pass readout kept after playback stops (e.g. ``6/6``)."""
return str(_last_completed_beat_readout or "").strip()
def clear_completed_beat_readout() -> None:
global _last_completed_beat_readout
_last_completed_beat_readout = ""
def remember_completed_beat_readout(readout: str) -> None:
global _last_completed_beat_readout
text = str(readout or "").strip()
if text:
_last_completed_beat_readout = text
def playback_status() -> Dict[str, Any]:
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
with _beat_run_lock:
@@ -965,7 +1024,7 @@ def playback_status() -> Dict[str, Any]:
beats_per_step = max(1, int(step.get("beats") or 1))
beat_count_raw = int(st0.get("beatCount", 0))
bt = max(1, int(beats_per_step))
beat_count = min(bt, max(1, beat_count_raw if beat_count_raw > 0 else 1))
beat_count = min(bt, max(0, beat_count_raw))
for j in range(min(idx, len(lane0))):
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
sequence_beat_at += beat_count
@@ -988,19 +1047,7 @@ def playback_status() -> Dict[str, Any]:
lane0_preset_name = nm or pid
else:
lane0_preset_name = pid
beat_readout = ""
if (
sequence_beats_per_pass > 0
and lane_states
and lane0_steps > 0
and lane_states[0]
and not lane_states[0].get("done")
):
tot = max(1, int(sequence_beats_per_pass))
at = int(sequence_beat_at)
# Pass position within this run: inclusive 1..tot
sp = min(tot, max(1, at if at > 0 else 1))
beat_readout = f"{sp}/{tot}"
beat_readout = _beat_readout_for_ctx(ctx)
return {
"active": True,
"advance_mode": ctx.get("advance_mode"),
@@ -1026,6 +1073,11 @@ async def process_active_beat_advance() -> None:
ctx = _beat_run
if not ctx:
return
if ctx.get("_pending_switch"):
return
if _is_sequence_pass_start(ctx):
if ctx.pop("_anchor_bar_on_pass_start", True):
_anchor_bar_phase_for_sequence_start()
lane_states: List[Dict[str, Any]] = ctx["lane_states"]
lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
loop = bool(ctx.get("loop"))
@@ -1041,18 +1093,21 @@ async def process_active_beat_advance() -> None:
step = lane_steps[int(st.get("stepIdx", 0))]
need = max(1, int(step.get("beats") or 1))
if int(st["beatCount"]) >= need:
st["beatCount"] = 0
if int(st.get("stepIdx", 0)) + 1 >= len(lane_steps):
at_end_of_lane = int(st.get("stepIdx", 0)) + 1 >= len(lane_steps)
if at_end_of_lane:
if loop:
if i == 0:
lane0_looped = True
st["beatCount"] = 0
# Force step-0 preset re-upload on loop wrap, even if wire id matches.
st["_last_wire"] = ""
st["stepIdx"] = 0
await _send_lane(i, st, ctx)
else:
st["beatCount"] = need
st["done"] = True
else:
st["beatCount"] = 0
st["stepIdx"] = int(st.get("stepIdx", 0)) + 1
await _send_lane(i, st, ctx)
if lane0_looped:
@@ -1061,6 +1116,8 @@ async def process_active_beat_advance() -> None:
else:
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
if all(s.get("done") for s in lane_states):
remember_completed_beat_readout(_beat_readout_for_ctx(ctx))
await asyncio.sleep(0)
await stop_playback(clear_devices=True)
return
@@ -1097,17 +1154,12 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
def _halt_playback_state() -> Optional[Dict[str, Any]]:
"""Drop active run state and cancel simulated beats; return the previous ctx."""
global _beat_run, _sim_beat_task, _sim_beat_token
"""Drop active run state; return the previous ctx."""
global _beat_run
ctx: Optional[Dict[str, Any]] = None
with _beat_run_lock:
ctx = _beat_run
_beat_run = None
_sim_beat_token += 1
st = _sim_beat_task
_sim_beat_task = None
if st and not st.done():
st.cancel()
return ctx
@@ -1115,6 +1167,10 @@ async def stop_playback(*, clear_devices: bool = True) -> None:
"""Stop sequence playback; optionally clear presets on targeted devices."""
clear_pending_play()
ctx = _halt_playback_state()
if ctx:
lane_states = ctx.get("lane_states") or []
if not lane_states or not all(s.get("done") for s in lane_states):
clear_completed_beat_readout()
if clear_devices and ctx:
await _clear_devices_after_sequence(ctx)
@@ -1166,18 +1222,39 @@ def _drain_beat_queue() -> None:
pass
def _reset_beat_side_effects() -> None:
"""Clear manual routes and queued beats so startup cannot select before presets land."""
def _clear_beat_route_only() -> None:
from util.beat_driver_route import update_beat_route
update_beat_route({"enabled": False})
def _reset_beat_side_effects() -> None:
"""Clear manual routes and queued beats so startup cannot select before presets land."""
global _last_beat_processed_ts
_clear_beat_route_only()
_drain_beat_queue()
with _beat_dedupe_lock:
_last_beat_processed_ts = 0.0
def _sequence_switch_wait_from_settings() -> str:
def _rearm_beat_dedupe_clock() -> None:
"""After a sequence handoff, enforce a full beat gap before the next accepted tick."""
global _last_beat_processed_ts
with _beat_dedupe_lock:
_last_beat_processed_ts = time.time()
def effective_sequence_switch_wait() -> str:
"""Beat-only when the simulated clock is driving; otherwise honour saved preference."""
try:
from util import audio_detector as ad_mod
from settings import get_settings
# Match ``_audio_drives_beat_clock``: mic may be "running" while sim still ticks.
if not ad_mod.shared_beat_detector_timing_sequences():
return "beat"
raw = get_settings().get("sequence_switch_wait", "beat")
mode = _normalize_wait_for({"wait_for": raw}) or "beat"
if mode == "phrase":
@@ -1187,6 +1264,10 @@ def _sequence_switch_wait_from_settings() -> str:
return "beat"
def _sequence_switch_wait_from_settings() -> str:
return effective_sequence_switch_wait()
def _normalize_wait_for(play_options: Optional[Dict[str, Any]]) -> Optional[str]:
"""``beat`` | ``downbeat`` | None (immediate)."""
if not isinstance(play_options, dict):
@@ -1213,21 +1294,11 @@ def _play_options_without_wait(play_options: Optional[Dict[str, Any]]) -> Option
return out
def _cancel_pending_beat_waiter() -> None:
global _pending_beat_task, _pending_beat_token
_pending_beat_token += 1
t = _pending_beat_task
_pending_beat_task = None
if t and not t.done():
t.cancel()
def clear_pending_play() -> None:
"""Drop a queued sequence start (e.g. user stop)."""
global _pending_play
with _pending_play_lock:
_pending_play = None
_cancel_pending_beat_waiter()
def pending_play_status() -> Dict[str, Any]:
@@ -1246,10 +1317,12 @@ def pending_play_status() -> Dict[str, Any]:
def _beat_phase_from_sources() -> Dict[str, Any]:
from util import audio_detector as ad_mod
if ad_mod.shared_beat_detector_running():
if ad_mod.shared_beat_detector_timing_sequences():
st = ad_mod.shared_beat_status_snapshot()
if st:
return dict(st)
if st.get("bar_beat") is not None:
bar_beat = int(st["bar_beat"])
is_down = bool(st.get("is_downbeat")) or bar_beat == 1
return {"bar_beat": bar_beat, "is_downbeat": is_down}
return dict(_last_thread_beat_phase)
@@ -1269,6 +1342,42 @@ def _mark_simulated_beat_phase(*, beats_per_bar: int = 4) -> None:
}
def _anchor_simulated_bar_phase(*, beats_per_bar: int = 4) -> None:
"""Align simulated bar phase to beat 1 (downbeat) for the current tick."""
global _sim_beat_counter, _last_thread_beat_phase
bpb = max(1, int(beats_per_bar))
c = max(1, int(_sim_beat_counter))
_sim_beat_counter = ((c - 1) // bpb) * bpb + 1
_last_thread_beat_phase = {
"bar_beat": 1,
"is_downbeat": True,
}
def _is_sequence_pass_start(ctx: Dict[str, Any]) -> bool:
"""True on beat 1 of step 1 (including after a loop wrap)."""
lane_states: List[Dict[str, Any]] = ctx.get("lane_states") or []
if not lane_states:
return False
st0 = lane_states[0]
if st0.get("done"):
return False
if int(st0.get("stepIdx", 0)) != 0:
return False
return int(st0.get("beatCount", 0)) == 0
def _anchor_bar_phase_for_sequence_start() -> None:
"""Sequence beat 1 and bar beat 1 begin on the same counted beat."""
_anchor_simulated_bar_phase()
try:
from util.audio_detector import anchor_shared_bar_phase
anchor_shared_bar_phase()
except Exception:
pass
def _queue_pending_start(
zone_id: str,
sequence_id: str,
@@ -1279,7 +1388,12 @@ def _queue_pending_start(
bpm: float,
) -> None:
global _pending_play
if effective_sequence_switch_wait() == "beat":
wait_for = "beat"
clear_pending_play()
with _beat_run_lock:
if _beat_run is not None:
_beat_run["_pending_switch"] = True
with _pending_play_lock:
_pending_play = {
"zone_id": str(zone_id),
@@ -1288,49 +1402,13 @@ def _queue_pending_start(
"play_options": _play_options_without_wait(play_options),
"wait_for": wait_for,
}
_ensure_pending_beat_waiter(bpm)
ensure_background_beat_clock_started()
_prime_pending_sequence_beat_clock(wait_for)
def _ensure_pending_beat_waiter(bpm: float) -> None:
"""When nothing is playing and audio is off, emit synthetic beats until pending starts."""
from util import audio_detector as ad_mod
if ad_mod.shared_beat_detector_running():
return
with _beat_run_lock:
if _beat_run:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
global _pending_beat_task, _pending_beat_token
t = _pending_beat_task
if t and not t.done():
t.cancel()
_pending_beat_token += 1
my_tok = _pending_beat_token
_pending_beat_task = loop.create_task(_pending_beat_wait_loop(bpm, my_tok))
async def _pending_beat_wait_loop(bpm: float, my_token: int) -> None:
from util import audio_detector as ad_mod
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
while True:
with _pending_play_lock:
if _pending_beat_token != my_token or _pending_play is None:
return
if ad_mod.shared_beat_detector_running():
return
await asyncio.sleep(interval)
with _pending_play_lock:
if _pending_beat_token != my_token or _pending_play is None:
return
if ad_mod.shared_beat_detector_running():
return
_mark_simulated_beat_phase()
push_thread_beat()
def _prime_pending_sequence_beat_clock(wait_for: str) -> None:
"""No-op: the background simulated clock fills beats when live audio is not timing."""
_ = wait_for
async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
@@ -1339,25 +1417,34 @@ async def _try_consume_pending_play(*, is_downbeat: bool) -> bool:
pending = _pending_play
if not pending:
return False
wait_for = str(pending.get("wait_for") or "beat").strip().lower()
# Re-read preference at consume time (e.g. audio stopped → simulated beat-only).
wait_for = effective_sequence_switch_wait()
if wait_for == "downbeat" and not is_downbeat:
return False
_pending_play = None
_cancel_pending_beat_waiter()
await _start_immediate(
pending["zone_id"],
pending["sequence_id"],
pending["profile_id"],
pending.get("play_options"),
sequence_handoff=True,
handoff_is_downbeat=is_downbeat,
)
_drain_beat_queue()
_rearm_beat_dedupe_clock()
_restart_background_beat_clock()
return True
def stop() -> None:
def stop(*, sequence_handoff: bool = False) -> None:
"""Stop server playback state without sending device clear (e.g. before starting another run)."""
clear_pending_play()
clear_completed_beat_readout()
_halt_playback_state()
_reset_beat_side_effects()
if sequence_handoff:
_clear_beat_route_only()
else:
_reset_beat_side_effects()
def push_thread_beat() -> None:
@@ -1367,41 +1454,62 @@ def push_thread_beat() -> None:
pass
def _min_processed_beat_gap_s() -> float:
from util.bpm_limits import min_beat_interval_s
return float(min_beat_interval_s()) * 0.92
def _accept_thread_beat_now() -> bool:
"""Drop beats closer than the BPM limit (audio + simulated may both fire)."""
global _last_beat_processed_ts
now = time.time()
gap = _min_processed_beat_gap_s()
with _beat_dedupe_lock:
if now - _last_beat_processed_ts < gap:
return False
_last_beat_processed_ts = now
return True
async def beat_consumer_loop() -> None:
while True:
n = 0
try:
while True:
_thread_beat_queue.get_nowait()
n += 1
_thread_beat_queue.get_nowait()
except queue.Empty:
pass
if n:
from util.beat_driver_route import notify_beat_detected
for _ in range(n):
phase = _beat_phase_from_sources()
is_down = bool(phase.get("is_downbeat"))
try:
await _try_consume_pending_play(is_downbeat=is_down)
except Exception as e:
print(f"[sequence-playback] pending start: {e}")
try:
await process_active_beat_advance()
except Exception as e:
print(f"[sequence-playback] beat advance: {e}")
try:
notify_beat_detected()
except Exception as e:
print(f"[sequence-playback] notify_beat_detected: {e}")
else:
await asyncio.sleep(0.012)
continue
if not _accept_thread_beat_now():
continue
from util.beat_driver_route import notify_beat_detected
phase = _beat_phase_from_sources()
is_down = bool(phase.get("is_downbeat"))
try:
await _try_consume_pending_play(is_downbeat=is_down)
except Exception as e:
print(f"[sequence-playback] pending start: {e}")
try:
await process_active_beat_advance()
except Exception as e:
print(f"[sequence-playback] beat advance: {e}")
try:
notify_beat_detected()
except Exception as e:
print(f"[sequence-playback] notify_beat_detected: {e}")
try:
from util import beat_status_broadcaster as beat_sse
beat_sse.request_beat_status_broadcast()
except Exception as e:
print(f"[sequence-playback] beat status broadcast: {e}")
def ensure_beat_consumer_started() -> None:
global _beat_consumer_started
with _beat_consumer_lock:
if _beat_consumer_started:
ensure_background_beat_clock_started()
return
try:
loop = asyncio.get_running_loop()
@@ -1409,46 +1517,80 @@ def ensure_beat_consumer_started() -> None:
return
_beat_consumer_started = True
loop.create_task(beat_consumer_loop())
ensure_background_beat_clock_started()
def _coerce_simulated_bpm(sequence_doc: Dict[str, Any], play_options: Optional[Dict[str, Any]]) -> float:
raw = None
if isinstance(play_options, dict):
o = play_options.get("simulated_bpm")
if o is not None:
raw = o
if raw is None and isinstance(sequence_doc, dict):
raw = sequence_doc.get("simulated_bpm")
try:
v = float(raw) if raw is not None else 120.0
except (TypeError, ValueError):
v = 120.0
return max(30.0, min(300.0, v))
async def _simulated_beat_loop(ctx: Dict[str, Any], my_token: int, bpm: float) -> None:
def _audio_drives_beat_clock() -> bool:
from util import audio_detector as ad_mod
interval = 60.0 / max(30.0, min(300.0, float(bpm)))
return ad_mod.shared_beat_detector_timing_sequences()
def ensure_background_beat_clock_started() -> None:
"""Start the always-on simulated BPM tick (no-op if already running)."""
global _background_beat_task, _background_beat_token
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
t = _background_beat_task
if t is not None and not t.done():
return
_background_beat_token += 1
my_tok = _background_beat_token
_background_beat_task = loop.create_task(_background_beat_loop(my_tok))
def _restart_background_beat_clock() -> None:
"""Restart the simulated clock so the next tick is a full interval away."""
global _background_beat_task, _background_beat_token
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
_background_beat_token += 1
my_tok = _background_beat_token
_background_beat_task = loop.create_task(_background_beat_loop(my_tok))
async def _background_beat_loop(my_token: int) -> None:
"""Tick at simulated BPM; push beats only when audio detection is off."""
from util.bpm_limits import clamp_bpm
while True:
with _beat_run_lock:
cur_tok = _sim_beat_token
active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
if _background_beat_token != my_token:
return
if ad_mod.shared_beat_detector_running():
await asyncio.sleep(0.12)
continue
bpm = _simulated_bpm_from_settings()
interval = 60.0 / clamp_bpm(bpm)
await asyncio.sleep(interval)
with _beat_run_lock:
cur_tok = _sim_beat_token
active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
if _background_beat_token != my_token:
return
if ad_mod.shared_beat_detector_running():
continue
_mark_simulated_beat_phase()
push_thread_beat()
if not _audio_drives_beat_clock():
push_thread_beat()
def simulated_beat_tick() -> int:
"""Monotonic counter incremented on each synthetic beat (UI flash when audio is off)."""
return int(_sim_beat_counter)
def simulated_beat_phase_snapshot(*, beats_per_bar: int = 4) -> Dict[str, Any]:
"""Bar phase from the last synthetic beat (for UI when audio detection is off)."""
bpb = max(1, int(beats_per_bar))
phase = dict(_last_thread_beat_phase)
bar_beat = int(phase.get("bar_beat") or 1)
bar_beat = min(bpb, max(1, bar_beat))
phase["bar_beat"] = bar_beat
phase["bar_phase_readout"] = f"{bar_beat}/{bpb}"
return phase
def _simulated_bpm_from_settings() -> float:
from settings import get_settings
from util.bpm_limits import clamp_bpm
return clamp_bpm(get_settings().get("audio_simulated_bpm"))
def stop_if_playing_sequence(sequence_id: str) -> bool:
@@ -1485,8 +1627,10 @@ async def start(
if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
raise ValueError("sequence not found")
wait_for = _sequence_switch_wait_from_settings()
if wait_for:
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
with _beat_run_lock:
active = _beat_run is not None
if wait_for and active:
bpm = _simulated_bpm_from_settings()
_queue_pending_start(
zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm
)
@@ -1499,14 +1643,17 @@ async def _start_immediate(
sequence_id: str,
profile_id: str,
play_options: Optional[Dict[str, Any]] = None,
*,
sequence_handoff: bool = False,
handoff_is_downbeat: bool = False,
) -> None:
global _beat_run, _sim_beat_task, _sim_beat_token
global _beat_run
from models.preset import Preset
from models.profile import Profile
from models.sequence import Sequence
from models.zone import Zone
stop()
stop(sequence_handoff=sequence_handoff)
seq_m = Sequence()
zone_m = Zone()
prof_m = Profile()
@@ -1534,16 +1681,16 @@ async def _start_immediate(
ctx["sequence_id"] = str(sequence_id)
ctx["zone_id"] = str(zone_id)
ctx["sequence_loop_beat"] = 0
if sequence_handoff:
ctx["_anchor_bar_on_pass_start"] = handoff_is_downbeat
_reset_beat_side_effects()
if sequence_handoff:
_clear_beat_route_only()
else:
_reset_beat_side_effects()
await _prime_all_lanes(ctx)
await _deliver_zone_brightness_for_sequence(ctx)
with _beat_run_lock:
_beat_run = ctx
bpm = _coerce_simulated_bpm(sequence_doc, play_options)
loop = asyncio.get_running_loop()
_sim_beat_token += 1
my_tok = _sim_beat_token
_sim_beat_task = loop.create_task(_simulated_beat_loop(ctx, my_tok, bpm))
ensure_background_beat_clock_started()

View File

@@ -0,0 +1,248 @@
"""UDP discovery and outbound WebSocket maintenance for Wi-Fi LED drivers."""
from __future__ import annotations
import asyncio
import json
import socket
import threading
import traceback
from typing import Any, Dict, Optional
from models import wifi_ws_clients as tcp_client_registry
from models.device import Device, normalize_mac
DISCOVERY_UDP_PORT = 8766
_tcp_device_lock = threading.Lock()
_udp_holder: Dict[str, Any] = {"closing": False}
_tasks: list[asyncio.Task] = []
def _ipv4_address(addr: str) -> Optional[str]:
s = (addr or "").strip()
if not s:
return None
parts = s.split(".")
if len(parts) != 4:
return None
try:
nums = [int(p) for p in parts]
except ValueError:
return None
if not all(0 <= n <= 255 for n in nums):
return None
return s
def _register_udp_device_sync(
device_name: str, peer_ip: str, mac, device_type=None
) -> None:
with _tcp_device_lock:
try:
d = Device()
did, persisted = d.upsert_wifi_tcp_client(
device_name, peer_ip, mac, device_type=device_type
)
if did and persisted:
print(
f"UDP device registered: mac={did} name={device_name!r} ip={peer_ip!r}"
)
except Exception as e:
print(f"UDP device registry failed: {e}")
traceback.print_exception(type(e), e, e.__traceback__)
def _process_udp_datagram(data: bytes, peer_ip: str) -> None:
line = data.split(b"\n", 1)[0].strip()
if not line:
return
try:
parsed = json.loads(line.decode("utf-8"))
except (UnicodeError, ValueError, TypeError):
return
if not isinstance(parsed, dict):
return
dns = str(parsed.get("device_name") or "").strip()
mac = parsed.get("mac") or parsed.get("device_mac") or parsed.get("sta_mac")
device_type = parsed.get("type") or parsed.get("device_type")
if not dns or not normalize_mac(mac):
return
_register_udp_device_sync(dns, peer_ip, mac, device_type)
if str(parsed.get("v") or "") == "1":
tcp_client_registry.ensure_driver_connection(peer_ip)
class _DiscoveryProtocol(asyncio.DatagramProtocol):
"""UDP echo + device registration (uvloop-safe; no sock_recvfrom)."""
def __init__(self, udp_holder: Optional[Dict[str, Any]] = None) -> None:
self._udp_holder = udp_holder
self._transport: Optional[asyncio.DatagramTransport] = None
def connection_made(self, transport: asyncio.BaseTransport) -> None:
self._transport = transport # type: ignore[assignment]
if self._udp_holder is not None:
self._udp_holder["transport"] = transport
def connection_lost(self, exc: Optional[BaseException]) -> None:
if self._udp_holder is not None:
self._udp_holder.pop("transport", None)
self._transport = None
def datagram_received(self, data: bytes, addr) -> None:
if self._udp_holder and self._udp_holder.get("closing"):
return
peer_ip = addr[0] if addr else ""
try:
_process_udp_datagram(data, peer_ip)
except Exception as e:
print(f"[UDP] process failed: {e!r}")
transport = self._transport
if transport is None:
return
try:
transport.sendto(data, addr)
except Exception as e:
print(f"[UDP] echo send failed: {e!r}")
def error_received(self, exc: Exception) -> None:
if self._udp_holder and self._udp_holder.get("closing"):
return
print(f"[UDP] socket error: {exc!r}")
def prime_wifi_outbound_driver_connections() -> None:
n = 0
try:
dev = Device()
for _mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
tcp_client_registry.ensure_driver_connection(ip)
n += 1
except Exception as e:
print(f"[startup] Wi-Fi driver connection prime failed: {e!r}")
traceback.print_exception(type(e), e, e.__traceback__)
return
if n:
print(f"[startup] primed outbound WebSocket for {n} Wi-Fi driver(s)")
async def _periodic_wifi_driver_hello_loop(settings, udp_holder) -> None:
try:
interval = float(settings.get("wifi_driver_hello_interval_s", 10.0))
except (TypeError, ValueError):
interval = 10.0
if interval <= 0:
return
try:
while True:
await asyncio.sleep(interval)
if udp_holder.get("closing"):
break
transport = udp_holder.get("transport")
if transport is None:
continue
try:
dev = Device()
except Exception as e:
print(f"[hello] device list failed: {e!r}")
continue
for _mac_key, doc in list(dev.items()):
if not isinstance(doc, dict):
continue
if doc.get("transport") != "wifi":
continue
ip = _ipv4_address(str(doc.get("address") or ""))
if not ip:
continue
if tcp_client_registry.tcp_client_connected(ip):
continue
name = (doc.get("name") or "").strip()
mac = normalize_mac(doc.get("id") or _mac_key)
if not name or not mac:
continue
line = (
json.dumps(
{"m": "hello", "device_name": name, "mac": mac},
separators=(",", ":"),
)
+ "\n"
)
try:
transport.sendto(line.encode("utf-8"), (ip, DISCOVERY_UDP_PORT))
except OSError as e:
print(f"[hello] UDP to {ip!r} failed: {e!r}")
except asyncio.CancelledError:
raise
async def _run_udp_discovery_server(udp_holder=None) -> None:
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
except OSError:
pass
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
except OSError:
pass
sock.bind(("0.0.0.0", DISCOVERY_UDP_PORT))
print(f"UDP discovery listening on 0.0.0.0:{DISCOVERY_UDP_PORT}")
transport, _protocol = await loop.create_datagram_endpoint(
lambda: _DiscoveryProtocol(udp_holder),
sock=sock,
)
try:
while not (udp_holder and udp_holder.get("closing")):
await asyncio.sleep(3600)
except asyncio.CancelledError:
raise
finally:
if udp_holder is not None:
udp_holder.pop("transport", None)
transport.close()
async def start_wifi_driver_runtime(settings) -> None:
global _udp_holder, _tasks
tcp_client_registry.set_settings(settings)
from util.device_status_broadcaster import broadcast_device_tcp_status
tcp_client_registry.set_tcp_status_broadcaster(broadcast_device_tcp_status)
_udp_holder = {"closing": False}
prime_wifi_outbound_driver_connections()
loop = asyncio.get_running_loop()
_tasks = [
loop.create_task(_run_udp_discovery_server(_udp_holder)),
loop.create_task(_periodic_wifi_driver_hello_loop(settings, _udp_holder)),
]
async def stop_wifi_driver_runtime() -> None:
global _udp_holder, _tasks
if _udp_holder is not None:
_udp_holder["closing"] = True
transport = _udp_holder.get("transport")
if transport is not None:
try:
transport.close()
except OSError:
pass
for task in list(_tasks):
if not task.done():
task.cancel()
if _tasks:
await asyncio.gather(*_tasks, return_exceptions=True)
_tasks = []
tcp_client_registry.cancel_all_driver_tasks()