diff --git a/apps/api/routes/rooms.py b/apps/api/routes/rooms.py new file mode 100644 index 0000000..f076439 --- /dev/null +++ b/apps/api/routes/rooms.py @@ -0,0 +1,219 @@ +""" +Room-based device control endpoints. + +Provides bulk control operations for devices within rooms: +- /rooms/{room_name}/lights - Control all lights in a room +- /rooms/{room_name}/heating - Control all thermostats in a room +""" + +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from packages.home_capabilities import load_layout + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Rooms"]) + + +@router.get("/rooms") +async def get_rooms() -> list[dict[str, str]]: + """Get list of all room IDs and names. + + Returns: + List of dicts with room id and name + """ + layout = load_layout() + + return [ + { + "id": room.id, + "name": room.name + } + for room in layout.rooms + ] + + +class LightsControlRequest(BaseModel): + """Request model for controlling lights in a room.""" + power: str # "on" or "off" + brightness: int | None = None # Optional brightness 0-100 + + +class HeatingControlRequest(BaseModel): + """Request model for controlling heating in a room.""" + target: float # Target temperature + + +def get_room_devices(room_name: str) -> list[dict[str, Any]]: + """Get all devices in a specific room from layout. + + Args: + room_name: Name of the room + + Returns: + List of device dicts with device_id, title, icon, rank, excluded + + Raises: + HTTPException: If room not found + """ + layout = load_layout() + + for room in layout.rooms: + if room.name == room_name: + return [ + { + "device_id": device.device_id, + "title": device.title, + "icon": device.icon, + "rank": device.rank, + "excluded": device.excluded + } + for device in room.devices + ] + + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Room '{room_name}' not found" + ) + + +@router.post("/rooms/{room_name}/lights", status_code=status.HTTP_202_ACCEPTED) +async def control_room_lights(room_name: str, request: LightsControlRequest) -> dict[str, Any]: + """Control all lights (light and relay devices) in a room. + + Args: + room_name: Name of the room + request: Light control parameters + + Returns: + dict with affected device_ids and command summary + """ + from apps.api.main import load_devices, publish_abstract_set + + # Get all devices in room + room_devices = get_room_devices(room_name) + + # Filter out excluded devices + room_device_ids = {d["device_id"] for d in room_devices if not d.get("excluded", False)} + + # Load all devices to filter by type + all_devices = load_devices() + + # Filter for light/relay devices in this room + light_devices = [ + d for d in all_devices + if d["device_id"] in room_device_ids and d["type"] in ("light", "relay") + ] + + if not light_devices: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No light devices found in room '{room_name}'" + ) + + # Build payload + payload = {"power": request.power} + if request.brightness is not None and request.power == "on": + payload["brightness"] = request.brightness + + # Send commands to all light devices + affected_ids = [] + errors = [] + + for device in light_devices: + try: + await publish_abstract_set( + device_type=device["type"], + device_id=device["device_id"], + payload=payload + ) + affected_ids.append(device["device_id"]) + logger.info(f"Sent command to {device['device_id']}: {payload}") + except Exception as e: + logger.error(f"Failed to control {device['device_id']}: {e}") + errors.append({ + "device_id": device["device_id"], + "error": str(e) + }) + + return { + "room": room_name, + "command": "lights", + "payload": payload, + "affected_devices": affected_ids, + "success_count": len(affected_ids), + "error_count": len(errors), + "errors": errors if errors else None + } + + +@router.post("/rooms/{room_name}/heating", status_code=status.HTTP_202_ACCEPTED) +async def control_room_heating(room_name: str, request: HeatingControlRequest) -> dict[str, Any]: + """Control all thermostats in a room. + + Args: + room_name: Name of the room + request: Heating control parameters + + Returns: + dict with affected device_ids and command summary + """ + from apps.api.main import load_devices, publish_abstract_set + + # Get all devices in room + room_devices = get_room_devices(room_name) + + # Filter out excluded devices + room_device_ids = {d["device_id"] for d in room_devices if not d.get("excluded", False)} + + # Load all devices to filter by type + all_devices = load_devices() + + # Filter for thermostat devices in this room + thermostat_devices = [ + d for d in all_devices + if d["device_id"] in room_device_ids and d["type"] == "thermostat" + ] + + if not thermostat_devices: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No thermostat devices found in room '{room_name}'" + ) + + # Build payload + payload = {"target": request.target} + + # Send commands to all thermostat devices + affected_ids = [] + errors = [] + + for device in thermostat_devices: + try: + await publish_abstract_set( + device_type="thermostat", + device_id=device["device_id"], + payload=payload + ) + affected_ids.append(device["device_id"]) + logger.info(f"Sent heating command to {device['device_id']}: {payload}") + except Exception as e: + logger.error(f"Failed to control {device['device_id']}: {e}") + errors.append({ + "device_id": device["device_id"], + "error": str(e) + }) + + return { + "room": room_name, + "command": "heating", + "payload": payload, + "affected_devices": affected_ids, + "success_count": len(affected_ids), + "error_count": len(errors), + "errors": errors if errors else None + } diff --git a/config/layout.yaml b/config/layout.yaml index 6170082..3d55084 100644 --- a/config/layout.yaml +++ b/config/layout.yaml @@ -139,6 +139,7 @@ rooms: title: Küche Putzlicht icon: 💡 rank: 143 + excluded: true - device_id: kueche_fensterbank_licht title: Küche Fensterbank icon: 💡 diff --git a/packages/home_capabilities/layout.py b/packages/home_capabilities/layout.py index 631e67e..ec05861 100644 --- a/packages/home_capabilities/layout.py +++ b/packages/home_capabilities/layout.py @@ -18,6 +18,7 @@ class DeviceTile(BaseModel): title: Display title for the device icon: Icon name or emoji for the device rank: Sort order within the room (lower = first) + excluded: Optional flag to exclude device from certain operations """ device_id: str = Field( @@ -40,6 +41,11 @@ class DeviceTile(BaseModel): ge=0, description="Sort order (lower values appear first)" ) + + excluded: bool = Field( + default=False, + description="Exclude device from bulk operations" + ) class Room(BaseModel):