dynamic dashboard initial

This commit is contained in:
2025-11-04 19:33:47 +01:00
parent 69e07056a1
commit ca623121a3
15 changed files with 1774 additions and 7 deletions

View File

@@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, AsyncGenerator from typing import Any, AsyncGenerator
@@ -16,6 +17,8 @@ from pydantic import BaseModel, ValidationError
from packages.home_capabilities import CAP_VERSION, LightState from packages.home_capabilities import CAP_VERSION, LightState
logger = logging.getLogger(__name__)
app = FastAPI( app = FastAPI(
title="Home Automation API", title="Home Automation API",
description="API for home automation system", description="API for home automation system",
@@ -71,6 +74,7 @@ class DeviceInfo(BaseModel):
device_id: str device_id: str
type: str type: str
name: str name: str
features: dict[str, Any] = {}
# Configuration helpers # Configuration helpers
@@ -145,19 +149,57 @@ async def get_devices() -> list[DeviceInfo]:
"""Get list of available devices. """Get list of available devices.
Returns: Returns:
list: List of device information list: List of device information including features
""" """
devices = load_devices() devices = load_devices()
return [ return [
DeviceInfo( DeviceInfo(
device_id=device["device_id"], device_id=device["device_id"],
type=device["type"], 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 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) @app.post("/devices/{device_id}/set", status_code=status.HTTP_202_ACCEPTED)
async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]: async def set_device(device_id: str, request: SetDeviceRequest) -> dict[str, str]:
"""Set device state. """Set device state.

116
apps/ui/api_client.py Normal file
View File

@@ -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": []}

View File

@@ -1,11 +1,17 @@
"""UI main entry point.""" """UI main entry point."""
import logging
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from apps.ui.api_client import fetch_devices, fetch_layout
logger = logging.getLogger(__name__)
# Initialize FastAPI app # Initialize FastAPI app
app = FastAPI( app = FastAPI(
title="Home Automation UI", title="Home Automation UI",
@@ -17,18 +23,91 @@ app = FastAPI(
templates_dir = Path(__file__).parent / "templates" templates_dir = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir)) 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) @app.get("/", response_class=HTMLResponse)
async def index(request: Request) -> HTMLResponse: async def index(request: Request) -> HTMLResponse:
"""Render the main UI page. """Redirect to dashboard.
Args: Args:
request: The FastAPI request object request: The FastAPI request object
Returns: 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: def main() -> None:

257
apps/ui/static/style.css Normal file
View File

@@ -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;
}
}

View File

@@ -0,0 +1,111 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Home Automation Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="container">
<header>
<h1>🏠 Home Automation</h1>
</header>
<main>
{% if rooms %}
{% for room in rooms %}
<section class="room">
<h2 class="room-title">{{ room.name }}</h2>
<div class="devices-grid">
{% for device in room.devices %}
<div class="device-tile" data-device-id="{{ device.device_id }}">
<div class="device-header">
<div class="device-icon">{{ device.icon }}</div>
<div class="device-info">
<h3 class="device-title">{{ device.title }}</h3>
<p class="device-id">{{ device.device_id }}</p>
</div>
</div>
<div class="device-state">
<span id="state-{{ device.device_id }}" class="state-text"></span>
</div>
{% if device.type == "light" and device.features.power %}
<div class="device-controls">
<button class="btn btn-on" onclick="setDeviceState('{{ device.device_id }}', 'on')">
AN
</button>
<button class="btn btn-off" onclick="setDeviceState('{{ device.device_id }}', 'off')">
AUS
</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</section>
{% endfor %}
{% else %}
<div class="empty-state">
<p>Keine Räume oder Geräte konfiguriert.</p>
<p class="hint">Prüfe config/layout.yaml und das API-Gateway.</p>
</div>
{% endif %}
</main>
<footer>
<p>Home Automation System v0.1.0</p>
</footer>
</div>
<script>
// Set device state via API
async function setDeviceState(deviceId, power) {
const stateSpan = document.getElementById(`state-${deviceId}`);
try {
stateSpan.textContent = `${power}...`;
const response = await fetch(`http://localhost:8001/devices/${deviceId}/set`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'light',
payload: {
power: power
}
})
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
console.log('Device state set:', deviceId, power, result);
stateSpan.textContent = `${power}`;
// Reset to "—" after 2 seconds
setTimeout(() => {
stateSpan.textContent = '—';
}, 2000);
} catch (error) {
console.error('Error setting device state:', error);
stateSpan.textContent = `✗ Error`;
// Reset after 3 seconds
setTimeout(() => {
stateSpan.textContent = '—';
}, 3000);
}
}
</script>
</body>
</html>

View File

@@ -32,3 +32,12 @@ devices:
topics: topics:
set: "vendor/test_lampe_2/set" set: "vendor/test_lampe_2/set"
state: "vendor/test_lampe_2/state" 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"

25
config/layout.yaml Normal file
View File

@@ -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: "<22>"
rank: 10
- name: Schlafzimmer
devices:
- device_id: test_lampe_3
title: Nachttischlampe
icon: "🛏️"
rank: 10

View File

@@ -1,5 +1,6 @@
"""Home capabilities package.""" """Home capabilities package."""
from packages.home_capabilities.light import CAP_VERSION, LightState 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"]

View File

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

View File

@@ -21,6 +21,8 @@ jinja2 = "^3.1.6"
apscheduler = "^3.11.0" apscheduler = "^3.11.0"
redis = "^7.0.1" redis = "^7.0.1"
paho-mqtt = "^2.1.0" paho-mqtt = "^2.1.0"
httpx = "^0.28.1"
beautifulsoup4 = "^4.14.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
ruff = "^0.6.0" ruff = "^0.6.0"

View File

@@ -28,7 +28,7 @@ BROKER_HOST = os.environ.get("MQTT_HOST", "172.16.2.16")
BROKER_PORT = int(os.environ.get("MQTT_PORT", "1883")) BROKER_PORT = int(os.environ.get("MQTT_PORT", "1883"))
# Devices to simulate # 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 (one per device)
device_states = { device_states = {
@@ -39,6 +39,10 @@ device_states = {
"test_lampe_2": { "test_lampe_2": {
"power": "off", "power": "off",
"brightness": 50 "brightness": 50
},
"test_lampe_3": {
"power": "off",
"brightness": 50
} }
} }

169
tools/test_api_client.py Normal file
View File

@@ -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()

210
tools/test_dashboard.py Normal file
View File

@@ -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()

288
tools/test_layout_loader.py Normal file
View File

@@ -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())

View File

@@ -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()