dynamic dashboard initial
This commit is contained in:
@@ -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.
|
||||
|
||||
116
apps/ui/api_client.py
Normal file
116
apps/ui/api_client.py
Normal 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": []}
|
||||
@@ -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:
|
||||
|
||||
257
apps/ui/static/style.css
Normal file
257
apps/ui/static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
111
apps/ui/templates/dashboard.html
Normal file
111
apps/ui/templates/dashboard.html
Normal 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>
|
||||
Reference in New Issue
Block a user