feat(zones): profile-scoped groups, zone modes, sequence brightness
- Optional profile_id on groups; UI and API for shared vs profile-only groups\n- Zone content_kind (presets vs sequences); edit modal shows matching sections; devices via groups only\n- Server sequence playback folds zone brightness into preset wire b (per MAC where needed)\n- Related preset/sequence/audio/beat-route and client updates Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
import asyncio
|
||||
from models.group import Group
|
||||
from models.device import Device
|
||||
@@ -13,46 +14,127 @@ groups = Group()
|
||||
devices = Device()
|
||||
_pi_settings = Settings()
|
||||
|
||||
@controller.get('')
|
||||
async def list_groups(request):
|
||||
"""List all groups."""
|
||||
return json.dumps(groups), 200, {'Content-Type': 'application/json'}
|
||||
|
||||
@controller.get('/<id>')
|
||||
async def get_group(request, id):
|
||||
"""Get a specific group by ID."""
|
||||
def _group_doc_visible_for_profile(doc, profile_id):
|
||||
if not isinstance(doc, dict):
|
||||
return False
|
||||
scoped = doc.get("profile_id")
|
||||
if scoped is None:
|
||||
scoped = doc.get("profileId")
|
||||
if scoped is None or str(scoped).strip() == "":
|
||||
return True
|
||||
if not profile_id:
|
||||
return False
|
||||
return str(scoped).strip() == str(profile_id).strip()
|
||||
|
||||
|
||||
def _filtered_groups_dict(session):
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
pid = get_current_profile_id(session)
|
||||
out = {}
|
||||
for gid, doc in groups.items():
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if _group_doc_visible_for_profile(doc, pid):
|
||||
out[str(gid)] = doc
|
||||
return out
|
||||
|
||||
|
||||
@controller.get("")
|
||||
@with_session
|
||||
async def list_groups(request, session):
|
||||
"""List groups visible for the current profile (shared + profile-scoped)."""
|
||||
return json.dumps(_filtered_groups_dict(session)), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@controller.get("/<id>")
|
||||
@with_session
|
||||
async def get_group(request, session, id):
|
||||
"""Get a specific group by ID (404 if scoped to another profile)."""
|
||||
group = groups.read(id)
|
||||
if group:
|
||||
return json.dumps(group), 200, {'Content-Type': 'application/json'}
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
if not group or not isinstance(group, dict):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
@controller.post('')
|
||||
async def create_group(request):
|
||||
"""Create a new group."""
|
||||
if not _group_doc_visible_for_profile(group, get_current_profile_id(session)):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
return json.dumps(group), 200, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _sanitize_group_profile_id_write(data, session):
|
||||
"""Allow ``profile_id`` only for the active profile, or null to share across profiles."""
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
cur = get_current_profile_id(session)
|
||||
if "profile_id" not in data and "profileId" not in data:
|
||||
return
|
||||
raw = data.get("profile_id")
|
||||
if raw is None and "profileId" in data:
|
||||
raw = data.get("profileId")
|
||||
if raw is None or raw == "":
|
||||
data.pop("profileId", None)
|
||||
data["profile_id"] = None
|
||||
return
|
||||
if not cur or str(raw).strip() != str(cur).strip():
|
||||
data.pop("profileId", None)
|
||||
data.pop("profile_id", None)
|
||||
|
||||
|
||||
@controller.post("")
|
||||
@with_session
|
||||
async def create_group(request, session):
|
||||
"""Create a new group (omit ``profile_id`` for shared; or ``profile_scoped``: true for this profile only)."""
|
||||
try:
|
||||
data = request.json or {}
|
||||
data = dict(request.json or {})
|
||||
name = data.get("name", "")
|
||||
profile_scoped = bool(data.pop("profile_scoped", False))
|
||||
_sanitize_group_profile_id_write(data, session)
|
||||
group_id = groups.create(name)
|
||||
if data:
|
||||
groups.update(group_id, data)
|
||||
return json.dumps(groups.read(group_id)), 201, {'Content-Type': 'application/json'}
|
||||
if profile_scoped:
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
cur = get_current_profile_id(session)
|
||||
if cur:
|
||||
groups.update(group_id, {"profile_id": str(cur)})
|
||||
return json.dumps(groups.read(group_id)), 201, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.put('/<id>')
|
||||
async def update_group(request, id):
|
||||
|
||||
@controller.put("/<id>")
|
||||
@with_session
|
||||
async def update_group(request, session, id):
|
||||
"""Update an existing group."""
|
||||
try:
|
||||
data = request.json
|
||||
if not isinstance(data, dict):
|
||||
return json.dumps({"error": "Invalid JSON"}), 400, {"Content-Type": "application/json"}
|
||||
data = dict(data)
|
||||
_sanitize_group_profile_id_write(data, session)
|
||||
if groups.update(id, data):
|
||||
return json.dumps(groups.read(id)), 200, {'Content-Type': 'application/json'}
|
||||
g = groups.read(id)
|
||||
if g:
|
||||
return json.dumps(g), 200, {"Content-Type": "application/json"}
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)}), 400
|
||||
|
||||
@controller.delete('/<id>')
|
||||
async def delete_group(request, id):
|
||||
"""Delete a group."""
|
||||
@controller.delete("/<id>")
|
||||
@with_session
|
||||
async def delete_group(request, session, id):
|
||||
"""Delete a group (not allowed for another profile's scoped group)."""
|
||||
g = groups.read(id)
|
||||
if not g or not isinstance(g, dict):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
if groups.delete(id):
|
||||
return json.dumps({"message": "Group deleted successfully"}), 200
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
@@ -87,13 +169,25 @@ def _group_driver_config_payload(doc):
|
||||
return dc
|
||||
|
||||
|
||||
@controller.post('/<id>/driver-config')
|
||||
async def push_group_driver_config(request, id):
|
||||
def _read_group_for_session(session, id):
|
||||
g = groups.read(id)
|
||||
if not g or not isinstance(g, dict):
|
||||
return None
|
||||
from controllers.zone import get_current_profile_id
|
||||
|
||||
if not _group_doc_visible_for_profile(g, get_current_profile_id(session)):
|
||||
return None
|
||||
return g
|
||||
|
||||
|
||||
@controller.post("/<id>/driver-config")
|
||||
@with_session
|
||||
async def push_group_driver_config(request, session, 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)
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
@@ -158,12 +252,13 @@ def _brightness_save_message_json(b_val: int) -> str:
|
||||
return json.dumps({"v": "1", "b": b_val, "save": True}, separators=(",", ":"))
|
||||
|
||||
|
||||
@controller.post('/<id>/brightness')
|
||||
async def push_group_output_brightness(request, id):
|
||||
@controller.post("/<id>/brightness")
|
||||
@with_session
|
||||
async def push_group_output_brightness(request, session, id):
|
||||
"""
|
||||
Push combined brightness (global × group(s) × device) to each member — one ``b`` per device.
|
||||
"""
|
||||
gdoc = groups.read(id)
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404
|
||||
|
||||
@@ -225,13 +320,14 @@ async def push_group_output_brightness(request, id):
|
||||
|
||||
|
||||
@controller.post("/<id>/identify")
|
||||
async def identify_group_devices(request, id):
|
||||
@with_session
|
||||
async def identify_group_devices(request, session, id):
|
||||
"""
|
||||
Run the same identify blink as ``POST /devices/<id>/identify`` for every registry member
|
||||
in parallel so all drivers in the group blink together.
|
||||
"""
|
||||
_ = request
|
||||
gdoc = groups.read(id)
|
||||
gdoc = _read_group_for_session(session, id)
|
||||
if not gdoc:
|
||||
return json.dumps({"error": "Group not found"}), 404, {"Content-Type": "application/json"}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from microdot import Microdot
|
||||
from microdot.session import with_session
|
||||
from models.preset import Preset
|
||||
from models.profile import Profile
|
||||
from models.pallet import Palette
|
||||
from models.device import Device, normalize_mac
|
||||
from models.transport import get_current_sender
|
||||
from util.driver_delivery import deliver_json_messages, deliver_preset_broadcast_then_per_device
|
||||
@@ -12,6 +13,18 @@ controller = Microdot()
|
||||
presets = Preset()
|
||||
profiles = Profile()
|
||||
|
||||
|
||||
def _palette_colors_for_profile(profile_id):
|
||||
prof = profiles.read(str(profile_id))
|
||||
if not isinstance(prof, dict):
|
||||
return None
|
||||
pid = prof.get("palette_id") or prof.get("paletteId")
|
||||
if not pid:
|
||||
return None
|
||||
cols = Palette().read(str(pid))
|
||||
return cols if isinstance(cols, list) else None
|
||||
|
||||
|
||||
def get_current_profile_id(session=None):
|
||||
"""Get the current active profile ID from session or fallback to first."""
|
||||
profile_list = profiles.list()
|
||||
@@ -153,6 +166,7 @@ async def send_presets(request, session):
|
||||
|
||||
# Build API-compliant preset map keyed by preset ID, include name
|
||||
current_profile_id = get_current_profile_id(session)
|
||||
palette_colors = _palette_colors_for_profile(current_profile_id)
|
||||
presets_by_name = {}
|
||||
for pid in preset_ids:
|
||||
preset_data = presets.read(str(pid))
|
||||
@@ -161,7 +175,7 @@ async def send_presets(request, session):
|
||||
if str(preset_data.get("profile_id")) != str(current_profile_id):
|
||||
continue
|
||||
preset_key = str(pid)
|
||||
preset_payload = build_preset_dict(preset_data)
|
||||
preset_payload = build_preset_dict(preset_data, palette_colors)
|
||||
preset_payload["name"] = preset_data.get("name", "")
|
||||
presets_by_name[preset_key] = preset_payload
|
||||
|
||||
@@ -316,9 +330,13 @@ async def push_driver_messages(request, session):
|
||||
return json.dumps({"error": "Send failed"}), 503, {'Content-Type': 'application/json'}
|
||||
|
||||
try:
|
||||
from util import sequence_playback as seq_pb
|
||||
from util.beat_driver_route import sync_beat_route_from_push_sequence
|
||||
|
||||
sync_beat_route_from_push_sequence(seq, target_macs=target_list)
|
||||
preserve = bool(seq_pb.playback_status().get("active"))
|
||||
sync_beat_route_from_push_sequence(
|
||||
seq, target_macs=target_list, preserve_parallel_lane_routes=preserve
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ async def play_sequence(request, session, id):
|
||||
try:
|
||||
from util.sequence_playback import start
|
||||
|
||||
await start(zone_id, str(id), str(current_profile_id))
|
||||
await start(zone_id, str(id), str(current_profile_id), data if isinstance(data, dict) else None)
|
||||
return json.dumps({"ok": True}), 200, {"Content-Type": "application/json"}
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)}), 400, {"Content-Type": "application/json"}
|
||||
|
||||
@@ -291,6 +291,7 @@ async def create_zone(request, session):
|
||||
names = [i.strip() for i in ids_str.split(",") if i.strip()]
|
||||
preset_ids = None
|
||||
group_ids = []
|
||||
content_kind = None
|
||||
else:
|
||||
data = request.json or {}
|
||||
name = data.get("name", "")
|
||||
@@ -305,11 +306,13 @@ async def create_zone(request, session):
|
||||
group_ids = [str(x) for x in group_ids if x is not None]
|
||||
else:
|
||||
group_ids = []
|
||||
raw_kind = data.get("content_kind")
|
||||
content_kind = raw_kind if raw_kind in ("presets", "sequences") else None
|
||||
|
||||
if not name:
|
||||
return json.dumps({"error": "Zone name cannot be empty"}), 400
|
||||
|
||||
zid = zones.create(name, names, preset_ids, group_ids)
|
||||
zid = zones.create(name, names, preset_ids, group_ids, content_kind)
|
||||
|
||||
profile_id = get_current_profile_id(session)
|
||||
if profile_id:
|
||||
|
||||
Reference in New Issue
Block a user