"""Groups and Scenes API routes.""" import asyncio import logging from pathlib import Path from typing import Any from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel from packages.home_capabilities import ( GroupConfig, GroupsConfigRoot, SceneConfig, ScenesConfigRoot, get_group_by_id, get_scene_by_id, load_groups, load_scenes, ) # Import from parent modules import sys sys.path.insert(0, str(Path(__file__).parent.parent)) from resolvers import ( DeviceDTO, resolve_group_devices, resolve_scene_step_devices, load_device_rooms, ) from main import load_devices, publish_abstract_set logger = logging.getLogger(__name__) router = APIRouter() # ============================================================================ # REQUEST/RESPONSE MODELS # ============================================================================ class GroupResponse(BaseModel): """Response model for a group.""" id: str name: str device_count: int devices: list[str] selector: dict[str, Any] | None = None capabilities: dict[str, bool] class GroupSetRequest(BaseModel): """Request to set state for all devices in a group.""" action: dict[str, Any] # e.g., {"type": "light", "payload": {"power": "on", "brightness": 50}} class SceneResponse(BaseModel): """Response model for a scene.""" id: str name: str steps: int class SceneRunRequest(BaseModel): """Request to execute a scene (currently empty, future: override params).""" pass class SceneExecutionResponse(BaseModel): """Response after scene execution.""" scene_id: str scene_name: str steps_executed: int devices_affected: int execution_plan: list[dict[str, Any]] # ============================================================================ # GROUPS ENDPOINTS # ============================================================================ @router.get("/groups", response_model=list[GroupResponse], tags=["groups"]) async def list_groups() -> list[GroupResponse]: """ List all available groups. Returns: list[GroupResponse]: List of groups with their devices """ try: # Load configuration groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml") devices = load_devices() device_rooms = load_device_rooms() # Build response for each group response = [] for group in groups_config.groups: # Resolve devices for this group resolved_devices = resolve_group_devices(group, devices, device_rooms) device_ids = [d["device_id"] for d in resolved_devices] # Convert selector to dict if present selector_dict = None if group.selector: selector_dict = { "type": group.selector.type, "room": group.selector.room, "tags": group.selector.tags, } response.append(GroupResponse( id=group.id, name=group.name, device_count=len(device_ids), devices=device_ids, selector=selector_dict, capabilities=group.capabilities, )) return response except Exception as e: logger.error(f"Error loading groups: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to load groups: {str(e)}" ) @router.get("/groups/{group_id}", response_model=GroupResponse, tags=["groups"]) async def get_group(group_id: str) -> GroupResponse: """ Get details for a specific group. Args: group_id: Group identifier Returns: GroupResponse: Group details with resolved devices """ try: # Load configuration groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml") devices = load_devices() device_rooms = load_device_rooms() # Find the group group = get_group_by_id(groups_config, group_id) if not group: available_groups = [g.id for g in groups_config.groups] raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group '{group_id}' not found. Available groups: {available_groups}" ) # Resolve devices resolved_devices = resolve_group_devices(group, devices, device_rooms) device_ids = [d["device_id"] for d in resolved_devices] # Convert selector to dict if present selector_dict = None if group.selector: selector_dict = { "type": group.selector.type, "room": group.selector.room, "tags": group.selector.tags, } return GroupResponse( id=group.id, name=group.name, device_count=len(device_ids), devices=device_ids, selector=selector_dict, capabilities=group.capabilities, ) except HTTPException: raise except Exception as e: logger.error(f"Error getting group {group_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get group: {str(e)}" ) @router.post("/groups/{group_id}/set", status_code=status.HTTP_202_ACCEPTED, tags=["groups"]) async def set_group(group_id: str, request: GroupSetRequest) -> dict[str, Any]: """ Set state for all devices in a group. This endpoint resolves the group to its devices and would send the action to each device. Currently returns execution plan. Args: group_id: Group identifier request: Action to apply to all devices in the group Returns: dict: Execution plan (devices and actions to be executed) """ try: # Load configuration groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml") devices = load_devices() device_rooms = load_device_rooms() # Find the group group = get_group_by_id(groups_config, group_id) if not group: available_groups = [g.id for g in groups_config.groups] raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Group '{group_id}' not found. Available groups: {available_groups}" ) # Resolve devices resolved_devices = resolve_group_devices(group, devices, device_rooms) if not resolved_devices: logger.warning(f"Group '{group_id}' resolved to 0 devices") # Execute actions via MQTT execution_plan = [] for device in resolved_devices: device_type = device["type"] device_id = device["device_id"] payload = request.action.get("payload", {}) # Publish MQTT command try: await publish_abstract_set(device_type, device_id, payload) execution_plan.append({ "device_id": device_id, "device_type": device_type, "action": request.action, "status": "published" }) except Exception as e: logger.error(f"Failed to publish to {device_id}: {e}") execution_plan.append({ "device_id": device_id, "device_type": device_type, "action": request.action, "status": "failed", "error": str(e) }) return { "group_id": group_id, "group_name": group.name, "devices_affected": len(resolved_devices), "execution_plan": execution_plan, "status": "executed" } except HTTPException: raise except Exception as e: logger.error(f"Error setting group {group_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to set group: {str(e)}" ) # ============================================================================ # SCENES ENDPOINTS # ============================================================================ @router.get("/scenes", response_model=list[SceneResponse], tags=["scenes"]) async def list_scenes() -> list[SceneResponse]: """ List all available scenes. Returns: list[SceneResponse]: List of scenes """ try: # Load configuration scenes_config = load_scenes(Path(__file__).parent.parent.parent.parent / "config" / "scenes.yaml") # Build response for each scene response = [] for scene in scenes_config.scenes: response.append(SceneResponse( id=scene.id, name=scene.name, steps=len(scene.steps), )) return response except Exception as e: logger.error(f"Error loading scenes: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to load scenes: {str(e)}" ) @router.get("/scenes/{scene_id}", response_model=SceneResponse, tags=["scenes"]) async def get_scene(scene_id: str) -> SceneResponse: """ Get details for a specific scene. Args: scene_id: Scene identifier Returns: SceneResponse: Scene details """ try: # Load configuration scenes_config = load_scenes(Path(__file__).parent.parent.parent.parent / "config" / "scenes.yaml") # Find the scene scene = get_scene_by_id(scenes_config, scene_id) if not scene: available_scenes = [s.id for s in scenes_config.scenes] raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Scene '{scene_id}' not found. Available scenes: {available_scenes}" ) return SceneResponse( id=scene.id, name=scene.name, steps=len(scene.steps), ) except HTTPException: raise except Exception as e: logger.error(f"Error getting scene {scene_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get scene: {str(e)}" ) @router.post("/scenes/{scene_id}/run", response_model=SceneExecutionResponse, tags=["scenes"]) async def run_scene(scene_id: str, request: SceneRunRequest | None = None) -> SceneExecutionResponse: """ Execute a scene. This endpoint resolves each step in the scene to its target devices and would execute the actions. Currently returns execution plan. Args: scene_id: Scene identifier request: Optional execution parameters (reserved for future use) Returns: SceneExecutionResponse: Execution plan and summary """ try: # Load configuration scenes_config = load_scenes(Path(__file__).parent.parent.parent.parent / "config" / "scenes.yaml") groups_config = load_groups(Path(__file__).parent.parent.parent.parent / "config" / "groups.yaml") devices = load_devices() device_rooms = load_device_rooms() # Find the scene scene = get_scene_by_id(scenes_config, scene_id) if not scene: available_scenes = [s.id for s in scenes_config.scenes] raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Scene '{scene_id}' not found. Available scenes: {available_scenes}" ) # Execute scene steps execution_plan = [] total_devices = 0 for i, step in enumerate(scene.steps, 1): # Resolve devices for this step resolved_devices = resolve_scene_step_devices(step, groups_config, devices, device_rooms) total_devices += len(resolved_devices) # Extract action payload action_payload = step.action.get("payload", {}) # Execute for each device step_executions = [] for device in resolved_devices: device_type = device["type"] device_id = device["device_id"] try: await publish_abstract_set(device_type, device_id, action_payload) step_executions.append({ "device_id": device_id, "status": "published" }) except Exception as e: logger.error(f"Failed to publish to {device_id} in step {i}: {e}") step_executions.append({ "device_id": device_id, "status": "failed", "error": str(e) }) # Build step info step_info = { "step": i, "devices_affected": len(resolved_devices), "device_ids": [d["device_id"] for d in resolved_devices], "action": step.action, "executions": step_executions, } # Add targeting info if step.group_id: step_info["target"] = {"type": "group_id", "value": step.group_id} elif step.selector: step_info["target"] = { "type": "selector", "selector_type": step.selector.type, "room": step.selector.room, } if step.delay_ms: step_info["delay_ms"] = step.delay_ms # Apply delay before next step await asyncio.sleep(step.delay_ms / 1000.0) execution_plan.append(step_info) return SceneExecutionResponse( scene_id=scene.id, scene_name=scene.name, steps_executed=len(scene.steps), devices_affected=total_devices, execution_plan=execution_plan, ) except HTTPException: raise except ValueError as e: # Handle unknown group_id in scene step raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e) ) except Exception as e: logger.error(f"Error running scene {scene_id}: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to run scene: {str(e)}" )