Files
home-automation/packages/home_capabilities/layout.py

159 lines
4.3 KiB
Python

"""UI Layout models and loader for config/layout.yaml."""
import logging
from pathlib import Path
from typing import Optional
import yaml
from pydantic import BaseModel, Field, field_validator
logger = logging.getLogger(__name__)
class DeviceTile(BaseModel):
"""Represents a device tile in the UI.
Attributes:
device_id: Unique identifier of the device
title: Display title for the device
icon: Icon name or emoji for the device
rank: Sort order within the room (lower = first)
"""
device_id: str = Field(
...,
description="Unique device identifier"
)
title: str = Field(
...,
description="Display title for the device"
)
icon: str = Field(
...,
description="Icon name or emoji"
)
rank: int = Field(
...,
ge=0,
description="Sort order (lower values appear first)"
)
class Room(BaseModel):
"""Represents a room containing devices.
Attributes:
name: Room name (e.g., "Wohnzimmer", "Küche")
devices: List of device tiles in this room
"""
name: str = Field(
...,
description="Room name"
)
devices: list[DeviceTile] = Field(
default_factory=list,
description="Device tiles in this room"
)
@field_validator('devices')
@classmethod
def validate_devices(cls, v: list[DeviceTile]) -> list[DeviceTile]:
"""Validate that devices list is not empty if provided."""
if v is not None and len(v) == 0:
logger.warning("Room has empty devices list")
return v
class UiLayout(BaseModel):
"""Represents the complete UI layout configuration.
Attributes:
rooms: List of rooms in the layout
"""
rooms: list[Room] = Field(
default_factory=list,
description="Rooms in the layout"
)
@field_validator('rooms')
@classmethod
def validate_rooms(cls, v: list[Room]) -> list[Room]:
"""Validate that rooms list is not empty."""
if not v or len(v) == 0:
raise ValueError("Layout must contain at least one room")
return v
def total_devices(self) -> int:
"""Calculate total number of devices across all rooms.
Returns:
int: Total device count
"""
return sum(len(room.devices) for room in self.rooms)
def load_layout(path: Optional[str] = None) -> UiLayout:
"""Load UI layout from YAML configuration file.
Args:
path: Optional path to layout.yaml. If None, uses default path
(apps/ui/config/layout.yaml relative to workspace root)
Returns:
UiLayout: Parsed and validated layout configuration
Raises:
FileNotFoundError: If layout file doesn't exist
ValueError: If layout validation fails
yaml.YAMLError: If YAML parsing fails
"""
# Determine config path
if path is None:
# Default: config/layout.yaml at workspace root
# This file is assumed to be in packages/home_capabilities/
workspace_root = Path(__file__).parent.parent.parent
config_path = workspace_root / "config" / "layout.yaml"
else:
config_path = Path(path)
# Check if file exists
if not config_path.exists():
raise FileNotFoundError(
f"Layout configuration not found: {config_path}. "
f"Please create a layout.yaml file with room and device definitions."
)
# Load YAML
try:
with open(config_path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f)
except yaml.YAMLError as e:
raise yaml.YAMLError(f"Failed to parse YAML in {config_path}: {e}")
if data is None:
raise ValueError(f"Layout file is empty: {config_path}")
# Validate and parse with Pydantic
try:
layout = UiLayout(**data)
except Exception as e:
raise ValueError(
f"Invalid layout configuration in {config_path}: {e}"
)
# Log summary
total_devices = layout.total_devices()
room_names = [room.name for room in layout.rooms]
logger.info(
f"Loaded layout: {len(layout.rooms)} rooms, "
f"{total_devices} total devices (Rooms: {', '.join(room_names)})"
)
return layout