455 lines
15 KiB
Python
455 lines
15 KiB
Python
"""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)}"
|
|
)
|