refactor(api): complete fastapi migration and related features

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

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

View File

@@ -13,8 +13,8 @@ watchfiles = "*"
requests = "*" requests = "*"
selenium = "*" selenium = "*"
adafruit-ampy = "*" adafruit-ampy = "*"
microdot = "*"
fastapi = "*" fastapi = "*"
python-multipart = "*"
websockets = "*" websockets = "*"
httpx = "*" httpx = "*"
numpy = "*" numpy = "*"

20
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "b6783f8de9b1f98387fccab1d9fed190f2acbf66f0f188e43fe91f28a6950ad1" "sha256": "898e7932e8decb3f1b5e1fd620883f2727cbd2f1c1295d8cd559105172d814cb"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@@ -597,15 +597,6 @@
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.1.2" "version": "==0.1.2"
}, },
"microdot": {
"hashes": [
"sha256:206c52870e3b1d5e6d387802e2ed0afae8c4598c80542a21e3efe377efc128c8",
"sha256:7bb9a69fa97a47d8fe07e61d9dd405804744132ca52d26705cf1173431ff7f4b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==2.6.2"
},
"mpremote": { "mpremote": {
"hashes": [ "hashes": [
"sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334", "sha256:2df2a50f3c8098cae8c732dbf2541e7e58185e7896513b45d05196901e049334",
@@ -901,6 +892,15 @@
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==1.2.2" "version": "==1.2.2"
}, },
"python-multipart": {
"hashes": [
"sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e",
"sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"
],
"index": "pypi",
"markers": "python_version >= '3.10'",
"version": "==0.0.32"
},
"pyyaml": { "pyyaml": {
"hashes": [ "hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",

View File

@@ -0,0 +1,49 @@
"""Wi-Fi radio for ESP-NOW only (hidden AP locks channel)."""
import time
import network
from settings import WIFI_CHANNEL_DEFAULT
def _channel(settings):
try:
return max(1, min(11, int(settings.get("wifi_channel", WIFI_CHANNEL_DEFAULT))))
except (TypeError, ValueError):
return WIFI_CHANNEL_DEFAULT
def init_espnow_radio(settings):
ch = _channel(settings)
name = settings.get("name") or "bridge"
password = settings.get("ap_password") or ""
network.WLAN(network.STA_IF).active(False)
network.WLAN(network.AP_IF).active(False)
time.sleep_ms(100)
ap = network.WLAN(network.AP_IF)
ap.active(True)
time.sleep_ms(50)
if password:
try:
ap.config(essid=name, password=password, channel=ch, hidden=True)
except TypeError:
ap.config(essid=name, channel=ch)
else:
try:
ap.config(essid=name, channel=ch, hidden=True)
except TypeError:
ap.config(essid=name, channel=ch)
sta = network.WLAN(network.STA_IF)
sta.active(True)
sta.config(pm=network.WLAN.PM_NONE)
try:
sta.config(channel=ch)
except Exception:
pass
print("espnow radio ch", ch)
return ch

View File

@@ -0,0 +1,7 @@
"""WebSocket uplink framing (Pi ↔ bridge)."""
BROADCAST_MAC = b"\xff\xff\xff\xff\xff\xff"
def pack_ws_uplink(peer, espnow_packet):
return bytes([0]) + peer + espnow_packet

View File

@@ -1 +1 @@
{"1":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000","#050500"],"2":[],"3":[],"4":[],"5":[],"6":[],"7":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"],"8":[],"9":[],"10":[],"11":[],"12":["#890b0b","#0b8935"],"13":[],"14":["#E8F4FF","#9ECFFF","#5080C8","#FFFFFF","#B0DCFF","#0A1520","#FF8020","#071018"]} {"1": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000", "#050500"], "2": [], "3": [], "4": [], "5": [], "6": [], "7": ["#FF0000", "#00FF00", "#0000FF", "#FFFF00", "#FF00FF", "#00FFFF", "#FFFFFF", "#000000"], "8": [], "9": [], "10": [], "11": [], "12": ["#890b0b", "#0b8935"], "13": [], "14": ["#E8F4FF", "#9ECFFF", "#5080C8", "#FFFFFF", "#B0DCFF", "#0A1520", "#FF8020", "#071018"], "15": []}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"1":{"name":"default","type":"zones","zones":["1","9","8","10"],"scenes":[],"palette_id":"1"},"2":{"name":"test","type":"zones","zones":["6","7"],"scenes":[],"palette_id":"12"},"3":{"name":"Winter","type":"zones","zones":["11","12"],"scenes":[],"palette_id":"14"}} {"1": {"name": "default", "type": "zones", "zones": ["1", "9", "8", "10"], "scenes": [], "palette_id": "1"}, "2": {"name": "test", "type": "zones", "zones": ["6", "7"], "scenes": [], "palette_id": "12"}, "3": {"name": "Winter", "type": "zones", "zones": ["11", "12"], "scenes": [], "palette_id": "14"}, "4": {"name": "t", "type": "zones", "zones": ["13"], "scenes": [], "palette_id": "15"}}

View File

@@ -1,2 +0,0 @@
from microdot.microdot import Microdot, Request, Response, abort, redirect, \
send_file # noqa: F401

View File

@@ -1,8 +0,0 @@
try:
from functools import wraps
except ImportError: # pragma: no cover
# MicroPython does not currently implement functools.wraps
def wraps(wrapped):
def _(wrapper):
return wrapper
return _

File diff suppressed because it is too large Load Diff

View File

@@ -1,225 +0,0 @@
try:
import jwt
HAS_JWT = True
except ImportError:
HAS_JWT = False
try:
import ubinascii
except ImportError:
import binascii as ubinascii
try:
import uhashlib as hashlib
except ImportError:
import hashlib
try:
import uhmac as hmac
except ImportError:
try:
import hmac
except ImportError:
hmac = None
import json
from microdot.microdot import invoke_handler
from microdot.helpers import wraps
class SessionDict(dict):
"""A session dictionary.
The session dictionary is a standard Python dictionary that has been
extended with convenience ``save()`` and ``delete()`` methods.
"""
def __init__(self, request, session_dict):
super().__init__(session_dict)
self.request = request
def save(self):
"""Update the session cookie."""
self.request.app._session.update(self.request, self)
def delete(self):
"""Delete the session cookie."""
self.request.app._session.delete(self.request)
class Session:
"""Session handling
:param app: The application instance.
:param secret_key: The secret key, as a string or bytes object.
:param cookie_options: A dictionary with cookie options to pass as
arguments to :meth:`Response.set_cookie()
<microdot.Response.set_cookie>`.
"""
secret_key = None
def __init__(self, app=None, secret_key=None, cookie_options=None):
self.secret_key = secret_key
self.cookie_options = cookie_options or {}
if app is not None:
self.initialize(app)
def initialize(self, app, secret_key=None, cookie_options=None):
if secret_key is not None:
self.secret_key = secret_key
if cookie_options is not None:
self.cookie_options = cookie_options
if 'path' not in self.cookie_options:
self.cookie_options['path'] = '/'
if 'http_only' not in self.cookie_options:
self.cookie_options['http_only'] = True
app._session = self
def get(self, request):
"""Retrieve the user session.
:param request: The client request.
The return value is a session dictionary with the data stored in the
user's session, or ``{}`` if the session data is not available or
invalid.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
if hasattr(request.g, '_session'):
return request.g._session
session = request.cookies.get('session')
if session is None:
request.g._session = SessionDict(request, {})
return request.g._session
request.g._session = SessionDict(request, self.decode(session))
return request.g._session
def update(self, request, session):
"""Update the user session.
:param request: The client request.
:param session: A dictionary with the update session data for the user.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.save` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session['foo'] = 'bar'
session.save()
return 'Hello, World!'
Calling this method adds a cookie with the updated session to the
request currently being processed.
"""
if not self.secret_key:
raise ValueError('The session secret key is not configured')
encoded_session = self.encode(session)
@request.after_request
def _update_session(request, response):
response.set_cookie('session', encoded_session,
**self.cookie_options)
return response
def delete(self, request):
"""Remove the user session.
:param request: The client request.
Applications would normally not call this method directly, instead they
would use the :meth:`SessionDict.delete` method on the session
dictionary, which calls this method. For example::
@app.route('/')
@with_session
def index(request, session):
session.delete()
return 'Hello, World!'
Calling this method adds a cookie removal header to the request
currently being processed.
"""
@request.after_request
def _delete_session(request, response):
response.delete_cookie('session', **self.cookie_options)
return response
def encode(self, payload, secret_key=None):
"""Encode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
return jwt.encode(payload, secret_key or self.secret_key,
algorithm='HS256')
else:
# Simple encoding for MicroPython: base64(json) + HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
payload_json = json.dumps(payload)
payload_b64 = ubinascii.b2a_base64(payload_json.encode()).decode().strip()
# Create HMAC signature
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
signature = ubinascii.b2a_base64(h.digest()).decode().strip()
return f"{payload_b64}.{signature}"
def decode(self, session, secret_key=None):
"""Decode session data using JWT if available, otherwise use simple HMAC."""
if HAS_JWT:
try:
payload = jwt.decode(session, secret_key or self.secret_key,
algorithms=['HS256'])
except jwt.exceptions.PyJWTError: # pragma: no cover
return {}
return payload
else:
try:
# Simple decoding for MicroPython
if '.' not in session:
return {}
payload_b64, signature = session.rsplit('.', 1)
payload_json = ubinascii.a2b_base64(payload_b64).decode()
# Verify HMAC signature
key = (secret_key or self.secret_key).encode() if isinstance(secret_key or self.secret_key, str) else (secret_key or self.secret_key)
if hmac:
# Use hmac module if available
h = hmac.new(key, payload_json.encode(), hashlib.sha256)
else:
# Fallback: simple SHA256(key + message)
h = hashlib.sha256(key + payload_json.encode())
expected_signature = ubinascii.b2a_base64(h.digest()).decode().strip()
if signature != expected_signature:
return {}
return json.loads(payload_json)
except Exception:
return {}
def with_session(f):
"""Decorator that passes the user session to the route handler.
The session dictionary is passed to the decorated function as an argument
after the request object. Example::
@app.route('/')
@with_session
def index(request, session):
return 'Hello, World!'
Note that the decorator does not save the session. To update the session,
call the :func:`session.save() <microdot.session.SessionDict.save>` method.
"""
@wraps(f)
async def wrapper(request, *args, **kwargs):
return await invoke_handler(
f, request, request.app._session.get(request), *args, **kwargs)
return wrapper

View File

@@ -1,70 +0,0 @@
from utemplate import recompile
_loader = None
class Template:
"""A template object.
:param template: The filename of the template to render, relative to the
configured template directory.
"""
@classmethod
def initialize(cls, template_dir='templates',
loader_class=recompile.Loader):
"""Initialize the templating subsystem.
:param template_dir: the directory where templates are stored. This
argument is optional. The default is to load
templates from a *templates* subdirectory.
:param loader_class: the ``utemplate.Loader`` class to use when loading
templates. This argument is optional. The default
is the ``recompile.Loader`` class, which
automatically recompiles templates when they
change.
"""
global _loader
_loader = loader_class(None, template_dir)
def __init__(self, template):
if _loader is None: # pragma: no cover
self.initialize()
#: The name of the template
self.name = template
self.template = _loader.load(template)
def generate(self, *args, **kwargs):
"""Return a generator that renders the template in chunks, with the
given arguments."""
return self.template(*args, **kwargs)
def render(self, *args, **kwargs):
"""Render the template with the given arguments and return it as a
string."""
return ''.join(self.generate(*args, **kwargs))
def generate_async(self, *args, **kwargs):
"""Return an asynchronous generator that renders the template in
chunks, using the given arguments."""
class sync_to_async_iter():
def __init__(self, iter):
self.iter = iter
def __aiter__(self):
return self
async def __anext__(self):
try:
return next(self.iter)
except StopIteration:
raise StopAsyncIteration
return sync_to_async_iter(self.generate(*args, **kwargs))
async def render_async(self, *args, **kwargs):
"""Render the template with the given arguments asynchronously and
return it as a string."""
response = ''
async for chunk in self.generate_async(*args, **kwargs):
response += chunk
return response

View File

@@ -1,231 +0,0 @@
import binascii
import hashlib
from microdot import Request, Response
from microdot.microdot import MUTED_SOCKET_ERRORS, print_exception
from microdot.helpers import wraps
class WebSocketError(Exception):
"""Exception raised when an error occurs in a WebSocket connection."""
pass
class WebSocket:
"""A WebSocket connection object.
An instance of this class is sent to handler functions to manage the
WebSocket connection.
"""
CONT = 0
TEXT = 1
BINARY = 2
CLOSE = 8
PING = 9
PONG = 10
#: Specify the maximum message size that can be received when calling the
#: ``receive()`` method. Messages with payloads that are larger than this
#: size will be rejected and the connection closed. Set to 0 to disable
#: the size check (be aware of potential security issues if you do this),
#: or to -1 to use the value set in
#: ``Request.max_body_length``. The default is -1.
#:
#: Example::
#:
#: WebSocket.max_message_length = 4 * 1024 # up to 4KB messages
max_message_length = -1
def __init__(self, request):
self.request = request
self.closed = False
async def handshake(self):
response = self._handshake_response()
await self.request.sock[1].awrite(
b'HTTP/1.1 101 Switching Protocols\r\n')
await self.request.sock[1].awrite(b'Upgrade: websocket\r\n')
await self.request.sock[1].awrite(b'Connection: Upgrade\r\n')
await self.request.sock[1].awrite(
b'Sec-WebSocket-Accept: ' + response + b'\r\n\r\n')
async def receive(self):
"""Receive a message from the client."""
while True:
opcode, payload = await self._read_frame()
send_opcode, data = self._process_websocket_frame(opcode, payload)
if send_opcode: # pragma: no cover
await self.send(data, send_opcode)
elif data: # pragma: no branch
return data
async def send(self, data, opcode=None):
"""Send a message to the client.
:param data: the data to send, given as a string or bytes.
:param opcode: a custom frame opcode to use. If not given, the opcode
is ``TEXT`` or ``BINARY`` depending on the type of the
data.
"""
frame = self._encode_websocket_frame(
opcode or (self.TEXT if isinstance(data, str) else self.BINARY),
data)
await self.request.sock[1].awrite(frame)
async def close(self):
"""Close the websocket connection."""
if not self.closed: # pragma: no cover
self.closed = True
await self.send(b'', self.CLOSE)
def _handshake_response(self):
connection = False
upgrade = False
websocket_key = None
for header, value in self.request.headers.items():
h = header.lower()
if h == 'connection':
connection = True
if 'upgrade' not in value.lower():
return self.request.app.abort(400)
elif h == 'upgrade':
upgrade = True
if not value.lower() == 'websocket':
return self.request.app.abort(400)
elif h == 'sec-websocket-key':
websocket_key = value
if not connection or not upgrade or not websocket_key:
return self.request.app.abort(400)
d = hashlib.sha1(websocket_key.encode())
d.update(b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
return binascii.b2a_base64(d.digest())[:-1]
@classmethod
def _parse_frame_header(cls, header):
fin = header[0] & 0x80
opcode = header[0] & 0x0f
if fin == 0 or opcode == cls.CONT: # pragma: no cover
raise WebSocketError('Continuation frames not supported')
has_mask = header[1] & 0x80
length = header[1] & 0x7f
if length == 126:
length = -2
elif length == 127:
length = -8
return fin, opcode, has_mask, length
def _process_websocket_frame(self, opcode, payload):
if opcode == self.TEXT:
payload = payload.decode()
elif opcode == self.BINARY:
pass
elif opcode == self.CLOSE:
raise WebSocketError('Websocket connection closed')
elif opcode == self.PING:
return self.PONG, payload
elif opcode == self.PONG: # pragma: no branch
return None, None
return None, payload
@classmethod
def _encode_websocket_frame(cls, opcode, payload):
frame = bytearray()
frame.append(0x80 | opcode)
if opcode == cls.TEXT:
payload = payload.encode()
if len(payload) < 126:
frame.append(len(payload))
elif len(payload) < (1 << 16):
frame.append(126)
frame.extend(len(payload).to_bytes(2, 'big'))
else:
frame.append(127)
frame.extend(len(payload).to_bytes(8, 'big'))
frame.extend(payload)
return frame
async def _read_frame(self):
header = await self.request.sock[0].read(2)
if len(header) != 2: # pragma: no cover
raise WebSocketError('Websocket connection closed')
fin, opcode, has_mask, length = self._parse_frame_header(header)
if length == -2:
length = await self.request.sock[0].read(2)
length = int.from_bytes(length, 'big')
elif length == -8:
length = await self.request.sock[0].read(8)
length = int.from_bytes(length, 'big')
max_allowed_length = Request.max_body_length \
if self.max_message_length == -1 else self.max_message_length
if length > max_allowed_length:
raise WebSocketError('Message too large')
if has_mask: # pragma: no cover
mask = await self.request.sock[0].read(4)
payload = await self.request.sock[0].read(length)
if has_mask: # pragma: no cover
payload = bytes(x ^ mask[i % 4] for i, x in enumerate(payload))
return opcode, payload
async def websocket_upgrade(request):
"""Upgrade a request handler to a websocket connection.
This function can be called directly inside a route function to process a
WebSocket upgrade handshake, for example after the user's credentials are
verified. The function returns the websocket object::
@app.route('/echo')
async def echo(request):
if not authenticate_user(request):
abort(401)
ws = await websocket_upgrade(request)
while True:
message = await ws.receive()
await ws.send(message)
"""
ws = WebSocket(request)
await ws.handshake()
@request.after_request
async def after_request(request, response):
return Response.already_handled
return ws
def websocket_wrapper(f, upgrade_function):
@wraps(f)
async def wrapper(request, *args, **kwargs):
ws = await upgrade_function(request)
try:
await f(request, ws, *args, **kwargs)
except OSError as exc:
if exc.errno not in MUTED_SOCKET_ERRORS: # pragma: no cover
raise
except WebSocketError:
pass
except Exception as exc:
print_exception(exc)
finally: # pragma: no cover
try:
await ws.close()
except Exception:
pass
return Response.already_handled
return wrapper
def with_websocket(f):
"""Decorator to make a route a WebSocket endpoint.
This decorator is used to define a route that accepts websocket
connections. The route then receives a websocket object as a second
argument that it can use to send and receive messages::
@app.route('/echo')
@with_websocket
async def echo(request, ws):
while True:
message = await ws.receive()
await ws.send(message)
"""
return websocket_wrapper(f, websocket_upgrade)

View File

@@ -1,14 +0,0 @@
class Loader:
def __init__(self, pkg, dir):
if dir == ".":
dir = ""
else:
dir = dir.replace("/", ".") + "."
if pkg and pkg != "__main__":
dir = pkg + "." + dir
self.p = dir
def load(self, name):
name = name.replace(".", "_")
return __import__(self.p + name, None, None, (name,)).render

View File

@@ -1,21 +0,0 @@
# (c) 2014-2020 Paul Sokolovsky. MIT license.
try:
from uos import stat, remove
except:
from os import stat, remove
from . import source
class Loader(source.Loader):
def load(self, name):
o_path = self.pkg_path + self.compiled_path(name)
i_path = self.pkg_path + self.dir + "/" + name
try:
o_stat = stat(o_path)
i_stat = stat(i_path)
if i_stat[8] > o_stat[8]:
# input file is newer, remove output to force recompile
remove(o_path)
finally:
return super().load(name)

View File

@@ -1,188 +0,0 @@
# (c) 2014-2019 Paul Sokolovsky. MIT license.
from . import compiled
class Compiler:
START_CHAR = "{"
STMNT = "%"
STMNT_END = "%}"
EXPR = "{"
EXPR_END = "}}"
def __init__(self, file_in, file_out, indent=0, seq=0, loader=None):
self.file_in = file_in
self.file_out = file_out
self.loader = loader
self.seq = seq
self._indent = indent
self.stack = []
self.in_literal = False
self.flushed_header = False
self.args = "*a, **d"
def indent(self, adjust=0):
if not self.flushed_header:
self.flushed_header = True
self.indent()
self.file_out.write("def render%s(%s):\n" % (str(self.seq) if self.seq else "", self.args))
self.stack.append("def")
self.file_out.write(" " * (len(self.stack) + self._indent + adjust))
def literal(self, s):
if not s:
return
if not self.in_literal:
self.indent()
self.file_out.write('yield """')
self.in_literal = True
self.file_out.write(s.replace('"', '\\"'))
def close_literal(self):
if self.in_literal:
self.file_out.write('"""\n')
self.in_literal = False
def render_expr(self, e):
self.indent()
self.file_out.write('yield str(' + e + ')\n')
def parse_statement(self, stmt):
tokens = stmt.split(None, 1)
if tokens[0] == "args":
if len(tokens) > 1:
self.args = tokens[1]
else:
self.args = ""
elif tokens[0] == "set":
self.indent()
self.file_out.write(stmt[3:].strip() + "\n")
elif tokens[0] == "include":
if not self.flushed_header:
# If there was no other output, we still need a header now
self.indent()
tokens = tokens[1].split(None, 1)
args = ""
if len(tokens) > 1:
args = tokens[1]
if tokens[0][0] == "{":
self.indent()
# "1" as fromlist param is uPy hack
self.file_out.write('_ = __import__(%s.replace(".", "_"), None, None, 1)\n' % tokens[0][2:-2])
self.indent()
self.file_out.write("yield from _.render(%s)\n" % args)
return
with self.loader.input_open(tokens[0][1:-1]) as inc:
self.seq += 1
c = Compiler(inc, self.file_out, len(self.stack) + self._indent, self.seq)
inc_id = self.seq
self.seq = c.compile()
self.indent()
self.file_out.write("yield from render%d(%s)\n" % (inc_id, args))
elif len(tokens) > 1:
if tokens[0] == "elif":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write(stmt + ":\n")
else:
self.indent()
self.file_out.write(stmt + ":\n")
self.stack.append(tokens[0])
else:
if stmt.startswith("end"):
assert self.stack[-1] == stmt[3:]
self.stack.pop(-1)
elif stmt == "else":
assert self.stack[-1] == "if"
self.indent(-1)
self.file_out.write("else:\n")
else:
assert False
def parse_line(self, l):
while l:
start = l.find(self.START_CHAR)
if start == -1:
self.literal(l)
return
self.literal(l[:start])
self.close_literal()
sel = l[start + 1]
#print("*%s=%s=" % (sel, EXPR))
if sel == self.STMNT:
end = l.find(self.STMNT_END)
assert end > 0
stmt = l[start + len(self.START_CHAR + self.STMNT):end].strip()
self.parse_statement(stmt)
end += len(self.STMNT_END)
l = l[end:]
if not self.in_literal and l == "\n":
break
elif sel == self.EXPR:
# print("EXPR")
end = l.find(self.EXPR_END)
assert end > 0
expr = l[start + len(self.START_CHAR + self.EXPR):end].strip()
self.render_expr(expr)
end += len(self.EXPR_END)
l = l[end:]
else:
self.literal(l[start])
l = l[start + 1:]
def header(self):
self.file_out.write("# Autogenerated file\n")
def compile(self):
self.header()
for l in self.file_in:
self.parse_line(l)
self.close_literal()
return self.seq
class Loader(compiled.Loader):
def __init__(self, pkg, dir):
super().__init__(pkg, dir)
self.dir = dir
if pkg == "__main__":
# if pkg isn't really a package, don't bother to use it
# it means we're running from "filesystem directory", not
# from a package.
pkg = None
self.pkg_path = ""
if pkg:
p = __import__(pkg)
if isinstance(p.__path__, str):
# uPy
self.pkg_path = p.__path__
else:
# CPy
self.pkg_path = p.__path__[0]
self.pkg_path += "/"
def input_open(self, template):
path = self.pkg_path + self.dir + "/" + template
return open(path)
def compiled_path(self, template):
return self.dir + "/" + template.replace(".", "_") + ".py"
def load(self, name):
try:
return super().load(name)
except (OSError, ImportError):
pass
compiled_path = self.pkg_path + self.compiled_path(name)
f_in = self.input_open(name)
f_out = open(compiled_path, "w")
c = Compiler(f_in, f_out, loader=self)
c.compile()
f_in.close()
f_out.close()
return super().load(name)

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Migrate Microdot controllers to native FastAPI (no compat layer)."""
from __future__ import annotations
import re
import sys
from pathlib import Path
CONTROLLERS = Path(__file__).resolve().parents[1] / "src" / "controllers"
IMPORT_BLOCK = """from fastapi import APIRouter, Request
from http_responses import J, html_response, plain, read_json, send_file
from http_session import with_session
"""
_JSON_HEADERS = re.compile(
r"return json\.dumps\(([\s\S]*?)\),\s*(\d+)\s*,\s*\{\s*"
r"['\"]Content-Type['\"]\s*:\s*['\"]application/json['\"]\s*,?\s*\}",
re.MULTILINE,
)
_JSON_PLAIN = re.compile(
r"^(\s*)return json\.dumps\((.+)\),\s*(\d+)\s*$",
re.MULTILINE,
)
_HTML_LINE = re.compile(
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*$",
re.MULTILINE,
)
_PLAIN_LINE = re.compile(
r"^(\s*)return ([^,\n]+),\s*(\d+),\s*\{['\"]Content-Type['\"]: ['\"]text/plain[^'\"]*['\"]\}\s*$",
re.MULTILINE,
)
_PAREN_JSON = re.compile(
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,\s*"
r"\{['\"]Content-Type['\"]: ['\"]application/json['\"]\}\s*,?\s*\)",
re.MULTILINE,
)
_PAREN_JSON_NOHDR = re.compile(
r"return \(\s*json\.dumps\(([\s\S]*?)\)\s*,\s*(\d+)\s*,?\s*\)",
re.MULTILINE,
)
_PAREN_HTML = re.compile(
r"return \(\s*([^,]+?)\s*,\s*(\d+)\s*,\s*"
r"\{['\"]Content-Type['\"]: ['\"]text/html['\"]\}\s*,?\s*\)",
re.DOTALL,
)
def _insert_imports(text: str) -> str:
if "from fastapi import APIRouter" in text:
return text
fut = re.search(r"^from __future__ import annotations\n", text, re.M)
if fut:
return text[: fut.end()] + "\n" + IMPORT_BLOCK + text[fut.end() :]
doc = re.match(r'("""[\s\S]*?"""\n+|\'\'\'[\s\S]*?\'\'\'\n+)', text)
if doc:
return text[: doc.end()] + "\n" + IMPORT_BLOCK + text[doc.end() :]
return IMPORT_BLOCK + text
def _strip_microdot(text: str) -> str:
text = re.sub(r"from microdot import Microdot(?:, send_file)?\n", "", text)
text = re.sub(r"from microdot\.session import with_session\n", "", text)
text = re.sub(r"from microdot import send_file\n", "", text)
text = text.replace("controller = Microdot()", "router = APIRouter()")
text = text.replace("@controller.", "@router.")
return text
def _convert_paths(text: str) -> str:
def fix(m: re.Match) -> str:
method, path = m.group(1), m.group(2)
if path == "":
path = "/"
path = re.sub(r"<(\w+)>", r"{\1}", path)
return f'@router.{method}("{path}")'
return re.sub(
r"@router\.(get|post|put|delete|patch)\(['\"]([^'\"]*)['\"]\)",
fix,
text,
)
def _convert_request_access(text: str) -> str:
text = text.replace("request.json or {}", "await read_json(request)")
text = re.sub(r"(?<![.\w])request\.json(?!\w)", "await read_json(request)", text)
text = text.replace("request.args.get", "request.query_params.get")
return text
def _convert_request_annotations(text: str) -> str:
text = re.sub(r"async def (\w+)\(request,", r"async def \1(request: Request,", text)
text = re.sub(r"async def (\w+)\(request\)", r"async def \1(request: Request)", text)
return text
def _convert_returns(text: str) -> str:
text = _PAREN_JSON.sub(r"return J(\1, \2)", text)
text = _PAREN_JSON_NOHDR.sub(r"return J(\1, \2)", text)
text = _PAREN_HTML.sub(r"return html_response(\1, \2)", text)
text = _JSON_HEADERS.sub(r"return J(\1, \2)", text)
text = _JSON_PLAIN.sub(r"\1return J(\2, \3)", text)
text = _HTML_LINE.sub(r"\1return html_response(\2, \3)", text)
text = _PLAIN_LINE.sub(r"\1return plain(\2, \3)", text)
text = re.sub(
r'^(\s*)return "([^"]+)",\s*(\d+)\s*$',
r'\1return plain("\2", \3)',
text,
flags=re.MULTILINE,
)
return text
def convert_file(path: Path) -> str:
text = path.read_text(encoding="utf-8")
if "Microdot" not in text:
return text
text = _strip_microdot(text)
text = _insert_imports(text)
text = _convert_paths(text)
text = _convert_request_access(text)
text = _convert_request_annotations(text)
text = _convert_returns(text)
return text
def main() -> int:
for path in sorted(CONTROLLERS.glob("*.py")):
if path.name == "__init__.py":
continue
path.write_text(convert_file(path), encoding="utf-8")
print(path.name)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,16 +1,16 @@
"""Application factory: Microdot routes and shared runtime startup.""" """Application factory: FastAPI routes and shared runtime startup."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import json
import os import os
import secrets import secrets
from typing import Any, Optional from typing import Any, Optional
from microdot import Microdot, send_file from fastapi import FastAPI
from microdot.session import Session from fastapi.responses import PlainTextResponse
from fastapi.staticfiles import StaticFiles
from settings import WIFI_CHANNEL_DEFAULT, get_settings from settings import WIFI_CHANNEL_DEFAULT, get_settings
import controllers.preset as preset import controllers.preset as preset
@@ -25,6 +25,8 @@ import controllers.settings as settings_controller
import controllers.device as device_controller import controllers.device as device_controller
import controllers.led_tool as led_tool_controller import controllers.led_tool as led_tool_controller
import controllers.wifi_bridge as wifi_bridge_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 ( from models.transport import (
BridgeSerialTransport, BridgeSerialTransport,
BridgeWsTransport, BridgeWsTransport,
@@ -37,6 +39,9 @@ from models.bridge_ws_client import init_bridge_client
from util.espnow_registry import handle_bridge_uplink from util.espnow_registry import handle_bridge_uplink
from util.bridge_runtime import set_bridge_uplink_handler from util.bridge_runtime import set_bridge_uplink_handler
from util.audio_detector import AudioBeatDetector 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: 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).""" """Revision of static/template assets (changes when UI files are saved)."""
if not live_reload_enabled(): if not live_reload_enabled():
return None return None
base = os.path.dirname(os.path.abspath(__file__)) base = _SRC_DIR
parts: list[str] = [] parts: list[str] = []
for sub in ("static", "templates"): for sub in ("static", "templates"):
root = os.path.join(base, sub) root = os.path.join(base, sub)
@@ -82,71 +87,42 @@ def dev_client_revision() -> Optional[str]:
return digest[:16] return digest[:16]
def create_microdot_app(*, inject_live_reload: bool = False) -> Microdot: def mount_controller_routers(app: FastAPI) -> None:
"""Build the Microdot app with mounted controllers and static routes.""" """Register all controller API routers."""
settings = get_settings() app.include_router(preset.router, prefix="/presets", tags=["presets"])
app = Microdot() app.include_router(profile.router, prefix="/profiles", tags=["profiles"])
app.include_router(group.router, prefix="/groups", tags=["groups"])
secret_key = settings.get( app.include_router(sequence.router, prefix="/sequences", tags=["sequences"])
"session_secret_key", "led-controller-secret-key-change-in-production" 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 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") @app.get("/")
def dev_build_id_route(request): async def index():
_ = request
return (
build_id,
200,
{
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-store",
},
)
@app.route("/")
def index(request):
_ = request
if build_id: if build_id:
try: return send_html_file("templates/index.html", inject=live_tag)
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_file("templates/index.html") return send_file("templates/index.html")
@app.route("/favicon.ico") @app.get("/favicon.ico")
def favicon(request): async def favicon():
_ = request return PlainTextResponse("", status_code=204)
return "", 204
@app.route("/static/<path:path>") static_dir = os.path.join(_SRC_DIR, "static")
def static_handler(request, path): if os.path.isdir(static_dir):
if ".." in path: app.mount("/static", StaticFiles(directory=static_dir), name="static")
return "Not found", 404
return send_file("static/" + path)
return app
class AppRuntime: class AppRuntime:
@@ -226,24 +202,43 @@ class AppRuntime:
from util import beat_driver_route from util import beat_driver_route
beat_driver_route.set_beat_route_main_loop(asyncio.get_running_loop()) 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 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() seq_pb.ensure_beat_consumer_started()
Device() Device()
if not test_mode:
await start_wifi_driver_runtime(self.settings)
async def shutdown(self) -> None: async def shutdown(self) -> None:
try:
await stop_wifi_driver_runtime()
except Exception:
pass
try: try:
self.audio_detector.stop() self.audio_detector.stop()
except Exception: except Exception:
pass pass
try:
from util import beat_status_broadcaster as beat_sse
await beat_sse.shutdown()
except Exception:
pass
try: try:
from util import sequence_playback as seq_pb from util import sequence_playback as seq_pb
seq_pb.stop() seq_pb.stop()
for attr in ("_pending_beat_task", "_sim_beat_task"): t = getattr(seq_pb, "_background_beat_task", None)
t = getattr(seq_pb, attr, None) if t is not None and not t.done():
if t is not None and not t.done(): t.cancel()
t.cancel()
except Exception: except Exception:
pass pass
@@ -259,10 +254,15 @@ def audio_status_payload(
st["sequence"] = sequence_playback.playback_status() st["sequence"] = sequence_playback.playback_status()
st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status() st["manual_beat_stride"] = beat_driver_route.manual_beat_stride_status()
seq = st.get("sequence") seq = st.get("sequence")
running = bool(st.get("running"))
beat_readout = "" beat_readout = ""
if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip(): if isinstance(seq, dict) and str(seq.get("beat_readout") or "").strip():
beat_readout = 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") mb = st.get("manual_beat_stride")
if isinstance(mb, dict) and mb.get("active"): if isinstance(mb, dict) and mb.get("active"):
try: try:
@@ -293,6 +293,28 @@ def audio_status_payload(
seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower() seq_wait = str(settings.get("sequence_switch_wait") or "beat").strip().lower()
if seq_wait not in ("beat", "downbeat"): if seq_wait not in ("beat", "downbeat"):
seq_wait = "beat" 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() 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 return st

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
from microdot import Microdot from fastapi import APIRouter, Request
from microdot.session import with_session 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.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.pallet import Palette 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 from util.profile_bundle import export_preset_bundle, import_preset_bundle
import json import json
controller = Microdot() router = APIRouter()
presets = Preset() presets = Preset()
profiles = Profile() profiles = Profile()
@@ -41,76 +43,75 @@ def get_current_profile_id(session=None):
return profile_list[0] return profile_list[0]
return None return None
@controller.get('') @router.get("/")
@with_session @with_session
async def list_presets(request, session): async def list_presets(request: Request, session):
"""List presets for the current profile.""" """List presets for the current profile."""
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not current_profile_id: if not current_profile_id:
return json.dumps({}), 200, {'Content-Type': 'application/json'} return J({}, 200)
scoped = { scoped = {
pid: pdata for pid, pdata in presets.items() pid: pdata for pid, pdata in presets.items()
if isinstance(pdata, dict) and str(pdata.get("profile_id")) == str(current_profile_id) 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 @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.""" """Export one preset as a JSON bundle."""
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
preset = presets.read(preset_id) preset = presets.read(preset_id)
if not preset or str(preset.get("profile_id")) != str(current_profile_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: try:
bundle = export_preset_bundle(preset_id, presets) bundle = export_preset_bundle(preset_id, presets)
return json.dumps(bundle), 200, {'Content-Type': 'application/json'} return J(bundle, 200)
except ValueError as e: 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 @with_session
async def import_preset(request, session): async def import_preset(request: Request, session):
"""Import a preset bundle into the current profile.""" """Import a preset bundle into the current profile."""
try: try:
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not current_profile_id: if not current_profile_id:
return json.dumps({"error": "No profile available"}), 404, {'Content-Type': 'application/json'} return J({"error": "No profile available"}, 404)
body = request.json or {} body = await read_json(request)
bundle = body.get("bundle") if isinstance(body, dict) else body bundle = body.get("bundle") if isinstance(body, dict) else body
if not isinstance(bundle, dict): 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) 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: 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: 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 @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).""" """Get a specific preset by ID (current profile only)."""
preset = presets.read(preset_id) preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if preset and str(preset.get("profile_id")) == str(current_profile_id): if preset and str(preset.get("profile_id")) == str(current_profile_id):
return json.dumps(preset), 200, {'Content-Type': 'application/json'} return J(preset, 200)
return json.dumps({"error": "Preset not found"}), 404 return J({"error": "Preset not found"}, 404)
@router.post("/")
@controller.post('')
@with_session @with_session
async def create_preset(request, session): async def create_preset(request: Request, session):
"""Create a new preset for the current profile.""" """Create a new preset for the current profile."""
try: try:
try: try:
data = request.json or {} data = await read_json(request)
except Exception: 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) current_profile_id = get_current_profile_id(session)
if not current_profile_id: 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) preset_id = presets.create(current_profile_id)
if not isinstance(data, dict): if not isinstance(data, dict):
data = {} data = {}
@@ -118,65 +119,46 @@ async def create_preset(request, session):
data["profile_id"] = str(current_profile_id) data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data): if presets.update(preset_id, data):
preset_data = presets.read(preset_id) preset_data = presets.read(preset_id)
return json.dumps({preset_id: preset_data}), 201, {'Content-Type': 'application/json'} return J({preset_id: preset_data}, 201)
return json.dumps({"error": "Failed to create preset"}), 400 return J({"error": "Failed to create preset"}, 400)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return J({"error": str(e)}, 400)
@router.put("/{preset_id}")
@controller.put('/<preset_id>')
@with_session @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).""" """Update an existing preset (current profile only)."""
try: try:
preset = presets.read(preset_id) preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id): 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: try:
data = request.json or {} data = await read_json(request)
except Exception: except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'} return J({"error": "Invalid JSON"}, 400)
if not isinstance(data, dict): if not isinstance(data, dict):
data = {} data = {}
data = dict(data) data = dict(data)
data["profile_id"] = str(current_profile_id) data["profile_id"] = str(current_profile_id)
if presets.update(preset_id, data): if presets.update(preset_id, data):
return json.dumps(presets.read(preset_id)), 200, {'Content-Type': 'application/json'} return J(presets.read(preset_id), 200)
return json.dumps({"error": "Preset not found"}), 404 return J({"error": "Preset not found"}, 404)
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}), 400 return J({"error": str(e)}, 400)
@router.delete("/{preset_id}")
@controller.delete('/<preset_id>')
@with_session @with_session
async def delete_preset(request, *args, **kwargs): async def delete_preset(request: Request, session, preset_id):
"""Delete a preset (current profile only).""" """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) preset = presets.read(preset_id)
current_profile_id = get_current_profile_id(session) current_profile_id = get_current_profile_id(session)
if not preset or str(preset.get("profile_id")) != str(current_profile_id): 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): if presets.delete(preset_id):
return json.dumps({"message": "Preset deleted successfully"}), 200 return J({"message": "Preset deleted successfully"}, 200)
return json.dumps({"error": "Preset not found"}), 404 return J({"error": "Preset not found"}, 404)
@router.post("/send")
@controller.post('/send')
@with_session @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). 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. Optional "destination_mac" / "to": single MAC when targets is omitted.
""" """
try: try:
data = request.json or {} data = await read_json(request)
except Exception: 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') preset_ids = data.get('preset_ids') or data.get('ids')
if not isinstance(preset_ids, list) or not preset_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 = data.get('save', True)
save_flag = bool(save_flag) save_flag = bool(save_flag)
default_id = data.get('default') default_id = data.get('default')
@@ -219,14 +200,14 @@ async def send_presets(request, session):
presets_by_name[preset_key] = preset_payload presets_by_name[preset_key] = preset_payload
if not presets_by_name: 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: if default_id is not None and str(default_id) not in presets_by_name:
default_id = None default_id = None
bridge = get_current_bridge() bridge = get_current_bridge()
if not 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 send_delay_s = 0.1
total_presets = len(presets_by_name) total_presets = len(presets_by_name)
@@ -300,18 +281,18 @@ async def send_presets(request, session):
delay_s=send_delay_s, delay_s=send_delay_s,
) )
except Exception: 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", "message": "Presets sent",
"presets_sent": total_presets, "presets_sent": total_presets,
"messages_sent": deliveries, "messages_sent": deliveries,
}), 200, {'Content-Type': 'application/json'} }, 200)
@controller.post('/push') @router.post("/push")
@with_session @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). 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": [...]}. or a single {"payload": {...}, "targets": [...]}.
""" """
try: try:
data = request.json or {} data = await read_json(request)
except Exception: except Exception:
return json.dumps({"error": "Invalid JSON"}), 400, {'Content-Type': 'application/json'} return J({"error": "Invalid JSON"}, 400)
seq = data.get("sequence") seq = data.get("sequence")
if not seq and data.get("payload") is not None: if not seq and data.get("payload") is not None:
seq = [data["payload"]] seq = [data["payload"]]
if not isinstance(seq, list) or not seq: 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") raw_targets = data.get("targets")
target_list = None target_list = None
@@ -344,7 +325,7 @@ async def push_driver_messages(request, session):
bridge = get_current_bridge() bridge = get_current_bridge()
if not bridge: if not bridge:
return json.dumps({"error": "Transport not configured"}), 503, {'Content-Type': 'application/json'} return J({"error": "Transport not configured"}, 503)
messages = [] messages = []
i = 0 i = 0
@@ -355,7 +336,7 @@ async def push_driver_messages(request, session):
messages.append(item) messages.append(item)
i += 1 i += 1
continue 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 nxt = seq[i + 1] if i + 1 < len(seq) else None
if ( if (
isinstance(nxt, dict) isinstance(nxt, dict)
@@ -392,7 +373,7 @@ async def push_driver_messages(request, session):
unicast=unicast, unicast=unicast,
) )
except Exception: except Exception:
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'} return J({"error": "Send failed"}, 503)
try: try:
from util import sequence_playback as seq_pb from util import sequence_playback as seq_pb
@@ -405,8 +386,8 @@ async def push_driver_messages(request, session):
except Exception: except Exception:
pass pass
return json.dumps({ return J({
"message": "Delivered", "message": "Delivered",
"deliveries": deliveries, "deliveries": deliveries,
}), 200, {'Content-Type': 'application/json'} }, 200)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
"""FastAPI entrypoint; Microdot controllers run behind an ASGI bridge.""" """FastAPI application entrypoint."""
from __future__ import annotations from __future__ import annotations
@@ -6,23 +6,26 @@ import json
import logging import logging
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any, Optional from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect import asyncio
from fastapi.responses import JSONResponse, PlainTextResponse
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from app_factory import ( from app_factory import (
AppRuntime, AppRuntime,
audio_status_payload, audio_status_payload,
create_microdot_app, dev_build_id,
dev_client_revision,
live_reload_enabled, 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 from models.transport import get_current_bridge
_runtime: Optional[AppRuntime] = None _runtime: Optional[AppRuntime] = None
_microdot_app = None
_test_mode = False _test_mode = False
@@ -38,6 +41,15 @@ def _bridge():
return get_current_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 @asynccontextmanager
async def _lifespan(app: FastAPI): async def _lifespan(app: FastAPI):
global _runtime global _runtime
@@ -51,14 +63,19 @@ async def _lifespan(app: FastAPI):
await _runtime.shutdown() 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 = 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) @api.get("/__dev/build-id", response_class=PlainTextResponse)
async def dev_build_id_route(): async def dev_build_id_route():
from app_factory import dev_build_id as current_build_id bid = dev_build_id()
bid = current_build_id()
if not bid: if not bid:
return PlainTextResponse("", status_code=404) return PlainTextResponse("", status_code=404)
return PlainTextResponse( return PlainTextResponse(
@@ -68,8 +85,6 @@ def _create_fastapi() -> FastAPI:
@api.get("/__dev/client-rev", response_class=PlainTextResponse) @api.get("/__dev/client-rev", response_class=PlainTextResponse)
async def dev_client_rev_route(): async def dev_client_rev_route():
from app_factory import dev_client_revision
rev = dev_client_revision() rev = dev_client_revision()
if not rev: if not rev:
return PlainTextResponse("", status_code=404) return PlainTextResponse("", status_code=404)
@@ -114,6 +129,7 @@ def _create_fastapi() -> FastAPI:
device_override=str(body.get("device_override") or ""), device_override=str(body.get("device_override") or ""),
device_select=device_select, device_select=device_select,
) )
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()} return {"ok": True, "status": _runtime.audio_detector.status()}
except Exception as e: except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500) 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 from util.audio_run_persist import write_audio_run_state
write_audio_run_state(enabled=False) write_audio_run_state(enabled=False)
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()} return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/reset") @api.post("/api/audio/reset")
@@ -158,6 +175,7 @@ def _create_fastapi() -> FastAPI:
{"ok": False, "error": "Audio detector is not running"}, {"ok": False, "error": "Audio detector is not running"},
status_code=409, status_code=409,
) )
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()} return {"ok": True, "status": _runtime.audio_detector.status()}
@api.post("/api/audio/anchor-bar") @api.post("/api/audio/anchor-bar")
@@ -170,6 +188,7 @@ def _create_fastapi() -> FastAPI:
{"ok": False, "error": "Audio detector is not running"}, {"ok": False, "error": "Audio detector is not running"},
status_code=409, status_code=409,
) )
_notify_audio_status_sse()
return {"ok": True, "status": _runtime.audio_detector.status()} return {"ok": True, "status": _runtime.audio_detector.status()}
@api.get("/api/audio/status") @api.get("/api/audio/status")
@@ -178,9 +197,54 @@ def _create_fastapi() -> FastAPI:
return JSONResponse({"error": "not ready"}, status_code=503) return JSONResponse({"error": "not ready"}, status_code=503)
return {"status": audio_status_payload(_runtime.audio_detector, _runtime.settings)} 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") @api.websocket("/ws")
async def ws_endpoint(websocket: WebSocket): 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 websocket.accept()
await register_device_status_ws(websocket)
await broadcast_device_tcp_snapshot_to(websocket)
bridge = _bridge() bridge = _bridge()
try: try:
while True: while True:
@@ -208,44 +272,10 @@ def _create_fastapi() -> FastAPI:
pass pass
except Exception: except Exception:
pass pass
finally:
await unregister_device_status_ws(websocket)
return api 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() app = create_application()

84
src/http_responses.py Normal file
View File

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

117
src/http_session.py Normal file
View File

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

View File

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

View File

@@ -57,16 +57,6 @@ class Sequence(Model):
if doc.get("advance_mode") != "beats": if doc.get("advance_mode") != "beats":
doc["advance_mode"] = "beats" doc["advance_mode"] = "beats"
changed = True 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: if "sequence_transition" not in doc:
doc["sequence_transition"] = 500 doc["sequence_transition"] = 500
changed = True changed = True
@@ -115,7 +105,6 @@ class Sequence(Model):
"advance_mode": "beats", "advance_mode": "beats",
"steps": [], "steps": [],
"step_duration_ms": 3000, "step_duration_ms": 3000,
"simulated_bpm": 120,
"sequence_transition": 500, "sequence_transition": 500,
"loop": True, "loop": True,
} }

View File

@@ -67,6 +67,21 @@ class Settings(dict):
self['bridge_serial_port'] = '' self['bridge_serial_port'] = ''
if 'bridge_serial_baudrate' not in self: if 'bridge_serial_baudrate' not in self:
self['bridge_serial_baudrate'] = 115200 self['bridge_serial_baudrate'] = 115200
# Wi-Fi LED drivers: controller opens WebSocket to device (firmware serves /ws)
if 'wifi_driver_ws_port' not in self:
self['wifi_driver_ws_port'] = 80
if 'wifi_driver_ws_path' not in self:
self['wifi_driver_ws_path'] = '/ws'
if 'wifi_driver_hello_interval_s' not in self:
self['wifi_driver_hello_interval_s'] = 10.0
if 'wifi_driver_connect_retry_window_s' not in self:
self['wifi_driver_connect_retry_window_s'] = 120.0
if 'wifi_driver_connect_stagger_max_s' not in self:
self['wifi_driver_connect_stagger_max_s'] = 2.5
if 'wifi_driver_ws_open_timeout' not in self:
self['wifi_driver_ws_open_timeout'] = 45.0
if 'wifi_driver_connect_retry_interval_s' not in self:
self['wifi_driver_connect_retry_interval_s'] = 2.0
# Zone UI global brightness (0255); shared across browsers/devices. # Zone UI global brightness (0255); shared across browsers/devices.
if 'global_brightness' not in self: if 'global_brightness' not in self:
self['global_brightness'] = 255 self['global_brightness'] = 255
@@ -81,6 +96,13 @@ class Settings(dict):
# Input gain for beat detection (percent, 0200). # Input gain for beat detection (percent, 0200).
if 'audio_input_volume' not in self: if 'audio_input_volume' not in self:
self['audio_input_volume'] = 100 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): def save(self):
try: try:

View File

@@ -1382,7 +1382,7 @@ class LightingController {
const presetNames = Object.keys(this.state.presets); const presetNames = Object.keys(this.state.presets);
if (presetNames.length === 0) { 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 { } else {
presetNames.forEach(presetName => { presetNames.forEach(presetName => {
const preset = this.state.presets[presetName]; const preset = this.state.presets[presetName];

View File

@@ -1,7 +1,9 @@
(() => { (() => {
let pollTimer = null; let beatEventSource = null;
let beatEventsReconnectTimer = null;
let audioDetectorRunning = false; let audioDetectorRunning = false;
let lastBeatSeq = 0; let lastBeatSeq = 0;
let lastSimulatedBeatTick = 0;
/** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */ /** Prior poll had server zone sequence playback active (`status.sequence.active === true`). */
let prevZoneSequencePlaybackActive = false; let prevZoneSequencePlaybackActive = false;
/** /**
@@ -14,26 +16,51 @@
let cachedBeatPhaseMs = 0; let cachedBeatPhaseMs = 0;
/** @type {{ device: string|number|null, device_override: string, device_select: string }} */ /** @type {{ device: string|number|null, device_override: string, device_select: string }} */
let cachedAudioRun = { device: null, device_override: "", device_select: "" }; 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) { function el(id) {
return document.getElementById(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 */ /** @param {Record<string, unknown>} status */
function updateBeatReadoutDisplays(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"]) { for (const id of ["audio-top-beat-readout", "audio-modal-beat-readout"]) {
const n = el(id); const n = el(id);
if (n) n.textContent = text; if (n) n.textContent = text;
} }
} }
function updateBpmDisplay(bpm) { function updateBpmDisplay(bpm, simulated = false) {
const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--"; const text = Number.isFinite(bpm) ? bpm.toFixed(1) : "--";
for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) { for (const id of ["audio-bpm-value", "audio-top-bpm-value"]) {
const node = el(id); const node = el(id);
if (node) node.textContent = text; 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. */ /** Zone sequence playback (server); only when `active === true` is beat X/Y meaningful. */
@@ -44,6 +71,43 @@
return !!(seq && seq.active); 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) { function updateHitTypeDisplay(hitType, confidence) {
const node = el("audio-hit-type-value"); const node = el("audio-hit-type-value");
if (!node) return; if (!node) return;
@@ -57,6 +121,8 @@
const readout = String((status && status.bar_phase_readout) || "").trim(); const readout = String((status && status.bar_phase_readout) || "").trim();
const phaseConf = Number((status && status.phase_confidence) || 0); const phaseConf = Number((status && status.phase_confidence) || 0);
const downbeat = !!(status && status.is_downbeat); const downbeat = !!(status && status.is_downbeat);
const simulated = !!(status && status.bpm_simulated);
const showPhase = !!(status && status.running) || simulated;
let text = readout || "--"; let text = readout || "--";
if (readout && Number.isFinite(phaseConf) && phaseConf > 0) { if (readout && Number.isFinite(phaseConf) && phaseConf > 0) {
text = `${text} (${Math.round(phaseConf * 100)}%)`; text = `${text} (${Math.round(phaseConf * 100)}%)`;
@@ -64,8 +130,8 @@
for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) { for (const id of ["audio-bar-phase-value", "audio-top-bar-phase"]) {
const node = el(id); const node = el(id);
if (!node) continue; if (!node) continue;
node.textContent = status && status.running ? text : ""; node.textContent = showPhase ? text : "";
node.classList.toggle("is-downbeat", downbeat && !!readout); node.classList.toggle("is-downbeat", downbeat && !!readout && showPhase);
} }
} }
@@ -75,6 +141,50 @@
top.classList.toggle("audio-running", !!on); 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) { function setResetDetectorEnabled(on) {
const btn = el("audio-reset-btn"); const btn = el("audio-reset-btn");
if (btn) btn.disabled = !on; if (btn) btn.disabled = !on;
@@ -150,21 +260,25 @@
return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable; return tag === "input" || tag === "textarea" || tag === "select" || target.isContentEditable;
} }
function flashBeatSyncButton(btn) { function flashBeatSyncButton(btn, simulated = false) {
if (!btn) return; if (!btn) return;
btn.classList.add("flash"); btn.classList.add(simulated ? "flash-simulated" : "flash");
setTimeout(() => btn.classList.remove("flash"), 90); setTimeout(() => btn.classList.remove(simulated ? "flash-simulated" : "flash"), 90);
} }
function flashBeat() { function flashBeat(simulated = false) {
const top = el("audio-top-indicator"); const top = el("audio-top-indicator");
const topSync = el("audio-top-beat-sync"); const topSync = el("audio-top-beat-sync");
if (topSync && top && top.classList.contains("audio-running")) { if (
flashBeatSyncButton(topSync); topSync &&
top &&
(top.classList.contains("audio-running") || simulated)
) {
flashBeatSyncButton(topSync, simulated);
} }
const modalSync = el("audio-modal-beat-sync"); const modalSync = el("audio-modal-beat-sync");
if (modalSync && audioDetectorRunning) { if (modalSync && (audioDetectorRunning || simulated)) {
flashBeatSyncButton(modalSync); 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() { async function persistInputVolume() {
const vol = getInputVolumePercent(); const vol = getInputVolumePercent();
updateInputVolumeReadout(); updateInputVolumeReadout();
@@ -228,11 +374,11 @@
} }
} }
function scheduleBeatPhaseFire(seq, delayMs) { function scheduleBeatPhaseFire(seq, delayMs, simulated = false) {
let tid = null; let tid = null;
const run = () => { const run = () => {
if (tid != null) pendingBeatPhaseTimers.delete(tid); if (tid != null) pendingBeatPhaseTimers.delete(tid);
flashBeat(); flashBeat(simulated);
try { try {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }), new CustomEvent("ledControllerAudioBeat", { detail: { beatSeq: seq } }),
@@ -252,23 +398,23 @@
/** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */ /** Stop detector and polling; does not clear “resume on load” prefs (used before restart). */
async function stopAudioOnly() { async function stopAudioOnly() {
audioDetectorRunning = false; audioDetectorRunning = false;
setTopBpmVisible(false);
setResetDetectorEnabled(false); setResetDetectorEnabled(false);
clearBeatPhaseTimers(); clearBeatPhaseTimers();
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
lastBeatSeq = 0; lastBeatSeq = 0;
lastSimulatedBeatTick = 0;
prevZoneSequencePlaybackActive = false; prevZoneSequencePlaybackActive = false;
headerBeatStickyIdleAfterSeq = false; headerBeatStickyIdleAfterSeq = false;
updateBeatReadoutDisplays({}); updateBeatReadoutDisplays({});
updateInputLevelDisplay(0); updateInputLevelDisplay(0);
setTopBpmVisible(true);
updateBpmDisplay(getSimulatedBpmPercent(), true);
try { try {
await fetch("/api/audio/stop", { method: "POST" }); await fetch("/api/audio/stop", { method: "POST" });
} catch (e) { } catch (e) {
console.warn("audio stop failed", e); console.warn("audio stop failed", e);
} }
ensureBeatEvents();
await pollStatus();
} }
/** User-initiated stop (run intent cleared on server). */ /** User-initiated stop (run intent cleared on server). */
@@ -276,11 +422,24 @@
await stopAudioOnly(); 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() { async function pollStatus() {
try { try {
const res = await fetch("/api/audio/status", { cache: "no-store" }); const status = await fetchAudioStatusOnce();
const data = await res.json(); applyAudioStatus(status);
const status = data?.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()) { if (status.error && String(status.error).trim()) {
const node = el("audio-hit-type-value"); const node = el("audio-hit-type-value");
if (node) { if (node) {
@@ -288,28 +447,41 @@
} }
updateBeatReadoutDisplays({}); updateBeatReadoutDisplays({});
audioDetectorRunning = !!status.running; audioDetectorRunning = !!status.running;
updateBpmDisplay(null);
updateInputLevelDisplay(0); updateInputLevelDisplay(0);
setTopBpmVisible(!!status.running); updateTopIndicatorFromStatus(status);
setResetDetectorEnabled(!!status.running); setResetDetectorEnabled(!!status.running);
if (!status.running && pollTimer) { if (!shouldKeepStatusPolling(status)) closeBeatEvents();
clearInterval(pollTimer);
pollTimer = null;
}
return; return;
} }
audioDetectorRunning = !!status.running; audioDetectorRunning = !!status.running;
const zoneSeqActive = sequencePlaybackActiveFromStatus(status); 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); setResetDetectorEnabled(!!status.running);
updateSequenceSyncControls(zoneSeqActive); updateSequenceSyncControls(zoneSeqActive || clientSequenceUiActive);
updateBpmDisplay(status.bpm); 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)); updateHitTypeDisplay(status.beat_type, Number(status.beat_type_confidence));
updateBarPhaseDisplay(status); updateBarPhaseDisplay(status);
updateInputLevelDisplay( updateInputLevelDisplay(
status.running ? Number(status.input_level) : 0, status.running ? Number(status.input_level) : 0,
); );
applyServerAudioUiFields(status); applyServerAudioUiFields(status);
if (typeof window.setSequenceSwitchSimulatedMode === "function") {
window.setSequenceSwitchSimulatedMode(bpmSimulated);
}
if (typeof window.applySequenceSwitchWaitFromServer === "function") { if (typeof window.applySequenceSwitchWaitFromServer === "function") {
window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait); window.applySequenceSwitchWaitFromServer(status.sequence_switch_wait);
} }
@@ -319,30 +491,56 @@
* `sequence` on each poll. * `sequence` on each poll.
*/ */
const beatSeq = Number(status.beat_seq || 0); const beatSeq = Number(status.beat_seq || 0);
const simTick = Number(status.simulated_beat_tick || 0);
const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive; const endedSeq = prevZoneSequencePlaybackActive && !zoneSeqActive;
const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive; const startedSeq = !prevZoneSequencePlaybackActive && zoneSeqActive;
prevZoneSequencePlaybackActive = zoneSeqActive; prevZoneSequencePlaybackActive = zoneSeqActive;
if (startedSeq) { if (startedSeq) {
headerBeatStickyIdleAfterSeq = false; 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) { if (endedSeq) {
clientSequenceUiActive = false;
headerBeatStickyIdleAfterSeq = true; headerBeatStickyIdleAfterSeq = true;
clearBeatPhaseTimers(); clearBeatPhaseTimers();
lastBeatSeq = beatSeq; 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) { if (beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq; lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs()); scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
headerBeatStickyIdleAfterSeq = false; headerBeatStickyIdleAfterSeq = false;
} }
} else if (beatSeq > lastBeatSeq) { } else if (!bpmSimulated && beatSeq > lastBeatSeq) {
lastBeatSeq = beatSeq; lastBeatSeq = beatSeq;
scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs()); scheduleBeatPhaseFire(beatSeq, getBeatPhaseDelayMs(), false);
} }
updateBeatReadoutDisplays(status); updateBeatReadoutDisplays(status);
if (shouldKeepStatusPolling(status)) {
ensureBeatEvents();
} else {
closeBeatEvents();
}
} catch (e) { } catch (e) {
console.warn("audio status poll failed", e); console.warn("audio status apply failed", e);
} }
} }
@@ -479,7 +677,7 @@
setSelectedDeviceId(selected); setSelectedDeviceId(selected);
updateBpmDisplay(null); updateBpmDisplay(null);
updateHitTypeDisplay("unknown", NaN); updateHitTypeDisplay("unknown", NaN);
pollTimer = setInterval(pollStatus, 250); ensureBeatEvents();
await pollStatus(); await pollStatus();
} }
@@ -594,6 +792,17 @@
}); });
updateInputVolumeReadout(); 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"]) { for (const id of ["audio-top-beat-sync", "audio-modal-beat-sync"]) {
const btn = el(id); const btn = el(id);
@@ -614,22 +823,24 @@
}); });
} }
async function resumePollingIfDetectorRunning() { async function resumeBeatEventsIfNeeded() {
try { try {
const res = await fetch("/api/audio/status", { cache: "no-store" }); const status = await fetchAudioStatusOnce();
const data = await res.json();
const status = data?.status || {};
audioDetectorRunning = !!status.running; audioDetectorRunning = !!status.running;
if (status.running && !pollTimer) { updateTopIndicatorFromStatus(status);
pollTimer = setInterval(pollStatus, 250); if (shouldKeepStatusPolling(status)) {
lastBeatSeq = Number(status.beat_seq || 0); lastBeatSeq = Number(status.beat_seq || 0);
lastSimulatedBeatTick = Number(status.simulated_beat_tick || 0);
prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status); prevZoneSequencePlaybackActive = sequencePlaybackActiveFromStatus(status);
await pollStatus(); applyAudioStatus(status);
ensureBeatEvents();
} else { } else {
updateSequenceSyncControls(sequencePlaybackActiveFromStatus(status)); updateSequenceSyncControls(
sequencePlaybackActiveFromStatus(status) || clientSequenceUiActive,
);
} }
} catch (e) { } catch (e) {
console.warn("audio resume poll check failed", e); console.warn("audio resume status check failed", e);
} }
} }
@@ -662,6 +873,17 @@
updateInputVolumeReadout(); 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() { async function loadServerAudioUiFields() {
@@ -677,27 +899,38 @@
setSelectedDeviceId(saved); setSelectedDeviceId(saved);
} }
updateInputLevelDisplay(status.running ? Number(status.input_level) : 0); 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) { } catch (e) {
console.warn("audio status load failed", 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) => { window.ledControllerSequencePlaybackChanged = (active) => {
clientSequenceUiActive = !!active;
updateSequenceSyncControls(!!active); updateSequenceSyncControls(!!active);
if (active) { if (active) {
setTopBpmVisible(true); setTopBpmVisible(true);
if (!audioDetectorRunning) {
updateBpmDisplay(getSimulatedBpmPercent(), true);
}
ensureBeatEvents();
void pollStatus();
return; return;
} }
if (!pollTimer) { void pollStatus();
setTopBpmVisible(false);
updateSequenceSyncControls(false);
}
}; };
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
bind(); bind();
await loadServerAudioUiFields(); await loadServerAudioUiFields();
await resumePollingIfDetectorRunning(); await resumeBeatEventsIfNeeded();
}); });
})(); })();

View File

@@ -1,6 +1,5 @@
// Device registry: name, id (storage key), type (led), transport (wifi|espnow), address // 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). */ /** Last TCP snapshot from WebSocket (so we can apply after async list render). */
let lastTcpSnapshotIps = null; let lastTcpSnapshotIps = null;
@@ -290,75 +289,51 @@ function mergeTcpSnapshotPresence(ip, connected) {
lastTcpSnapshotIps = Array.from(set); 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) { function applyTransportVisibility(transport) {
const isWifi = transport === 'wifi'; const isWifi = transport === 'wifi';
const esp = document.getElementById('edit-device-address-espnow'); 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 wifiWrap = document.getElementById('edit-device-address-wifi-wrap');
const drvWrap = document.getElementById('edit-device-wifi-driver-wrap'); const drvWrap = document.getElementById('edit-device-wifi-driver-wrap');
if (esp) esp.hidden = isWifi; if (esp) esp.hidden = isWifi;
if (espDrv) espDrv.hidden = isWifi;
if (wifiWrap) wifiWrap.hidden = !isWifi; if (wifiWrap) wifiWrap.hidden = !isWifi;
if (drvWrap) drvWrap.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) { function getAddressForPayload(transport) {
if (transport === 'wifi') { if (transport === 'wifi') {
const el = document.getElementById('edit-device-address-wifi'); const el = document.getElementById('edit-device-address-wifi');
const v = (el && el.value.trim()) || ''; const v = (el && el.value.trim()) || '';
return v || null; return v || null;
} }
const boxEl = document.getElementById('edit-device-address-boxes'); const macEl = document.getElementById('edit-device-address-mac');
if (!boxEl) return null; const hex = normalizeMacInput(macEl && macEl.value);
const boxes = boxEl.querySelectorAll('.hex-addr-box');
const hex = Array.from(boxes).map((b) => b.value).join('').toLowerCase();
return hex || null; return hex || null;
} }
@@ -395,30 +370,18 @@ function collectDeviceEditPayload() {
} }
if (co && co.value) payload.wifi_color_order = co.value; if (co && co.value) payload.wifi_color_order = co.value;
if (ws && ws.value) payload.wifi_startup_mode = ws.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 }; 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() { async function loadDevicesModal() {
const container = document.getElementById('devices-list-modal'); const container = document.getElementById('devices-list-modal');
if (!container) return; if (!container) return;
@@ -508,7 +471,7 @@ function renderDevicesList(devices) {
if (ids.length === 0) { if (ids.length === 0) {
const p = document.createElement('p'); const p = document.createElement('p');
p.className = 'muted-text'; p.className = 'muted-text';
p.textContent = 'No devices yet. Wi-Fi drivers will appear here when they connect over TCP.'; p.textContent = 'No devices yet.';
container.appendChild(p); container.appendChild(p);
return; return;
} }
@@ -622,18 +585,13 @@ function renderDevicesList(devices) {
} }
function openEditDeviceModal(devId, dev) { 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 modal = document.getElementById('edit-device-modal');
const idInput = document.getElementById('edit-device-id'); const idInput = document.getElementById('edit-device-id');
const storageLabel = document.getElementById('edit-device-storage-id'); const storageLabel = document.getElementById('edit-device-storage-id');
const nameInput = document.getElementById('edit-device-name'); const nameInput = document.getElementById('edit-device-name');
const typeSel = document.getElementById('edit-device-type'); const typeSel = document.getElementById('edit-device-type');
const transportSel = document.getElementById('edit-device-transport'); 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'); const wifiInput = document.getElementById('edit-device-address-wifi');
if (!modal || !idInput) return; if (!modal || !idInput) return;
idInput.value = devId; idInput.value = devId;
@@ -643,8 +601,22 @@ function openEditDeviceModal(devId, dev) {
const tr = (dev && dev.transport) || 'espnow'; const tr = (dev && dev.transport) || 'espnow';
if (transportSel) transportSel.value = tr; if (transportSel) transportSel.value = tr;
applyTransportVisibility(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) || '') : ''; 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 wName = document.getElementById('edit-device-wifi-driver-name');
const wLeds = document.getElementById('edit-device-wifi-num-leds'); const wLeds = document.getElementById('edit-device-wifi-num-leds');
const wCo = document.getElementById('edit-device-wifi-color-order'); const wCo = document.getElementById('edit-device-wifi-color-order');
@@ -689,35 +661,11 @@ function openEditDeviceModal(devId, dev) {
obr.value = String(bv); obr.value = String(bv);
if (obv) obv.textContent = String(bv); if (obv) obv.textContent = String(bv);
} }
refreshEditDeviceDebug();
modal.classList.add('active'); modal.classList.add('active');
} }
async function updateDevice(devId, name, type, transport, address, wifiDriverFields, outputBrightness) { async function updateDevice(devId, payload) {
try { 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)}`, { const res = await fetch(`/devices/${encodeURIComponent(devId)}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -796,8 +744,6 @@ document.addEventListener('DOMContentLoaded', () => {
refreshDevicesListQuiet(); refreshDevicesListQuiet();
}); });
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
const devOutBr = document.getElementById('edit-device-output-brightness'); const devOutBr = document.getElementById('edit-device-output-brightness');
const devOutBrVal = document.getElementById('edit-device-output-brightness-value'); const devOutBrVal = document.getElementById('edit-device-output-brightness-value');
if (devOutBr && devOutBrVal) { if (devOutBr && devOutBrVal) {
@@ -810,7 +756,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (transportEdit) { if (transportEdit) {
transportEdit.addEventListener('change', () => { transportEdit.addEventListener('change', () => {
applyTransportVisibility(transportEdit.value); applyTransportVisibility(transportEdit.value);
refreshEditDeviceDebug();
}); });
} }
@@ -878,38 +823,11 @@ document.addEventListener('DOMContentLoaded', () => {
} }
if (editForm) { if (editForm) {
editForm.addEventListener('input', () => refreshEditDeviceDebug());
editForm.addEventListener('change', () => refreshEditDeviceDebug());
editForm.addEventListener('submit', async (e) => { editForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const { devId, payload } = collectDeviceEditPayload(); const { devId, payload } = collectDeviceEditPayload();
if (!devId) return; if (!devId) return;
const transport = payload.transport || 'espnow'; const ok = await updateDevice(devId, payload);
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,
);
if (!ok) return; if (!ok) return;
try { try {
const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, { const brRes = await fetch(`/devices/${encodeURIComponent(devId)}/brightness`, {
@@ -925,21 +843,12 @@ document.addEventListener('DOMContentLoaded', () => {
} catch (e) { } catch (e) {
console.warn('brightness push failed', e); console.warn('brightness push failed', e);
} }
if (transport === 'wifi' && wifiDriverFields) { const pushRes = await pushWifiDriverConfig(
const dn = document.getElementById('edit-device-wifi-driver-name'); devId,
const nl = document.getElementById('edit-device-wifi-num-leds'); getDriverConfigPushFields(payload.transport || 'espnow', payload.name),
const co = document.getElementById('edit-device-wifi-color-order'); );
const ws = document.getElementById('edit-device-wifi-startup-mode'); if (!pushRes.ok) return;
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;
}
await loadDevicesModal(); await loadDevicesModal();
refreshEditDeviceDebug();
}); });
} }
if (editCloseBtn) { if (editCloseBtn) {

View File

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

View File

@@ -404,13 +404,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label); row.appendChild(label);
if (isFirmwareBuiltinPattern(patternName)) { 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 {
const sendBtn = document.createElement('button'); const sendBtn = document.createElement('button');
sendBtn.className = 'btn btn-primary btn-small'; sendBtn.className = 'btn btn-primary btn-small';
sendBtn.textContent = 'Send'; sendBtn.textContent = 'Send';

View File

@@ -269,7 +269,6 @@ document.addEventListener('DOMContentLoaded', () => {
const presetBackgroundInput = document.getElementById('preset-background-input'); const presetBackgroundInput = document.getElementById('preset-background-input');
const presetBackgroundButton = document.getElementById('preset-background-btn'); const presetBackgroundButton = document.getElementById('preset-background-btn');
const presetManualModeInput = document.getElementById('preset-manual-mode-input'); 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 presetManualModeLabel = document.getElementById('preset-manual-mode-label');
const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap'); const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap');
const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input'); const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input');
@@ -447,16 +446,6 @@ document.addEventListener('DOMContentLoaded', () => {
if (presetManualModeLabel) { if (presetManualModeLabel) {
presetManualModeLabel.style.opacity = ok ? '' : '0.55'; 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) { if (!ok) {
presetManualModeInput.checked = false; presetManualModeInput.checked = false;
} }
@@ -521,12 +510,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Get max colors for current pattern // Get max colors for current pattern
const maxColors = getMaxColors(); const maxColors = getMaxColors();
const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : '';
if (currentPresetColors.length === 0) { if (currentPresetColors.length === 0) {
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'muted-text'; 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); presetColorsContainer.appendChild(empty);
return; return;
} }
@@ -536,7 +524,7 @@ document.addEventListener('DOMContentLoaded', () => {
const info = document.createElement('p'); const info = document.createElement('p');
info.className = 'muted-text'; info.className = 'muted-text';
info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;'; 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); presetColorsContainer.appendChild(info);
} }
@@ -1443,7 +1431,7 @@ document.addEventListener('DOMContentLoaded', () => {
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId)); const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
if (availableToAdd.length === 0) { 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 { } else {
availableToAdd.forEach(presetId => { availableToAdd.forEach(presetId => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
@@ -2421,8 +2409,7 @@ const renderTabPresets = async (zoneId, options = {}) => {
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'muted-text'; empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; empty.style.gridColumn = '1 / -1';
empty.textContent = empty.textContent = 'No presets on this zone yet.';
"No presets or sequences on this zone yet. Open Edit to add presets or sequences.";
presetsList.appendChild(empty); presetsList.appendChild(empty);
} }
} else { } else {

View File

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

View File

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

View File

@@ -1023,7 +1023,7 @@ async function refreshEditTabPresetsUi(zoneId) {
addEl.innerHTML = ""; addEl.innerHTML = "";
if (availableToAdd.length === 0) { if (availableToAdd.length === 0) {
addEl.innerHTML = 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 { } else {
const addWrap = document.createElement("div"); const addWrap = document.createElement("div");
addWrap.className = "zone-devices-add profiles-actions"; addWrap.className = "zone-devices-add profiles-actions";

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" class="simulated-bpm-mode">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <button class="btn btn-secondary" id="groups-close-btn">Close</button>
</div> </div>
</div> </div>
<p class="muted-text" style="margin-top:0;">Assign drivers to a group, set WiFi defaults once per group, then attach groups to a zone for standalone presets (sequences use each lanes groups only). By default new groups are <strong>shared</strong> across all profiles; tick “this profile only” to hide a group from other profiles.</p>
<div class="profiles-actions zone-modal-create-row"> <div class="profiles-actions zone-modal-create-row">
<input type="text" id="new-group-name" placeholder="Group name"> <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;"> <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"> <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;"> <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;"> <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>
<label class="zone-devices-label">Devices in this group</label> <label class="zone-devices-label">Devices in this group</label>
<div id="edit-group-devices-editor" class="zone-devices-editor"></div> <div id="edit-group-devices-editor" class="zone-devices-editor"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;"> <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> <button type="button" class="btn btn-secondary btn-small" id="edit-group-identify-btn">Identify devices in group</button>
</div> </div>
<p class="muted-text" style="margin-top:0.25rem;">Runs identify on every driver in the group at the same time so they blink together.</p>
<label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label> <label for="edit-group-output-brightness" style="margin-top:0.75rem;display:block;">Group output brightness (0255)</label>
<div class="profiles-actions" style="align-items: center; gap: 0.75rem;"> <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;"> <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> <option value="wifi">WiFi</option>
</select> </select>
<div id="edit-device-address-espnow" style="margin-top:0.75rem;"> <div id="edit-device-address-espnow" style="margin-top:0.75rem;">
<label class="device-field-label">MAC (12 hex, optional)</label> <label for="edit-device-address-mac">MAC (12 hex, optional)</label>
<div id="edit-device-address-boxes" class="hex-address-row" aria-label="MAC address"></div> <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>
<div id="edit-device-address-wifi-wrap" style="margin-top:0.75rem;" hidden> <div id="edit-device-address-wifi-wrap" style="margin-top:0.75rem;" hidden>
<label for="edit-device-address-wifi">Address (IP or hostname)</label> <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"> <input type="text" id="edit-device-address-wifi" placeholder="192.168.1.50" autocomplete="off">
</div> </div>
<div id="edit-device-wifi-driver-wrap" hidden> <div id="edit-device-wifi-driver-wrap" hidden>
<p class="muted-text" style="margin-top:0.75rem;margin-bottom:0.35rem;">On-device settings (sent over WiFi when connected). For shared defaults across several drivers, use <strong>Groups</strong>.</p>
<label for="edit-device-wifi-driver-name">Display name</label> <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"> <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> <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;"> <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> <span id="edit-device-output-brightness-value" class="muted-text" style="min-width:2.5rem;">255</span>
</div> </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> </form>
</div> </div>
</div> </div>
@@ -369,17 +375,9 @@
<input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;"> <input type="text" id="sequence-editor-name" placeholder="Sequence name" style="width:100%;max-width:24rem;">
</div> </div>
<div id="sequence-editor-beats-panel" style="margin:0 0 0.75rem 0;"> <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;"> <label style="display:block;">
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;">
<input type="checkbox" id="sequence-editor-loop" checked> <input type="checkbox" id="sequence-editor-loop" checked>
Loop sequence (restart from the first step after the last) Loop
</label> </label>
</div> </div>
<div id="sequence-editor-lanes"></div> <div id="sequence-editor-lanes"></div>
@@ -433,19 +431,18 @@
<div class="form-group" style="margin-top: 0.5rem; margin-bottom: 0;"> <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;"> <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"> <input type="checkbox" id="preset-manual-mode-input">
Manual mode (single-shot where supported) Manual mode
</label> </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;"> <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> <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"> <input type="number" id="preset-manual-beat-n-input" min="1" max="64" value="1" style="width: 4rem;" autocomplete="off">
<span class="muted-text" style="font-size: 0.85em;">beats (this app only)</span> <span>beats</span>
</div> </div>
</div> </div>
<div class="preset-editor-field" id="preset-reverse-group" hidden> <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;"> <label for="preset-reverse-input" style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0;">
<input type="checkbox" id="preset-reverse-input"> <input type="checkbox" id="preset-reverse-input">
Reverse direction (strip installed upside down) Reverse direction
</label> </label>
</div> </div>
<div class="preset-editor-field preset-mode-field" id="preset-mode-group" hidden> <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> <button type="button" class="btn btn-secondary" id="pattern-editor-close-btn">Close</button>
</div> </div>
</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;"> <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> <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"> <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>
<div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;"> <div id="pattern-create-n-section" class="n-params-section" style="margin-bottom: 0.5rem;">
<h3 class="muted-text">Readable parameter names</h3> <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-params-grid">
<div class="n-param-group"> <div class="n-param-group">
<label for="pattern-create-n1"></label> <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;"> <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> <label for="pattern-create-file">Pattern file</label>
<input type="file" id="pattern-create-file" accept=".py,text/x-python,.PY"> <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> <textarea id="pattern-create-code" rows="5" style="width: 100%; font-family: monospace; font-size: 0.85rem;" placeholder="# class MyPattern: ..."></textarea>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@@ -613,8 +609,6 @@
<button class="btn btn-secondary" id="help-close-btn">Close</button> <button class="btn btn-secondary" id="help-close-btn">Close</button>
</div> </div>
</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"> <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 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> <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> </select>
<button type="button" class="btn btn-secondary btn-small" id="audio-refresh-btn" title="Refresh device list">Refresh</button> <button type="button" class="btn btn-secondary btn-small" id="audio-refresh-btn" title="Refresh device list">Refresh</button>
</div> </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>
<div class="form-group"> <div class="form-group">
<label>Beat indicators</label> <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-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> <span id="audio-bar-phase-value" class="audio-top-bar-phase" aria-live="polite" title="Bar phase (beat in bar)"></span>
</button> </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>
<div class="form-group"> <div class="form-group">
<label>Detected hit type</label> <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 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 id="audio-input-level-bar" class="audio-input-level-bar"></div>
</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>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" class="btn btn-primary" id="audio-start-btn">Start</button> <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> <ul id="bridge-connection-details" class="settings-bridge-connection-details muted-text" aria-live="polite"></ul>
<h3 class="settings-subheading">USB serial</h3> <h3 class="settings-subheading">USB serial</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi ↔ bridge over USB/UART. The bridge still uses WiFi radio for ESP-NOW only.</p>
<div class="form-group"> <div class="form-group">
<label for="bridge-serial-label">Profile label</label> <label for="bridge-serial-label">Profile label</label>
<input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off"> <input type="text" id="bridge-serial-label" placeholder="e.g. Pi USB bridge" autocomplete="off">
@@ -1199,7 +1193,6 @@
</div> </div>
<h3 class="settings-subheading">WiFi</h3> <h3 class="settings-subheading">WiFi</h3>
<p class="muted-text" style="margin-top:0;margin-bottom:0.75rem;">Pi joins the bridge access point, then connects to <code>ws://&lt;bridge-ip&gt;/ws</code>.</p>
<div class="form-group"> <div class="form-group">
<label for="bridge-wifi-interface">WiFi adapter</label> <label for="bridge-wifi-interface">WiFi adapter</label>
<select id="bridge-wifi-interface"> <select id="bridge-wifi-interface">
@@ -1246,7 +1239,6 @@
</div> </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> <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> <iframe id="led-tool-iframe" title="LED device settings editor" src="about:blank" allow="serial" class="settings-led-tool-iframe"></iframe>
</div> </div>

View File

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

View File

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

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

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

View File

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

View File

@@ -1,9 +1,11 @@
"""Deliver v1 JSON to drivers via bridge devices envelope.""" """Deliver v1 JSON via ESP-NOW bridge and/or outbound Wi-Fi driver WebSockets."""
import asyncio import asyncio
import json 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 ( from util.bridge_envelope import (
BROADCAST_MAC, BROADCAST_MAC,
build_devices_envelope, build_devices_envelope,
@@ -12,6 +14,7 @@ from util.bridge_envelope import (
split_v1_body_for_espnow, split_v1_body_for_espnow,
) )
from util.espnow_message import build_message from util.espnow_message import build_message
_MAX_JSON_ESPNOW = 240 _MAX_JSON_ESPNOW = 240
@@ -38,6 +41,19 @@ def _body_from_message(msg: Union[str, bytes, bytearray, Dict[str, Any]]) -> Opt
return None 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: async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s: float) -> int:
deliveries = 0 deliveries = 0
try: try:
@@ -53,6 +69,91 @@ async def _deliver_v1_body(bridge, mac_key: str, body: Dict[str, Any], delay_s:
return deliveries 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( def build_preset_json_chunks(
presets_by_name: Dict[str, Any], 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]: def _unicast_mac_keys(target_macs: Optional[List[str]]) -> List[str]:
"""One formatted MAC per target; empty list means broadcast."""
if not target_macs: if not target_macs:
return [BROADCAST_MAC] return [BROADCAST_MAC]
keys: List[str] = [] keys: List[str] = []
seen: set = set() seen: set[str] = set()
for raw in target_macs: for raw in target_macs:
h = normalize_mac_key(raw) h = normalize_mac_key(raw)
if h and h not in seen: 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] 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( async def deliver_json_messages(
bridge, bridge,
messages, messages,
@@ -119,30 +282,110 @@ async def deliver_json_messages(
unicast: bool = False, unicast: bool = False,
): ):
""" """
Deliver v1 JSON to drivers. Default: ESP-NOW broadcast (``ff:ff:…``); drivers Deliver v1 JSON to ESP-NOW (bridge) and Wi-Fi (outbound WebSocket) drivers.
filter on ``groups`` in the body. Set ``unicast=True`` only for per-device settings
or single-device identify.
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 from models.transport import get_current_bridge
active = get_current_bridge() or bridge active = get_current_bridge() or bridge
if active is None: if active is None:
raise RuntimeError("Transport not configured") raise RuntimeError("Transport not configured")
if unicast and target_macs: if not messages:
mac_keys = _unicast_mac_keys(target_macs) return 0, 0
else:
mac_keys = [BROADCAST_MAC]
ordered_macs = _ordered_target_macs(target_macs)
deliveries = 0 deliveries = 0
for mac_key in mac_keys:
if not ordered_macs:
wifi_targets = _wifi_targets(devices_model, None)
for msg in messages: for msg in messages:
text = _message_text(msg)
body = _body_from_message(msg) body = _body_from_message(msg)
if not body: if body:
continue deliveries += await _deliver_v1_body(active, BROADCAST_MAC, body, 0)
deliveries += await _deliver_v1_body(active, mac_key, body, delay_s) 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) return deliveries, len(messages)

View File

@@ -1,7 +1,8 @@
"""Server-side zone sequence playback (audio beats or simulated BPM). """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 A background clock at ``audio_simulated_bpm`` ticks continuously. When the audio detector is
emits beats at the sequence ``simulated_bpm`` rate until playback stops or live audio starts. 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 from __future__ import annotations
@@ -10,6 +11,7 @@ import asyncio
import json import json
import queue import queue
import threading import threading
import time
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from util.espnow_message import resolve_preset_background_hex 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) _thread_beat_queue: "queue.Queue[int]" = queue.Queue(maxsize=256)
_beat_consumer_started = False _beat_consumer_started = False
_beat_consumer_lock = threading.Lock() _beat_consumer_lock = threading.Lock()
_last_beat_processed_ts = 0.0
_beat_dedupe_lock = threading.Lock()
_sim_beat_task: Optional[asyncio.Task] = None _background_beat_task: Optional[asyncio.Task] = None
_sim_beat_token = 0 _background_beat_token = 0
_beat_run: Optional[Dict[str, Any]] = None _beat_run: Optional[Dict[str, Any]] = None
_beat_run_lock = threading.Lock() _beat_run_lock = threading.Lock()
_pending_play: Optional[Dict[str, Any]] = None _pending_play: Optional[Dict[str, Any]] = None
_pending_play_lock = threading.Lock() _pending_play_lock = threading.Lock()
_pending_beat_task: Optional[asyncio.Task] = None
_pending_beat_token = 0
_last_thread_beat_phase: Dict[str, Any] = { _last_thread_beat_phase: Dict[str, Any] = {
"is_downbeat": True, "is_downbeat": True,
"bar_beat": 1, "bar_beat": 1,
} }
_sim_beat_counter = 0 _sim_beat_counter = 0
_last_completed_beat_readout = ""
def _norm_mac(raw: Any) -> Optional[str]: 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]: def playback_status() -> Dict[str, Any]:
"""Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum.""" """Snapshot for UI (e.g. audio status poll): lane 0 step + beats within step, total steps sum."""
with _beat_run_lock: 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)) beats_per_step = max(1, int(step.get("beats") or 1))
beat_count_raw = int(st0.get("beatCount", 0)) beat_count_raw = int(st0.get("beatCount", 0))
bt = max(1, int(beats_per_step)) 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))): for j in range(min(idx, len(lane0))):
sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1)) sequence_beat_at += max(1, int((lane0[j] or {}).get("beats") or 1))
sequence_beat_at += beat_count sequence_beat_at += beat_count
@@ -988,19 +1047,7 @@ def playback_status() -> Dict[str, Any]:
lane0_preset_name = nm or pid lane0_preset_name = nm or pid
else: else:
lane0_preset_name = pid lane0_preset_name = pid
beat_readout = "" beat_readout = _beat_readout_for_ctx(ctx)
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}"
return { return {
"active": True, "active": True,
"advance_mode": ctx.get("advance_mode"), "advance_mode": ctx.get("advance_mode"),
@@ -1026,6 +1073,11 @@ async def process_active_beat_advance() -> None:
ctx = _beat_run ctx = _beat_run
if not ctx: if not ctx:
return 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"] lane_states: List[Dict[str, Any]] = ctx["lane_states"]
lanes: List[List[Dict[str, Any]]] = ctx["lanes"] lanes: List[List[Dict[str, Any]]] = ctx["lanes"]
loop = bool(ctx.get("loop")) loop = bool(ctx.get("loop"))
@@ -1041,18 +1093,21 @@ async def process_active_beat_advance() -> None:
step = lane_steps[int(st.get("stepIdx", 0))] step = lane_steps[int(st.get("stepIdx", 0))]
need = max(1, int(step.get("beats") or 1)) need = max(1, int(step.get("beats") or 1))
if int(st["beatCount"]) >= need: if int(st["beatCount"]) >= need:
st["beatCount"] = 0 at_end_of_lane = int(st.get("stepIdx", 0)) + 1 >= len(lane_steps)
if int(st.get("stepIdx", 0)) + 1 >= len(lane_steps): if at_end_of_lane:
if loop: if loop:
if i == 0: if i == 0:
lane0_looped = True lane0_looped = True
st["beatCount"] = 0
# Force step-0 preset re-upload on loop wrap, even if wire id matches. # Force step-0 preset re-upload on loop wrap, even if wire id matches.
st["_last_wire"] = "" st["_last_wire"] = ""
st["stepIdx"] = 0 st["stepIdx"] = 0
await _send_lane(i, st, ctx) await _send_lane(i, st, ctx)
else: else:
st["beatCount"] = need
st["done"] = True st["done"] = True
else: else:
st["beatCount"] = 0
st["stepIdx"] = int(st.get("stepIdx", 0)) + 1 st["stepIdx"] = int(st.get("stepIdx", 0)) + 1
await _send_lane(i, st, ctx) await _send_lane(i, st, ctx)
if lane0_looped: if lane0_looped:
@@ -1061,6 +1116,8 @@ async def process_active_beat_advance() -> None:
else: else:
ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1 ctx["sequence_loop_beat"] = int(ctx.get("sequence_loop_beat", 0)) + 1
if all(s.get("done") for s in lane_states): 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) await stop_playback(clear_devices=True)
return return
@@ -1097,17 +1154,12 @@ async def _clear_devices_after_sequence(ctx: Dict[str, Any]) -> None:
def _halt_playback_state() -> Optional[Dict[str, Any]]: def _halt_playback_state() -> Optional[Dict[str, Any]]:
"""Drop active run state and cancel simulated beats; return the previous ctx.""" """Drop active run state; return the previous ctx."""
global _beat_run, _sim_beat_task, _sim_beat_token global _beat_run
ctx: Optional[Dict[str, Any]] = None ctx: Optional[Dict[str, Any]] = None
with _beat_run_lock: with _beat_run_lock:
ctx = _beat_run ctx = _beat_run
_beat_run = None _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 return ctx
@@ -1115,6 +1167,10 @@ async def stop_playback(*, clear_devices: bool = True) -> None:
"""Stop sequence playback; optionally clear presets on targeted devices.""" """Stop sequence playback; optionally clear presets on targeted devices."""
clear_pending_play() clear_pending_play()
ctx = _halt_playback_state() 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: if clear_devices and ctx:
await _clear_devices_after_sequence(ctx) await _clear_devices_after_sequence(ctx)
@@ -1166,18 +1222,39 @@ def _drain_beat_queue() -> None:
pass pass
def _reset_beat_side_effects() -> None: def _clear_beat_route_only() -> None:
"""Clear manual routes and queued beats so startup cannot select before presets land."""
from util.beat_driver_route import update_beat_route from util.beat_driver_route import update_beat_route
update_beat_route({"enabled": False}) 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() _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: try:
from util import audio_detector as ad_mod
from settings import get_settings 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") raw = get_settings().get("sequence_switch_wait", "beat")
mode = _normalize_wait_for({"wait_for": raw}) or "beat" mode = _normalize_wait_for({"wait_for": raw}) or "beat"
if mode == "phrase": if mode == "phrase":
@@ -1187,6 +1264,10 @@ def _sequence_switch_wait_from_settings() -> str:
return "beat" 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]: def _normalize_wait_for(play_options: Optional[Dict[str, Any]]) -> Optional[str]:
"""``beat`` | ``downbeat`` | None (immediate).""" """``beat`` | ``downbeat`` | None (immediate)."""
if not isinstance(play_options, dict): if not isinstance(play_options, dict):
@@ -1213,21 +1294,11 @@ def _play_options_without_wait(play_options: Optional[Dict[str, Any]]) -> Option
return out 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: def clear_pending_play() -> None:
"""Drop a queued sequence start (e.g. user stop).""" """Drop a queued sequence start (e.g. user stop)."""
global _pending_play global _pending_play
with _pending_play_lock: with _pending_play_lock:
_pending_play = None _pending_play = None
_cancel_pending_beat_waiter()
def pending_play_status() -> Dict[str, Any]: 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]: def _beat_phase_from_sources() -> Dict[str, Any]:
from util import audio_detector as ad_mod 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() st = ad_mod.shared_beat_status_snapshot()
if st: if st.get("bar_beat") is not None:
return dict(st) 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) 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( def _queue_pending_start(
zone_id: str, zone_id: str,
sequence_id: str, sequence_id: str,
@@ -1279,7 +1388,12 @@ def _queue_pending_start(
bpm: float, bpm: float,
) -> None: ) -> None:
global _pending_play global _pending_play
if effective_sequence_switch_wait() == "beat":
wait_for = "beat"
clear_pending_play() clear_pending_play()
with _beat_run_lock:
if _beat_run is not None:
_beat_run["_pending_switch"] = True
with _pending_play_lock: with _pending_play_lock:
_pending_play = { _pending_play = {
"zone_id": str(zone_id), "zone_id": str(zone_id),
@@ -1288,49 +1402,13 @@ def _queue_pending_start(
"play_options": _play_options_without_wait(play_options), "play_options": _play_options_without_wait(play_options),
"wait_for": wait_for, "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: def _prime_pending_sequence_beat_clock(wait_for: str) -> None:
"""When nothing is playing and audio is off, emit synthetic beats until pending starts.""" """No-op: the background simulated clock fills beats when live audio is not timing."""
from util import audio_detector as ad_mod _ = wait_for
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()
async def _try_consume_pending_play(*, is_downbeat: bool) -> bool: 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 pending = _pending_play
if not pending: if not pending:
return False 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: if wait_for == "downbeat" and not is_downbeat:
return False return False
_pending_play = None _pending_play = None
_cancel_pending_beat_waiter()
await _start_immediate( await _start_immediate(
pending["zone_id"], pending["zone_id"],
pending["sequence_id"], pending["sequence_id"],
pending["profile_id"], pending["profile_id"],
pending.get("play_options"), 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 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).""" """Stop server playback state without sending device clear (e.g. before starting another run)."""
clear_pending_play() clear_pending_play()
clear_completed_beat_readout()
_halt_playback_state() _halt_playback_state()
_reset_beat_side_effects() if sequence_handoff:
_clear_beat_route_only()
else:
_reset_beat_side_effects()
def push_thread_beat() -> None: def push_thread_beat() -> None:
@@ -1367,41 +1454,62 @@ def push_thread_beat() -> None:
pass 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: async def beat_consumer_loop() -> None:
while True: while True:
n = 0
try: try:
while True: _thread_beat_queue.get_nowait()
_thread_beat_queue.get_nowait()
n += 1
except queue.Empty: 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) 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: def ensure_beat_consumer_started() -> None:
global _beat_consumer_started global _beat_consumer_started
with _beat_consumer_lock: with _beat_consumer_lock:
if _beat_consumer_started: if _beat_consumer_started:
ensure_background_beat_clock_started()
return return
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -1409,46 +1517,80 @@ def ensure_beat_consumer_started() -> None:
return return
_beat_consumer_started = True _beat_consumer_started = True
loop.create_task(beat_consumer_loop()) 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: def _audio_drives_beat_clock() -> bool:
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:
from util import audio_detector as ad_mod 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: while True:
with _beat_run_lock: if _background_beat_token != my_token:
cur_tok = _sim_beat_token
active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
return return
if ad_mod.shared_beat_detector_running(): bpm = _simulated_bpm_from_settings()
await asyncio.sleep(0.12) interval = 60.0 / clamp_bpm(bpm)
continue
await asyncio.sleep(interval) await asyncio.sleep(interval)
with _beat_run_lock: if _background_beat_token != my_token:
cur_tok = _sim_beat_token
active = _beat_run
if cur_tok != my_token or active is None or active is not ctx:
return return
if ad_mod.shared_beat_detector_running():
continue
_mark_simulated_beat_phase() _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: 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): if not sequence_doc or str(sequence_doc.get("profile_id")) != str(profile_id):
raise ValueError("sequence not found") raise ValueError("sequence not found")
wait_for = _sequence_switch_wait_from_settings() wait_for = _sequence_switch_wait_from_settings()
if wait_for: with _beat_run_lock:
bpm = _coerce_simulated_bpm(sequence_doc, play_options) active = _beat_run is not None
if wait_for and active:
bpm = _simulated_bpm_from_settings()
_queue_pending_start( _queue_pending_start(
zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm zone_id, sequence_id, profile_id, play_options, wait_for, bpm=bpm
) )
@@ -1499,14 +1643,17 @@ async def _start_immediate(
sequence_id: str, sequence_id: str,
profile_id: str, profile_id: str,
play_options: Optional[Dict[str, Any]] = None, play_options: Optional[Dict[str, Any]] = None,
*,
sequence_handoff: bool = False,
handoff_is_downbeat: bool = False,
) -> None: ) -> None:
global _beat_run, _sim_beat_task, _sim_beat_token global _beat_run
from models.preset import Preset from models.preset import Preset
from models.profile import Profile from models.profile import Profile
from models.sequence import Sequence from models.sequence import Sequence
from models.zone import Zone from models.zone import Zone
stop() stop(sequence_handoff=sequence_handoff)
seq_m = Sequence() seq_m = Sequence()
zone_m = Zone() zone_m = Zone()
prof_m = Profile() prof_m = Profile()
@@ -1534,16 +1681,16 @@ async def _start_immediate(
ctx["sequence_id"] = str(sequence_id) ctx["sequence_id"] = str(sequence_id)
ctx["zone_id"] = str(zone_id) ctx["zone_id"] = str(zone_id)
ctx["sequence_loop_beat"] = 0 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 _prime_all_lanes(ctx)
await _deliver_zone_brightness_for_sequence(ctx) await _deliver_zone_brightness_for_sequence(ctx)
with _beat_run_lock: with _beat_run_lock:
_beat_run = ctx _beat_run = ctx
ensure_background_beat_clock_started()
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))

View File

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

View File

@@ -22,7 +22,7 @@ Tests for the LED Controller project live under **`tests/`** (pytest + legacy sc
| `udp_server.py` | UDP discovery / hello test listener (port **8766**) | | `udp_server.py` | UDP discovery / hello test listener (port **8766**) |
| `bridge_broadcast_test.py` | Manual bridge WebSocket broadcast script | | `bridge_broadcast_test.py` | Manual bridge WebSocket broadcast script |
| `ws.py` | WebSocket client checks | | `ws.py` | WebSocket client checks |
| `web.py` | Local dev static server (not the main app) | | `web.py` | Local dev server on port 5000 (`pipenv run web`) |
| `conftest.py` | Pytest fixtures | | `conftest.py` | Pytest fixtures |
| `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) | | `models/` | Model unit tests (`run_all.py`, `test_zone.py`, …) |
@@ -50,6 +50,6 @@ Requires **Selenium**, Chrome/Chromium, and a matching **ChromeDriver**.
python tests/models/run_all.py python tests/models/run_all.py
``` ```
### Local static server ### Local dev server (port 5000)
`tests/web.py` serves files for quick UI experiments; it is **not** the Microdot app. For the real server use **`pipenv run run`** from the repo root. `pipenv run web` runs the FastAPI app on **http://localhost:5000** (production-style default is **`pipenv run run`** on port 80).

View File

@@ -14,9 +14,8 @@ from starlette.testclient import TestClient
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src" SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
for p in (str(PROJECT_ROOT), str(LIB_PATH), str(SRC_PATH)): for p in (str(PROJECT_ROOT), str(SRC_PATH)):
if p in sys.path: if p in sys.path:
sys.path.remove(p) sys.path.remove(p)
sys.path.insert(0, p) sys.path.insert(0, p)
@@ -81,7 +80,7 @@ def server(monkeypatch, tmp_path_factory):
tmp_db_dir = tmp_root / "db" tmp_db_dir = tmp_root / "db"
tmp_settings_file = tmp_root / "settings.json" tmp_settings_file = tmp_root / "settings.json"
for p in (str(SRC_PATH), str(LIB_PATH), str(PROJECT_ROOT)): for p in (str(SRC_PATH), str(PROJECT_ROOT)):
if p in sys.path: if p in sys.path:
sys.path.remove(p) sys.path.remove(p)
sys.path.insert(0, p) sys.path.insert(0, p)

View File

@@ -115,14 +115,36 @@ def parse_args() -> argparse.Namespace:
return parser.parse_args() return parser.parse_args()
def _clamp_detected_bpm(bpm: float | None) -> float | None:
if bpm is None:
return None
try:
from util.bpm_limits import clamp_bpm_optional
return clamp_bpm_optional(bpm)
except ImportError:
v = float(bpm)
if v <= 0:
return None
return max(60.0, min(200.0, v))
def _estimate_bpm(beat_times: Deque[float]) -> float | None: def _estimate_bpm(beat_times: Deque[float]) -> float | None:
if len(beat_times) < 3: if len(beat_times) < 3:
return None return None
try:
from util.bpm_limits import max_beat_interval_s, min_beat_interval_s
ioi_min = min_beat_interval_s()
ioi_max = max_beat_interval_s()
except ImportError:
ioi_min = 0.3
ioi_max = 1.0
intervals = np.diff(np.array(beat_times, dtype=np.float64)) intervals = np.diff(np.array(beat_times, dtype=np.float64))
valid = intervals[(intervals > 0.2) & (intervals < 2.0)] valid = intervals[(intervals >= ioi_min) & (intervals <= ioi_max)]
if valid.size == 0: if valid.size == 0:
return None return None
return 60.0 / float(np.median(valid)) return _clamp_detected_bpm(60.0 / float(np.median(valid)))
def _is_plausible_ioi( def _is_plausible_ioi(
@@ -131,7 +153,7 @@ def _is_plausible_ioi(
now_s: float, now_s: float,
*, *,
min_ratio: float = 0.42, min_ratio: float = 0.42,
max_ratio: float = 2.5, max_ratio: float = 3.5,
) -> bool: ) -> bool:
"""Reject double-time / half-time false triggers vs recent median interval.""" """Reject double-time / half-time false triggers vs recent median interval."""
if last_trigger_s <= 0 or len(beat_times) < 2: if last_trigger_s <= 0 or len(beat_times) < 2:
@@ -251,7 +273,7 @@ def _resolve_bpm(
) -> float | None: ) -> float | None:
estimated = _estimate_bpm(beat_times) estimated = _estimate_bpm(beat_times)
if estimated is None: if estimated is None:
return aubio_bpm return _clamp_detected_bpm(aubio_bpm)
if aubio_bpm is None or aubio_bpm <= 0: if aubio_bpm is None or aubio_bpm <= 0:
return estimated return estimated
ratio = float(aubio_bpm) / estimated ratio = float(aubio_bpm) / estimated
@@ -400,7 +422,7 @@ class BeatDetectRuntime:
if self.tempo is not None: if self.tempo is not None:
aubio_hit = bool(self.tempo(f32)[0]) aubio_hit = bool(self.tempo(f32)[0])
val = float(self.tempo.get_bpm()) val = float(self.tempo.get_bpm())
aubio_bpm = val if val > 0 else None aubio_bpm = _clamp_detected_bpm(val if val > 0 else None)
if now_s is None: if now_s is None:
now_s = time.time() now_s = time.time()

View File

@@ -5,11 +5,10 @@ pytest_plugins = ["api_server"]
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src" SRC_PATH = PROJECT_ROOT / "src"
LIB_PATH = PROJECT_ROOT / "lib"
# Last insert(0) wins: order must be (root, lib, src) so src/models wins over # Last insert(0) wins: order must be (root, src) so src/models wins over
# tests/models (same package name "models" on sys.path when pytest imports tests). # tests/models (same package name "models" on sys.path when pytest imports tests).
for p in (str(PROJECT_ROOT), str(LIB_PATH), str(SRC_PATH)): for p in (str(PROJECT_ROOT), str(SRC_PATH)):
if p in sys.path: if p in sys.path:
sys.path.remove(p) sys.path.remove(p)
sys.path.insert(0, p) sys.path.insert(0, p)

View File

@@ -27,7 +27,6 @@ def test_sequence():
assert sequence["lanes"] == [[]] assert sequence["lanes"] == [[]]
assert sequence.get("lanes_group_ids") == [[]] assert sequence.get("lanes_group_ids") == [[]]
assert sequence.get("advance_mode") == "beats" assert sequence.get("advance_mode") == "beats"
assert sequence.get("simulated_bpm") == 120
assert sequence["step_duration_ms"] == 3000 assert sequence["step_duration_ms"] == 3000
assert sequence["loop"] is True assert sequence["loop"] is True
assert sequence.get("sequence_transition") == 500 assert sequence.get("sequence_transition") == 500
@@ -43,7 +42,6 @@ def test_sequence():
"step_duration_ms": 5000, "step_duration_ms": 5000,
"loop": True, "loop": True,
"advance_mode": "beats", "advance_mode": "beats",
"simulated_bpm": 128,
} }
result = sequences.update(sequence_id, update_data) result = sequences.update(sequence_id, update_data)
assert result is True assert result is True
@@ -58,7 +56,6 @@ def test_sequence():
assert len(updated["lanes"][0]) == 2 assert len(updated["lanes"][0]) == 2
assert updated["lanes"][0][0]["beats"] == 2 assert updated["lanes"][0][0]["beats"] == 2
assert updated.get("advance_mode") == "beats" assert updated.get("advance_mode") == "beats"
assert updated.get("simulated_bpm") == 128
assert updated["step_duration_ms"] == 5000 assert updated["step_duration_ms"] == 5000
assert updated["loop"] is True assert updated["loop"] is True

View File

@@ -1,23 +1,18 @@
"""Audio input device_select persistence (Pulse name must survive start).""" """Audio input device_select persistence (Pulse name must survive start)."""
import asyncio
import json
import os
import sys import sys
import threading
import time
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
import requests from fastapi import FastAPI
from starlette.testclient import TestClient
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src" SRC_PATH = PROJECT_ROOT / "src"
if str(SRC_PATH) not in sys.path: if str(SRC_PATH) not in sys.path:
sys.path.insert(0, str(SRC_PATH)) sys.path.insert(0, str(SRC_PATH))
from microdot import Microdot # noqa: E402
from util.audio_run_persist import read_audio_run_state, write_audio_run_state # noqa: E402 from util.audio_run_persist import read_audio_run_state, write_audio_run_state # noqa: E402
SNOWBALL = ( SNOWBALL = (
@@ -25,28 +20,6 @@ SNOWBALL = (
) )
def _start_app(app: Microdot, port: int = 0):
def runner():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(app.start_server(host="127.0.0.1", port=port))
finally:
loop.close()
thread = threading.Thread(target=runner, daemon=True)
thread.start()
deadline = time.time() + 5.0
while time.time() < deadline:
server = getattr(app, "server", None)
if server and getattr(server, "sockets", None):
sockets = server.sockets or []
if sockets:
return thread, sockets[0].getsockname()[1]
time.sleep(0.05)
raise RuntimeError("server failed to start")
@pytest.fixture @pytest.fixture
def audio_run_path(tmp_path, monkeypatch): def audio_run_path(tmp_path, monkeypatch):
path = tmp_path / "audio_run.json" path = tmp_path / "audio_run.json"
@@ -69,12 +42,12 @@ def test_write_start_keeps_pulse_device_select_not_portaudio_index(audio_run_pat
def test_put_device_saves_pulse_name(audio_run_path): def test_put_device_saves_pulse_name(audio_run_path):
app = Microdot() api = FastAPI()
@app.route("/api/audio/device", methods=["PUT"]) @api.put("/api/audio/device")
async def audio_set_device(request): async def audio_set_device(payload: dict | None = None):
payload = request.json if isinstance(request.json, dict) else {} body = payload if isinstance(payload, dict) else {}
device_select = str(payload.get("device_select") or "").strip() device_select = str(body.get("device_select") or "").strip()
from util.audio_run_persist import read_audio_run_state, write_audio_run_state from util.audio_run_persist import read_audio_run_state, write_audio_run_state
prev = read_audio_run_state() prev = read_audio_run_state()
@@ -86,13 +59,11 @@ def test_put_device_saves_pulse_name(audio_run_path):
) )
return {"ok": True, "audio_run": read_audio_run_state()} return {"ok": True, "audio_run": read_audio_run_state()}
_, port = _start_app(app) with TestClient(api) as client:
base = f"http://127.0.0.1:{port}" resp = client.put(
resp = requests.put( "/api/audio/device",
f"{base}/api/audio/device", json={"device_select": SNOWBALL, "device_override": ""},
json={"device_select": SNOWBALL, "device_override": ""}, )
timeout=5,
)
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.json() body = resp.json()
assert body["audio_run"]["device_select"] == SNOWBALL assert body["audio_run"]["device_select"] == SNOWBALL
@@ -112,15 +83,15 @@ def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
fake_resolve, fake_resolve,
) )
app = Microdot() api = FastAPI()
@app.route("/api/audio/start", methods=["POST"]) @api.post("/api/audio/start")
async def audio_start(request): async def audio_start(payload: dict | None = None):
payload = request.json if isinstance(request.json, dict) else {} body = payload if isinstance(payload, dict) else {}
device = payload.get("device", None) device = body.get("device", None)
if device in ("", None): if device in ("", None):
device = None device = None
device_select = str(payload.get("device_select") or "").strip() device_select = str(body.get("device_select") or "").strip()
if not device_select and device not in ("", None): if not device_select and device not in ("", None):
device_select = str(device).strip() device_select = str(device).strip()
from util.pulse_audio_devices import resolve_capture_device from util.pulse_audio_devices import resolve_capture_device
@@ -138,29 +109,26 @@ def test_start_preserves_device_select_in_status(audio_run_path, monkeypatch):
st["audio_run"] = read_audio_run_state() st["audio_run"] = read_audio_run_state()
return {"ok": True, "status": st} return {"ok": True, "status": st}
@app.route("/api/audio/status") @api.get("/api/audio/status")
async def audio_status(request): async def audio_status():
_ = request
from util.audio_run_persist import read_audio_run_state from util.audio_run_persist import read_audio_run_state
st = detector.status() st = detector.status()
st["audio_run"] = read_audio_run_state() st["audio_run"] = read_audio_run_state()
return {"status": st} return {"status": st}
_, port = _start_app(app) with TestClient(api) as client:
base = f"http://127.0.0.1:{port}" start = client.post(
start = requests.post( "/api/audio/start",
f"{base}/api/audio/start", json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""},
json={"device": SNOWBALL, "device_select": SNOWBALL, "device_override": ""}, )
timeout=5, assert start.status_code == 200, start.text
) run = start.json()["status"]["audio_run"]
assert start.status_code == 200, start.text assert run["device_select"] == SNOWBALL
run = start.json()["status"]["audio_run"] assert run["device"] == 2
assert run["device_select"] == SNOWBALL
assert run["device"] == 2
status = requests.get(f"{base}/api/audio/status", timeout=5).json()["status"] status = client.get("/api/audio/status").json()["status"]
assert status["audio_run"]["device_select"] == SNOWBALL assert status["audio_run"]["device_select"] == SNOWBALL
def test_pulse_device_list_uses_stable_pulse_ids(): def test_pulse_device_list_uses_stable_pulse_ids():

View File

@@ -9,7 +9,11 @@ SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path: if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH) sys.path.insert(0, SRC_PATH)
from util.audio_detector import AudioBeatDetector # noqa: E402 from util.audio_detector import ( # noqa: E402
AudioBeatDetector,
set_shared_beat_detector,
shared_beat_detector_timing_sequences,
)
class _FakeRuntime: class _FakeRuntime:
@@ -83,7 +87,47 @@ def test_silence_gap_starts_holdover_and_resets_tempo_once():
det._maybe_recover_after_silence_gap(rt) det._maybe_recover_after_silence_gap(rt)
assert rt.reset_tempo_calls == 1 assert rt.reset_tempo_calls == 1
det._record_beat(120.0) det._record_beat(120.0)
assert det._holdover_active is False assert det._holdover_active is True
def test_timing_sequences_true_while_holdover_active():
det = AudioBeatDetector()
set_shared_beat_detector(det)
try:
with det._lock:
det._running = True
det._status["running"] = True
det._status["bpm"] = 120.0
det._record_beat(120.0)
assert det._holdover_active is True
assert shared_beat_detector_timing_sequences() is True
finally:
set_shared_beat_detector(None)
def test_timing_sequences_false_when_running_without_beats():
det = AudioBeatDetector()
set_shared_beat_detector(det)
try:
with det._lock:
det._running = True
det._status["running"] = True
assert shared_beat_detector_timing_sequences() is False
det._record_beat(120.0)
assert shared_beat_detector_timing_sequences() is True
det._stop_bpm_holdover()
with det._lock:
det._last_real_beat_ts = time.time() - 5.0
assert shared_beat_detector_timing_sequences() is False
finally:
set_shared_beat_detector(None)
def test_record_beat_keeps_previous_bpm_when_new_readout_invalid():
det = AudioBeatDetector()
det._record_beat(128.0)
det._record_beat(None)
assert det.status()["bpm"] == 128.0
def test_holdover_last_beat_does_not_block_tempo_retry(): def test_holdover_last_beat_does_not_block_tempo_retry():

34
tests/test_audio_sse.py Normal file
View File

@@ -0,0 +1,34 @@
"""Server-sent events for audio/beat status."""
import asyncio
import json
import pytest
@pytest.mark.asyncio
async def test_initial_sse_line_includes_status(monkeypatch):
from util import beat_status_broadcaster as bsb
bsb.configure(
loop=asyncio.get_running_loop(),
status_builder=lambda: {"bpm_simulated": True, "beat_seq": 3},
)
line = await bsb.initial_sse_line()
assert line.startswith("data: ")
payload = json.loads(line[6:])
assert payload["type"] == "status"
assert payload["status"]["beat_seq"] == 3
def test_audio_events_sse_first_chunk(server):
c = server["client"]
with c.stream("GET", "/api/audio/events") as resp:
assert resp.status_code == 200
assert "text/event-stream" in resp.headers.get("content-type", "")
chunk = next(resp.iter_bytes())
text = chunk.decode("utf-8")
assert text.startswith("data: ")
payload = json.loads(text.strip().removeprefix("data: "))
assert payload.get("type") == "status"
assert "bpm_simulated" in payload.get("status", {})

View File

@@ -26,3 +26,14 @@ def test_resolve_bpm_prefers_intervals_over_wrong_aubio():
bpm = _resolve_bpm(times, 70.0) bpm = _resolve_bpm(times, 70.0)
assert bpm is not None assert bpm is not None
assert abs(bpm - 120.0) < 5.0 assert abs(bpm - 120.0) < 5.0
def test_resolve_bpm_clamps_runaway_aubio():
times = deque([0.0])
assert _resolve_bpm(times, 400.0) == 200.0
assert _resolve_bpm(times, 999.0) == 200.0
def test_resolve_bpm_clamps_slow_aubio():
times = deque([0.0])
assert _resolve_bpm(times, 30.0) == 60.0

45
tests/test_bpm_limits.py Normal file
View File

@@ -0,0 +1,45 @@
"""BPM clamp helpers."""
import os
import sys
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util.audio_detector import AudioBeatDetector # noqa: E402
from util.bpm_limits import (
BPM_MAX,
BPM_MIN,
clamp_bpm,
clamp_bpm_optional,
max_beat_interval_s,
max_beat_min_ioi_ms,
min_beat_interval_s,
)
def test_clamp_bpm_bounds():
assert clamp_bpm(120) == 120.0
assert clamp_bpm(400) == float(BPM_MAX)
assert clamp_bpm(20) == float(BPM_MIN)
def test_clamp_bpm_optional():
assert clamp_bpm_optional(None) is None
assert clamp_bpm_optional(0) is None
assert clamp_bpm_optional(350) == float(BPM_MAX)
def test_beat_interval_bounds():
assert abs(min_beat_interval_s() - 60.0 / BPM_MAX) < 1e-9
assert abs(max_beat_interval_s() - 60.0 / BPM_MIN) < 1e-9
assert abs(max_beat_min_ioi_ms() - 60_000.0 / BPM_MAX) < 1e-6
def test_status_clamps_high_bpm():
det = AudioBeatDetector()
with det._lock:
det._status["bpm"] = 350.0
assert det.status()["bpm"] == float(BPM_MAX)

View File

@@ -43,18 +43,32 @@ def test_deliver_json_messages_defaults_broadcast():
def __init__(self): def __init__(self):
self.keys = [] self.keys = []
async def send(self, envelope): async def send(self, envelope, addr=None):
del addr
devs = envelope.get("dv") or envelope.get("devices") or {} devs = envelope.get("dv") or envelope.get("devices") or {}
self.keys.extend(devs.keys()) self.keys.extend(devs.keys())
return True return True
class _Devices:
def read(self, mac):
return {
"id": mac,
"name": mac,
"transport": "espnow",
"address": mac,
}
def items(self):
return []
async def _run(): async def _run():
bridge = _Bridge() bridge = _Bridge()
await deliver_json_messages( await deliver_json_messages(
bridge, bridge,
[json.dumps({"v": "1", "select": ["2"]})], [json.dumps({"v": "1", "select": ["2"]})],
["188b0e1560a8", "e8f60a16ea10"],
None, None,
_Devices(),
delay_s=0,
) )
return bridge.keys return bridge.keys

View File

@@ -607,39 +607,45 @@ def test_profiles_ui(browser: BrowserTest) -> bool:
return passed == total return passed == total
def test_mobile_tab_presets_two_columns(): def test_mobile_tab_presets_three_columns():
""" """
Verify that the zone preset selecting area shows roughly two preset tiles per row On a phone-sized viewport the zone strip is hidden; zones are chosen from the
on a phone-sized viewport. header Zones menu. Preset tiles use a 3-column grid (see style.css).
""" """
bt = BrowserTest(base_url=BASE_URL, headless=True) bt = BrowserTest(base_url=BASE_URL, headless=True)
if not bt.setup(): if not bt.setup():
assert False, "Failed to start browser" assert False, "Failed to start browser"
try: try:
# Simulate a mobile viewport
bt.driver.set_window_size(400, 800) bt.driver.set_window_size(400, 800)
assert bt.navigate('/'), "Failed to load main page" assert bt.navigate('/'), "Failed to load main page"
# Click the first zone button to load presets for that zone # Desktop zone buttons live in .zones-container which is display:none on mobile.
first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10) WebDriverWait(bt.driver, 10).until(
assert first_tab is not None, "No zone buttons found" EC.presence_of_element_located(
first_tab.click() (By.CSS_SELECTOR, '#zones-menu-dropdown .zones-menu-item')
)
)
assert bt.click_element(By.ID, 'zones-menu-btn'), "Failed to open Zones menu"
assert bt.click_element(
By.CSS_SELECTOR, '#zones-menu-dropdown .zones-menu-item'
), "Failed to select zone from mobile menu"
_browser_sleep(1) _browser_sleep(1)
container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10) container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10)
assert container is not None, "presets-list-zone not found" assert container is not None, "presets-list-zone not found"
tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row') tiles = bt.driver.find_elements(
# Need at least 2 presets to make this meaningful By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row'
)
assert len(tiles) >= 2, "Fewer than 2 presets found for zone" assert len(tiles) >= 2, "Fewer than 2 presets found for zone"
container_width = container.size['width'] container_width = container.size['width']
first_width = tiles[0].size['width'] first_width = tiles[0].size['width']
# Each tile should be about half the container width (tolerate some margin) # Three columns on max-width 600px (~one third of the row, minus gaps).
assert 0.4 * container_width <= first_width <= 0.6 * container_width, ( assert 0.22 * container_width <= first_width <= 0.42 * container_width, (
f"Preset tile width {first_width} not ~half of container {container_width}" f"Preset tile width {first_width} not ~third of container {container_width}"
) )
finally: finally:
bt.teardown() bt.teardown()

