chore(release): beta-1.03
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,10 +2,14 @@ from microdot import Microdot
|
||||
from models.device import (
|
||||
Device,
|
||||
derive_device_mac,
|
||||
normalize_mac,
|
||||
validate_device_transport,
|
||||
validate_device_type,
|
||||
)
|
||||
from models.group import Group
|
||||
from models.transport import get_current_sender
|
||||
from settings import Settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
from models.wifi_ws_clients import (
|
||||
normalize_tcp_peer_ip,
|
||||
send_json_line_to_ip,
|
||||
@@ -52,8 +56,28 @@ def _compact_v1_json(*, presets=None, select=None, save=False):
|
||||
# Seconds after identify blink before selecting built-in ``off`` (tests may monkeypatch).
|
||||
IDENTIFY_OFF_DELAY_S = 2.0
|
||||
|
||||
|
||||
def _validate_output_brightness(value):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
b = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("output_brightness must be an integer 0–255")
|
||||
if b < 0 or b > 255:
|
||||
raise ValueError("output_brightness must be between 0 and 255")
|
||||
return b
|
||||
|
||||
|
||||
def _brightness_save_message_json(b_val: int) -> str:
|
||||
b_val = max(0, min(255, int(b_val)))
|
||||
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
||||
|
||||
|
||||
controller = Microdot()
|
||||
devices = Device()
|
||||
_group_registry = Group()
|
||||
_pi_settings = Settings()
|
||||
|
||||
|
||||
def _device_live_connected(dev_dict):
|
||||
@@ -154,6 +178,42 @@ async def list_devices(request):
|
||||
return json.dumps(devices_data), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.post("/resolve-brightness")
|
||||
async def resolve_brightness_batch(request):
|
||||
"""
|
||||
POST JSON ``{ \"macs\": [\"..\"], \"zone_brightness\": optional 0–255 }``.
|
||||
Returns ``{ \"values\": { mac: combined_int } }`` — global × group(s) × device × zone (optional).
|
||||
"""
|
||||
try:
|
||||
data = request.json or {}
|
||||
except Exception:
|
||||
data = {}
|
||||
macs = data.get("macs")
|
||||
if not isinstance(macs, list):
|
||||
return json.dumps({"error": "macs must be an array"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
zb = None
|
||||
if isinstance(data, dict) and data.get("zone_brightness") is not None:
|
||||
try:
|
||||
zb = _validate_output_brightness(data.get("zone_brightness"))
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
values = {}
|
||||
for raw in macs:
|
||||
m = normalize_mac(str(raw))
|
||||
if not m:
|
||||
continue
|
||||
values[m] = effective_brightness_for_mac(
|
||||
_pi_settings,
|
||||
_group_registry,
|
||||
devices,
|
||||
m,
|
||||
zone_brightness=zb,
|
||||
)
|
||||
return json.dumps({"values": values}), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
async def get_device(request, id):
|
||||
"""Get a device by ID (includes ``connected`` for live Wi-Fi WebSocket presence)."""
|
||||
@@ -239,7 +299,17 @@ async def update_device(request, id):
|
||||
data["transport"] = validate_device_transport(data.get("transport"))
|
||||
if "zones" in data and isinstance(data["zones"], list):
|
||||
data["zones"] = [str(t) for t in data["zones"]]
|
||||
if "output_brightness" in data:
|
||||
data["output_brightness"] = _validate_output_brightness(data.get("output_brightness"))
|
||||
prev_doc = devices.read(id)
|
||||
if devices.update(id, data):
|
||||
if prev_doc and "name" in data:
|
||||
on = str(prev_doc.get("name") or "").strip()
|
||||
nn = str(data.get("name") or "").strip()
|
||||
if on and nn and on != nn:
|
||||
from util.beat_driver_route import remap_beat_route_device_name
|
||||
|
||||
remap_beat_route_device_name(on, nn)
|
||||
return json.dumps(devices.read(id)), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
@@ -320,6 +390,120 @@ async def identify_device(request, id):
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/brightness")
|
||||
async def push_device_output_brightness(request, id):
|
||||
"""
|
||||
Push combined brightness to the driver: global × group(s) × device × optional ``zone_brightness``
|
||||
in JSON body — single ``b`` (``v``/``b``/``save``). Wi‑Fi or ESP‑NOW.
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = request.json or {}
|
||||
zb = None
|
||||
if isinstance(body, dict) and body.get("zone_brightness") is not None:
|
||||
try:
|
||||
zb = _validate_output_brightness(body.get("zone_brightness"))
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
b_val = effective_brightness_for_mac(
|
||||
_pi_settings,
|
||||
_group_registry,
|
||||
devices,
|
||||
id,
|
||||
zone_brightness=zb,
|
||||
)
|
||||
|
||||
msg = _brightness_save_message_json(b_val)
|
||||
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||
|
||||
if transport == "wifi":
|
||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||
if not ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
ok = await send_json_line_to_ip(ip, msg)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
else:
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
return json.dumps({"error": "Transport not configured"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
await sender.send(msg, addr=id)
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 503, {"Content-Type": "application/json"}
|
||||
|
||||
return json.dumps({"message": "brightness sent", "brightness": b_val}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/driver-config")
|
||||
async def push_driver_config(request, id):
|
||||
"""
|
||||
Push ``device_config`` to a Wi‑Fi LED driver over WebSocket.
|
||||
Body JSON: optional ``name``, ``num_leds``, ``color_order``, ``startup_mode`` (default|last|off).
|
||||
"""
|
||||
dev = devices.read(id)
|
||||
if not dev:
|
||||
return json.dumps({"error": "Device not found"}), 404, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
return json.dumps({"error": "driver-config is only for Wi-Fi devices"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
wifi_ip = str(dev.get("address") or "").strip()
|
||||
if not wifi_ip:
|
||||
return json.dumps({"error": "Device has no IP address"}), 400, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = request.json or {}
|
||||
dc = {}
|
||||
if isinstance(body.get("name"), str) and body["name"].strip():
|
||||
dc["name"] = body["name"].strip()
|
||||
if "num_leds" in body:
|
||||
try:
|
||||
n = int(body["num_leds"])
|
||||
if 1 <= n <= 2048:
|
||||
dc["num_leds"] = n
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if isinstance(body.get("color_order"), str):
|
||||
co = body["color_order"].strip().lower()
|
||||
if co in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
|
||||
dc["color_order"] = co
|
||||
if isinstance(body.get("startup_mode"), str):
|
||||
sm = body["startup_mode"].strip().lower()
|
||||
if sm in ("default", "last", "off"):
|
||||
dc["startup_mode"] = sm
|
||||
if not dc:
|
||||
return json.dumps(
|
||||
{
|
||||
"error": "Provide at least one of name, num_leds, color_order, startup_mode"
|
||||
}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
msg = json.dumps(
|
||||
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
||||
)
|
||||
ok = await send_json_line_to_ip(wifi_ip, msg)
|
||||
if not ok:
|
||||
return json.dumps({"error": "Wi-Fi driver not connected"}), 503, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
return json.dumps({"message": "driver-config sent"}), 200, {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@controller.post("/<id>/patterns/push")
|
||||
async def push_patterns_ota(request, id):
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
from microdot import Microdot
|
||||
from models.group import Group
|
||||
from models.device import Device
|
||||
from models.transport import get_current_sender
|
||||
from models.wifi_ws_clients import normalize_tcp_peer_ip, send_json_line_to_ip
|
||||
from settings import Settings
|
||||
from util.brightness_combine import effective_brightness_for_mac
|
||||
import json
|
||||
|
||||
controller = Microdot()
|
||||
groups = Group()
|
||||
devices = Device()
|
||||
_pi_settings = Settings()
|
||||
|
||||
@controller.get('')
|
||||
async def list_groups(request):
|
||||
@@ -48,3 +55,150 @@ async def delete_group(request, id):
|
||||
if groups.delete(id):
|
||||
return json.dumps({"message": "Group deleted successfully"}), 200
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
|
||||
def _group_driver_config_payload(doc):
|
||||
"""Build ``device_config`` dict from stored group Wi‑Fi defaults (non-empty only)."""
|
||||
dc = {}
|
||||
if not isinstance(doc, dict):
|
||||
return dc
|
||||
nm = doc.get("wifi_driver_display_name")
|
||||
if isinstance(nm, str) and nm.strip():
|
||||
dc["name"] = nm.strip()
|
||||
nled = doc.get("wifi_driver_num_leds")
|
||||
if nled is not None:
|
||||
try:
|
||||
n = int(nled)
|
||||
if 1 <= n <= 2048:
|
||||
dc["num_leds"] = n
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
co = doc.get("wifi_color_order")
|
||||
if isinstance(co, str):
|
||||
c = co.strip().lower()
|
||||
if c in ("rgb", "rbg", "grb", "gbr", "brg", "bgr"):
|
||||
dc["color_order"] = c
|
||||
sm = doc.get("wifi_startup_mode")
|
||||
if isinstance(sm, str):
|
||||
s = sm.strip().lower()
|
||||
if s in ("default", "last", "off"):
|
||||
dc["startup_mode"] = s
|
||||
return dc
|
||||
|
||||
|
||||
@controller.post('/<id>/driver-config')
|
||||
async def push_group_driver_config(request, id):
|
||||
"""
|
||||
Push group Wi‑Fi defaults to every Wi‑Fi device listed in the group (TCP WebSocket).
|
||||
Uses stored ``wifi_*`` fields on the group; optional JSON body may override for this send only.
|
||||
"""
|
||||
gdoc = groups.read(id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
body = request.json or {}
|
||||
merged = dict(gdoc)
|
||||
if isinstance(body, dict):
|
||||
for k in (
|
||||
"wifi_driver_display_name",
|
||||
"wifi_driver_num_leds",
|
||||
"wifi_color_order",
|
||||
"wifi_startup_mode",
|
||||
):
|
||||
if k in body:
|
||||
merged[k] = body[k]
|
||||
dc = _group_driver_config_payload(merged)
|
||||
if not dc:
|
||||
return json.dumps(
|
||||
{"error": "No driver defaults on this group (set display name, LEDs, colour order, or power-on pattern)"}
|
||||
), 400, {"Content-Type": "application/json"}
|
||||
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
sent = 0
|
||||
errors = []
|
||||
for mac in mac_list:
|
||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(m) != 12:
|
||||
continue
|
||||
dev = devices.read(m)
|
||||
if not dev:
|
||||
errors.append({"mac": m, "error": "not in registry"})
|
||||
continue
|
||||
if (dev.get("transport") or "").lower() != "wifi":
|
||||
continue
|
||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||
if not ip:
|
||||
errors.append({"mac": m, "error": "no IP"})
|
||||
continue
|
||||
msg = json.dumps(
|
||||
{"v": "1", "device_config": dc, "save": True}, separators=(",", ":")
|
||||
)
|
||||
ok = await send_json_line_to_ip(ip, msg)
|
||||
if ok:
|
||||
sent += 1
|
||||
else:
|
||||
errors.append({"mac": m, "error": "driver not connected"})
|
||||
|
||||
return json.dumps(
|
||||
{"message": "driver-config sent", "sent": sent, "errors": errors}
|
||||
), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _brightness_save_message_json(b_val: int) -> str:
|
||||
b_val = max(0, min(255, int(b_val)))
|
||||
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
||||
|
||||
|
||||
@controller.post('/<id>/brightness')
|
||||
async def push_group_output_brightness(request, id):
|
||||
"""
|
||||
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
|
||||
"""
|
||||
gdoc = groups.read(id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
mac_list = gdoc.get("devices") if isinstance(gdoc.get("devices"), list) else []
|
||||
sent = 0
|
||||
errors = []
|
||||
for mac in mac_list:
|
||||
m = str(mac).strip().lower().replace(":", "").replace("-", "")
|
||||
if len(m) != 12:
|
||||
continue
|
||||
dev = devices.read(m)
|
||||
if not dev:
|
||||
errors.append({"mac": m, "error": "not in registry"})
|
||||
continue
|
||||
b_val = effective_brightness_for_mac(
|
||||
_pi_settings,
|
||||
groups,
|
||||
devices,
|
||||
m,
|
||||
zone_brightness=None,
|
||||
)
|
||||
msg = _brightness_save_message_json(b_val)
|
||||
transport = (dev.get("transport") or "espnow").strip().lower()
|
||||
if transport == "wifi":
|
||||
ip = normalize_tcp_peer_ip(str(dev.get("address") or ""))
|
||||
if not ip:
|
||||
errors.append({"mac": m, "error": "no IP"})
|
||||
continue
|
||||
ok = await send_json_line_to_ip(ip, msg)
|
||||
if ok:
|
||||
sent += 1
|
||||
else:
|
||||
errors.append({"mac": m, "error": "driver not connected"})
|
||||
else:
|
||||
sender = get_current_sender()
|
||||
if not sender:
|
||||
errors.append({"mac": m, "error": "transport not configured"})
|
||||
continue
|
||||
try:
|
||||
await sender.send(msg, addr=m)
|
||||
sent += 1
|
||||
except Exception as e:
|
||||
errors.append({"mac": m, "error": str(e)})
|
||||
|
||||
return json.dumps(
|
||||
{"message": "brightness sent", "sent": sent, "errors": errors}
|
||||
), 200, {"Content-Type": "application/json"}
|
||||
|
||||
@@ -318,7 +318,7 @@ async def push_driver_messages(request, session):
|
||||
try:
|
||||
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
||||
|
||||
sync_beat_route_from_push_sequence(seq)
|
||||
sync_beat_route_from_push_sequence(seq, target_macs=target_list)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ async def create_zone(request, session):
|
||||
ids_str = request.form.get("ids", "1").strip()
|
||||
names = [i.strip() for i in ids_str.split(",") if i.strip()]
|
||||
preset_ids = None
|
||||
group_ids = []
|
||||
else:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
@@ -297,11 +298,18 @@ async def create_zone(request, session):
|
||||
if names is None:
|
||||
names = data.get("ids")
|
||||
preset_ids = data.get("presets", None)
|
||||
group_ids = data.get("group_ids")
|
||||
if group_ids is None:
|
||||
group_ids = []
|
||||
if isinstance(group_ids, list):
|
||||
group_ids = [str(x) for x in group_ids if x is not None]
|
||||
else:
|
||||
group_ids = []
|
||||
|
||||
if not name:
|
||||
return json.dumps({"error": "Zone name cannot be empty"}), 400
|
||||
|
||||
zid = zones.create(name, names, preset_ids)
|
||||
zid = zones.create(name, names, preset_ids, group_ids)
|
||||
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
@@ -333,7 +341,12 @@ async def clone_zone(request, session, id):
|
||||
data = request.json or {}
|
||||
source_name = source.get("name") or f"Zone {id}"
|
||||
new_name = data.get("name") or f"{source_name} Copy"
|
||||
clone_id = zones.create(new_name, source.get("names"), source.get("presets"))
|
||||
clone_id = zones.create(
|
||||
new_name,
|
||||
source.get("names"),
|
||||
source.get("presets"),
|
||||
source.get("group_ids"),
|
||||
)
|
||||
extra = {k: v for k, v in source.items() if k not in ("name", "names", "presets")}
|
||||
if extra:
|
||||
zones.update(clone_id, extra)
|
||||
|
||||
Reference in New Issue
Block a user