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:
@@ -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
|
||||
|
||||
@@ -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 0–255 }``.
|
||||
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``). Wi‑Fi or ESP‑NOW.
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -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 Wi‑Fi 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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 1–11 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')
|
||||
|
||||
|
||||
@@ -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": "Wi‑Fi interface (device) is required"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return J({"error": "Wi‑Fi 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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
84
src/http_responses.py
Normal 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
117
src/http_session.py
Normal 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)
|
||||
@@ -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})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 (0–255); 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, 0–200).
|
||||
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:
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 Wi‑Fi 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');
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 handler’s 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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 Wi‑Fi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lane’s 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 (0–255)</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 Wi‑Fi 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 Wi‑Fi 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">Wi‑Fi</h3>
|
||||
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://<bridge-ip>/ws</code>.</p>
|
||||
<div class="form-group">
|
||||
<label for="bridge-wifi-interface">Wi‑Fi 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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
156
src/util/beat_status_broadcaster.py
Normal file
156
src/util/beat_status_broadcaster.py
Normal 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
40
src/util/bpm_limits.py
Normal 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)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
248
src/util/wifi_driver_runtime.py
Normal file
248
src/util/wifi_driver_runtime.py
Normal 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()
|
||||
Reference in New Issue
Block a user