Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
300 changes: 265 additions & 35 deletions svc/app/adapter.py

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions svc/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@
HALIO_SITE_ID = os.getenv("HALIO_SITE_ID", "")
HALIO_API_KEY = os.getenv("HALIO_API_KEY", "")

# Panel to Halio Window UUID mapping file
WINDOW_MAPPING_FILE = os.path.join(_SVC_DIR, DATA_DIR, "window_mapping.json")
# Panel to Halio Window UUID mapping file
WINDOW_MAPPING_FILE = os.path.join(_SVC_DIR, DATA_DIR, "window_mapping.json")

# Local group ID (e.g., G-facade) to Halio Group UUID mapping file
GROUP_MAPPING_FILE = os.path.join(_SVC_DIR, DATA_DIR, "group_mapping.json")
29 changes: 16 additions & 13 deletions svc/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ class Panel(BaseModel):
last_change_ts: float = Field(default=0.0, description="Unix timestamp of last level change")


class Group(BaseModel):
"""Group represents a collection of panels that can be controlled together."""
id: str = Field(description="Group identifier (e.g., G-facade, G-1)")
name: str = Field(description="Human-readable group name")
member_ids: List[str] = Field(default_factory=list, description="List of panel IDs in this group")
class Group(BaseModel):
"""Group represents a collection of panels that can be controlled together."""
id: str = Field(description="Group identifier (e.g., G-facade, G-1)")
name: str = Field(description="Human-readable group name")
member_ids: List[str] = Field(default_factory=list, description="List of panel IDs in this group")
hidden: bool = Field(default=False, description="Whether to hide this group from the UI")


class CommandRequest(BaseModel):
Expand Down Expand Up @@ -55,16 +56,18 @@ class HealthResponse(BaseModel):
mode: str = Field(description="Current operation mode: 'sim' (simulator) or 'real' (Halio API)")


class GroupCreate(BaseModel):
"""Request to create a new group."""
name: str = Field(description="Name for the new group")
member_ids: List[str] = Field(default_factory=list, description="Panel IDs to include in the group")
class GroupCreate(BaseModel):
"""Request to create a new group."""
name: str = Field(description="Name for the new group")
member_ids: List[str] = Field(default_factory=list, description="Panel IDs to include in the group")
hidden: bool = Field(default=False, description="Whether to hide this group from the UI")


class GroupUpdate(BaseModel):
"""Request to update an existing group."""
name: Optional[str] = Field(default=None, description="New name for the group (optional)")
member_ids: Optional[List[str]] = Field(default=None, description="New list of panel IDs (optional)")
class GroupUpdate(BaseModel):
"""Request to update an existing group."""
name: Optional[str] = Field(default=None, description="New name for the group (optional)")
member_ids: Optional[List[str]] = Field(default=None, description="New list of panel IDs (optional)")
hidden: Optional[bool] = Field(default=None, description="Whether to hide this group from the UI (optional)")


class DeleteGroupResponse(BaseModel):
Expand Down
16 changes: 12 additions & 4 deletions svc/app/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ def list_panels(service: ControlService = Depends(get_service)) -> List[Panel]:
description="Returns a list of all groups with their member panel IDs",
tags=["Groups"]
)
def list_groups(service: ControlService = Depends(get_service)) -> List[Group]:
def list_groups(
include_hidden: bool = Query(default=False, description="Include hidden groups"),
service: ControlService = Depends(get_service),
) -> List[Group]:
"""Get all groups."""
return service.list_groups()
groups = service.list_groups()
if not include_hidden:
groups = [g for g in groups if not getattr(g, "hidden", False)]
return groups