View File

@@ -0,0 +1,461 @@
"""Tests for dual-transport delivery (ESP-NOW bridge + Wi-Fi WebSocket) and Wi-Fi runtime."""
from __future__ import annotations
import asyncio
import json
import socket
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SRC_PATH = PROJECT_ROOT / "src"
for p in (str(PROJECT_ROOT), str(SRC_PATH)):
if p in sys.path:
sys.path.remove(p)
sys.path.insert(0, p)
_models = sys.modules.get("models")
if _models is not None:
_mf = (getattr(_models, "__file__", "") or "").replace("\\", "/")
if "/tests/models" in _mf:
for key in list(sys.modules):
if key == "models" or key.startswith("models."):
del sys.modules[key]
from util.bridge_envelope import BROADCAST_MAC # noqa: E402
class FakeDevices:
def __init__(self, docs: Dict[str, Dict[str, Any]]):
self._docs = docs
def read(self, mac: str) -> Optional[Dict[str, Any]]:
return self._docs.get(mac)
def items(self):
return self._docs.items()
class RecordingBridge:
def __init__(self) -> None:
self.envelopes: List[Dict[str, Any]] = []
async def send(self, data, addr=None):
del addr
if isinstance(data, dict):
self.envelopes.append(data)
elif isinstance(data, str):
self.envelopes.append(json.loads(data))
return True
def mac_keys(self) -> List[str]:
keys: List[str] = []
for env in self.envelopes:
devs = env.get("dv") or env.get("devices") or {}
keys.extend(devs.keys())
return keys
@pytest.fixture
def bridge():
return RecordingBridge()
@pytest.fixture
def espnow_devices():
return FakeDevices(
{
"188b0e1560a8": {
"id": "188b0e1560a8",
"name": "esp-a",
"transport": "espnow",
"address": "188b0e1560a8",
},
"e8f60a16ea10": {
"id": "e8f60a16ea10",
"name": "esp-b",
"transport": "espnow",
"address": "e8f60a16ea10",
},
}
)
@pytest.fixture
def mixed_devices():
return FakeDevices(
{
"188b0e1560a8": {
"id": "188b0e1560a8",
"name": "esp-a",
"transport": "espnow",
"address": "188b0e1560a8",
},
"102030405060": {
"id": "102030405060",
"name": "wifi-a",
"transport": "wifi",
"address": "192.168.50.10",
},
}
)
def test_wifi_message_for_device_narrows_select():
from util.driver_delivery import _wifi_message_for_device
msg = json.dumps(
{"v": "1", "select": {"wifi-a": 0, "esp-a": 1}},
separators=(",", ":"),
)
narrowed = _wifi_message_for_device(msg, "wifi-a")
body = json.loads(narrowed)
assert body["select"] == {"wifi-a": 0}
def test_combine_preset_chunks_for_wifi():
from util.driver_delivery import _combine_preset_chunks_for_wifi
chunks = [
json.dumps({"v": "1", "presets": {"a": {"p": "on"}}}, separators=(",", ":")),
json.dumps(
{"v": "1", "presets": {"b": {"p": "blink"}}, "save": True, "default": "b"},
separators=(",", ":"),
),
]
combined = json.loads(_combine_preset_chunks_for_wifi(chunks))
assert combined["presets"]["a"]["p"] == "on"
assert combined["presets"]["b"]["p"] == "blink"
assert combined["save"] is True
assert combined["default"] == "b"
def test_deliver_json_broadcast_espnow_only(bridge, espnow_devices, monkeypatch):
from util import driver_delivery
wifi_sends: list[tuple[str, str]] = []
async def fake_wifi(ip, msg):
wifi_sends.append((ip, msg))
return True
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
async def _run():
return await driver_delivery.deliver_json_messages(
bridge,
[json.dumps({"v": "1", "select": ["off"]})],
None,
espnow_devices,
delay_s=0,
)
deliveries, n = asyncio.run(_run())
assert n == 1
assert deliveries >= 1
assert bridge.mac_keys() == [BROADCAST_MAC]
assert wifi_sends == []
def test_deliver_json_broadcast_includes_wifi(bridge, mixed_devices, monkeypatch):
from util import driver_delivery
wifi_sends: list[tuple[str, str]] = []
async def fake_wifi(ip, msg):
wifi_sends.append((ip, msg))
return True
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
async def _run():
return await driver_delivery.deliver_json_messages(
bridge,
[json.dumps({"v": "1", "select": ["off"]})],
None,
mixed_devices,
delay_s=0,
)
deliveries, _n = asyncio.run(_run())
assert deliveries >= 2
assert bridge.mac_keys() == [BROADCAST_MAC]
assert len(wifi_sends) == 1
assert wifi_sends[0][0] == "192.168.50.10"
def test_deliver_json_targeted_espnow_unicasts(bridge, espnow_devices, monkeypatch):
from util import driver_delivery
monkeypatch.setattr(
driver_delivery,
"send_json_line_to_ip",
AsyncMock(return_value=True),
)
async def _run():
return await driver_delivery.deliver_json_messages(
bridge,
[json.dumps({"v": "1", "select": ["2"]})],
["188b0e1560a8", "e8f60a16ea10"],
espnow_devices,
delay_s=0,
)
asyncio.run(_run())
keys = bridge.mac_keys()
assert "18:8b:0e:15:60:a8" in keys
assert "e8:f6:0a:16:ea:10" in keys
assert BROADCAST_MAC not in keys
def test_deliver_json_targeted_wifi_uses_websocket(bridge, mixed_devices, monkeypatch):
from util import driver_delivery
wifi_sends: list[tuple[str, str]] = []
async def fake_wifi(ip, msg):
wifi_sends.append((ip, msg))
return True
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
async def _run():
await driver_delivery.deliver_json_messages(
bridge,
[json.dumps({"v": "1", "select": {"wifi-a": 0}})],
["102030405060"],
mixed_devices,
delay_s=0,
)
asyncio.run(_run())
assert bridge.mac_keys() == []
assert len(wifi_sends) == 1
assert wifi_sends[0][0] == "192.168.50.10"
body = json.loads(wifi_sends[0][1])
assert body["select"] == {"wifi-a": 0}
def test_deliver_json_unicast_flag_wifi(bridge, mixed_devices, monkeypatch):
from util import driver_delivery
wifi_sends: list[str] = []
async def fake_wifi(ip, msg):
wifi_sends.append(msg)
return True
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
async def _run():
await driver_delivery.deliver_json_messages(
bridge,
[json.dumps({"v": "1", "b": 128})],
["102030405060"],
mixed_devices,
delay_s=0,
unicast=True,
)
asyncio.run(_run())
assert len(wifi_sends) == 1
assert bridge.mac_keys() == []
def test_deliver_preset_broadcast_then_per_device_wifi(
bridge, mixed_devices, monkeypatch
):
from util import driver_delivery
wifi_sends: list[str] = []
async def fake_wifi(ip, msg):
wifi_sends.append(msg)
return True
monkeypatch.setattr(driver_delivery, "send_json_line_to_ip", fake_wifi)
chunks = [
json.dumps(
{"v": "1", "presets": {"p1": {"p": "on"}}, "save": True},
separators=(",", ":"),
)
]
async def _run():
return await driver_delivery.deliver_preset_broadcast_then_per_device(
bridge,
chunks,
None,
mixed_devices,
default_id=None,
delay_s=0,
)
count = asyncio.run(_run())
assert count >= 2
assert bridge.mac_keys() == [BROADCAST_MAC]
assert len(wifi_sends) == 1
combined = json.loads(wifi_sends[0])
assert "p1" in combined["presets"]
def test_deliver_json_requires_bridge(monkeypatch):
from util import driver_delivery
import models.transport as transport_mod
monkeypatch.setattr(transport_mod, "get_current_bridge", lambda: None)
async def _run():
with pytest.raises(RuntimeError, match="Transport not configured"):
await driver_delivery.deliver_json_messages(
None, ["{}"], None, FakeDevices({}), delay_s=0
)
asyncio.run(_run())
def test_device_status_broadcaster_send_text():
from util.device_status_broadcaster import (
_ws_send_text,
broadcast_device_tcp_snapshot_to,
broadcast_device_tcp_status,
register_device_status_ws,
unregister_device_status_ws,
)
class StarletteLikeWS:
def __init__(self):
self.out: list[str] = []
async def send_text(self, msg: str):
self.out.append(msg)
class SendTextOnlyWS:
def __init__(self):
self.out: list[str] = []
async def send(self, msg: str):
self.out.append(msg)
async def _run():
starlette = StarletteLikeWS()
legacy_ws = SendTextOnlyWS()
await _ws_send_text(starlette, '{"ok":true}')
await _ws_send_text(legacy_ws, '{"ok":true}')
assert starlette.out == ['{"ok":true}']
assert legacy_ws.out == ['{"ok":true}']
await register_device_status_ws(starlette)
await broadcast_device_tcp_status("192.168.1.5", True)
assert len(starlette.out) == 2
status = json.loads(starlette.out[1])
assert status["type"] == "device_tcp"
assert status["ip"] == "192.168.1.5"
assert status["connected"] is True
await broadcast_device_tcp_snapshot_to(starlette)
snapshot = json.loads(starlette.out[2])
assert snapshot["type"] == "device_tcp_snapshot"
assert "connected_ips" in snapshot
await unregister_device_status_ws(starlette)
asyncio.run(_run())
def test_process_udp_datagram_registers_and_connects(monkeypatch):
from util import wifi_driver_runtime
registered: list[tuple[str, str, str]] = []
connected: list[str] = []
def fake_register(device_name, peer_ip, mac, device_type=None):
del device_type
registered.append((device_name, peer_ip, str(mac)))
monkeypatch.setattr(
wifi_driver_runtime,
"_register_udp_device_sync",
fake_register,
)
monkeypatch.setattr(
wifi_driver_runtime.tcp_client_registry,
"ensure_driver_connection",
lambda ip: connected.append(ip),
)
line = json.dumps(
{"v": "1", "device_name": "strip-a", "mac": "aabbccddeeff", "type": "led"}
).encode()
wifi_driver_runtime._process_udp_datagram(line, "192.168.1.42")
assert registered == [("strip-a", "192.168.1.42", "aabbccddeeff")]
assert connected == ["192.168.1.42"]
def test_process_udp_datagram_ignores_invalid():
from util.wifi_driver_runtime import _process_udp_datagram
_process_udp_datagram(b"not-json\n", "10.0.0.1")
_process_udp_datagram(b'{"v":"1"}\n', "10.0.0.1")
def test_discovery_protocol_uses_datagram_endpoint(monkeypatch):
pytest.importorskip("uvloop")
import uvloop
from util.wifi_driver_runtime import _DiscoveryProtocol
async def _run():
echoed: list[bytes] = []
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
holder: dict = {"closing": False}
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
transport, _protocol = await loop.create_datagram_endpoint(
lambda: _DiscoveryProtocol(holder),
sock=sock,
)
class _EchoClient(asyncio.DatagramProtocol):
def connection_made(self, t):
self._transport = t
def datagram_received(self, data, addr):
del addr
echoed.append(data)
client_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
client_sock.bind(("127.0.0.1", 0))
client_transport, _ = await loop.create_datagram_endpoint(
_EchoClient,
sock=client_sock,
)
payload = b'{"v":"1","device_name":"x","mac":"112233445566"}\n'
client_transport.sendto(payload, ("127.0.0.1", port))
await asyncio.sleep(0.05)
holder["closing"] = True
client_transport.close()
transport.close()
return echoed, payload
monkeypatch.setattr(
"util.wifi_driver_runtime._register_udp_device_sync",
lambda *a, **k: None,
)
monkeypatch.setattr(
"util.wifi_driver_runtime.tcp_client_registry.ensure_driver_connection",
lambda _ip: None,
)
echoed, payload = asyncio.run(_run())
assert echoed == [payload]

