diff --git a/apps/api/main.py b/apps/api/main.py
index 242694e..2e17dad 100644
--- a/apps/api/main.py
+++ b/apps/api/main.py
@@ -2,6 +2,7 @@
import asyncio
import json
+import logging
import os
from pathlib import Path
from typing import Any, AsyncGenerator
@@ -16,6 +17,8 @@ from pydantic import BaseModel, ValidationError
from packages.home_capabilities import CAP_VERSION, LightState
+logger = logging.getLogger(__name__)
+
app = FastAPI(
title="Home Automation API",
description="API for home automation system",
@@ -71,6 +74,7 @@ class DeviceInfo(BaseModel):
device_id: str
type: str
name: str
+ features: dict[str, Any] = {}
# Configuration helpers
@@ -145,19 +149,57 @@ async def get_devices() -> list[DeviceInfo]:
"""Get list of available devices.
Returns:
- list: List of device information
+ list: List of device information including features
"""
devices = load_devices()
return [
DeviceInfo(
device_id=device["device_id"],
type=device["type"],
- name=device.get("name", device["device_id"])
+ name=device.get("name", device["device_id"]),
+ features=device.get("features", {})
)
for device in devices
]
+@app.get("/layout")
+async def get_layout() -> dict[str, Any]:
+ """Get UI layout configuration.
+
+ Returns:
+ dict: Layout configuration with rooms and device tiles
+ """
+ from packages.home_capabilities import load_layout
+
+ try:
+ layout = load_layout()
+
+ # Convert Pydantic models to dict
+ rooms = []
+ for room in layout.rooms:
+ devices = []
+ for tile in room.devices:
+ devices.append({
+ "device_id": tile.device_id,
+ "title": tile.title,
+ "icon": tile.icon,
+ "rank": tile.rank
+ })
+
+ rooms.append({
+ "name": room.name,
+ "devices": devices
+ })
+
+ return {"rooms": rooms}
+
+ except Exception as e:
+ logger.error(f"Error loading layout: {e}")
+ # Return empty layout on error
+ return {"rooms": []}
+
+
@app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
"""Set device state.
diff --git a/apps/ui/api_client.py b/apps/ui/api_client.py
new file mode 100644
index 0000000..17bec53
--- /dev/null
+++ b/apps/ui/api_client.py
@@ -0,0 +1,116 @@
+"""
+HTTP Client for fetching devices from the API Gateway.
+"""
+import logging
+from typing import Optional
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+def fetch_devices(api_base: str = "http://localhost:8001") -> list[dict]:
+ """
+ Fetch devices from the API Gateway.
+
+ Args:
+ api_base: Base URL of the API Gateway (default: http://localhost:8001)
+
+ Returns:
+ List of device dictionaries. Each device contains at least:
+ - device_id (str): Unique device identifier
+ - type (str): Device type (e.g., "light")
+ - name (str): Human-readable device name
+
+ Returns empty list on error.
+ """
+ url = f"{api_base}/devices"
+
+ try:
+ response = httpx.get(url, timeout=3.0)
+ response.raise_for_status()
+
+ devices = response.json()
+
+ # API returns a list directly
+ if not isinstance(devices, list):
+ logger.warning(f"Unexpected response format from {url}: expected list, got {type(devices)}")
+ return []
+
+ logger.info(f"Fetched {len(devices)} devices from API Gateway")
+ return devices
+
+ except httpx.TimeoutException:
+ logger.warning(f"Timeout while fetching devices from {url}")
+ return []
+
+ except httpx.HTTPStatusError as e:
+ logger.warning(f"HTTP error {e.response.status_code} while fetching devices from {url}")
+ return []
+
+ except httpx.RequestError as e:
+ logger.warning(f"Request error while fetching devices from {url}: {e}")
+ return []
+
+ except Exception as e:
+ logger.warning(f"Unexpected error while fetching devices from {url}: {e}")
+ return []
+
+
+def fetch_layout(api_base: str = "http://localhost:8001") -> dict:
+ """
+ Fetch UI layout from the API Gateway.
+
+ Args:
+ api_base: Base URL of the API Gateway (default: http://localhost:8001)
+
+ Returns:
+ Layout dictionary with structure:
+ {
+ "rooms": [
+ {
+ "name": str,
+ "devices": [
+ {
+ "device_id": str,
+ "title": str,
+ "icon": str,
+ "rank": int
+ }
+ ]
+ }
+ ]
+ }
+
+ Returns empty layout on error.
+ """
+ url = f"{api_base}/layout"
+
+ try:
+ response = httpx.get(url, timeout=3.0)
+ response.raise_for_status()
+
+ layout = response.json()
+
+ # API returns a dict with "rooms" key
+ if not isinstance(layout, dict) or "rooms" not in layout:
+ logger.warning(f"Unexpected response format from {url}: expected dict with 'rooms', got {type(layout)}")
+ return {"rooms": []}
+
+ logger.info(f"Fetched layout with {len(layout['rooms'])} rooms from API Gateway")
+ return layout
+
+ except httpx.TimeoutException:
+ logger.warning(f"Timeout while fetching layout from {url}")
+ return {"rooms": []}
+
+ except httpx.HTTPStatusError as e:
+ logger.warning(f"HTTP error {e.response.status_code} while fetching layout from {url}")
+ return {"rooms": []}
+
+ except httpx.RequestError as e:
+ logger.warning(f"Request error while fetching layout from {url}: {e}")
+ return {"rooms": []}
+
+ except Exception as e:
+ logger.warning(f"Unexpected error while fetching layout from {url}: {e}")
+ return {"rooms": []}
diff --git a/apps/ui/main.py b/apps/ui/main.py
index c6127df..1b01b65 100644
--- a/apps/ui/main.py
+++ b/apps/ui/main.py
@@ -1,11 +1,17 @@
"""UI main entry point."""
+import logging
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
+from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
+from apps.ui.api_client import fetch_devices, fetch_layout
+
+logger = logging.getLogger(__name__)
+
# Initialize FastAPI app
app = FastAPI(
title="Home Automation UI",
@@ -17,18 +23,91 @@ app = FastAPI(
templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
+# Setup static files
+static_dir = Path(__file__).parent / "static"
+static_dir.mkdir(exist_ok=True)
+app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
+
@app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse:
- """Render the main UI page.
+ """Redirect to dashboard.
Args:
request: The FastAPI request object
Returns:
- HTMLResponse: Rendered HTML template
+ HTMLResponse: Rendered dashboard
"""
- return templates.TemplateResponse("index.html", {"request": request})
+ return await dashboard(request)
+
+
+@app.get("/dashboard", response_class=HTMLResponse)
+async def dashboard(request: Request) -> HTMLResponse:
+ """Render the dashboard with rooms and devices.
+
+ Args:
+ request: The FastAPI request object
+
+ Returns:
+ HTMLResponse: Rendered dashboard template
+ """
+ try:
+ # Load layout from API
+ layout_data = fetch_layout()
+
+ # Fetch devices from API (now includes features)
+ api_devices = fetch_devices()
+
+ # Create device lookup by device_id
+ device_map = {d["device_id"]: d for d in api_devices}
+
+ # Build rooms with merged device data
+ rooms = []
+ for room in layout_data.get("rooms", []):
+ devices = []
+ for tile in room.get("devices", []):
+ # Merge tile data with API device data
+ device_data = {
+ "device_id": tile["device_id"],
+ "title": tile["title"],
+ "icon": tile["icon"],
+ "rank": tile["rank"],
+ }
+
+ # Add type, name, and features from API if available
+ if tile["device_id"] in device_map:
+ api_device = device_map[tile["device_id"]]
+ device_data["type"] = api_device.get("type")
+ device_data["name"] = api_device.get("name")
+ device_data["features"] = api_device.get("features", {})
+ else:
+ device_data["features"] = {}
+
+ devices.append(device_data)
+
+ # Sort devices by rank (ascending)
+ devices.sort(key=lambda d: d["rank"])
+
+ rooms.append({
+ "name": room["name"],
+ "devices": devices
+ })
+
+ logger.info(f"Rendering dashboard with {len(rooms)} rooms")
+
+ return templates.TemplateResponse("dashboard.html", {
+ "request": request,
+ "rooms": rooms
+ })
+
+ except Exception as e:
+ logger.error(f"Error rendering dashboard: {e}", exc_info=True)
+ # Fallback to empty dashboard
+ return templates.TemplateResponse("dashboard.html", {
+ "request": request,
+ "rooms": []
+ })
def main() -> None:
diff --git a/apps/ui/static/style.css b/apps/ui/static/style.css
new file mode 100644
index 0000000..d06b227
--- /dev/null
+++ b/apps/ui/static/style.css
@@ -0,0 +1,257 @@
+/* Home Automation Dashboard Styles */
+
+:root {
+ --primary-color: #3b82f6;
+ --primary-hover: #2563eb;
+ --bg-color: #f8fafc;
+ --card-bg: #ffffff;
+ --text-primary: #1e293b;
+ --text-secondary: #64748b;
+ --border-color: #e2e8f0;
+ --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+ --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background-color: var(--bg-color);
+ color: var(--text-primary);
+ line-height: 1.6;
+}
+
+.container {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 1rem;
+}
+
+/* Header */
+header {
+ background-color: var(--card-bg);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+ box-shadow: var(--shadow);
+}
+
+header h1 {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--text-primary);
+}
+
+/* Main Content */
+main {
+ margin-bottom: 2rem;
+}
+
+/* Room Section */
+.room {
+ background-color: var(--card-bg);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+ box-shadow: var(--shadow);
+}
+
+.room-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+ color: var(--text-primary);
+ border-bottom: 2px solid var(--border-color);
+ padding-bottom: 0.5rem;
+}
+
+/* Device Grid */
+.devices-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 1rem;
+}
+
+/* Tablet: 2 columns */
+@media (max-width: 1024px) {
+ .devices-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Mobile: 1 column */
+@media (max-width: 640px) {
+ .devices-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Device Tile */
+.device-tile {
+ background-color: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: 0.5rem;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ transition: all 0.2s;
+}
+
+.device-tile:hover {
+ box-shadow: var(--shadow-lg);
+ transform: translateY(-2px);
+}
+
+.device-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+}
+
+.device-icon {
+ font-size: 2.5rem;
+ flex-shrink: 0;
+ line-height: 1;
+}
+
+.device-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.device-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+ margin-bottom: 0.25rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.device-id {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ font-family: 'Monaco', 'Courier New', monospace;
+}
+
+/* Device State */
+.device-state {
+ padding: 0.5rem;
+ background-color: var(--card-bg);
+ border-radius: 0.375rem;
+ text-align: center;
+}
+
+.state-text {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+/* Device Controls */
+.device-controls {
+ display: flex;
+ gap: 0.5rem;
+}
+
+/* Buttons */
+.btn {
+ flex: 1;
+ padding: 0.625rem 1rem;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 0.875rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+}
+
+.btn-on {
+ background-color: #10b981;
+ color: white;
+}
+
+.btn-on:hover {
+ background-color: #059669;
+}
+
+.btn-off {
+ background-color: #ef4444;
+ color: white;
+}
+
+.btn-off:hover {
+ background-color: #dc2626;
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn-primary {
+ background-color: var(--primary-color);
+ color: white;
+}
+
+.btn-primary:hover {
+ background-color: var(--primary-hover);
+}
+
+.btn-primary:active {
+ transform: scale(0.98);
+}
+
+/* Empty State */
+.empty-state {
+ background-color: var(--card-bg);
+ border-radius: 0.5rem;
+ padding: 3rem 1.5rem;
+ text-align: center;
+ box-shadow: var(--shadow);
+}
+
+.empty-state p {
+ font-size: 1.125rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.5rem;
+}
+
+.empty-state .hint {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+/* Footer */
+footer {
+ text-align: center;
+ padding: 1rem;
+ color: var(--text-secondary);
+ font-size: 0.875rem;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ header h1 {
+ font-size: 1.5rem;
+ }
+
+ .room-title {
+ font-size: 1.25rem;
+ }
+
+ .container {
+ padding: 0.75rem;
+ }
+
+ .room {
+ padding: 1rem;
+ }
+}
diff --git a/apps/ui/templates/dashboard.html b/apps/ui/templates/dashboard.html
new file mode 100644
index 0000000..c5d3cbd
--- /dev/null
+++ b/apps/ui/templates/dashboard.html
@@ -0,0 +1,111 @@
+
+
+
+
+
+ Home Automation Dashboard
+
+
+
+
+
+
+
+ {% if rooms %}
+ {% for room in rooms %}
+
+ {{ room.name }}
+
+
+ {% for device in room.devices %}
+
+
+
+
+ β
+
+
+ {% if device.type == "light" and device.features.power %}
+
+
+
+
+ {% endif %}
+
+ {% endfor %}
+
+
+ {% endfor %}
+ {% else %}
+
+
Keine RΓ€ume oder GerΓ€te konfiguriert.
+
PrΓΌfe config/layout.yaml und das API-Gateway.
+
+ {% endif %}
+
+
+
+
+
+
+
+
diff --git a/config/devices.yaml b/config/devices.yaml
index 723ee7f..9ffb8fb 100644
--- a/config/devices.yaml
+++ b/config/devices.yaml
@@ -32,3 +32,12 @@ devices:
topics:
set: "vendor/test_lampe_2/set"
state: "vendor/test_lampe_2/state"
+ - device_id: test_lampe_3
+ type: light
+ cap_version: "light@1.2.0"
+ technology: zigbee2mqtt
+ features:
+ power: true
+ topics:
+ set: "vendor/test_lampe_3/set"
+ state: "vendor/test_lampe_3/state"
diff --git a/config/layout.yaml b/config/layout.yaml
new file mode 100644
index 0000000..72c2292
--- /dev/null
+++ b/config/layout.yaml
@@ -0,0 +1,25 @@
+# UI Layout Configuration
+# Defines rooms and device tiles for the home automation UI
+
+rooms:
+ - name: Wohnzimmer
+ devices:
+ - device_id: test_lampe_2
+ title: Deckenlampe
+ icon: "π‘"
+ rank: 5
+ - device_id: test_lampe_1
+ title: Stehlampe
+ icon: "οΏ½"
+ rank: 10
+
+ - name: Schlafzimmer
+ devices:
+ - device_id: test_lampe_3
+ title: Nachttischlampe
+ icon: "ποΈ"
+ rank: 10
+
+
+
+
diff --git a/packages/home_capabilities/__init__.py b/packages/home_capabilities/__init__.py
index d186d13..9ad36cc 100644
--- a/packages/home_capabilities/__init__.py
+++ b/packages/home_capabilities/__init__.py
@@ -1,5 +1,6 @@
"""Home capabilities package."""
from packages.home_capabilities.light import CAP_VERSION, LightState
+from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
-__all__ = ["LightState", "CAP_VERSION"]
+__all__ = ["LightState", "CAP_VERSION", "DeviceTile", "Room", "UiLayout", "load_layout"]
diff --git a/packages/home_capabilities/layout.py b/packages/home_capabilities/layout.py
new file mode 100644
index 0000000..49c49ac
--- /dev/null
+++ b/packages/home_capabilities/layout.py
@@ -0,0 +1,158 @@
+"""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
diff --git a/pyproject.toml b/pyproject.toml
index 8c2b3db..b6fc3fb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -21,6 +21,8 @@ jinja2 = "^3.1.6"
apscheduler = "^3.11.0"
redis = "^7.0.1"
paho-mqtt = "^2.1.0"
+httpx = "^0.28.1"
+beautifulsoup4 = "^4.14.2"
[tool.poetry.group.dev.dependencies]
ruff = "^0.6.0"
diff --git a/tools/sim_test_lampe.py b/tools/sim_test_lampe.py
index db65543..4b8d4b6 100644
--- a/tools/sim_test_lampe.py
+++ b/tools/sim_test_lampe.py
@@ -28,7 +28,7 @@ BROKER_HOST = os.environ.get("MQTT_HOST", "172.16.2.16")
BROKER_PORT = int(os.environ.get("MQTT_PORT", "1883"))
# Devices to simulate
-DEVICES = ["test_lampe_1", "test_lampe_2"]
+DEVICES = ["test_lampe_1", "test_lampe_2", "test_lampe_3"]
# Device states (one per device)
device_states = {
@@ -39,6 +39,10 @@ device_states = {
"test_lampe_2": {
"power": "off",
"brightness": 50
+ },
+ "test_lampe_3": {
+ "power": "off",
+ "brightness": 50
}
}
diff --git a/tools/test_api_client.py b/tools/test_api_client.py
new file mode 100644
index 0000000..87a9816
--- /dev/null
+++ b/tools/test_api_client.py
@@ -0,0 +1,169 @@
+"""
+Acceptance tests for API Client (fetch_devices).
+
+Tests:
+1. API running: fetch_devices() returns list with test_lampe_1, test_lampe_2
+2. API down: fetch_devices() returns empty list without crash
+"""
+import sys
+import time
+import subprocess
+from apps.ui.api_client import fetch_devices
+
+
+def test_api_running():
+ """Test 1: API is running -> fetch_devices() returns devices."""
+ print("Test 1: API running")
+ print("-" * 60)
+
+ devices = fetch_devices("http://localhost:8001")
+
+ if not devices:
+ print(" β FAILED: Expected devices, got empty list")
+ return False
+
+ print(f" β Received {len(devices)} devices")
+
+ # Check structure
+ for device in devices:
+ device_id = device.get("device_id")
+ device_type = device.get("type")
+ name = device.get("name")
+
+ if not device_id:
+ print(f" β FAILED: Device missing 'device_id': {device}")
+ return False
+
+ if not device_type:
+ print(f" β FAILED: Device missing 'type': {device}")
+ return False
+
+ if not name:
+ print(f" β FAILED: Device missing 'name': {device}")
+ return False
+
+ print(f" β’ {device_id} (type={device_type}, name={name})")
+
+ # Check for expected devices
+ device_ids = {d["device_id"] for d in devices}
+ expected = {"test_lampe_1", "test_lampe_2"}
+
+ if not expected.issubset(device_ids):
+ missing = expected - device_ids
+ print(f" β FAILED: Missing devices: {missing}")
+ return False
+
+ print(" β All expected devices present")
+ print()
+ return True
+
+
+def test_api_down():
+ """Test 2: API is down -> fetch_devices() returns empty list without crash."""
+ print("Test 2: API down (invalid port)")
+ print("-" * 60)
+
+ try:
+ devices = fetch_devices("http://localhost:9999")
+
+ if devices != []:
+ print(f" β FAILED: Expected empty list, got {len(devices)} devices")
+ return False
+
+ print(" β Returns empty list without crash")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: Exception raised: {e}")
+ print()
+ return False
+
+
+def test_api_timeout():
+ """Test 3: API timeout -> fetch_devices() returns empty list."""
+ print("Test 3: API timeout")
+ print("-" * 60)
+
+ # Use httpbin.org delay endpoint to simulate slow API
+ try:
+ start = time.time()
+ devices = fetch_devices("https://httpbin.org/delay/5") # 5s delay, but 3s timeout
+ elapsed = time.time() - start
+
+ if devices != []:
+ print(f" β FAILED: Expected empty list, got {len(devices)} devices")
+ return False
+
+ if elapsed >= 4.0:
+ print(f" β FAILED: Timeout not enforced (took {elapsed:.1f}s)")
+ return False
+
+ print(f" β Returns empty list after timeout ({elapsed:.1f}s)")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: Exception raised: {e}")
+ print()
+ return False
+
+
+def main():
+ """Run all acceptance tests."""
+ print("=" * 60)
+ print("Testing API Client (fetch_devices)")
+ print("=" * 60)
+ print()
+
+ # Check if API is running
+ print("Prerequisites: Checking if API is running on port 8001...")
+ try:
+ devices = fetch_devices("http://localhost:8001")
+ if devices:
+ print(f"β API is running ({len(devices)} devices found)")
+ print()
+ else:
+ print("β API might not be running (no devices returned)")
+ print(" Start API with: poetry run uvicorn apps.api.main:app --port 8001")
+ print()
+ except Exception as e:
+ print(f"β Cannot reach API: {e}")
+ print(" Start API with: poetry run uvicorn apps.api.main:app --port 8001")
+ print()
+ sys.exit(1)
+
+ results = []
+
+ # Test 1: API running
+ results.append(("API running", test_api_running()))
+
+ # Test 2: API down
+ results.append(("API down", test_api_down()))
+
+ # Test 3: Timeout
+ results.append(("API timeout", test_api_timeout()))
+
+ # Summary
+ print("=" * 60)
+ passed = sum(1 for _, result in results if result)
+ total = len(results)
+ print(f"Results: {passed}/{total} tests passed")
+ print("=" * 60)
+
+ for name, result in results:
+ status = "β" if result else "β"
+ print(f" {status} {name}")
+
+ if passed == total:
+ print()
+ print("All tests passed!")
+ sys.exit(0)
+ else:
+ print()
+ print("Some tests failed.")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/test_dashboard.py b/tools/test_dashboard.py
new file mode 100644
index 0000000..93c3f22
--- /dev/null
+++ b/tools/test_dashboard.py
@@ -0,0 +1,210 @@
+"""
+Acceptance tests for Dashboard Route.
+
+Tests:
+1. GET /dashboard loads without errors
+2. Rooms are shown in layout.yaml order
+3. Devices within each room are sorted by rank (ascending)
+4. GET / redirects to dashboard
+"""
+import sys
+import httpx
+
+
+def test_dashboard_loads():
+ """Test 1: Dashboard loads without errors."""
+ print("Test 1: Dashboard loads")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
+ response.raise_for_status()
+
+ html = response.text
+
+ # Check for essential elements
+ if "Home Automation" not in html:
+ print(" β FAILED: Missing title 'Home Automation'")
+ return False
+
+ if "Wohnzimmer" not in html:
+ print(" β FAILED: Missing room 'Wohnzimmer'")
+ return False
+
+ if "Schlafzimmer" not in html:
+ print(" β FAILED: Missing room 'Schlafzimmer'")
+ return False
+
+ print(" β Dashboard loads successfully")
+ print(" β Contains expected rooms")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def test_room_order():
+ """Test 2: Rooms appear in layout.yaml order."""
+ print("Test 2: Room order matches layout.yaml")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
+ response.raise_for_status()
+
+ html = response.text
+
+ # Find positions of room titles
+ wohnzimmer_pos = html.find('class="room-title">Wohnzimmer<')
+ schlafzimmer_pos = html.find('class="room-title">Schlafzimmer<')
+
+ if wohnzimmer_pos == -1:
+ print(" β FAILED: Room 'Wohnzimmer' not found")
+ return False
+
+ if schlafzimmer_pos == -1:
+ print(" β FAILED: Room 'Schlafzimmer' not found")
+ return False
+
+ # Wohnzimmer should appear before Schlafzimmer
+ if wohnzimmer_pos > schlafzimmer_pos:
+ print(" β FAILED: Room order incorrect")
+ print(f" Wohnzimmer at position {wohnzimmer_pos}")
+ print(f" Schlafzimmer at position {schlafzimmer_pos}")
+ return False
+
+ print(" β Rooms appear in correct order:")
+ print(" 1. Wohnzimmer")
+ print(" 2. Schlafzimmer")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def test_device_rank_sorting():
+ """Test 3: Devices are sorted by rank (ascending)."""
+ print("Test 3: Devices sorted by rank")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
+ response.raise_for_status()
+
+ html = response.text
+
+ # In Wohnzimmer: Deckenlampe (rank=5) should come before Stehlampe (rank=10)
+ deckenlampe_pos = html.find('device-title">Deckenlampe<')
+ stehlampe_pos = html.find('device-title">Stehlampe<')
+
+ if deckenlampe_pos == -1:
+ print(" β FAILED: Device 'Deckenlampe' not found")
+ return False
+
+ if stehlampe_pos == -1:
+ print(" β FAILED: Device 'Stehlampe' not found")
+ return False
+
+ if deckenlampe_pos > stehlampe_pos:
+ print(" β FAILED: Devices not sorted by rank")
+ print(f" Deckenlampe (rank=5) at position {deckenlampe_pos}")
+ print(f" Stehlampe (rank=10) at position {stehlampe_pos}")
+ return False
+
+ print(" β Devices sorted by rank (ascending):")
+ print(" Wohnzimmer: Deckenlampe (rank=5) β Stehlampe (rank=10)")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def test_root_redirect():
+ """Test 4: GET / shows dashboard."""
+ print("Test 4: Root path (/) shows dashboard")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/", timeout=5.0, follow_redirects=True)
+ response.raise_for_status()
+
+ html = response.text
+
+ # Should contain dashboard elements
+ if "Home Automation" not in html:
+ print(" β FAILED: Root path doesn't show dashboard")
+ return False
+
+ if "Wohnzimmer" not in html:
+ print(" β FAILED: Root path missing rooms")
+ return False
+
+ print(" β Root path shows dashboard")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def main():
+ """Run all acceptance tests."""
+ print("=" * 60)
+ print("Testing Dashboard Route")
+ print("=" * 60)
+ print()
+
+ # Check if UI is running
+ print("Prerequisites: Checking if UI is running on port 8002...")
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=3.0)
+ print("β UI is running")
+ print()
+ except Exception as e:
+ print(f"β Cannot reach UI: {e}")
+ print(" Start UI with: poetry run uvicorn apps.ui.main:app --port 8002")
+ print()
+ sys.exit(1)
+
+ results = []
+
+ # Run tests
+ results.append(("Dashboard loads", test_dashboard_loads()))
+ results.append(("Room order", test_room_order()))
+ results.append(("Device rank sorting", test_device_rank_sorting()))
+ results.append(("Root redirect", test_root_redirect()))
+
+ # Summary
+ print("=" * 60)
+ passed = sum(1 for _, result in results if result)
+ total = len(results)
+ print(f"Results: {passed}/{total} tests passed")
+ print("=" * 60)
+
+ for name, result in results:
+ status = "β" if result else "β"
+ print(f" {status} {name}")
+
+ if passed == total:
+ print()
+ print("All tests passed!")
+ sys.exit(0)
+ else:
+ print()
+ print("Some tests failed.")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/test_layout_loader.py b/tools/test_layout_loader.py
new file mode 100644
index 0000000..baa1832
--- /dev/null
+++ b/tools/test_layout_loader.py
@@ -0,0 +1,288 @@
+#!/usr/bin/env python3
+"""Test layout loader and models."""
+
+import sys
+import tempfile
+from pathlib import Path
+
+# Add parent directory to path
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+from packages.home_capabilities.layout import DeviceTile, Room, UiLayout, load_layout
+
+
+def test_device_tile_creation():
+ """Test DeviceTile model creation."""
+ print("Test 1: DeviceTile Creation")
+
+ tile = DeviceTile(
+ device_id="test_lamp",
+ title="Test Lamp",
+ icon="π‘",
+ rank=10
+ )
+
+ assert tile.device_id == "test_lamp"
+ assert tile.title == "Test Lamp"
+ assert tile.icon == "π‘"
+ assert tile.rank == 10
+
+ print(" β DeviceTile created successfully")
+ return True
+
+
+def test_room_creation():
+ """Test Room model creation."""
+ print("\nTest 2: Room Creation")
+
+ tiles = [
+ DeviceTile(device_id="lamp1", title="Lamp 1", icon="π‘", rank=1),
+ DeviceTile(device_id="lamp2", title="Lamp 2", icon="π‘", rank=2)
+ ]
+
+ room = Room(name="Living Room", devices=tiles)
+
+ assert room.name == "Living Room"
+ assert len(room.devices) == 2
+ assert room.devices[0].rank == 1
+
+ print(" β Room with 2 devices created")
+ return True
+
+
+def test_ui_layout_creation():
+ """Test UiLayout model creation."""
+ print("\nTest 3: UiLayout Creation")
+
+ rooms = [
+ Room(
+ name="Room 1",
+ devices=[DeviceTile(device_id="d1", title="D1", icon="π‘", rank=1)]
+ ),
+ Room(
+ name="Room 2",
+ devices=[DeviceTile(device_id="d2", title="D2", icon="π‘", rank=1)]
+ )
+ ]
+
+ layout = UiLayout(rooms=rooms)
+
+ assert len(layout.rooms) == 2
+ assert layout.total_devices() == 2
+
+ print(" β UiLayout with 2 rooms created")
+ print(f" β Total devices: {layout.total_devices()}")
+ return True
+
+
+def test_layout_validation_empty_rooms():
+ """Test that layout validation rejects empty rooms list."""
+ print("\nTest 4: Validation - Empty Rooms")
+
+ try:
+ UiLayout(rooms=[])
+ print(" β Should have raised ValueError")
+ return False
+ except ValueError as e:
+ if "at least one room" in str(e):
+ print(f" β Correct validation error: {e}")
+ return True
+ else:
+ print(f" β Wrong error message: {e}")
+ return False
+
+
+def test_load_layout_from_file():
+ """Test loading layout from YAML file."""
+ print("\nTest 5: Load Layout from YAML")
+
+ yaml_content = """
+rooms:
+ - name: Wohnzimmer
+ devices:
+ - device_id: test_lampe_1
+ title: Stehlampe
+ icon: "π‘"
+ rank: 10
+ - device_id: test_lampe_2
+ title: Tischlampe
+ icon: "π‘"
+ rank: 20
+
+ - name: Schlafzimmer
+ devices:
+ - device_id: test_lampe_3
+ title: Nachttischlampe
+ icon: "ποΈ"
+ rank: 5
+"""
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
+ f.write(yaml_content)
+ temp_path = Path(f.name)
+
+ try:
+ layout = load_layout(str(temp_path))
+
+ assert len(layout.rooms) == 2
+ assert layout.rooms[0].name == "Wohnzimmer"
+ assert len(layout.rooms[0].devices) == 2
+ assert layout.total_devices() == 3
+
+ # Check device sorting by rank
+ wohnzimmer_devices = layout.rooms[0].devices
+ assert wohnzimmer_devices[0].rank == 10
+ assert wohnzimmer_devices[1].rank == 20
+
+ print(" β Layout loaded from YAML")
+ print(f" β Rooms: {len(layout.rooms)}")
+ print(f" β Total devices: {layout.total_devices()}")
+ print(f" β Room order preserved: {[r.name for r in layout.rooms]}")
+
+ return True
+
+ finally:
+ temp_path.unlink()
+
+
+def test_load_layout_missing_file():
+ """Test error handling for missing file."""
+ print("\nTest 6: Missing File Error")
+
+ try:
+ load_layout("/nonexistent/path/layout.yaml")
+ print(" β Should have raised FileNotFoundError")
+ return False
+ except FileNotFoundError as e:
+ if "not found" in str(e):
+ print(f" β Correct error for missing file")
+ return True
+ else:
+ print(f" β Wrong error message: {e}")
+ return False
+
+
+def test_load_layout_invalid_yaml():
+ """Test error handling for invalid YAML."""
+ print("\nTest 7: Invalid YAML Error")
+
+ yaml_content = """
+rooms:
+ - name: Room1
+ devices: [invalid yaml structure
+"""
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
+ f.write(yaml_content)
+ temp_path = Path(f.name)
+
+ try:
+ load_layout(str(temp_path))
+ print(" β Should have raised YAMLError")
+ temp_path.unlink()
+ return False
+ except Exception as e:
+ temp_path.unlink()
+ if "YAML" in str(type(e).__name__) or "parse" in str(e).lower():
+ print(f" β Correct YAML parsing error")
+ return True
+ else:
+ print(f" β Unexpected error: {type(e).__name__}: {e}")
+ return False
+
+
+def test_load_layout_missing_required_fields():
+ """Test validation for missing required fields."""
+ print("\nTest 8: Missing Required Fields")
+
+ yaml_content = """
+rooms:
+ - name: Room1
+ devices:
+ - device_id: lamp1
+ title: Lamp
+ # Missing: icon, rank
+"""
+
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
+ f.write(yaml_content)
+ temp_path = Path(f.name)
+
+ try:
+ load_layout(str(temp_path))
+ print(" β Should have raised ValueError")
+ temp_path.unlink()
+ return False
+ except ValueError as e:
+ temp_path.unlink()
+ if "icon" in str(e) or "rank" in str(e) or "required" in str(e).lower():
+ print(f" β Validation error for missing fields")
+ return True
+ else:
+ print(f" β Wrong error: {e}")
+ return False
+
+
+def test_load_default_layout():
+ """Test loading default layout from workspace."""
+ print("\nTest 9: Load Default Layout (config/layout.yaml)")
+
+ try:
+ layout = load_layout()
+
+ print(f" β Default layout loaded")
+ print(f" β Rooms: {len(layout.rooms)} ({', '.join(r.name for r in layout.rooms)})")
+ print(f" β Total devices: {layout.total_devices()}")
+
+ for room in layout.rooms:
+ print(f" - {room.name}: {len(room.devices)} devices")
+ for device in room.devices:
+ print(f" β’ {device.title} (id={device.device_id}, rank={device.rank})")
+
+ return True
+
+ except FileNotFoundError:
+ print(" β Default layout.yaml not found (expected at config/layout.yaml)")
+ return True # Not a failure if file doesn't exist yet
+ except Exception as e:
+ print(f" β Error loading default layout: {e}")
+ return False
+
+
+def main():
+ """Run all tests."""
+ print("=" * 60)
+ print("Testing Layout Loader and Models")
+ print("=" * 60)
+
+ tests = [
+ test_device_tile_creation,
+ test_room_creation,
+ test_ui_layout_creation,
+ test_layout_validation_empty_rooms,
+ test_load_layout_from_file,
+ test_load_layout_missing_file,
+ test_load_layout_invalid_yaml,
+ test_load_layout_missing_required_fields,
+ test_load_default_layout,
+ ]
+
+ results = []
+ for test in tests:
+ try:
+ results.append(test())
+ except Exception as e:
+ print(f" β Unexpected exception: {e}")
+ results.append(False)
+
+ print("\n" + "=" * 60)
+ passed = sum(results)
+ total = len(results)
+ print(f"Results: {passed}/{total} tests passed")
+ print("=" * 60)
+
+ return 0 if all(results) else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/test_responsive_dashboard.py b/tools/test_responsive_dashboard.py
new file mode 100644
index 0000000..919d2e6
--- /dev/null
+++ b/tools/test_responsive_dashboard.py
@@ -0,0 +1,296 @@
+"""
+Acceptance tests for Responsive Dashboard.
+
+Tests:
+1. Desktop: 4 columns grid
+2. Tablet: 2 columns grid
+3. Mobile: 1 column grid
+4. POST requests work (via API check)
+5. No horizontal scrolling on any viewport
+"""
+import sys
+import httpx
+from bs4 import BeautifulSoup
+
+
+def test_dashboard_html_structure():
+ """Test 1: Dashboard has correct HTML structure."""
+ print("Test 1: Dashboard HTML Structure")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
+ response.raise_for_status()
+
+ html = response.text
+ soup = BeautifulSoup(html, 'html.parser')
+
+ # Check for grid container
+ grid = soup.find('div', class_='devices-grid')
+ if not grid:
+ print(" β FAILED: Missing .devices-grid container")
+ return False
+
+ # Check for device tiles
+ tiles = soup.find_all('div', class_='device-tile')
+ if len(tiles) < 2:
+ print(f" β FAILED: Expected at least 2 device tiles, found {len(tiles)}")
+ return False
+
+ # Check for state spans
+ state_spans = soup.find_all('span', id=lambda x: x and x.startswith('state-'))
+ if len(state_spans) < 2:
+ print(f" β FAILED: Expected state spans, found {len(state_spans)}")
+ return False
+
+ # Check for ON/OFF buttons
+ btn_on = soup.find_all('button', class_='btn-on')
+ btn_off = soup.find_all('button', class_='btn-off')
+
+ if not btn_on or not btn_off:
+ print(" β FAILED: Missing ON/OFF buttons")
+ return False
+
+ print(f" β Found {len(tiles)} device tiles")
+ print(f" β Found {len(state_spans)} state indicators")
+ print(f" β Found {len(btn_on)} ON buttons and {len(btn_off)} OFF buttons")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def test_responsive_css():
+ """Test 2: CSS has responsive grid rules."""
+ print("Test 2: Responsive CSS")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/static/style.css", timeout=5.0)
+ response.raise_for_status()
+
+ css = response.text
+
+ # Check for desktop 4 columns
+ if 'grid-template-columns: repeat(4, 1fr)' not in css:
+ print(" β FAILED: Missing desktop grid (4 columns)")
+ return False
+
+ # Check for tablet media query (2 columns)
+ if 'max-width: 1024px' not in css or 'repeat(2, 1fr)' not in css:
+ print(" β FAILED: Missing tablet media query (2 columns)")
+ return False
+
+ # Check for mobile media query (1 column)
+ if 'max-width: 640px' not in css:
+ print(" β FAILED: Missing mobile media query")
+ return False
+
+ print(" β Desktop: 4 columns (grid-template-columns: repeat(4, 1fr))")
+ print(" β Tablet: 2 columns (@media max-width: 1024px)")
+ print(" β Mobile: 1 column (@media max-width: 640px)")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def test_javascript_functions():
+ """Test 3: JavaScript POST function exists."""
+ print("Test 3: JavaScript POST Function")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
+ response.raise_for_status()
+
+ html = response.text
+
+ # Check for setDeviceState function
+ if 'function setDeviceState' not in html and 'async function setDeviceState' not in html:
+ print(" β FAILED: Missing setDeviceState function")
+ return False
+
+ # Check for fetch POST call
+ if 'fetch(' not in html or 'method: \'POST\'' not in html:
+ print(" β FAILED: Missing fetch POST call")
+ return False
+
+ # Check for correct API endpoint pattern
+ if '/devices/${deviceId}/set' not in html and '/devices/' not in html:
+ print(" β FAILED: Missing correct API endpoint")
+ return False
+
+ # Check for JSON payload
+ if 'type: \'light\'' not in html and '"type":"light"' not in html:
+ print(" β FAILED: Missing correct JSON payload")
+ return False
+
+ print(" β setDeviceState function defined")
+ print(" β Uses fetch with POST method")
+ print(" β Correct endpoint: /devices/{deviceId}/set")
+ print(" β Correct payload: {type:'light', payload:{power:...}}")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def test_device_controls():
+ """Test 4: Devices have correct controls."""
+ print("Test 4: Device Controls")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
+ response.raise_for_status()
+
+ html = response.text
+ soup = BeautifulSoup(html, 'html.parser')
+
+ # Find device tiles
+ tiles = soup.find_all('div', class_='device-tile')
+
+ for tile in tiles:
+ device_id = tile.get('data-device-id')
+
+ # Check for device header
+ header = tile.find('div', class_='device-header')
+ if not header:
+ print(f" β FAILED: Device {device_id} missing header")
+ return False
+
+ # Check for icon and title
+ icon = tile.find('div', class_='device-icon')
+ title = tile.find('h3', class_='device-title')
+ device_id_elem = tile.find('p', class_='device-id')
+
+ if not icon or not title or not device_id_elem:
+ print(f" β FAILED: Device {device_id} missing icon/title/id")
+ return False
+
+ # Check for state indicator
+ state_span = tile.find('span', id=f'state-{device_id}')
+ if not state_span:
+ print(f" β FAILED: Device {device_id} missing state indicator")
+ return False
+
+ print(f" β All {len(tiles)} devices have:")
+ print(" β’ Icon, title, and device_id")
+ print(" β’ State indicator (span#state-{{device_id}})")
+ print(" β’ ON/OFF buttons (for lights with power feature)")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def test_rank_sorting():
+ """Test 5: Devices sorted by rank."""
+ print("Test 5: Device Rank Sorting")
+ print("-" * 60)
+
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=5.0)
+ response.raise_for_status()
+
+ html = response.text
+
+ # In Wohnzimmer: Deckenlampe (rank=5) should come before Stehlampe (rank=10)
+ deckenlampe_pos = html.find('device-title">Deckenlampe<')
+ stehlampe_pos = html.find('device-title">Stehlampe<')
+
+ if deckenlampe_pos == -1 or stehlampe_pos == -1:
+ print(" βΉ INFO: Test devices not found (expected for test)")
+ print()
+ return True
+
+ if deckenlampe_pos > stehlampe_pos:
+ print(" β FAILED: Devices not sorted by rank")
+ return False
+
+ print(" β Devices sorted by rank (Deckenlampe before Stehlampe)")
+ print()
+ return True
+
+ except Exception as e:
+ print(f" β FAILED: {e}")
+ print()
+ return False
+
+
+def main():
+ """Run all acceptance tests."""
+ print("=" * 60)
+ print("Testing Responsive Dashboard")
+ print("=" * 60)
+ print()
+
+ # Check if UI is running
+ print("Prerequisites: Checking if UI is running on port 8002...")
+ try:
+ response = httpx.get("http://localhost:8002/dashboard", timeout=3.0)
+ print("β UI is running")
+ print()
+ except Exception as e:
+ print(f"β Cannot reach UI: {e}")
+ print(" Start UI with: poetry run uvicorn apps.ui.main:app --port 8002")
+ print()
+ sys.exit(1)
+
+ results = []
+
+ # Run tests
+ results.append(("HTML Structure", test_dashboard_html_structure()))
+ results.append(("Responsive CSS", test_responsive_css()))
+ results.append(("JavaScript POST", test_javascript_functions()))
+ results.append(("Device Controls", test_device_controls()))
+ results.append(("Rank Sorting", test_rank_sorting()))
+
+ # Summary
+ print("=" * 60)
+ passed = sum(1 for _, result in results if result)
+ total = len(results)
+ print(f"Results: {passed}/{total} tests passed")
+ print("=" * 60)
+
+ for name, result in results:
+ status = "β" if result else "β"
+ print(f" {status} {name}")
+
+ print()
+ print("Manual Tests Required:")
+ print(" 1. Open http://localhost:8002/dashboard in browser")
+ print(" 2. Resize browser window to test responsive breakpoints:")
+ print(" - Desktop (>1024px): Should show 4 columns")
+ print(" - Tablet (640-1024px): Should show 2 columns")
+ print(" - Mobile (<640px): Should show 1 column")
+ print(" 3. Click ON/OFF buttons and check Network tab in DevTools")
+ print(" - Should see POST to http://localhost:8001/devices/.../set")
+ print(" - No JavaScript errors in Console")
+ print(" 4. Verify no horizontal scrolling at any viewport size")
+
+ if passed == total:
+ print()
+ print("All automated tests passed!")
+ sys.exit(0)
+ else:
+ print()
+ print("Some tests failed.")
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()