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 + + + +
+
+

🏠 Home Automation

+
+ +
+ {% if rooms %} + {% for room in rooms %} +
+

{{ room.name }}

+ +
+ {% for device in room.devices %} +
+
+
{{ device.icon }}
+
+

{{ device.title }}

+

{{ device.device_id }}

+
+
+ +
+ β€” +
+ + {% 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()