View File

@@ -55,6 +55,9 @@ def test_main_routes(server):
with c.websocket_connect("/ws") as ws: with c.websocket_connect("/ws") as ws:
ws.send_text('{"v":"1","select":["off"]}') ws.send_text('{"v":"1","select":["off"]}')
snapshot = ws.receive_json()
assert snapshot.get("type") == "device_tcp_snapshot"
assert isinstance(snapshot.get("connected_ips"), list)
def test_settings_controller(server): def test_settings_controller(server):
@@ -66,6 +69,9 @@ def test_settings_controller(server):
data = resp.json() data = resp.json()
assert isinstance(data, dict) assert isinstance(data, dict)
assert "wifi_channel" in data assert "wifi_channel" in data
assert "wifi_driver_ws_port" in data
assert "wifi_driver_ws_path" in data
assert data.get("wifi_driver_ws_path") == "/ws"
resp = c.get(f"{base_url}/settings/wifi/ap") resp = c.get(f"{base_url}/settings/wifi/ap")
assert resp.status_code == 200 assert resp.status_code == 200
@@ -183,6 +189,37 @@ def test_profiles_presets_zones_endpoints(server, monkeypatch):
assert sent_result["presets_sent"] >= 1 assert sent_result["presets_sent"] >= 1
assert len(bridge.sent) >= 1 assert len(bridge.sent) >= 1
wifi_sends = []
async def _fake_wifi_send(ip, msg):
wifi_sends.append((ip, msg))
return True
import util.driver_delivery as driver_delivery_mod
monkeypatch.setattr(driver_delivery_mod, "send_json_line_to_ip", _fake_wifi_send)
resp = c.post(
f"{base_url}/devices",
json={
"name": "pytest-wifi-preset",
"transport": "wifi",
"address": "192.168.50.20",
"mac": "203040506070",
},
)
assert resp.status_code == 201
bridge.sent.clear()
resp = c.post(
f"{base_url}/presets/send",
json={"preset_ids": [new_preset_id], "save": False},
)
assert resp.status_code == 200
assert len(bridge.sent) >= 1
assert len(wifi_sends) >= 1
assert wifi_sends[0][0] == "192.168.50.20"
resp = c.delete(f"{base_url}/devices/203040506070")
assert resp.status_code == 200
resp = c.delete(f"{base_url}/presets/{new_preset_id}") resp = c.delete(f"{base_url}/presets/{new_preset_id}")
assert resp.status_code == 200 assert resp.status_code == 200
resp = c.get(f"{base_url}/presets/{new_preset_id}") resp = c.get(f"{base_url}/presets/{new_preset_id}")