@router.post(
Expand All @@ -86,6 +92,8 @@ def set_level(
ok, applied, msg = service.set_group_level(body.target_id, body.level)

if not ok:
if msg == "panel not assigned to any group":
raise HTTPException(status_code=400, detail=msg)
if msg in ("panel not found", "group not found"):
raise HTTPException(status_code=404, detail=msg)
if "dwell" in msg:
Expand All @@ -110,7 +118,7 @@ def set_level(
def create_group(body: GroupCreate, service: ControlService = Depends(get_service)) -> Group:
"""Create a new group."""
try:
return service.create_group(body.name, body.member_ids)
return service.create_group(body.name, body.member_ids, body.hidden)
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))

Expand All @@ -135,7 +143,7 @@ def update_group(
) -> Group:
"""Update an existing group."""
try:
return service.update_group(group_id, body.name, body.member_ids)
return service.update_group(group_id, body.name, body.member_ids, body.hidden)
except KeyError:
raise HTTPException(status_code=404, detail="group not found")
except RuntimeError as e:
Expand Down
139 changes: 89 additions & 50 deletions svc/app/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,74 @@
from .simulator import Simulator
from .adapter import RealAdapter
from .config import MODE, MIN_DWELL_SECONDS
from .state import audit, update_panel_state
from .state import audit, update_panel_state, load_groups

logger = logging.getLogger(__name__)


class ControlService:
def __init__(self) -> None:
self.mode = MODE
class ControlService:
def __init__(self) -> None:
self.mode = MODE
if self.mode == "real":
self.backend = RealAdapter()
else:
self.backend = Simulator()

# read
def list_panels(self):
return self.backend.list_panels()
else:
self.backend = Simulator()

def _find_group_for_panel(self, panel_id: str):
groups = load_groups()
matches = [g for g in groups.values() if panel_id in g.member_ids]
if not matches:
return None
matches.sort(key=lambda g: (len(g.member_ids), g.id))
if len(matches) > 1:
logger.warning(
"Panel %s is in multiple groups (%s); using %s",
panel_id,
", ".join(g.id for g in matches),
matches[0].id,
)
return matches[0]

# read
def list_panels(self):
return self.backend.list_panels()

def list_groups(self):
return self.backend.list_groups()

# write
def set_panel_level(
self, panel_id: str, level: TintLevel, actor: str = "api"
) -> Tuple[bool, List[str], str]:
logger.info(
f"Service.set_panel_level called: panel={panel_id} level={level} "
f"mode={self.mode} actor={actor}"
)
try:
ok = self.backend.set_panel(panel_id, level, MIN_DWELL_SECONDS)
if ok:
applied = [panel_id]
msg = "panel updated"
def set_panel_level(
self, panel_id: str, level: TintLevel, actor: str = "api"
) -> Tuple[bool, List[str], str]:
logger.info(
f"Service.set_panel_level called: panel={panel_id} level={level} "
f"mode={self.mode} actor={actor}"
)
if self.mode == "real":
group = self._find_group_for_panel(panel_id)
if not group:
msg = "panel not assigned to any group"
logger.error(
"Panel %s is not assigned to any group; cannot route to group tinting",
panel_id,
)
audit(actor, "panel", panel_id, int(level), [], msg)
return False, [], msg

logger.info(
"Routing panel %s command to group %s",
panel_id,
group.id,
)
ok, applied, msg = self.set_group_level(group.id, level, actor=actor)
if ok:
msg = f"panel {panel_id} routed to group {group.id}"
return ok, applied, msg
try:
ok = self.backend.set_panel(panel_id, level, MIN_DWELL_SECONDS)
if ok:
applied = [panel_id]
msg = "panel updated"
logger.info(f"✓ Panel {panel_id} update SUCCESS")
# Update panel state in database when command is successful
# This keeps displayed tint level accurate based on successful API responses
Expand All @@ -56,35 +91,39 @@ def set_panel_level(
logger.error(f"✗ Panel {panel_id} update FAILED: panel not found - {e}")
return False, [], "panel not found"

def set_group_level(
self, group_id: str, level: TintLevel, actor: str = "api"
) -> Tuple[bool, List[str], str]:
try:
applied = self.backend.set_group(group_id, level, MIN_DWELL_SECONDS)
ok = len(applied) > 0
msg = "group updated" if ok else "no panels updated due to dwell time"
if ok:
# Update panel states for all panels that were successfully updated
for panel_id in applied:
try:
update_panel_state(panel_id, int(level))
logger.debug(f"Updated panel state for {panel_id} to level {level}")
except Exception as e:
logger.warning(f"Failed to update panel state for {panel_id}: {e}")
audit(actor, "group", group_id, int(level), applied, msg)
return ok, applied, msg
except KeyError:
return False, [], "group not found"

def create_group(self, name: str, member_ids: List[str]):
if not hasattr(self.backend, "create_group"):
raise RuntimeError("group create not supported in this mode")
return self.backend.create_group(name, member_ids)
def set_group_level(
self, group_id: str, level: TintLevel, actor: str = "api"
) -> Tuple[bool, List[str], str]:
try:
ok, applied, msg = self.backend.set_group(group_id, level, MIN_DWELL_SECONDS)
if ok:
# Update panel states for all panels that were successfully updated
for panel_id in applied:
try:
update_panel_state(panel_id, int(level))
logger.debug(f"Updated panel state for {panel_id} to level {level}")
except Exception as e:
logger.warning(f"Failed to update panel state for {panel_id}: {e}")
audit(actor, "group", group_id, int(level), applied, msg)
return ok, applied, msg
except KeyError:
return False, [], "group not found"

def update_group(self, group_id: str, name: str | None, member_ids: List[str] | None):
if not hasattr(self.backend, "update_group"):
raise RuntimeError("group update not supported in this mode")
return self.backend.update_group(group_id, name, member_ids)
def create_group(self, name: str, member_ids: List[str], hidden: bool = False):
if not hasattr(self.backend, "create_group"):
raise RuntimeError("group create not supported in this mode")
return self.backend.create_group(name, member_ids, hidden)

def update_group(
self,
group_id: str,
name: str | None,
member_ids: List[str] | None,
hidden: bool | None,
):
if not hasattr(self.backend, "update_group"):
raise RuntimeError("group update not supported in this mode")
return self.backend.update_group(group_id, name, member_ids, hidden)

def delete_group(self, group_id: str) -> bool:
if not hasattr(self.backend, "delete_group"):
Expand Down
49 changes: 28 additions & 21 deletions svc/app/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ def _commit(panel_id=panel_id):
return True


def set_group(self, group_id: str, level: TintLevel, min_dwell: int) -> List[str]:
if group_id not in self.snap.groups:
raise KeyError(group_id)
def set_group(
self, group_id: str, level: TintLevel, min_dwell: int
) -> tuple[bool, List[str], str]:
if group_id not in self.snap.groups:
raise KeyError(group_id)

applied: List[str] = []
threads: List[threading.Thread] = []
Expand Down Expand Up @@ -82,10 +84,12 @@ def _commit(panel_id=pid):
threads.append(t)
applied.append(pid)

# do not join threads let the API return quickly
return applied
# do not join threads let the API return quickly
ok = len(applied) > 0
msg = "group updated" if ok else "no panels updated due to dwell time"
return ok, applied, msg

def create_group(self, name: str, member_ids: List[str]) -> Group:
def create_group(self, name: str, member_ids: List[str], hidden: bool = False) -> Group:
# generate a simple id like G-1 G-2 ...
existing_ids = set(self.snap.groups.keys())
n = 1
Expand All @@ -96,18 +100,19 @@ def create_group(self, name: str, member_ids: List[str]) -> Group:
# filter to only valid panel ids
valid_ids = [pid for pid in member_ids if pid in self.snap.panels]

group = Group(id=gid, name=name, member_ids=valid_ids)
self.snap.groups[gid] = group
group = Group(id=gid, name=name, member_ids=valid_ids, hidden=hidden)
self.snap.groups[gid] = group

save_snapshot(self.snap)
return group

def update_group(
self,
group_id: str,
name: str | None,
member_ids: List[str] | None,
) -> Group:
def update_group(
self,
group_id: str,
name: str | None,
member_ids: List[str] | None,
hidden: bool | None,
) -> Group:
if group_id not in self.snap.groups:
raise KeyError(group_id)

Expand All @@ -116,13 +121,15 @@ def update_group(
if name is not None:
g.name = name

if member_ids is not None:
# normalize to only existing panels
new_ids = [pid for pid in member_ids if pid in self.snap.panels]
g.member_ids = list(new_ids)

save_snapshot(self.snap)
return g
if member_ids is not None:
# normalize to only existing panels
new_ids = [pid for pid in member_ids if pid in self.snap.panels]
g.member_ids = list(new_ids)
if hidden is not None:
g.hidden = hidden

save_snapshot(self.snap)
return g

def delete_group(self, group_id: str) -> bool:
if group_id not in self.snap.groups:
Expand Down
Loading
Loading