View File

@@ -1,16 +0,0 @@
import pytest
pytest.skip("Legacy manual server script (not a pytest suite).", allow_module_level=True)
from microdot import Microdot
from src.profile import profile_app
app = Microdot()
@app.route('/')
async def index(request):
return 'Hello, world!'
app.mount(profile_app, url_prefix="/profile")
app.run(port=8080, debug=True)

View File

@@ -13,6 +13,56 @@ if SRC_PATH not in sys.path:
from util import sequence_playback as sp # noqa: E402 from util import sequence_playback as sp # noqa: E402
def test_effective_switch_wait_ignores_saved_downbeat_when_audio_off(monkeypatch):
class FakeSettings:
def get(self, key, default=None):
if key == "sequence_switch_wait":
return "downbeat"
return default
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
assert sp.effective_sequence_switch_wait() == "beat"
def test_simulated_mode_forces_beat_switch_wait(monkeypatch):
class FakeSettings:
def get(self, key, default=None):
if key == "sequence_switch_wait":
return "downbeat"
return default
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
assert sp._sequence_switch_wait_from_settings() == "beat"
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: True
)
assert sp._sequence_switch_wait_from_settings() == "downbeat"
def test_beat_switch_when_audio_running_but_sim_clocks(monkeypatch):
"""Mic on without timing sequences: still beat-only (not downbeat)."""
class FakeSettings:
def get(self, key, default=None):
if key == "sequence_switch_wait":
return "downbeat"
return default
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_running", lambda: True
)
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
assert sp.effective_sequence_switch_wait() == "beat"
def test_normalize_wait_for(): def test_normalize_wait_for():
assert sp._normalize_wait_for({"wait_for": "beat"}) == "beat" assert sp._normalize_wait_for({"wait_for": "beat"}) == "beat"
assert sp._normalize_wait_for({"start_on": "downbeat"}) == "downbeat" assert sp._normalize_wait_for({"start_on": "downbeat"}) == "downbeat"
@@ -37,19 +87,49 @@ def test_queue_and_clear_pending():
assert sp.pending_play_status()["pending"] is False assert sp.pending_play_status()["pending"] is False
def test_try_consume_pending_beat(): def test_try_consume_pending_beat(monkeypatch):
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
sp.clear_pending_play() sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0) sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0)
async def fake_start(*_a, **_k): async def fake_start(*_a, **_k):
return None return None
sp._start_immediate = fake_start # type: ignore[method-assign] monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True
assert sp.pending_play_status()["pending"] is False assert sp.pending_play_status()["pending"] is False
def test_try_consume_pending_downbeat_skips_upbeat(): def test_try_consume_pending_beat_accepts_upbeat(monkeypatch):
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", None, "beat", bpm=120.0)
sp._mark_simulated_beat_phase()
sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False}
async def fake_start(*_a, **_k):
return None
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is True
sp.clear_pending_play()
def test_try_consume_pending_downbeat_skips_upbeat(monkeypatch):
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: True
)
class FakeSettings:
def get(self, key, default=None):
if key == "sequence_switch_wait":
return "downbeat"
return default
monkeypatch.setattr("settings.get_settings", lambda: FakeSettings())
sp.clear_pending_play() sp.clear_pending_play()
sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0) sp._queue_pending_start("z1", "s1", "p1", None, "downbeat", bpm=120.0)
assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is False assert asyncio.run(sp._try_consume_pending_play(is_downbeat=False)) is False
@@ -63,12 +143,94 @@ def test_try_consume_pending_downbeat_skips_upbeat():
sp.clear_pending_play() sp.clear_pending_play()
def test_sequence_pass_start_anchors_bar_phase_to_one():
sp.stop()
sp._sim_beat_counter = 7
sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False}
ctx = {
"lanes": [[{"preset_id": "1", "beats": 6}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": False,
"sequence_loop_beat": 0,
"presets_map": {},
}
with sp._beat_run_lock:
sp._beat_run = ctx
assert sp._is_sequence_pass_start(ctx) is True
sp._anchor_bar_phase_for_sequence_start()
phase = sp.simulated_beat_phase_snapshot()
assert phase["bar_beat"] == 1
assert phase["is_downbeat"] is True
assert phase["bar_phase_readout"] == "1/4"
asyncio.run(sp.process_active_beat_advance())
st = sp.playback_status()
assert st["beat_readout"] == "1/6"
assert sp.simulated_beat_phase_snapshot()["bar_beat"] == 1
sp.stop()
def test_sequence_pass_start_not_mid_pass():
ctx = {
"lanes": [[{"preset_id": "1", "beats": 2}, {"preset_id": "2", "beats": 2}]],
"lane_states": [{"stepIdx": 1, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": False,
}
assert sp._is_sequence_pass_start(ctx) is False
def test_completed_beat_readout_survives_stop_playback():
sp.stop()
sp.clear_completed_beat_readout()
ctx = {
"lanes": [[{"preset_id": "1", "beats": 6}]],
"lane_states": [{"stepIdx": 0, "beatCount": 6, "done": True}],
"num_lanes": 1,
"loop": False,
"sequence_loop_beat": 6,
"presets_map": {},
}
with sp._beat_run_lock:
sp._beat_run = ctx
sp.remember_completed_beat_readout(sp._beat_readout_for_ctx(ctx))
asyncio.run(sp.stop_playback(clear_devices=False))
assert sp.last_completed_beat_readout() == "6/6"
assert sp.playback_status()["active"] is False
sp.stop()
def test_playback_beat_readout_six_beat_sequence():
"""Beat readout is 1..tot with no duplicate 1 at start or missing final beat."""
sp.stop()
ctx = {
"lanes": [[{"preset_id": "1", "beats": 6}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": False,
"sequence_loop_beat": 0,
"presets_map": {},
}
with sp._beat_run_lock:
sp._beat_run = ctx
assert sp.playback_status()["beat_readout"] == ""
for n in range(1, 5):
ctx["lane_states"][0]["beatCount"] = n
assert sp.playback_status()["beat_readout"] == f"{n}/6"
ctx["lane_states"][0]["beatCount"] = 5
assert sp.playback_status()["beat_readout"] == "6/6"
ctx["lane_states"][0]["beatCount"] = 6
ctx["lane_states"][0]["done"] = True
assert sp.playback_status()["beat_readout"] == "6/6"
sp.stop()
def test_downbeat_start_counts_trigger_beat(monkeypatch): def test_downbeat_start_counts_trigger_beat(monkeypatch):
"""The downbeat that starts playback is beat 1 of the step, not beat 0.""" """The downbeat that starts playback is beat 1 of the step, not beat 0."""
sp.clear_pending_play() sp.clear_pending_play()
sp.stop() sp.stop()
async def fake_start(_z, _s, _p, _opts): async def fake_start(_z, _s, _p, _opts, **_kwargs):
sp._beat_run = { sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 4}]], "lanes": [[{"preset_id": "1", "beats": 4}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}], "lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],

View File

@@ -0,0 +1,156 @@
"""Background simulated beat clock vs live audio."""
import asyncio
import os
import sys
import time
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import sequence_playback as sp # noqa: E402
from util.audio_detector import AudioBeatDetector, set_shared_beat_detector # noqa: E402
def _loop_ctx():
return {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
}
async def _run_background_beats(*, bpm: float, seconds: float, audio_running: bool) -> int:
sp.stop()
sp.clear_pending_play()
det = AudioBeatDetector()
set_shared_beat_detector(det)
try:
with det._lock:
det._running = bool(audio_running)
det._status["running"] = bool(audio_running)
if audio_running:
det._status["bpm"] = float(bpm)
except Exception:
pass
ctx = _loop_ctx()
with sp._beat_run_lock:
sp._beat_run = ctx
sp._beat_consumer_started = False
sp._background_beat_task = None
sp.ensure_beat_consumer_started()
monkeypatch_bpm = bpm
def fake_bpm():
return monkeypatch_bpm
orig = sp._simulated_bpm_from_settings
sp._simulated_bpm_from_settings = fake_bpm # type: ignore[method-assign]
try:
await asyncio.sleep(seconds)
finally:
sp._simulated_bpm_from_settings = orig # type: ignore[method-assign]
with sp._beat_run_lock:
st = sp._beat_run["lane_states"][0] if sp._beat_run else {}
beat_count = int(st.get("beatCount", 0))
tick = sp.simulated_beat_tick()
sp.stop()
set_shared_beat_detector(None)
return beat_count, tick
def test_background_beats_continue_past_four_with_audio_off():
beat_count, tick = asyncio.run(
_run_background_beats(bpm=200.0, seconds=2.5, audio_running=False)
)
assert beat_count > 4, f"expected more than 4 beats, got {beat_count}"
assert tick > 4, f"expected tick past 4, got {tick}"
def test_background_advances_sequence_when_audio_on_without_beats():
beat_count, tick = asyncio.run(
_run_background_beats(bpm=200.0, seconds=2.5, audio_running=True)
)
assert beat_count > 4, f"sim should fill when audio is on but not clocking, got {beat_count}"
assert tick > 4, f"background tick should still count, got {tick}"
def test_holdover_fills_beats_between_sparse_real_detections():
det = AudioBeatDetector()
set_shared_beat_detector(det)
try:
with det._lock:
det._running = True
det._status["running"] = True
async def run():
sp.stop()
sp.clear_pending_play()
ctx = _loop_ctx()
with sp._beat_run_lock:
sp._beat_run = ctx
sp._beat_consumer_started = False
sp._background_beat_task = None
sp.ensure_beat_consumer_started()
det._record_beat(120.0)
await asyncio.sleep(2.2)
with sp._beat_run_lock:
beat_count = int(ctx["lane_states"][0].get("beatCount", 0))
sp.stop()
return beat_count
beat_count = asyncio.run(run())
assert beat_count > 2, f"holdover should advance between kicks, got {beat_count}"
finally:
set_shared_beat_detector(None)
def test_live_audio_advances_sequence_when_running():
det = AudioBeatDetector()
set_shared_beat_detector(det)
try:
with det._lock:
det._running = True
det._status["running"] = True
async def run():
sp.stop()
sp.clear_pending_play()
ctx = _loop_ctx()
with sp._beat_run_lock:
sp._beat_run = ctx
sp._beat_consumer_started = False
sp._background_beat_task = None
sp.ensure_beat_consumer_started()
gap = sp._min_processed_beat_gap_s() + 0.01
for _ in range(8):
det._record_beat(400.0)
await asyncio.sleep(gap)
with sp._beat_run_lock:
beat_count = int(ctx["lane_states"][0].get("beatCount", 0))
sp.stop()
return beat_count
beat_count = asyncio.run(run())
assert beat_count > 4, f"audio should drive sequence, got {beat_count}"
finally:
set_shared_beat_detector(None)
def test_beat_dedupe_drops_double_fire():
sp.stop()
sp.clear_pending_play()
sp._accept_thread_beat_now()
assert sp._accept_thread_beat_now() is False
time.sleep(sp._min_processed_beat_gap_s() + 0.02)
assert sp._accept_thread_beat_now() is True
sp.stop()

View File

@@ -0,0 +1,580 @@
"""Simulated BPM: sequence switching timing and beat regularity."""
import asyncio
import os
import sys
import time
from typing import List
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SRC_PATH = os.path.join(PROJECT_ROOT, "src")
if SRC_PATH not in sys.path:
sys.path.insert(0, SRC_PATH)
from util import sequence_playback as sp # noqa: E402
from util.audio_detector import AudioBeatDetector, set_shared_beat_detector # noqa: E402
class _FakeSettings:
def __init__(self, **values):
self._values = values
def get(self, key, default=None):
return self._values.get(key, default)
def _install_simulated_bpm(monkeypatch, bpm: float, *, sequence_switch_wait: str = "beat"):
monkeypatch.setattr(
"settings.get_settings",
lambda: _FakeSettings(
audio_simulated_bpm=bpm,
sequence_switch_wait=sequence_switch_wait,
),
)
det = AudioBeatDetector()
set_shared_beat_detector(det)
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
def _beat_timestamps(seconds: float) -> List[float]:
async def collect():
sp.stop()
sp.clear_pending_play()
set_shared_beat_detector(None)
sp._beat_consumer_started = False
sp._background_beat_task = None
sp.ensure_beat_consumer_started()
stamps: List[float] = []
last = sp.simulated_beat_tick()
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
tick = sp.simulated_beat_tick()
if tick != last:
stamps.append(time.monotonic())
last = tick
await asyncio.sleep(0.005)
sp.stop()
return stamps
return asyncio.run(collect())
def _intervals(stamps: List[float]) -> List[float]:
return [stamps[i + 1] - stamps[i] for i in range(len(stamps) - 1)]
def test_effective_switch_wait_is_beat_when_audio_off_even_if_saved_downbeat(monkeypatch):
_install_simulated_bpm(monkeypatch, 60.0, sequence_switch_wait="downbeat")
assert sp.effective_sequence_switch_wait() == "beat"
set_shared_beat_detector(None)
def test_e2e_switch_on_next_beat_while_mic_running_sim_clocks(monkeypatch):
"""End-to-end: audio running flag set, sim BPM ticks, switch on next beat not downbeat."""
bpm = 120.0
_install_simulated_bpm(monkeypatch, bpm, sequence_switch_wait="downbeat")
det = AudioBeatDetector()
set_shared_beat_detector(det)
with det._lock:
det._running = True
det._status["running"] = True
sp.stop()
sp.clear_pending_play()
sp._beat_consumer_started = False
sp._background_beat_task = None
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
switch_events: List[tuple] = []
async def track_start(_z, seq_id, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
switch_events.append((time.monotonic(), str(seq_id), int(phase.get("bar_beat") or 0)))
monkeypatch.setattr(sp, "_start_immediate", track_start)
async def run():
sp.ensure_beat_consumer_started()
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._mark_simulated_beat_phase()
sp._mark_simulated_beat_phase()
assert sp._beat_phase_from_sources()["bar_beat"] == 2
t_queue = time.monotonic()
sp._queue_pending_start(
"z1", "2", "1", None, sp.effective_sequence_switch_wait(), bpm=bpm
)
assert sp.pending_play_status()["wait_for"] == "beat"
beat_interval = 60.0 / bpm
for _ in range(6):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
sp.push_thread_beat()
await asyncio.sleep(0.05)
return t_queue, switch_events, beat_interval
t_queue, events, beat_interval = asyncio.run(run())
assert len(events) == 1, f"expected one switch, got {events}"
_t, seq_id, bar_beat = events[0]
assert seq_id == "2"
assert bar_beat == 3, f"expected switch on next beat (bar 3), got bar {bar_beat}"
assert _t - t_queue < beat_interval * 1.1, (
f"switch took too long ({_t - t_queue:.2f}s) for {bpm} BPM"
)
sp.stop()
with det._lock:
det._running = False
det._status["running"] = False
set_shared_beat_detector(None)
def test_simulated_beat_intervals_steady_at_60_bpm(monkeypatch):
bpm = 60.0
_install_simulated_bpm(monkeypatch, bpm)
expected = 60.0 / bpm
stamps = _beat_timestamps(seconds=5.5)
assert len(stamps) >= 4, f"expected several beats, got {len(stamps)}"
for gap in _intervals(stamps):
assert abs(gap - expected) < 0.12, f"beat gap {gap:.3f}s expected ~{expected:.3f}s"
set_shared_beat_detector(None)
def test_simulated_switch_consumes_on_upbeat_not_only_downbeat(monkeypatch):
"""With downbeat saved but audio off, switch must happen on the next beat (e.g. bar 3), not bar 1."""
_install_simulated_bpm(monkeypatch, 120.0, sequence_switch_wait="downbeat")
assert sp.effective_sequence_switch_wait() == "beat"
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
consumed_bar_beats: List[int] = []
async def fake_start(_z, _s, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
consumed_bar_beats.append(int(phase.get("bar_beat") or 0))
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
}
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._mark_simulated_beat_phase()
sp._mark_simulated_beat_phase()
assert sp._beat_phase_from_sources()["bar_beat"] == 2
wait_for = sp.effective_sequence_switch_wait()
assert wait_for == "beat"
sp._queue_pending_start("z1", "2", "1", None, wait_for, bpm=120.0)
assert sp.pending_play_status()["wait_for"] == "beat"
for _ in range(6):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
phase = sp._beat_phase_from_sources()
is_down = bool(phase.get("is_downbeat"))
await sp._try_consume_pending_play(is_downbeat=is_down)
return consumed_bar_beats
consumed = asyncio.run(run())
assert consumed == [3], f"expected switch on bar beat 3 (next beat), got {consumed}"
sp.stop()
set_shared_beat_detector(None)
def test_simulated_switch_waits_for_downbeat_only_when_pending_downbeat(monkeypatch):
"""Control: downbeat pending with live audio must skip upbeats."""
monkeypatch.setattr(
"settings.get_settings",
lambda: _FakeSettings(
audio_simulated_bpm=120,
sequence_switch_wait="downbeat",
),
)
det = AudioBeatDetector()
set_shared_beat_detector(det)
with det._lock:
det._running = True
det._status["running"] = True
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
consumed_bar_beats: List[int] = []
async def fake_start(_z, _s, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
consumed_bar_beats.append(int(phase.get("bar_beat") or 0))
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
sp._queue_pending_start("z1", "2", "1", None, "downbeat", bpm=120.0)
for _ in range(6):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
phase = sp._beat_phase_from_sources()
await sp._try_consume_pending_play(
is_downbeat=bool(phase.get("is_downbeat"))
)
return consumed_bar_beats
consumed = asyncio.run(run())
assert consumed == [1], f"downbeat pending should wait for bar 1, got {consumed}"
sp.stop()
with det._lock:
det._running = False
det._status["running"] = False
set_shared_beat_detector(None)
def test_pending_switch_freezes_current_sequence(monkeypatch):
"""While waiting for the next beat, the running sequence must not advance."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
sp._beat_consumer_started = False
sp._background_beat_task = None
sp.ensure_beat_consumer_started()
ctx = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
with sp._beat_run_lock:
sp._beat_run = ctx
async def fake_start(_z, _s, _p, _opts, **_kwargs):
return None
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
sp._queue_pending_start(
"z1", "2", "1", None, sp.effective_sequence_switch_wait(), bpm=120.0
)
assert ctx.get("_pending_switch") is True
await asyncio.sleep(2.5)
return int(ctx.get("lane_states", [{}])[0].get("beatCount", 0))
beat_count = asyncio.run(run())
assert beat_count == 0, f"sequence should freeze while pending, got {beat_count}"
sp.stop()
set_shared_beat_detector(None)
def test_pending_switch_drains_piled_beats_after_slow_start(monkeypatch):
"""Beats queued during a slow handoff must not advance the new sequence twice."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
async def slow_start(_z, _s, _p, _opts, **_kwargs):
sp.push_thread_beat()
sp.push_thread_beat()
await asyncio.sleep(0.05)
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
}
monkeypatch.setattr("util.sequence_playback._start_immediate", slow_start)
async def run():
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._queue_pending_start("z1", "2", "1", None, "beat", bpm=120.0)
assert await sp._try_consume_pending_play(is_downbeat=False) is True
piled = 0
while True:
try:
sp._thread_beat_queue.get_nowait()
piled += 1
except Exception:
break
assert piled == 0, f"piled beats should be drained, found {piled}"
await sp.process_active_beat_advance()
with sp._beat_run_lock:
ctx = sp._beat_run
assert ctx is not None
return int(ctx["lane_states"][0].get("beatCount", 0))
beat_count = asyncio.run(run())
assert beat_count == 1, f"expected single advance after switch, got {beat_count}"
sp.stop()
set_shared_beat_detector(None)
def test_handoff_rearm_blocks_immediate_double_advance(monkeypatch):
"""After a switch, piled beats must not advance the new sequence twice in a row."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
async def slow_start(_z, _s, _p, _opts, **kwargs):
sp.push_thread_beat()
await asyncio.sleep(0.02)
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
"_anchor_bar_on_pass_start": False,
}
monkeypatch.setattr("util.sequence_playback._start_immediate", slow_start)
monkeypatch.setattr("util.sequence_playback._restart_background_beat_clock", lambda: None)
async def run():
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "1",
}
sp._queue_pending_start("z1", "2", "1", None, "beat", bpm=120.0)
sp._accept_thread_beat_now()
assert await sp._try_consume_pending_play(is_downbeat=False) is True
assert sp._accept_thread_beat_now() is False
await sp.process_active_beat_advance()
sp.push_thread_beat()
assert sp._accept_thread_beat_now() is False
with sp._beat_run_lock:
ctx = sp._beat_run
return int(ctx["lane_states"][0].get("beatCount", 0))
beat_count = asyncio.run(run())
assert beat_count == 1, f"handoff should advance once, got {beat_count}"
sp.stop()
set_shared_beat_detector(None)
def test_mid_bar_handoff_keeps_bar_phase(monkeypatch):
"""Switching on an upbeat must not snap the bar readout back to 1/4."""
_install_simulated_bpm(monkeypatch, 120.0)
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 3
sp._last_thread_beat_phase = {"bar_beat": 3, "is_downbeat": False}
async def fake_start(_z, _s, _p, _opts, **kwargs):
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 99}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": True,
"sequence_loop_beat": 0,
"sequence_id": "2",
"_anchor_bar_on_pass_start": kwargs.get("handoff_is_downbeat", False),
}
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
monkeypatch.setattr("util.sequence_playback._restart_background_beat_clock", lambda: None)
async def run():
sp._queue_pending_start("z1", "2", "1", None, "beat", bpm=120.0)
assert await sp._try_consume_pending_play(is_downbeat=False) is True
await sp.process_active_beat_advance()
return sp._sim_beat_counter, sp._last_thread_beat_phase["bar_beat"]
counter, bar_beat = asyncio.run(run())
assert counter == 3, f"mid-bar handoff should keep sim counter, got {counter}"
assert bar_beat == 3, f"mid-bar handoff should keep bar beat, got {bar_beat}"
sp.stop()
set_shared_beat_detector(None)
def test_idle_start_is_immediate_not_pending(monkeypatch):
"""First sequence with nothing playing should not wait for the next beat."""
_install_simulated_bpm(monkeypatch, 60.0)
sp.stop()
sp.clear_pending_play()
class FakeSeq:
def read(self, _sid):
return {"profile_id": "1", "lanes": [[{"preset_id": "1", "beats": 1}]]}
monkeypatch.setitem(sys.modules, "models.sequence", type(sys)("models.sequence"))
sys.modules["models.sequence"].Sequence = FakeSeq # type: ignore[attr-defined]
started = []
async def fake_start(z, s, p, opts):
started.append((z, s, p))
monkeypatch.setattr("util.sequence_playback._start_immediate", fake_start)
async def run():
t0 = time.monotonic()
await sp.start("z1", "1", "1", None)
return time.monotonic() - t0
elapsed = asyncio.run(run())
assert sp.pending_play_status()["pending"] is False
assert started == [("z1", "1", "1")]
assert elapsed < 0.05, f"idle start should be immediate, took {elapsed:.3f}s"
sp.stop()
set_shared_beat_detector(None)
def test_active_switch_still_queues_pending(monkeypatch):
_install_simulated_bpm(monkeypatch, 60.0, sequence_switch_wait="downbeat")
sp.stop()
with sp._beat_run_lock:
sp._beat_run = {
"lanes": [[{"preset_id": "1", "beats": 1}]],
"lane_states": [{"stepIdx": 0, "beatCount": 0, "done": False}],
"num_lanes": 1,
"loop": False,
"sequence_id": "1",
}
class FakeSeq:
def read(self, _sid):
return {"profile_id": "1", "lanes": [[{"preset_id": "1", "beats": 1}]]}
monkeypatch.setitem(sys.modules, "models.sequence", type(sys)("models.sequence"))
sys.modules["models.sequence"].Sequence = FakeSeq # type: ignore[attr-defined]
monkeypatch.setattr("util.sequence_playback._start_immediate", lambda *a, **k: None)
async def run():
await sp.start("z1", "2", "1", None)
asyncio.run(run())
st = sp.pending_play_status()
assert st["pending"] is True
assert st["sequence_id"] == "2"
assert st["wait_for"] == "beat", f"simulated switch must queue beat, got {st['wait_for']!r}"
sp.stop()
set_shared_beat_detector(None)
def test_pending_switch_uses_beat_after_audio_stops(monkeypatch):
"""Queued while live audio was timing (downbeat) must switch on beat once sim clocks."""
monkeypatch.setattr(
"settings.get_settings",
lambda: _FakeSettings(
audio_simulated_bpm=120,
sequence_switch_wait="downbeat",
),
)
det = AudioBeatDetector()
set_shared_beat_detector(det)
sp.stop()
sp.clear_pending_play()
sp._sim_beat_counter = 0
sp._last_thread_beat_phase = {"bar_beat": 1, "is_downbeat": True}
consumed_bar_beats: List[int] = []
async def fake_start(_z, _s, _p, _opts, **_kwargs):
phase = sp._beat_phase_from_sources()
consumed_bar_beats.append(int(phase.get("bar_beat") or 0))
monkeypatch.setattr(sp, "_start_immediate", fake_start)
async def run():
with det._lock:
det._running = True
det._status["running"] = True
det._holdover_active = True
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: True
)
sp._queue_pending_start("z1", "2", "1", None, "downbeat", bpm=120.0)
assert sp.pending_play_status()["wait_for"] == "downbeat"
with det._lock:
det._running = False
det._status["running"] = False
det._holdover_active = False
monkeypatch.setattr(
"util.audio_detector.shared_beat_detector_timing_sequences", lambda: False
)
sp._mark_simulated_beat_phase()
sp._mark_simulated_beat_phase()
for _ in range(4):
if not sp.pending_play_status()["pending"]:
break
sp._mark_simulated_beat_phase()
phase = sp._beat_phase_from_sources()
await sp._try_consume_pending_play(
is_downbeat=bool(phase.get("is_downbeat"))
)
return consumed_bar_beats
consumed = asyncio.run(run())
assert consumed == [3], (
f"after audio stop, pending should consume on next beat (bar 3), got {consumed}"
)
sp.stop()
set_shared_beat_detector(None)
def test_audio_status_reports_beat_switch_when_simulated(server):
"""API: audio off + saved downbeat still exposes beat-only switch wait."""
c = server["client"]
c.put("/settings", json={"sequence_switch_wait": "downbeat"})
c.post("/api/audio/stop")
status = c.get("/api/audio/status").json()["status"]
assert status.get("bpm_simulated") is True
assert status.get("sequence_switch_wait") == "beat"
assert status.get("sequence_switch_wait_saved") == "downbeat"

View File

@@ -1,342 +1,35 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Local development server: FastAPI app on port 5000."""
Local development web server - imports and runs src.main with port 5000
""" from __future__ import annotations
import sys
import os import os
import asyncio import sys
import signal
# Add project root, src, and lib to path PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC_PATH = os.path.join(PROJECT_ROOT, "src")
src_path = os.path.join(project_root, 'src')
lib_path = os.path.join(project_root, 'lib')
# Add to path in the right order - src must be first so 'models' and 'controllers' can be imported sys.path.insert(0, SRC_PATH)
# This ensures imports like 'from models.preset import Preset' work os.chdir(SRC_PATH)
sys.path.insert(0, src_path)
sys.path.insert(0, lib_path)
sys.path.insert(0, project_root)
# Mock MicroPython modules before importing main
class MockMachine:
class WDT:
def __init__(self, timeout):
pass
def feed(self):
pass
class MockESPNow:
def __init__(self):
self.active_value = False
self.peers = []
self.websocket_client = None # Store single WebSocket connection
def active(self, value):
self.active_value = value
print(f"[MOCK] ESPNow active: {value}")
def add_peer(self, peer):
self.peers.append(peer)
if hasattr(peer, 'hex'):
print(f"[MOCK] Added peer: {peer.hex()}")
else:
print(f"[MOCK] Added peer: {peer}")
def register_websocket(self, ws):
"""Register a WebSocket connection to forward ESPNow data to."""
self.websocket_client = ws
print(f"[MOCK] Registered WebSocket client")
def unregister_websocket(self, ws):
"""Unregister a WebSocket connection."""
if self.websocket_client == ws:
self.websocket_client = None
print(f"[MOCK] Unregistered WebSocket client")
async def asend(self, peer, data):
if hasattr(peer, 'hex'):
print(f"[MOCK] Would send to {peer.hex()}: {data}")
else:
print(f"[MOCK] Would send to {peer}: {data}")
# Forward data to the connected WebSocket client
if self.websocket_client:
try:
await self.websocket_client.send(data)
print(f"[MOCK] Forwarded to WebSocket client")
except Exception as e:
print(f"[MOCK] WebSocket client disconnected: {e}")
self.websocket_client = None
class MockAIOESPNow:
def __init__(self):
self.espnow = MockESPNow()
def active(self, value):
self.espnow.active(value)
return self.espnow
def add_peer(self, peer):
self.espnow.add_peer(peer)
async def asend(self, peer, data):
await self.espnow.asend(peer, data)
# Store reference to mock instance for WebSocket registration
@property
def mock_instance(self):
return self.espnow
# Create mock ESPNow instance and store reference for WebSocket registration
mock_espnow_instance = MockESPNow()
mock_aioespnow = MockAIOESPNow()
mock_aioespnow.espnow = mock_espnow_instance # Use the shared instance
# Create mock ESPNow instance and store reference for WebSocket registration
mock_espnow_instance = MockESPNow()
mock_aioespnow = MockAIOESPNow()
mock_aioespnow.espnow = mock_espnow_instance # Use the shared instance
# Install mocks in sys.modules before any imports
sys.modules['machine'] = MockMachine()
# Store the mock instance in the module so it can be accessed
aioespnow_module = type('module', (), {'AIOESPNow': MockAIOESPNow, '_mock_instance': mock_espnow_instance})()
sys.modules['aioespnow'] = aioespnow_module
class MockWLAN:
def __init__(self, interface):
self.interface = interface
def active(self, value):
print(f"[MOCK] WLAN({self.interface}) active: {value}")
sys.modules['network'] = type('module', (), {
'WLAN': MockWLAN,
'STA_IF': 0
})()
# Mock asyncio.sleep_ms for regular Python
_original_sleep = asyncio.sleep
async def sleep_ms(ms):
await _original_sleep(ms / 1000.0)
# Patch asyncio.sleep_ms
asyncio.sleep_ms = sleep_ms
# Patch sys.print_exception for regular Python (MicroPython has this, regular Python doesn't)
if not hasattr(sys, 'print_exception'):
import traceback
sys.print_exception = lambda e, file=None: traceback.print_exception(type(e), e, e.__traceback__, file=file)
# Patch builtins.open to redirect /db/ paths to project db directory
import builtins
_original_open = builtins.open
def patched_open(file, mode='r', *args, **kwargs):
if isinstance(file, str):
if file.startswith('/db/'):
# Redirect to project db directory
filename = os.path.basename(file)
file = os.path.join(project_root, 'db', filename)
elif not os.path.isabs(file):
# For relative paths starting with templates/ or static/,
# always resolve to src/ directory
if file.startswith('templates/') or file.startswith('static/'):
file = os.path.join(src_path, file)
# For other relative paths, check if they exist in current dir
# If not, try src/ directory
elif not os.path.exists(file):
src_file = os.path.join(src_path, file)
if os.path.exists(src_file):
file = src_file
return _original_open(file, mode, *args, **kwargs)
builtins.open = patched_open
# Also patch os.mkdir to handle /db path
original_mkdir = os.mkdir
def patched_mkdir(path):
if path == "/db":
# Use project db directory instead
db_path = os.path.join(project_root, "db")
if not os.path.exists(db_path):
os.makedirs(db_path, exist_ok=True)
else:
original_mkdir(path)
os.mkdir = patched_mkdir
# Create a flag to stop the infinite loop
_stop_flag = False
# Patch gc.collect to check stop flag
import gc as gc_module
_original_collect = gc_module.collect
def collect():
global _stop_flag
if _stop_flag:
raise KeyboardInterrupt("Stop requested")
return _original_collect()
gc_module.collect = collect
# Change to src directory for file paths (where templates and static are)
# main.py expects templates/ and static/ to be relative to the working directory
os.chdir(src_path)
# Override settings path for local development
# Import settings module and patch the path before main imports it
import importlib.util import importlib.util
spec = importlib.util.spec_from_file_location("settings", os.path.join(src_path, "settings.py"))
spec = importlib.util.spec_from_file_location(
"settings", os.path.join(SRC_PATH, "settings.py")
)
settings_module = importlib.util.module_from_spec(spec) settings_module = importlib.util.module_from_spec(spec)
sys.modules['settings'] = settings_module sys.modules["settings"] = settings_module
spec.loader.exec_module(settings_module) spec.loader.exec_module(settings_module)
settings_module.Settings.SETTINGS_FILE = os.path.join(project_root, 'settings.json') settings_module.Settings.SETTINGS_FILE = os.path.join(PROJECT_ROOT, "settings.json")
# Patch the Model class file path before importing
# We need to monkey-patch the model.py file's behavior
import importlib.util
model_spec = importlib.util.spec_from_file_location("models.model", os.path.join(src_path, "models", "model.py"))
model_module = importlib.util.module_from_spec(model_spec)
# Patch os.mkdir in the model module's context
original_mkdir = os.mkdir
def patched_mkdir(path):
if path == "/db":
db_path = os.path.join(project_root, "db")
if not os.path.exists(db_path):
os.makedirs(db_path, exist_ok=True)
else:
original_mkdir(path)
# Set up the module's namespace with patched os
model_module.__dict__['os'] = type('os', (), {'mkdir': patched_mkdir, 'path': os.path})()
model_spec.loader.exec_module(model_module)
sys.modules['models.model'] = model_module
# Now patch the Model class to fix file paths
# The issue is that Model.__init__ sets self.file and immediately calls load()
# before we can patch it. We need to replace __init__ completely.
# Also clear any existing singleton instances
Model = model_module.Model
# Clear singleton instances for all Model subclasses
for attr_name in dir(model_module):
attr = getattr(model_module, attr_name)
if isinstance(attr, type) and issubclass(attr, Model) and attr != Model:
if hasattr(attr, '_instance'):
delattr(attr, '_instance')
original_save = Model.save
original_load = Model.load
original_set_defaults = Model.set_defaults
def patched_init(self):
# Only initialize once (check if already initialized)
if hasattr(self, '_initialized'):
return
# Create db directory if it doesn't exist (use project db, not /db)
db_path = os.path.join(project_root, "db")
if not os.path.exists(db_path):
os.makedirs(db_path, exist_ok=True)
self.class_name = self.__class__.__name__
# Set file path to project db directory from the start
self.file = os.path.join(project_root, 'db', f"{self.class_name.lower()}.json")
super(Model, self).__init__()
# Now call load with the correct path already set
# Call the patched load method (defined below)
Model.load(self)
self._initialized = True
def patched_save(self):
# Ensure file path is correct before saving (this will also fix print statements)
if hasattr(self, 'file') and self.file.startswith('/db/'):
filename = os.path.basename(self.file)
self.file = os.path.join(project_root, 'db', filename)
# Also ensure the directory exists
db_dir = os.path.dirname(self.file)
if not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
return original_save(self)
def patched_load(self):
# Ensure file path is correct before loading
if hasattr(self, 'file') and self.file.startswith('/db/'):
filename = os.path.basename(self.file)
self.file = os.path.join(project_root, 'db', filename)
try:
with open(self.file, 'r') as file:
import json
loaded_settings = json.load(file)
# Use dict.update() directly, not the subclass's update() method
dict.update(self, loaded_settings)
print(f"{self.class_name} loaded successfully.")
except FileNotFoundError:
# File doesn't exist yet - this is normal on first run
print(f"No existing {self.class_name} file found, creating defaults.")
self.set_defaults()
self.save()
except Exception as e:
# Other errors - log and create defaults
print(f"Error loading {self.class_name}: {type(e).__name__}: {e}")
self.set_defaults()
self.save()
# Apply patches - load must be patched before init uses it
Model.load = patched_load
Model.__init__ = patched_init
Model.save = patched_save
# Patch with_websocket decorator before importing main to register WebSocket connections
from microdot.websocket import with_websocket as original_with_websocket
def patched_with_websocket(f):
"""Patched with_websocket decorator that registers connections with mock ESPNow."""
@original_with_websocket
async def wrapped_handler(request, ws):
# Register WebSocket connection with mock ESPNow
mock_espnow_instance.register_websocket(ws)
try:
# Call original handler
await f(request, ws)
finally:
# Unregister when connection closes
mock_espnow_instance.unregister_websocket(ws)
return wrapped_handler
# Now import main (which will use the patched settings module and model)
# Import as a module file directly to avoid package import issues
main_spec = importlib.util.spec_from_file_location("main", os.path.join(src_path, "main.py"))
main_module = importlib.util.module_from_spec(main_spec)
# Patch with_websocket in the main module before executing it
main_module.__dict__['with_websocket'] = patched_with_websocket
main_spec.loader.exec_module(main_module)
main = main_module.main
def signal_handler(sig, frame):
"""Handle Ctrl+C gracefully."""
global _stop_flag
print("\nShutting down server...")
_stop_flag = True
# Force exit since main has an infinite loop
sys.exit(0)
async def run_web():
"""Run main with port 5000."""
print("Starting LED Controller Web Server (Local Development)")
print("=" * 60)
print(f"Server will run on http://localhost:5000")
print("Press Ctrl+C to stop")
print("=" * 60)
# Set up signal handler
signal.signal(signal.SIGINT, signal_handler)
try:
# Call main with port 5000
await main(port=5000)
except KeyboardInterrupt:
print("\nShutting down server...")
except Exception as e:
print(f"Error: {e}")
raise
if __name__ == "__main__": if __name__ == "__main__":
try: import uvicorn
asyncio.run(run_web())
except KeyboardInterrupt: from fastapi_app import app
print("\nExiting...")
except SystemExit: print("Starting LED Controller Web Server (Local Development)")
pass print("=" * 60)
print("Server will run on http://localhost:5000")
print("Press Ctrl+C to stop")
print("=" * 60)
uvicorn.run(app, host="0.0.0.0", port=5000)