This commit is contained in:
2026-01-12 19:03:50 +00:00
parent 0da76927ce
commit d4b8dcd455
48 changed files with 1352 additions and 723 deletions
Binary file not shown.
Binary file not shown.
+30 -2
View File
@@ -4,12 +4,13 @@ from __future__ import annotations
from typing import Any, Callable from typing import Any, Callable
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from core.box_controller import BoxController from core.box_controller import BoxController
from core.lifecycle import NetworkLifecycle from core.lifecycle import NetworkLifecycle
from core.pairing import PairingManager, PairingState
from spotify.base import SpotifyBackend from spotify.base import SpotifyBackend
from wifi.base import WifiBackend from wifi.base import WifiBackend
@@ -24,6 +25,7 @@ def create_app(
lifecycle: NetworkLifecycle, lifecycle: NetworkLifecycle,
wifi_backend: WifiBackend, wifi_backend: WifiBackend,
spotify_backend: SpotifyBackend, spotify_backend: SpotifyBackend,
pairing_manager: PairingManager,
persist_state: Callable[[], None], persist_state: Callable[[], None],
) -> FastAPI: ) -> FastAPI:
app = FastAPI() app = FastAPI()
@@ -40,6 +42,7 @@ def create_app(
state = controller.state state = controller.state
return { return {
"box_id": state.box_id, "box_id": state.box_id,
"pairing_state": pairing_manager.state.value,
"wifi_state": snapshot.wifi_state.value, "wifi_state": snapshot.wifi_state.value,
"ip_address": snapshot.ip_address, "ip_address": snapshot.ip_address,
"connected_ssid": snapshot.connected_ssid, "connected_ssid": snapshot.connected_ssid,
@@ -59,10 +62,13 @@ def create_app(
} }
@app.post("/command") @app.post("/command")
def command(request: CommandRequest) -> dict[str, Any]: def command(request: CommandRequest, http_request: Request) -> dict[str, Any]:
cmd = request.command cmd = request.command
payload = request.payload or {} payload = request.payload or {}
if _requires_pairing(cmd) and not _is_authorized(pairing_manager, http_request):
raise HTTPException(status_code=403, detail="pairing required")
if cmd == "nfc_on": if cmd == "nfc_on":
uid = payload.get("uid") uid = payload.get("uid")
if not isinstance(uid, str): if not isinstance(uid, str):
@@ -125,3 +131,25 @@ def create_app(
return {"ok": True} return {"ok": True}
return app return app
def _requires_pairing(command: str) -> bool:
allowed_without_pairing = {
"wifi_add_profile",
"wifi_reset",
"spotify_set_tokens",
"spotify_clear",
}
return command not in allowed_without_pairing
def _is_authorized(pairing: PairingManager, http_request: Request) -> bool:
if not pairing.is_paired():
return False
token = pairing.get_api_token()
if not token:
return False
header = http_request.headers.get("X-API-Token")
if not header:
return False
return header == token
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
"""Server announce and pairing polling client."""
from __future__ import annotations
import asyncio
import json
import urllib.request
from dataclasses import dataclass
from typing import Any
from core.identity import compute_fingerprint
from core.pairing import PairingManager, PairingState
from storage.secret_store import SecretStore
@dataclass
class AnnounceConfig:
server_url: str
firmware_version: str
capabilities: dict[str, Any]
announce_interval_seconds: int = 30
polling_interval_seconds: int = 10
def build_payload(
box_id: str,
secret_seed: str,
config: AnnounceConfig,
) -> dict[str, Any]:
fingerprint = compute_fingerprint(box_id, secret_seed)
return {
"box_id": box_id,
"fingerprint": fingerprint,
"firmware_version": config.firmware_version,
"capabilities": config.capabilities,
}
def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any] | None:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url,
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=5) as resp:
body = resp.read().decode("utf-8")
except Exception:
return None
try:
return json.loads(body)
except json.JSONDecodeError:
return None
async def announce_loop(
box_id: str,
secrets: SecretStore,
pairing: PairingManager,
config: AnnounceConfig,
stop_event: asyncio.Event,
) -> None:
secret_seed = secrets.ensure_secret_seed()
payload = build_payload(box_id, secret_seed, config)
announce_url = f"{config.server_url}/api/boxes/announce"
while not stop_event.is_set():
if not pairing.is_paired():
await asyncio.to_thread(_post_json, announce_url, payload)
await asyncio.sleep(config.announce_interval_seconds)
async def pairing_poll_loop(
box_id: str,
pairing: PairingManager,
config: AnnounceConfig,
stop_event: asyncio.Event,
) -> None:
poll_url = f"{config.server_url}/api/boxes/poll-pairing"
payload = {"box_id": box_id}
while not stop_event.is_set():
if pairing.is_paired():
await asyncio.sleep(config.polling_interval_seconds)
continue
pairing.set_state(PairingState.PAIRING_PENDING)
response = await asyncio.to_thread(_post_json, poll_url, payload)
if response and response.get("paired") is True:
token = response.get("api_token")
if isinstance(token, str) and token:
pairing.save_api_token(token)
await asyncio.sleep(config.polling_interval_seconds)
+17 -2
View File
@@ -2,14 +2,29 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import secrets import secrets
import string import string
PREFIX = "klangkiste-" PREFIX = "klangkiste-"
ALPHABET = string.ascii_lowercase + string.digits LETTERS = string.ascii_lowercase
DIGITS = string.digits
SUFFIX_LENGTH = 10 SUFFIX_LENGTH = 10
STATIC_APP_SALT = "klangkiste-static-app-salt-v1"
def generate_box_id() -> str: def generate_box_id() -> str:
suffix = "".join(secrets.choice(ALPHABET) for _ in range(SUFFIX_LENGTH)) start_with_letter = secrets.choice([True, False])
suffix_chars = []
for i in range(SUFFIX_LENGTH):
if (i % 2 == 0) == start_with_letter:
suffix_chars.append(secrets.choice(LETTERS))
else:
suffix_chars.append(secrets.choice(DIGITS))
suffix = "".join(suffix_chars)
return f"{PREFIX}{suffix}" return f"{PREFIX}{suffix}"
def compute_fingerprint(box_id: str, secret_seed: str) -> str:
payload = f"{box_id}{secret_seed}{STATIC_APP_SALT}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()
+61
View File
@@ -0,0 +1,61 @@
"""Pairing state management for the Box."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from storage.state_store import StateStore
from storage.secret_store import SecretStore
class PairingState(str, Enum):
UNPAIRED = "UNPAIRED"
PAIRING_PENDING = "PAIRING_PENDING"
PAIRED = "PAIRED"
@dataclass
class PairingSnapshot:
state: PairingState
class PairingManager:
def __init__(self, state_store: StateStore, secret_store: SecretStore) -> None:
self._state_store = state_store
self._secret_store = secret_store
self._state = PairingState.UNPAIRED
self._load()
@property
def state(self) -> PairingState:
return self._state
def set_state(self, state: PairingState) -> None:
self._state = state
self._save()
def snapshot(self) -> PairingSnapshot:
return PairingSnapshot(state=self._state)
def is_paired(self) -> bool:
return self._state == PairingState.PAIRED
def get_api_token(self) -> str | None:
return self._secret_store.get_api_token()
def save_api_token(self, token: str) -> None:
self._secret_store.save_api_token(token)
self.set_state(PairingState.PAIRED)
def _load(self) -> None:
data = self._state_store.load()
pairing = data.get("pairing") if isinstance(data.get("pairing"), dict) else {}
state_raw = pairing.get("state")
if isinstance(state_raw, str) and state_raw in PairingState.__members__:
self._state = PairingState[state_raw]
def _save(self) -> None:
data = self._state_store.load()
data["pairing"] = {"state": self._state.name}
self._state_store.save(data)
+37
View File
@@ -0,0 +1,37 @@
{
"box_id": "klangkiste-x1u8i4x5e4",
"settings": {},
"tags": {
"UID_1": {
"type": "folder",
"path": "media/book_1"
},
"UID_2": {
"type": "folder",
"path": "media/book_2"
},
"UID_3": {
"type": "folder",
"path": "media/book_3"
},
"UID_4": {
"type": "folder",
"path": "media/book_4"
},
"UID_5": {
"type": "folder",
"path": "media/book_5"
},
"UID_6": {
"type": "folder",
"path": "media/book_6"
}
},
"server_url": "http://127.0.0.1:7000",
"firmware_version": "0.1.0",
"capabilities": {
"nfc": true,
"audio": true,
"spotify": true
}
}
+1
View File
@@ -0,0 +1 @@
gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i
+14
View File
@@ -0,0 +1,14 @@
{
"wifi": {
"connected_ssid": null
},
"resume": {
"UID_1": {
"file_index": 1,
"position": 79
}
},
"pairing": {
"state": "PAIRING_PENDING"
}
}
+52 -10
View File
@@ -13,9 +13,11 @@ from typing import Any
import uvicorn import uvicorn
from api.http import create_app from api.http import create_app
from core.announce import AnnounceConfig, announce_loop, pairing_poll_loop
from core.box_controller import BoxController from core.box_controller import BoxController
from core.identity import generate_box_id from core.identity import generate_box_id
from core.lifecycle import NetworkLifecycle from core.lifecycle import NetworkLifecycle
from core.pairing import PairingManager
from core.state import BoxState, RuntimeState from core.state import BoxState, RuntimeState
from player.mock_player import MockPlayer from player.mock_player import MockPlayer
from setup.webui import create_setup_app from setup.webui import create_setup_app
@@ -35,7 +37,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument( parser.add_argument(
"--config", "--config",
default="config/box_config.json", default="config/box_config.json",
help=\"Path to local box config JSON (legacy seed)\", help="Path to local box config JSON (legacy seed)",
) )
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
@@ -137,6 +139,12 @@ def main() -> int:
box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {})) box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {}))
if isinstance(box_meta.get("tags"), dict): if isinstance(box_meta.get("tags"), dict):
box_meta["tags"] = _normalize_tag_paths(box_meta.get("tags", {})) box_meta["tags"] = _normalize_tag_paths(box_meta.get("tags", {}))
if "server_url" not in box_meta:
box_meta["server_url"] = os.getenv("KLANGKISTE_SERVER_URL", "http://127.0.0.1:7000")
if "firmware_version" not in box_meta:
box_meta["firmware_version"] = "0.1.0"
if "capabilities" not in box_meta:
box_meta["capabilities"] = {"nfc": True, "audio": True, "spotify": True}
box_store.save(box_meta) box_store.save(box_meta)
state_store = StateStore(base_dir) state_store = StateStore(base_dir)
@@ -144,9 +152,11 @@ def main() -> int:
state = _state_from_data(box_meta, state_data) state = _state_from_data(box_meta, state_data)
secrets = SecretStore(base_dir, state.box_id) secrets = SecretStore(base_dir, state.box_id)
secrets.ensure_secret_seed()
wifi_backend = MockWifiBackend(state_store, secrets) wifi_backend = MockWifiBackend(state_store, secrets)
spotify_backend = MockSpotifyBackend(secrets) spotify_backend = MockSpotifyBackend(secrets)
lifecycle = NetworkLifecycle(wifi_backend) lifecycle = NetworkLifecycle(wifi_backend)
pairing_manager = PairingManager(state_store, secrets)
controller = BoxController( controller = BoxController(
state=state, state=state,
player=MockPlayer(), player=MockPlayer(),
@@ -177,15 +187,25 @@ def main() -> int:
return 0 return 0
if args.command == "run": if args.command == "run":
asyncio.run( try:
_run_with_api( asyncio.run(
controller, _run_with_api(
state_store, controller,
lifecycle, state_store,
wifi_backend, lifecycle,
spotify_backend, wifi_backend,
spotify_backend,
pairing_manager,
secrets,
AnnounceConfig(
server_url=str(box_meta.get("server_url")),
firmware_version=str(box_meta.get("firmware_version")),
capabilities=dict(box_meta.get("capabilities", {})),
),
)
) )
) except KeyboardInterrupt:
return 0
return 0 return 0
return 0 return 0
@@ -213,12 +233,16 @@ async def _run_with_api(
lifecycle: NetworkLifecycle, lifecycle: NetworkLifecycle,
wifi_backend: MockWifiBackend, wifi_backend: MockWifiBackend,
spotify_backend: MockSpotifyBackend, spotify_backend: MockSpotifyBackend,
pairing_manager: PairingManager,
secrets: SecretStore,
announce_config: AnnounceConfig,
) -> None: ) -> None:
app = create_app( app = create_app(
controller, controller,
lifecycle, lifecycle,
wifi_backend, wifi_backend,
spotify_backend, spotify_backend,
pairing_manager,
lambda: _persist_resume(controller, storage), lambda: _persist_resume(controller, storage),
) )
api_server = uvicorn.Server( api_server = uvicorn.Server(
@@ -234,7 +258,7 @@ async def _run_with_api(
setup_server = uvicorn.Server( setup_server = uvicorn.Server(
uvicorn.Config( uvicorn.Config(
setup_app, setup_app,
host="127.0.0.1", host="0.0.0.0",
port=9000, port=9000,
log_level="warning", log_level="warning",
access_log=False, access_log=False,
@@ -244,6 +268,18 @@ async def _run_with_api(
tick_task = asyncio.create_task( tick_task = asyncio.create_task(
_auto_tick_loop(controller, storage, stop_event) _auto_tick_loop(controller, storage, stop_event)
) )
announce_task = asyncio.create_task(
announce_loop(
controller.state.box_id,
secrets=secrets,
pairing=pairing_manager,
config=announce_config,
stop_event=stop_event,
)
)
poll_task = asyncio.create_task(
pairing_poll_loop(controller.state.box_id, pairing_manager, announce_config, stop_event)
)
api_task = asyncio.create_task(api_server.serve()) api_task = asyncio.create_task(api_server.serve())
setup_task = asyncio.create_task(setup_server.serve()) setup_task = asyncio.create_task(setup_server.serve())
@@ -261,6 +297,12 @@ async def _run_with_api(
tick_task.cancel() tick_task.cancel()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
await tick_task await tick_task
announce_task.cancel()
poll_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await announce_task
with contextlib.suppress(asyncio.CancelledError):
await poll_task
setup_task.cancel() setup_task.cancel()
with contextlib.suppress(asyncio.CancelledError): with contextlib.suppress(asyncio.CancelledError):
await setup_task await setup_task
+1
View File
@@ -2,3 +2,4 @@ fastapi==0.115.6
uvicorn==0.30.6 uvicorn==0.30.6
pydantic==2.9.2 pydantic==2.9.2
cryptography==43.0.3 cryptography==43.0.3
python-multipart==0.0.9
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+35
View File
@@ -36,6 +36,35 @@ class SecretStore:
token = fernet.encrypt(payload) token = fernet.encrypt(payload)
self._path.write_bytes(token) self._path.write_bytes(token)
def ensure_secret_seed(self) -> str:
data = self.load_secrets()
seed = data.get("secret_seed")
if isinstance(seed, str) and seed:
return seed
seed_value = self._generate_seed()
data["secret_seed"] = seed_value
self.save_secrets(data)
return seed_value
def get_secret_seed(self) -> str | None:
data = self.load_secrets()
seed = data.get("secret_seed")
if isinstance(seed, str) and seed:
return seed
return None
def save_api_token(self, token: str) -> None:
data = self.load_secrets()
data["api_token"] = token
self.save_secrets(data)
def get_api_token(self) -> str | None:
data = self.load_secrets()
token = data.get("api_token")
if isinstance(token, str) and token:
return token
return None
def clear_wifi_secrets(self) -> None: def clear_wifi_secrets(self) -> None:
data = self.load_secrets() data = self.load_secrets()
if "wifi_profiles" in data: if "wifi_profiles" in data:
@@ -55,3 +84,9 @@ class SecretStore:
dklen=32, dklen=32,
) )
return base64.urlsafe_b64encode(raw) return base64.urlsafe_b64encode(raw)
@staticmethod
def _generate_seed() -> str:
import secrets
return secrets.token_hex(32)
Binary file not shown.
Binary file not shown.
Binary file not shown.
+17 -2
View File
@@ -7,6 +7,10 @@ echo "🚀 Starting Klangkiste (Box + GUI)"
cleanup() { cleanup() {
echo "" echo ""
echo "🛑 Stopping Klangkiste..." echo "🛑 Stopping Klangkiste..."
if [[ -n "${SERVER_PID:-}" ]]; then
kill -TERM "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
if [[ -n "${GUI_PID:-}" ]]; then if [[ -n "${GUI_PID:-}" ]]; then
kill -TERM "$GUI_PID" 2>/dev/null || true kill -TERM "$GUI_PID" 2>/dev/null || true
wait "$GUI_PID" 2>/dev/null || true wait "$GUI_PID" 2>/dev/null || true
@@ -14,11 +18,21 @@ cleanup() {
} }
trap cleanup INT TERM EXIT trap cleanup INT TERM EXIT
# Server starten (FastAPI + GUI)
echo "▶️ Starting Server..."
(
cd server
python3 main.py
) &
SERVER_PID=$!
# GUI starten (Vite) # GUI starten (Vite)
echo "▶️ Starting GUI..." GUI_PORT="${KLANGKISTE_GUI_PORT:-5174}"
echo "▶️ Starting GUI on port ${GUI_PORT}..."
( (
cd gui cd gui
npm run dev npm run dev -- --host 0.0.0.0 --port "${GUI_PORT}" --strictPort
) & ) &
GUI_PID=$! GUI_PID=$!
@@ -29,6 +43,7 @@ cd box
python3 main.py run python3 main.py run
echo "" echo ""
echo "✅ Server PID: $SERVER_PID"
echo "✅ GUI PID: $GUI_PID" echo "✅ GUI PID: $GUI_PID"
echo "🛑 Press Ctrl+C to stop everything" echo "🛑 Press Ctrl+C to stop everything"
echo "" echo ""
+34 -34
View File
@@ -1,31 +1,31 @@
# Klangkiste Dev Playbook # Klangkiste Dev-Playbook
## Purpose ## Zweck
This playbook documents the "virtual box" development environment. It is a complete, hardware-free simulation that runs entirely inside the repo and lets you test setup, playback, and API flows end-to-end. Dieses Playbook dokumentiert die "virtuelle Box"-Entwicklungsumgebung. Es ist eine vollstaendige, hardwarefreie Simulation, die komplett im Repo laeuft und Setup-, Playback- und API-Flows end-to-end testbar macht.
Docs live in the repo-level `docs/` to keep all dev guidance in one predictable place for both Box and GUI contributors. Die Doku liegt bewusst im Repo-weiten `docs/`-Ordner, damit Box- und GUI-Entwickler einen gemeinsamen Einstiegspunkt haben.
## Prerequisites ## Voraussetzungen
- Python 3 - Python 3
- Node.js + npm - Node.js + npm
- From repo root - Aus dem Repo-Root ausfuehren
## Step-by-step ## Schritt-fuer-Schritt
Quickstart (3-5 minutes): Quickstart (3-5 Minuten):
1) Start everything: 1) Alles starten:
``` ```
./dev.sh ./dev.sh
``` ```
2) Open the WiFi setup UI: 2) Setup-WebGUI oeffnen:
``` ```
http://127.0.0.1:9000/setup http://127.0.0.1:9000/setup
``` ```
3) Add a WiFi profile and submit. 3) WLAN-Profil eintragen und speichern.
4) Check status: 4) Status pruefen:
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
5) Start playback: 5) Playback starten:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -33,26 +33,26 @@ curl -X POST http://127.0.0.1:8000/command \
``` ```
## Troubleshooting ## Troubleshooting
- Box not starting: - Box startet nicht:
- Ensure dependencies are installed and ports 8000/9000 are free. - Pruefe Abhaengigkeiten und ob Ports 8000/9000 frei sind.
- GUI cannot reach API: - GUI erreicht API nicht:
- Check `VITE_BOX_API_URL` and Box logs. - Pruefe `VITE_BOX_API_URL` und Box-Logs.
## What Is Mock vs Real ## Was ist Mock vs. real
- Mock: WiFi, IP, Spotify, audio, hardware. These are logical simulations only. - Mock: WLAN, IP, Spotify, Audio, Hardware. Diese Backends sind logische Simulationen.
- Real: Box process, playback state machine, API, CLI, resume logic, media traversal. - Real: Box-Prozess, Playback-State-Machine, API, CLI, Resume-Logik, Media-Traversal.
## Link Index ## Link-Index
- Dev workflow: `docs/dev-workflow.md` - Dev-Workflow: `docs/dev-workflow.md`
- Box lifecycle: `docs/box-lifecycle.md` - Box-Lifecycle: `docs/box-lifecycle.md`
- Setup WebGUI: `docs/setup-webgui.md` - Setup-WebGUI: `docs/setup-webgui.md`
- HTTP API contract: `docs/api.md` - HTTP-API Vertrag: `docs/api.md`
- GUI control panel: `docs/gui-control-panel.md` - GUI Control Panel: `docs/gui-control-panel.md`
- Playback testing: `docs/playback-testing.md` - Playback-Tests: `docs/playback-testing.md`
- Offline scenarios: `docs/offline-scenarios.md` - Offline-Szenarien: `docs/offline-scenarios.md`
- Security & storage: `docs/security-storage.md` - Security & Storage: `docs/security-storage.md`
- CLI reference: `docs/cli.md` - CLI-Referenz: `docs/cli.md`
- Acceptance tests: `docs/acceptance-tests.md` - Acceptance-Tests: `docs/acceptance-tests.md`
- Debugging guide: `docs/debugging.md` - Debugging-Guide: `docs/debugging.md`
- Virtual box notes: `docs/virtual-box.md` - Virtual-Box-Notizen: `docs/virtual-box.md`
- Legacy storage notes: `docs/storage.md` - Legacy-Storage-Notizen: `docs/storage.md`
+346 -334
View File
@@ -1,560 +1,572 @@
# Acceptance Tests # Acceptance-Tests
## Purpose ## Zweck
Comprehensive test catalog derived from the current codebase. Each test is runnable in Simulation and described for Real Box hardware. Behaviors not implemented are explicitly marked. Konsistenter Testkatalog basierend auf dem aktuellen Code. Alle Tests trennen Simulation und Hardware. Nicht implementierte Punkte sind explizit markiert.
## Prerequisites ## Voraussetzungen
- Repo root - Repo-Root
- Python 3 - Python 3
- Box API running for Simulation: `python3 box/main.py run` - Box API fuer Simulation: `python3 box/main.py run`
- GUI optional: `cd gui && npm run dev` - GUI optional: `cd gui && npm run dev`
## Step-by-step ## Schritt-fuer-Schritt
Use the tests below in order or individually. Die Tests koennen einzeln oder der Reihe nach ausgefuehrt werden.
--- ---
## Test A: Box-Start & Identitaet (box_id) # Lokale Box-Tests (L1-L10)
1) Goal ## Test L1: Box-Start & box_id
- Verify box_id is created once and persists.
2) Preconditions 1) Ziel
- Simulation: `box/data/box.json` may be missing. - box_id wird einmalig erzeugt und bleibt stabil.
- Real Box: storage is empty or fresh install.
3) Procedure Simulation / Emulation 2) Voraussetzungen
- Start Box: - Simulation: `box/data/box.json` kann fehlen.
- Hardware: frischer Start.
3) Durchfuehrung Simulation / Emulation
- Box starten:
``` ```
python3 box/main.py run python3 box/main.py run
``` ```
- Check box_id in API: - Status lesen:
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
- Inspect file: - Datei pruefen:
``` ```
cat box/data/box.json cat box/data/box.json
``` ```
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Power on device. - Box einschalten.
- Open status UI (if available) or API endpoint. - Statusseite/API pruefen (sofern erreichbar).
5) Expected Result 5) Erwartetes Ergebnis
- `/status` contains `box_id` with format `klangkiste-<10 chars a-z0-9>`. - `box_id` im Format `klangkiste-<10 chars a-z0-9>`.
- `box/data/box.json` contains `box_id` and is stable across restarts. - `box/data/box.json` bleibt stabil ueber Neustarts.
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- `box_id` missing: check write permissions for `box/data/`. - `box_id` fehlt: Schreibrechte in `box/data/` pruefen.
- `box_id` changes: indicates manual deletion of `box/data/box.json`.
--- ---
## Test B: WLAN-Ersteinrichtung (Setup WebGUI) ## Test L2: WLAN-Ersteinrichtung (Setup WebGUI)
1) Goal 1) Ziel
- Configure WiFi profile via setup UI and reach WIFI_ONLINE. - WLAN-Profil per Setup UI anlegen.
2) Preconditions 2) Voraussetzungen
- No WiFi profiles stored. - Keine WLAN-Profile gespeichert.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Reset WiFi profiles: - WLAN reset:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"wifi_reset"}' -d '{"command":"wifi_reset"}'
``` ```
- Open Setup UI: - Setup UI oeffnen:
``` ```
http://127.0.0.1:9000/setup http://127.0.0.1:9000/setup
``` ```
- Choose SSID and enter password, click "Verbinden". - SSID waehlen, Passwort eingeben, speichern.
- Check status: - Status pruefen:
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Power on device. - Nicht testbar mit aktuellem Code: kein echter AP/WLAN-Stack.
- Connect to device AP SSID (not implemented in code).
- Open setup page and submit WiFi credentials.
5) Expected Result 5) Erwartetes Ergebnis
- `wifi_state = WIFI_ONLINE` - `wifi_state = WIFI_ONLINE`
- `connected_ssid` matches selected SSID - `connected_ssid` gesetzt
- `wifi_profiles_count` increments
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- Setup UI unreachable: port 9000 occupied or Box not running. - Setup UI nicht erreichbar: Port 9000 frei, Box laeuft.
- Not testable with current code: AP SSID and real WiFi connect.
--- ---
## Test C: Persistenz & Neustart ## Test L3: Persistenz & Neustart
1) Goal 1) Ziel
- Ensure resume state and WiFi profiles persist across restarts. - Resume und WLAN-Profile bleiben ueber Neustart erhalten.
2) Preconditions 2) Voraussetzungen
- At least one WiFi profile stored. - WLAN-Profil vorhanden.
- Playback started once. - Playback einmal gestartet.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Add WiFi profile (if needed): - WLAN-Profil setzen:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}'
``` ```
- Start playback: - Playback starten:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}'
``` ```
- Stop playback: - Playback stoppen:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"nfc_off","payload":{"uid":"UID_1"}}' -d '{"command":"nfc_off","payload":{"uid":"UID_1"}}'
``` ```
- Restart Box process. - Box-Prozess neu starten.
- Check status: - Status pruefen:
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Start playback via NFC. - Nicht testbar mit aktuellem Code: kein echter WLAN-Stack.
- Power cycle device.
- Verify WiFi profile and resume state persist.
5) Expected Result 5) Erwartetes Ergebnis
- `box/data/state.json` contains `resume`. - `box/data/state.json` enthaelt Resume.
- `box/data/secrets.enc` exists and contains profiles (encrypted). - `box/data/secrets.enc` existiert.
- After restart, `wifi_state` is not UNCONFIGURED.
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- Resume missing: check `box/data/state.json`. - Resume fehlt: `box/data/state.json` pruefen.
- WiFi profiles missing: check `box/data/secrets.enc` creation.
--- ---
## Test D: HTTP API Status & Commands ## Test L4: HTTP API Status & Commands
1) Goal 1) Ziel
- Verify /status and /command are functional. - /status und /command liefern Antworten und aendern Status.
2) Preconditions 2) Voraussetzungen
- Box running. - Box laeuft.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Status: - Status:
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
- Command: - Command (vor Pairing -> 403 erwartet):
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"play_pause"}' -d '{"command":"play_pause"}'
``` ```
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Same as above once API is reachable in real network. - Gleich wie Simulation, sofern API erreichbar.
5) Expected Result 5) Erwartetes Ergebnis
- HTTP 200 responses. - Vor Pairing: HTTP 403 fuer gesperrte Commands.
- `/status` fields updated after commands. - Nach Pairing (mit Token): HTTP 200 und Status aktualisiert.
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- CORS errors: ensure API is running. - 400: Payload pruefen.
- 400 errors: check payload requirements.
--- ---
## Test E: GUI-Steuerung ## Test L5: GUI-Steuerung
1) Goal 1) Ziel
- Verify GUI can control playback and display status. - GUI steuert Box und zeigt Live-Status.
2) Preconditions 2) Voraussetzungen
- Box running on 127.0.0.1:8000. - GUI laeuft auf 127.0.0.1:5174.
- GUI running on 127.0.0.1:5174.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Open GUI: - GUI oeffnen, Buttons klicken.
``` - Status aendert sich innerhalb von 1s.
http://127.0.0.1:5174
```
- Click Play / Pause, Next, Prev, Vol +, Vol -.
- Click NFC UID_1 ON/OFF.
- Watch status refresh (polling every 1s).
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Use GUI from another device pointing to Box IP. - GUI auf Box-IP ausrichten (spaeter).
5) Expected Result 5) Erwartetes Ergebnis
- `/status` changes after each button. - `/status` aendert sich nach Commands.
- GUI shows updated status and last_error if present.
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- GUI shows "Failed to fetch": verify VITE_BOX_API_URL. - GUI "Failed to fetch": `VITE_BOX_API_URL` pruefen.
--- ---
## Test F: Playback & NFC ## Test L6: Playback & NFC
1) Goal 1) Ziel
- Verify NFC starts playback from tags. - NFC startet Playback.
2) Preconditions 2) Voraussetzungen
- Tags exist in `box/data/box.json`. - Tags in `box/data/box.json`.
- Media exists under `box/data/media`. - Medien in `box/data/media`.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Start playback:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}'
``` ```
- Observe `/status`:
```
curl http://127.0.0.1:8000/status
```
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Place NFC tag mapped to UID_1. - NFC-Tag auflegen (spaeter).
5) Expected Result 5) Erwartetes Ergebnis
- `playback_state.state = PLAYING` - `playback_state.state = PLAYING`
- `active_uid = UID_1`
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- No playback: check tags in `box/data/box.json` and files in `box/data/media`. - Tag fehlt: `box/data/box.json` pruefen.
--- ---
## Test G: Resume-Logik ## Test L7: Resume-Logik
1) Goal 1) Ziel
- Resume picks up last file index and position per UID. - Resume pro UID funktioniert.
2) Preconditions 2) Voraussetzungen
- Playback started at least once for UID_1. - Playback einmal gestartet.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Start playback, wait ~5s. - `nfc_on UID_1`, warten, `nfc_off UID_1`, dann `nfc_on UID_1`.
- Stop with `nfc_off`.
- Start again with `nfc_on`.
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Place NFC tag, remove, place again. - NFC auflegen/abnehmen (spaeter).
5) Expected Result 5) Erwartetes Ergebnis
- Resume uses stored `file_index` and `position`. - Resume nutzt gespeicherte Position.
- `box/data/state.json` contains resume for UID_1.
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- Resume resets to 0: check `state.json` and path changes. - Resume = 0: `box/data/state.json` pruefen.
--- ---
## Test H: Ordner- & Rekursionslogik ## Test L8: Rekursion & Ordnerstruktur
1) Goal 1) Ziel
- Verify recursive ordering (DFS, folders first, lexicographic). - Rekursive Reihenfolge (DFS) ist korrekt.
2) Preconditions 2) Voraussetzungen
- Nested media structure in `box/data/media`. - Verschachtelte Ordner in `box/data/media`.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Create nested structure in `box/data/media/book_6/CD1/...`. - Tag auf `media/book_6` setzen, `nfc_on`.
- Point a tag to `media/book_6` in `box/data/box.json`.
- Start playback with `nfc_on`.
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Copy nested folders to the device media path. - Medienstruktur auf Box kopieren (spaeter).
- Start playback via NFC.
5) Expected Result 5) Erwartetes Ergebnis
- Files play in DFS order: subfolders sorted, then files. - DFS-Reihenfolge, lexikografisch.
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- Ordering unexpected: check folder names and lexicographic sort. - Reihenfolge falsch: Ordnernamen pruefen.
--- ---
## Test I: Offline-Szenarien (Box intern) ## Test L9: Offline-Szenarien (Box intern)
1) Goal 1) Ziel
- Verify WiFi and Spotify states in offline/online flows. - WiFi/Spotify-Status reagieren auf Commands.
2) Preconditions 2) Voraussetzungen
- Box running. - Box laeuft.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- WiFi reset: - `wifi_reset` und `spotify_clear` ausfuehren.
```
curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \
-d '{"command":"wifi_reset"}'
```
- Spotify clear:
```
curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \
-d '{"command":"spotify_clear"}'
```
4) Procedure Real Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Not testable with current code: real network loss and Spotify connectivity. - Nicht testbar mit aktuellem Code.
5) Expected Result 5) Erwartetes Ergebnis
- `wifi_state = WIFI_UNCONFIGURED` - `wifi_state = WIFI_UNCONFIGURED`
- `spotify_status = NOT_CONFIGURED` - `spotify_status = NOT_CONFIGURED`
6) Deviations / Troubleshooting 6) Abweichungen / Fehlersuche
- Status unchanged: ensure you read `/status` after command. - Status aendert sich nicht: `/status` pruefen.
--- ---
## Test J: Fehlerfaelle (Box intern) ## Test L10: Fehlerfaelle (Box intern)
1) Goal 1) Ziel
- Verify error sound and error reporting. - Fehler setzen `last_error`.
2) Preconditions 2) Voraussetzungen
- Box running. - Box laeuft.
3) Procedure Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Trigger error:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"trigger_error","payload":{"type":"uid_unmapped"}}' -d '{"command":"trigger_error","payload":{"type":"uid_unmapped"}}'
``` ```
- Check `/status` for `last_error`.
4) Procedure Real Box (Hardware)
- Not testable with current code: actual error sound output.
5) Expected Result
- `playback_state.last_error` equals error type.
- Box returns to `IDLE` after error.
6) Deviations / Troubleshooting
- No error shown: verify `/status` and that errors are not cleared automatically.
---
## Test K: Announce -> neue Box erscheint im Server
1) Ziel
- Box meldet sich beim Server und erscheint als UNPAIRED / neue Box.
2) Voraussetzungen
- Simulation: Server-Komponente vorhanden und laeuft.
- Real Box: Server erreichbar im Netzwerk.
3) Durchfuehrung Simulation / Emulation
- Nicht testbar mit aktuellem Code: Box sendet keine Announce-Requests.
4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code: Announce-Logik ist nicht implementiert.
5) Erwartetes Ergebnis
- Nicht testbar mit aktuellem Code.
6) Abweichungen / Fehlersuche
- Wenn implementiert: Pruefe Server-Logs, API-Endpunkte und Box-Statusfelder.
---
## Test L: Mehrere Boxen gleichzeitig
1) Ziel
- Mehrere Boxen werden als separate, unbekannte Boxen erkannt.
2) Voraussetzungen
- Simulation: Mehrere Box-Prozesse mit unterschiedlichen `box_id`.
3) Durchfuehrung Simulation / Emulation
- Nicht testbar mit aktuellem Code: Announce/Server-Erkennung fehlt.
4) Durchfuehrung Reale Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code. - Nicht testbar mit aktuellem Code.
5) Erwartetes Ergebnis 5) Erwartetes Ergebnis
- Nicht testbar mit aktuellem Code. - `playback_state.last_error` gesetzt.
6) Abweichungen / Fehlersuche 6) Abweichungen / Fehlersuche
- Wenn implementiert: Pruefe Duplikate anhand `box_id` und `fingerprint`. - Kein Fehler sichtbar: `/status` pruefen.
--- ---
## Test M: Pairing-Freigabe ueber Server-GUI # Server/Pairing-Tests (A-J)
## Test A: Box-Erststart & Identitaet
1) Ziel 1) Ziel
- Box wird nach Freigabe gepairt und erhaelt API-Token. - box_id wird einmalig erzeugt.
2) Voraussetzungen 2) Voraussetzungen
- Simulation: Server-GUI und Pairing-Endpunkte implementiert. - Box gestartet.
3) Durchfuehrung Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Nicht testbar mit aktuellem Code: Pairing-Flow fehlt. - Siehe Test L1.
4) Durchfuehrung Reale Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code. - Siehe Test L1.
5) Erwartetes Ergebnis 5) Erwartetes Ergebnis
- Nicht testbar mit aktuellem Code. - box_id stabil.
6) Abweichungen / Fehlersuche 6) Abweichungen / Fehlersuche
- Wenn implementiert: Pruefe Token-Speicherung in `box/data/secrets.enc`. - Siehe Test L1.
--- ---
## Test N: Verhalten vor Pairing (keine Steuerung erlaubt) ## Test B: Announce -> neue Box erscheint im Server
1) Ziel 1) Ziel
- Vor Pairing sind Steuerbefehle blockiert. - Box meldet sich beim Server und erscheint als UNPAIRED.
2) Voraussetzungen 2) Voraussetzungen
- Server-Integration und Auth-Checks vorhanden. - Server laeuft:
3) Durchfuehrung Simulation / Emulation
- Nicht testbar mit aktuellem Code: kein Auth/Pairing implementiert.
4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code.
5) Erwartetes Ergebnis
- Nicht testbar mit aktuellem Code.
6) Abweichungen / Fehlersuche
- Wenn implementiert: Pruefe API-Responses (401/403) und Server-Logs.
---
## Test O: Verhalten nach Pairing
1) Ziel
- Nach Pairing sind Steuerbefehle erlaubt und Box reagiert.
2) Voraussetzungen
- Pairing-Flow implementiert.
3) Durchfuehrung Simulation / Emulation
- Nicht testbar mit aktuellem Code.
4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code.
5) Erwartetes Ergebnis
- Nicht testbar mit aktuellem Code.
6) Abweichungen / Fehlersuche
- Wenn implementiert: Pruefe Token in `secrets.enc` und Server-GUI Status.
---
## Test P: Neustart vor/nach Pairing
1) Ziel
- Box behaelt Pairing-Status und Token ueber Neustarts.
2) Voraussetzungen
- Pairing implementiert.
3) Durchfuehrung Simulation / Emulation
- Nicht testbar mit aktuellem Code.
4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code.
5) Erwartetes Ergebnis
- Nicht testbar mit aktuellem Code.
6) Abweichungen / Fehlersuche
- Wenn implementiert: Pruefe `secrets.enc` vor/nach Neustart.
---
## Test Q: Factory Reset -> neue Identitaet
1) Ziel
- Factory Reset loescht box_id und erzeugt neue Identitaet beim naechsten Start.
2) Voraussetzungen
- Box laeuft einmalig und `box/data/` existiert.
3) Durchfuehrung Simulation / Emulation
- Stoppe Box.
- Loesche folgende Dateien:
``` ```
rm -f box/data/box.json box/data/state.json box/data/secrets.enc python3 server/main.py
``` ```
- Starte Box neu:
3) Durchfuehrung Simulation / Emulation
- Box starten:
``` ```
python3 box/main.py run python3 box/main.py run
``` ```
- Pruefe `/status`. - 30-60s warten (Announce-Intervall).
- Server-GUI oeffnen:
```
http://127.0.0.1:7000/ui
```
- Optional: Server-Storage pruefen:
```
cat server/data/boxes.json
```
4) Durchfuehrung Reale Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code: kein Factory-Reset-Mechanismus implementiert. - Box einschalten.
- Server-GUI oeffnen, Box sollte erscheinen.
5) Erwartetes Ergebnis 5) Erwartetes Ergebnis
- Neue `box_id` erscheint in `/status`. - Box erscheint als UNPAIRED in der Server-GUI.
- `server/data/boxes.json` enthaelt `box_id` und `fingerprint`.
6) Abweichungen / Fehlersuche 6) Abweichungen / Fehlersuche
- `box_id` bleibt gleich: Dateien wurden nicht geloescht. - Keine Box sichtbar: Server-URL in `box/data/box.json` pruefen.
- Server nicht erreichbar: Server-Prozess/Port 7000 pruefen.
--- ---
## Test R: Offline / Server nicht erreichbar ## Test C: Mehrere Boxen gleichzeitig
1) Ziel 1) Ziel
- Box laeuft weiter, wenn Server nicht erreichbar ist. - Mehrere Boxen werden separat erkannt.
2) Voraussetzungen 2) Voraussetzungen
- Server-Integration implementiert. - Mehrere Boxen mit unterschiedlichen `box_id`.
3) Durchfuehrung Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Nicht testbar mit aktuellem Code: keine Server-Kommunikation. - Nicht testbar mit aktuellem Code: Box nutzt festen `box/data/`-Pfad und kann nicht mehrfach parallel laufen.
4) Durchfuehrung Reale Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code. - Zwei physische Boxen einschalten.
- Server-GUI zeigt beide UNPAIRED an.
5) Erwartetes Ergebnis 5) Erwartetes Ergebnis
- Nicht testbar mit aktuellem Code. - Zwei Eintraege mit unterschiedlichen `box_id`.
6) Abweichungen / Fehlersuche 6) Abweichungen / Fehlersuche
- Wenn implementiert: pruefe lokale Funktionen (Playback, NFC) laufen weiter. - Duplikate: `box_id` doppelt? Factory-Reset pruefen.
--- ---
## Test S: Simulation vs. Hardware Unterschiede ## Test D: Pairing-Freigabe ueber Server-GUI
1) Ziel 1) Ziel
- Dokumentiere, was real vs. mock ist. - Pairing liefert API-Token und Box wird PAIRED.
2) Voraussetzungen
- Box erscheint als UNPAIRED in `/ui`.
3) Durchfuehrung Simulation / Emulation
- Server-GUI oeffnen:
```
http://127.0.0.1:7000/ui
```
- Button "Box hinzufuegen" klicken.
- API-Token via Server-API abrufen:
```
curl -X POST http://127.0.0.1:7000/api/boxes/pair -H "Content-Type: application/json" -d '{"box_id":"<box_id>"}'
```
4) Durchfuehrung Reale Box (Hardware)
- Gleiches Vorgehen ueber Server-GUI.
5) Erwartetes Ergebnis
- Box erscheint in der gepaarten Liste.
- `api_token` wird an die Box ausgeliefert.
6) Abweichungen / Fehlersuche
- Pairing bleibt aus: `server/data/boxes.json` pruefen.
---
## Test E: Verhalten vor Pairing (keine Steuerung erlaubt)
1) Ziel
- Vor Pairing werden Steuerbefehle blockiert.
2) Voraussetzungen
- Box UNPAIRED.
3) Durchfuehrung Simulation / Emulation
```
curl -i -X POST http://127.0.0.1:8000/command -H "Content-Type: application/json" -d '{"command":"play_pause"}'
```
4) Durchfuehrung Reale Box (Hardware)
- Gleicher Request gegen Box-IP.
5) Erwartetes Ergebnis
- HTTP 403 "pairing required".
6) Abweichungen / Fehlersuche
- Wenn 200: Pairing-Status und API-Token pruefen.
---
## Test F: Verhalten nach Pairing
1) Ziel
- Nach Pairing sind Commands erlaubt (mit Token).
2) Voraussetzungen
- Box PAIRED und api_token vorhanden.
3) Durchfuehrung Simulation / Emulation
```
curl -i -X POST http://127.0.0.1:8000/command -H "Content-Type: application/json" -H "X-API-Token: <api_token>" -d '{"command":"play_pause"}'
```
4) Durchfuehrung Reale Box (Hardware)
- Gleiches Vorgehen gegen Box-IP.
5) Erwartetes Ergebnis
- HTTP 200, Status aktualisiert.
6) Abweichungen / Fehlersuche
- 403: Token pruefen, Pairing-Status pruefen.
---
## Test G: Neustart vor/nach Pairing
1) Ziel
- Pairing-Status und Token bleiben ueber Neustart erhalten.
2) Voraussetzungen
- Box PAIRED.
3) Durchfuehrung Simulation / Emulation
- Box-Prozess stoppen, neu starten.
- Mit Token Command senden.
4) Durchfuehrung Reale Box (Hardware)
- Power cycle.
5) Erwartetes Ergebnis
- Box bleibt PAIRED, Token weiterhin gueltig.
6) Abweichungen / Fehlersuche
- Token fehlt: `box/data/secrets.enc` pruefen.
---
## Test H: Factory Reset -> neue Identitaet
1) Ziel
- Factory Reset erzeugt neue Identitaet.
2) Voraussetzungen
- Box einmal gestartet.
3) Durchfuehrung Simulation / Emulation
- Box stoppen.
- Dateien loeschen:
```
rm -f box/data/box.json box/data/state.json box/data/secrets.enc
```
- Box neu starten.
4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code: kein Reset-Mechanismus.
5) Erwartetes Ergebnis
- Neue `box_id`.
6) Abweichungen / Fehlersuche
- `box_id` bleibt gleich: Dateien nicht geloescht.
---
## Test I: Offline / Server nicht erreichbar
1) Ziel
- Box laeuft weiter ohne Server.
2) Voraussetzungen
- Box laeuft, Server gestoppt.
3) Durchfuehrung Simulation / Emulation
- Server stoppen.
- Playback starten (mit Token falls gepairt).
4) Durchfuehrung Reale Box (Hardware)
- Server vom Netz trennen.
5) Erwartetes Ergebnis
- Lokale Playback-Logik laeuft weiter.
- Server-GUI zeigt Box ggf. als OFFLINE nach Timeout.
6) Abweichungen / Fehlersuche
- Box stoppt: lokale Logs pruefen.
---
## Test J: Simulation vs. Hardware Unterschiede
1) Ziel
- Unterschiede klar dokumentieren.
2) Voraussetzungen 2) Voraussetzungen
- Box laeuft in Simulation. - Box laeuft in Simulation.
3) Durchfuehrung Simulation / Emulation 3) Durchfuehrung Simulation / Emulation
- Beachte: WiFi/IP/Spotify/Audio sind Mock. - WiFi/IP/Spotify/Audio sind Mock.
- Setup UI und API funktionieren lokal. - Setup UI und API funktionieren lokal.
4) Durchfuehrung Reale Box (Hardware) 4) Durchfuehrung Reale Box (Hardware)
- Nicht testbar mit aktuellem Code: Hardware-Backends fehlen. - Echte Backends notwendig (nicht implementiert).
5) Erwartetes Ergebnis 5) Erwartetes Ergebnis
- Simulation laeuft ohne Hardware. - Simulation laeuft ohne Hardware.
- Hardware-Backends koennen spaeter ersetzt werden.
6) Abweichungen / Fehlersuche 6) Abweichungen / Fehlersuche
- Wenn Hardware genutzt wird, fehlen Implementierungen. - Hardware-Backends fehlen.
+41 -27
View File
@@ -1,19 +1,19 @@
# Box HTTP API # Box HTTP API
## Purpose ## Zweck
Defines the stable contract between the GUI/CLI and the Box. Stabiler Vertrag zwischen GUI/CLI und Box.
The Box is authoritative; the client only sends commands and reads status. Die Box ist autoritativ, der Client sendet nur Commands und liest Status.
## Prerequisites ## Voraussetzungen
- Box running on `http://127.0.0.1:8000` - Box laeuft auf `http://127.0.0.1:8000`
## Step-by-step ## Schritt-fuer-Schritt
### Status ### Status
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
### Command (example) ### Command (Beispiel)
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -21,12 +21,13 @@ curl -X POST http://127.0.0.1:8000/command \
``` ```
## GET /status ## GET /status
Response fields: Felder:
- `box_id`: unique device ID - `box_id`: eindeutige Box-ID
- `pairing_state`: `UNPAIRED | PAIRING_PENDING | PAIRED`
- `wifi_state`: `WIFI_UNCONFIGURED | WIFI_CONNECTING | WIFI_ONLINE | WIFI_OFFLINE` - `wifi_state`: `WIFI_UNCONFIGURED | WIFI_CONNECTING | WIFI_ONLINE | WIFI_OFFLINE`
- `ip_address`: simulated IP (`192.168.4.1` or `127.0.0.1`) - `ip_address`: simulierte IP (`192.168.4.1` oder `127.0.0.1`)
- `connected_ssid`: connected SSID or `null` - `connected_ssid`: verbundene SSID oder `null`
- `wifi_profiles_count`: number of stored profiles - `wifi_profiles_count`: Anzahl gespeicherter Profile
- `spotify_status`: `NOT_CONFIGURED | READY` - `spotify_status`: `NOT_CONFIGURED | READY`
- `playback_state`: - `playback_state`:
- `state`: `IDLE | PLAYING | PAUSED` - `state`: `IDLE | PLAYING | PAUSED`
@@ -39,10 +40,11 @@ Response fields:
- `volume` - `volume`
- `last_error` - `last_error`
Example response: Beispielantwort:
``` ```
{ {
"box_id": "klangkiste-a9f3k7m2q8", "box_id": "klangkiste-a9f3k7m2q8",
"pairing_state": "UNPAIRED",
"wifi_state": "WIFI_ONLINE", "wifi_state": "WIFI_ONLINE",
"ip_address": "127.0.0.1", "ip_address": "127.0.0.1",
"connected_ssid": "HomeWiFi", "connected_ssid": "HomeWiFi",
@@ -63,7 +65,7 @@ Example response:
``` ```
## POST /command ## POST /command
Request body: Request Body:
``` ```
{ {
"command": "nfc_on", "command": "nfc_on",
@@ -71,7 +73,7 @@ Request body:
} }
``` ```
Supported commands: Unterstuetzte Commands:
- `nfc_on` `{ uid }` - `nfc_on` `{ uid }`
- `nfc_off` `{ uid }` - `nfc_off` `{ uid }`
- `play_pause` - `play_pause`
@@ -79,8 +81,8 @@ Supported commands:
- `prev` - `prev`
- `volume_up` - `volume_up`
- `volume_down` - `volume_down`
- `vol_up` (alias) - `vol_up` (Alias)
- `vol_down` (alias) - `vol_down` (Alias)
- `stop` - `stop`
- `trigger_error` `{ type }` - `trigger_error` `{ type }`
- `wifi_add_profile` `{ ssid, password, priority? }` - `wifi_add_profile` `{ ssid, password, priority? }`
@@ -88,27 +90,39 @@ Supported commands:
- `spotify_set_tokens` `{ access_token, refresh_token, expires_at, account_id? }` - `spotify_set_tokens` `{ access_token, refresh_token, expires_at, account_id? }`
- `spotify_clear` - `spotify_clear`
Example: add WiFi profile ## Auth fuer Commands
- Vor Pairing sind nur diese Commands erlaubt:
- `wifi_add_profile`, `wifi_reset`, `spotify_set_tokens`, `spotify_clear`
- Alle anderen Commands erfordern `X-API-Token` im Header.
Beispiel mit Token:\n```
curl -X POST http://127.0.0.1:8000/command \\
-H "Content-Type: application/json" \\
-H "X-API-Token: <api_token>" \\
-d '{"command":"nfc_on","payload":{"uid":"UID_1"}}'
```
Beispiel: WLAN-Profil hinzufuegen
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}'
``` ```
Example: set Spotify tokens Beispiel: Spotify Tokens setzen
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}'
``` ```
## Checklist ## Checkliste
-`GET /status` returns JSON - ✅ `GET /status` liefert JSON
-`POST /command` returns `{ "ok": true }` - ✅ `POST /command` liefert `{ "ok": true }`
-No secrets appear in `/status` -Keine Secrets in `/status`
## Troubleshooting ## Troubleshooting
- 400 responses: - 400 Responses:
- Check required fields in the payload. - Payload pruefen (Pflichtfelder).
- CORS errors: - CORS-Fehler:
- Ensure Box API is running on the expected host/port. - API muss laufen und erreichbar sein.
+36 -31
View File
@@ -1,42 +1,47 @@
# Box Lifecycle # Box-Lifecycle
## Purpose ## Zweck
Defines the virtual box lifecycle: identity, WiFi states, and restart behavior. Definiert Identitaet, WLAN-Status und Neustartverhalten der virtuellen Box.
## Prerequisites ## Voraussetzungen
- Box started with `python3 box/main.py run` - Box gestartet mit `python3 box/main.py run`
## Step-by-step ## Schritt-fuer-Schritt
### Box identity ### Box-Identitaet
- Generated on first start if `box/data/box.json` has no `box_id`. - Wird beim ersten Start erzeugt, wenn `box/data/box.json` keine `box_id` enthaelt.
- Format: `klangkiste-<10 chars a-z0-9>` - Format: `klangkiste-<10 Zeichen a-z0-9>`
- Stored in `box/data/box.json` - Persistiert in `box/data/box.json`
### WiFi states (logical) ### WLAN-Zustaende (logisch)
- `WIFI_UNCONFIGURED`: no profiles stored - `WIFI_UNCONFIGURED`: keine Profile gespeichert
- `WIFI_CONNECTING`: reserved for future; not emitted in mock - `WIFI_CONNECTING`: reserviert fuer spaeter (im Mock nicht aktiv)
- `WIFI_ONLINE`: profile exists and mock is "connected" - `WIFI_ONLINE`: Profil vorhanden und verbunden
- `WIFI_OFFLINE`: profile exists but not connected - `WIFI_OFFLINE`: Profil vorhanden, aber nicht verbunden
### IP model (informational) ### IP-Modell (informativ)
- Setup/AP mode: `192.168.4.1` - Setup/AP-Modus: `192.168.4.1`
- Normal mode: `127.0.0.1` - Normalbetrieb: `127.0.0.1`
### Restart behavior ### Neustartverhalten
- Box reloads `box_id`, resume state, and WiFi profiles from `box/data/` - Box laedt `box_id`, Resume-State und WLAN-Profile aus `box/data/`
- Playback resume and WiFi status are preserved - Resume und WLAN-Status bleiben erhalten
### Resets ### Resets
- WiFi reset: clears WiFi profiles only (`wifi_reset` command) - WLAN-Reset: nur WLAN-Profile loeschen (`wifi_reset`)
- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` - Factory-Reset: `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` loeschen
## Checklist ### Pairing-Status
- ✅ After first boot, `box/data/box.json` contains `box_id` - `UNPAIRED`: Box ist nicht gekoppelt
- `GET /status` returns `wifi_state` and `ip_address` - `PAIRING_PENDING`: Box pollt Pairing-Status beim Server
- ✅ WiFi reset clears profiles, `wifi_state=WIFI_UNCONFIGURED` - `PAIRED`: Box ist gekoppelt, API-Token vorhanden
## Checkliste
- ✅ Nach erstem Start existiert `box/data/box.json` mit `box_id`
-`GET /status` liefert `wifi_state` und `ip_address`
- ✅ WLAN-Reset setzt `wifi_state=WIFI_UNCONFIGURED`
## Troubleshooting ## Troubleshooting
- `wifi_state` not changing: - `wifi_state` aendert sich nicht:
- Add a WiFi profile via setup UI or API. - WLAN-Profil via Setup UI oder API hinzufuegen.
- Missing `box_id`: - `box_id` fehlt:
- Ensure `box/data/box.json` is writable. - Schreibrechte fuer `box/data/box.json` pruefen.
+19 -19
View File
@@ -1,34 +1,34 @@
# Box CLI Reference # CLI-Referenz
## Purpose ## Zweck
List all CLI interactions available in the virtual box. Alle CLI-Interaktionen der virtuellen Box.
## Prerequisites ## Voraussetzungen
- Box code available in `box/` - Box-Code in `box/`
## Step-by-step ## Schritt-fuer-Schritt
### One-shot commands ### One-shot Commands
Status: Status:
``` ```
python3 box/main.py status python3 box/main.py status
``` ```
Simulate NFC: NFC simulieren:
``` ```
python3 box/main.py simulate-nfc UID_1 python3 box/main.py simulate-nfc UID_1
python3 box/main.py simulate-nfc-off UID_1 python3 box/main.py simulate-nfc-off UID_1
``` ```
Manual tick: Manuelles Tick:
``` ```
python3 box/main.py tick 30 python3 box/main.py tick 30
``` ```
### Interactive run mode ### Interaktiver Run-Modus
``` ```
python3 box/main.py run python3 box/main.py run
``` ```
Commands inside run mode: Commands im Run-Modus:
``` ```
status status
nfc <UID> nfc <UID>
@@ -43,13 +43,13 @@ help
exit | quit exit | quit
``` ```
## Checklist ## Checkliste
-`status` prints runtime state -`status` gibt Runtime-State aus
-`simulate-nfc` starts playback -`simulate-nfc` startet Playback
-`run` accepts interactive commands -`run` akzeptiert interaktive Commands
## Troubleshooting ## Troubleshooting
- Commands not found: - Commands nicht gefunden:
- Ensure you run from repo root or use full path to `box/main.py`. - Aus Repo-Root ausfuehren oder `box/main.py` voll referenzieren.
- No playback: - Kein Playback:
- Check tags in `box/data/box.json` and media under `box/data/media`. - Tags in `box/data/box.json` und Medien in `box/data/media` pruefen.
+59 -60
View File
@@ -1,99 +1,98 @@
# Debugging Guide # Debugging-Guide
## Purpose ## Zweck
Fast diagnosis guide: "If X happens, check Y" with separate paths for Simulation and Real Box. Schnelle Diagnose: "Wenn X passiert, pruefe Y" getrennt nach Simulation und Hardware.
## Prerequisites ## Voraussetzungen
- Box running (simulation or device) - Box laeuft (Simulation oder Hardware)
- Access to `/status` and data files - Zugriff auf `/status` und Datenfiles
## Step-by-step ## Schritt-fuer-Schritt
Use the sections below by symptom. Nutze die Sektionen nach Symptomen.
## Simulation (IDE) ## Simulation (IDE)
### If GUI shows "Failed to fetch" ### GUI zeigt "Failed to fetch"
- Check API is running: - API pruefen:
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
- Verify GUI API base: - GUI API-Base pruefen:
- `VITE_BOX_API_URL` or default `http://localhost:8000` - `VITE_BOX_API_URL` oder Default `http://localhost:8000`
### If playback does not start ### Playback startet nicht
- Check tags: - Tags pruefen:
``` ```
cat box/data/box.json cat box/data/box.json
``` ```
- Check media files: - Medien pruefen:
``` ```
ls -la box/data/media ls -la box/data/media
``` ```
- Check `/status`: - Status pruefen:
``` ```
curl http://127.0.0.1:8000/status curl http://127.0.0.1:8000/status
``` ```
### If resume does not persist ### Resume bleibt leer
- Check state file: - State pruefen:
``` ```
cat box/data/state.json cat box/data/state.json
``` ```
- Ensure playback was stopped via `nfc_off` or end-of-media. - Playback mit `nfc_off` beenden.
### If setup UI not reachable ### Setup UI nicht erreichbar
- Check port 9000 free. - Port 9000 frei?
- Ensure Box is running. - Box laeuft?
### If box not recognized by server ### Box wird nicht erkannt (Server)
- Not testable with current code: no announce/pairing implemented. - Server-URL in `box/data/box.json` pruefen (`server_url`).
- Server laeuft auf Port 7000?
- `server/data/boxes.json` pruefen.
### If box appears multiple times ### Box erscheint mehrfach
- Not testable with current code: no announce/pairing implemented. - `box_id` in `box/data/box.json` pruefen.
- Factory-Reset nur gezielt ausfuehren.
### If fingerprint changes ### Fingerprint aendert sich
- Not testable with current code: fingerprint not implemented. - `secret_seed` in `box/data/secrets.enc` darf sich nicht aendern.
- `box/data/box.json` darf nicht geloescht werden.
### If pairing hangs ### Pairing haengt
- Not testable with current code: pairing flow not implemented. - `server/data/boxes.json` pruefen: state, api_token.
- Box-Status: `pairing_state` in `/status` pruefen.
## Real Box (Hardware) ## Reale Box (Hardware)
### If device not reachable in browser ### Box nicht im Browser erreichbar
- Ensure it is powered on and connected to the same network. - Stromversorgung ok?
- Check the IP shown by your router. - Im gleichen Netzwerk?
- Router-IP pruefen.
### If NFC does nothing ### NFC reagiert nicht
- Confirm tag UID exists in `box.json`. - UID in `box.json` vorhanden?
- Confirm NFC hardware is working (not implemented in code). - NFC-Hardware (nicht implementiert) pruefen.
### If audio is silent ### Audio bleibt stumm
- Audio backend is mock in current code. - Audio-Backend ist Mock im aktuellen Code.
- Not testable with current code. - Nicht testbar mit aktuellem Code.
### If box not recognized by server ### Box wird nicht erkannt / erscheint mehrfach / Fingerprint wechselt / Pairing haengt
- Not testable with current code: no announce/pairing implemented. - Server-GUI (`/ui`) pruefen.
- Box-IP/Server-IP/VLAN pruefen.
- Box-Logs pruefen.
### If box appears multiple times ## Files
- Not testable with current code: no announce/pairing implemented. - `box/data/box.json`: Identitaet, Tags, Settings
- `box/data/state.json`: Resume und WiFi-State
- `box/data/secrets.enc`: verschluesselte Secrets
### If fingerprint changes ## Statusfelder
- Not testable with current code: fingerprint not implemented.
### If pairing hangs
- Not testable with current code: pairing flow not implemented.
## Files to inspect
- `box/data/box.json`: identity, tags, settings
- `box/data/state.json`: resume and wifi state
- `box/data/secrets.enc`: encrypted secrets
## Status fields to watch
- `wifi_state`, `ip_address`, `connected_ssid` - `wifi_state`, `ip_address`, `connected_ssid`
- `playback_state.state`, `playback_state.active_uid` - `playback_state.state`, `playback_state.active_uid`
- `playback_state.position`, `playback_state.file_index` - `playback_state.position`, `playback_state.file_index`
- `playback_state.last_error` - `playback_state.last_error`
## Troubleshooting ## Troubleshooting
- 400 from `/command`: - 400 von `/command`:
- Check required payload fields in `docs/api.md`. - Payload pruefen (Pflichtfelder).
- No change after command: - Keine Aenderung nach Command:
- Poll `/status` after sending. - `/status` danach erneut abrufen.
+31 -29
View File
@@ -1,22 +1,22 @@
# Dev Workflow # Dev-Workflow
## Purpose ## Zweck
How to start/stop the virtual box, how to access logs, and how to point the GUI to the API. Start/Stop der virtuellen Box, Logs, und GUI-API-Konfiguration.
## Prerequisites ## Voraussetzungen
- Python 3 - Python 3
- Node.js + npm - Node.js + npm
- Run from repo root - Aus dem Repo-Root ausfuehren
## Step-by-step ## Schritt-fuer-Schritt
### Start everything (recommended) ### Alles starten (empfohlen)
``` ```
./dev.sh ./dev.sh
``` ```
- GUI runs in the background - GUI laeuft im Hintergrund.
- Box runs in the foreground (you can type CLI commands) - Box laeuft im Vordergrund und akzeptiert CLI-Kommandos.
### Start separately ### Komponenten separat starten
Box only: Box only:
``` ```
python3 box/main.py run python3 box/main.py run
@@ -28,34 +28,36 @@ npm run dev
``` ```
## Ports / Hosts ## Ports / Hosts
- Server API + GUI: `http://127.0.0.1:7000` (GUI unter `/ui`)
- Box API: `http://127.0.0.1:8000` - Box API: `http://127.0.0.1:8000`
- Setup UI: `http://127.0.0.1:9000/setup` - Setup UI: `http://127.0.0.1:9000/setup`
- GUI: `http://127.0.0.1:5174` - GUI: `http://127.0.0.1:5174`
## API URL for GUI ## API-URL fuer die GUI
Default is `http://localhost:8000`. Override with: Default ist `http://localhost:8000`. Override:
``` ```
VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev
``` ```
## Logs ## Logs ansehen
- Box logs appear in the same terminal where you run `python3 box/main.py run`. - Server-Logs: Terminal mit `python3 server/main.py`
- GUI logs appear in the browser console. - Box-Logs: Terminal mit `python3 box/main.py run`
- GUI-Logs: Browser-Konsole
## Stop / Cleanup ## Sauber beenden
- Press `Ctrl+C` in the terminal where the box is running. - `Ctrl+C` im Box-Terminal.
- `dev.sh` will also stop the GUI process. - `dev.sh` beendet die GUI automatisch.
## Checklist ## Checkliste
- Run `./dev.sh` -`./dev.sh` gestartet
- ✅ Box shows interactive prompt - ✅ Box-CLI Prompt sichtbar
- ✅ GUI loads in browser - ✅ GUI im Browser erreichbar
-`curl http://127.0.0.1:8000/status` returns JSON -`curl http://127.0.0.1:8000/status` liefert JSON
## Troubleshooting ## Troubleshooting
- Port already in use: - Port belegt:
- 8000 or 9000: stop other processes, or change ports in `box/main.py`. - 8000/9000: andere Prozesse beenden oder Ports in `box/main.py` aendern.
- CORS error in browser: - CORS-Fehler:
- Ensure API is running; CORS is already enabled in the Box API. - API muss laufen und erreichbar sein.
- GUI shows "Failed to fetch": - GUI zeigt "Failed to fetch":
- Check `VITE_BOX_API_URL` and Box API port. - `VITE_BOX_API_URL` pruefen.
+27 -24
View File
@@ -1,21 +1,21 @@
# GUI Control Panel # GUI Control Panel
## Purpose ## Zweck
How to use the Vite-based GUI to control and observe the virtual box. Nutzung des GUI Control Panels zur Steuerung und Beobachtung der virtuellen Box.
## Prerequisites ## Voraussetzungen
- Box running on `http://127.0.0.1:8000` - Box laeuft auf `http://127.0.0.1:8000`
- GUI started (`npm run dev` or `./dev.sh`) - GUI gestartet (`npm run dev` oder `./dev.sh`)
- Bei gepaarter Box: `VITE_BOX_API_TOKEN` gesetzt
## Step-by-step ## Schritt-fuer-Schritt
1) Open GUI: 1) GUI oeffnen:
``` ```
http://127.0.0.1:5174 http://127.0.0.1:5174
``` ```
2) Click buttons to send commands. 2) Buttons klicken, Status beobachten.
3) Observe status updates (polling + refresh after command).
## Controls (current in GUI) ## Controls (im GUI vorhanden)
- Play / Pause - Play / Pause
- Next - Next
- Prev - Prev
@@ -25,29 +25,32 @@ http://127.0.0.1:5174
- NFC UID_1 OFF - NFC UID_1 OFF
- Refresh Status - Refresh Status
## Controls (available via API only) ## Controls (nur API)
- WiFi add/reset - WiFi add/reset
- Spotify set/clear - Spotify set/clear
Use API for those: Beispiel via API:
``` ```
## Auth-Token fuer GUI
- Nach Pairing: Token ueber Server-API oder Server-GUI ermitteln.\n- GUI mit Token starten:\n```\nVITE_BOX_API_TOKEN=<api_token> npm run dev\n```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}'
``` ```
## Expected results ## Erwartete Ergebnisse
- After any button click, status refreshes within 1 second. - Nach jedem Klick wird der Status innerhalb von 1s aktualisiert.
- `last_error` is visible if an error occurs. - `last_error` wird angezeigt, wenn gesetzt.
## Checklist ## Checkliste
- ✅ GUI loads and shows API base URL - ✅ GUI laedt und zeigt API-URL
- ✅ Status updates every 1s - ✅ Status aktualisiert sich automatisch
-Clicking a button updates `/status` -Buttons aendern `/status`
## Troubleshooting ## Troubleshooting
- GUI shows "Failed to fetch": - GUI zeigt "Failed to fetch":
- Check `VITE_BOX_API_URL`. - `VITE_BOX_API_URL` pruefen.
- Verify Box API is running. - Box API muss laufen.
- Buttons have no effect: - Buttons ohne Wirkung:
- Check the API command names in `docs/api.md`. - API-Command-Namen in `docs/api.md` pruefen.
+22 -22
View File
@@ -1,57 +1,57 @@
# Offline Scenarios # Offline-Szenarien
## Purpose ## Zweck
Simulate online/offline behavior in the virtual box and verify status fields. Offline/Online-Verhalten in der virtuellen Box pruefen.
## Prerequisites ## Voraussetzungen
- Box running on `http://127.0.0.1:8000` - Box laeuft auf `http://127.0.0.1:8000`
## Step-by-step ## Schritt-fuer-Schritt
### WiFi offline (no profiles) ### WLAN offline (keine Profile)
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"wifi_reset"}' -d '{"command":"wifi_reset"}'
``` ```
Expected: Erwartung:
- `wifi_state = WIFI_UNCONFIGURED` - `wifi_state = WIFI_UNCONFIGURED`
- `connected_ssid = null` - `connected_ssid = null`
### WiFi online ### WLAN online
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}'
``` ```
Expected: Erwartung:
- `wifi_state = WIFI_ONLINE` - `wifi_state = WIFI_ONLINE`
- `connected_ssid = HomeWiFi` - `connected_ssid = HomeWiFi`
### Spotify unavailable ### Spotify nicht konfiguriert
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"spotify_clear"}' -d '{"command":"spotify_clear"}'
``` ```
Expected: Erwartung:
- `spotify_status = NOT_CONFIGURED` - `spotify_status = NOT_CONFIGURED`
### Spotify ready ### Spotify bereit
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}'
``` ```
Expected: Erwartung:
- `spotify_status = READY` - `spotify_status = READY`
## Checklist ## Checkliste
- ✅ WiFi reset shows UNCONFIGURED - ✅ WLAN-Reset zeigt UNCONFIGURED
- ✅ WiFi add profile shows ONLINE - ✅ WLAN-Profil zeigt ONLINE
- ✅ Spotify clear shows NOT_CONFIGURED - ✅ Spotify clear zeigt NOT_CONFIGURED
- ✅ Spotify set shows READY - ✅ Spotify set zeigt READY
## Troubleshooting ## Troubleshooting
- Status not changing: - Status aendert sich nicht:
- Ensure `/status` is polled after sending commands. - `/status` nach Commands abfragen.
- Check that Box is running and secrets file is writable. - Schreibrechte auf `box/data/secrets.enc` pruefen.
+34 -37
View File
@@ -1,65 +1,62 @@
# Playback Testing # Playback-Tests
## Purpose ## Zweck
Test NFC playback, recursive media order, resume, and playback controls. Tests fuer NFC-Playback, Rekursion, Resume und Buttons.
## Prerequisites ## Voraussetzungen
- Box running: `python3 box/main.py run` - Box laeuft: `python3 box/main.py run`
- Media in `box/data/media/` - Medien unter `box/data/media/`
## Step-by-step ## Schritt-fuer-Schritt
### NFC simulation ### NFC-Simulation
Start playback: Start:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}'
``` ```
Stop playback: Stop:
``` ```
curl -X POST http://127.0.0.1:8000/command \ curl -X POST http://127.0.0.1:8000/command \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"command":"nfc_off","payload":{"uid":"UID_1"}}' -d '{"command":"nfc_off","payload":{"uid":"UID_1"}}'
``` ```
### Resume tests ### Resume
1) `nfc_on UID_1` 1) `nfc_on UID_1`
2) Wait or use `tick` in CLI 2) Warten (Auto-Tick)
3) `nfc_off UID_1` 3) `nfc_off UID_1`
4) `nfc_on UID_1` again 4) `nfc_on UID_1`
Expected: position resumes from saved state. Erwartung: Resume nutzt gespeicherte Position.
### Tag switching ### Tag-Wechsel
1) `nfc_on UID_1` 1) `nfc_on UID_1`
2) `nfc_on UID_2` 2) `nfc_on UID_2`
3) `nfc_on UID_1` 3) `nfc_on UID_1`
Expected: UID_1 resumes from previous position. Erwartung: UID_1 resumed.
### Recursive order ### Rekursive Reihenfolge
- Order is depth-first, folders first, lexicographic. - Ordner werden DFS traversiert (Unterordner zuerst, dann Dateien).
- Change folder names or nesting to see ordering changes. - Sortierung lexikografisch.
### End of media behavior ### Ende-des-Mediums
- When the last file ends: - Letzte Datei endet -> Playback stoppt, Position wird am Ende gespeichert.
- Playback stops
- Position saved at end
- State becomes IDLE
### Buttons ### Buttons
- `next`: file_index + 1 - `next`: file_index + 1
- `prev`: if position > 3s then position=0, else file_index - 1 - `prev`: wenn position > 3s -> position=0, sonst file_index - 1
- `play_pause`: toggle PLAYING/PAUSED - `play_pause`: toggelt PLAYING/PAUSED
- `volume_up` / `volume_down`: change volume within 0..max - `volume_up` / `volume_down`: innerhalb 0..max
## Checklist ## Checkliste
- ✅ Resume works for same UID - ✅ Resume pro UID
- ✅ Tag switching preserves per-UID resume - ✅ Tag-Wechsel speichert und resumed
- ✅ Recursive order matches folder structure - ✅ Rekursion entspricht Ordnerstruktur
- ✅ End-of-media stops playback and saves - ✅ Ende-des-Mediums stoppt und speichert
## Troubleshooting ## Troubleshooting
- No playback starts: - Kein Playback:
- Check tags in `box/data/box.json`. - Tags in `box/data/box.json` pruefen.
- Ensure files exist under `box/data/media`. - Dateien in `box/data/media` pruefen.
- Duration is 0: - Duration = 0:
- Filenames not matched; mock uses hash-based durations for unknown names. - Unbekannte Dateinamen -> Hash-Dauer, nicht 0.
+38 -37
View File
@@ -1,60 +1,61 @@
# Security & Storage # Security & Storage
## Purpose ## Zweck
Explain where data lives, what is encrypted, and how keys are derived. Beschreibt Datenablage, Verschluesselung und Key-Ableitung.
## Prerequisites ## Voraussetzungen
- Box running at least once to create `box/data/` - Box mindestens einmal gestartet
## Step-by-step ## Schritt-fuer-Schritt
1) Start the box: 1) Box starten:
``` ```
python3 box/main.py run python3 box/main.py run
``` ```
2) Inspect data files: 2) Datenordner pruefen:
``` ```
ls -la box/data ls -la box/data
``` ```
## Data layout ## Datenstruktur
``` ```
box/data/ box/data/
├── box.json # box_id, settings, tags (plaintext) ├── box.json # box_id, settings, tags (Klartext)
├── state.json # resume, wifi state (plaintext) ├── state.json # resume, wifi state (Klartext)
├── media/ # audio files ├── media/ # Audiodateien
└── secrets.enc # encrypted secrets (JSON inside) └── secrets.enc # verschluesselte Secrets
``` ```
## What is plaintext ## Klartext
- box_id - box_id
- box name - Box-Name
- volume - Lautstaerke
- resume positions - Resume-Positionen
- playback state - Playback-State
- media paths - Media-Pfade
- UI state - UI-Zustaende
## What is encrypted ## Verschluesselt
- WiFi profiles - WLAN-Profile
- Spotify tokens - Spotify Tokens
- Server tokens (future) - Server Tokens (api_token)
- secret_seed (Fingerprint-Seed)
## Key derivation ## Key-Ableitung
- Key = PBKDF2-HMAC-SHA256(box_id + STATIC_SALT) - Key = PBKDF2-HMAC-SHA256(box_id + STATIC_SALT)
- Key is never stored on disk - Key wird nie gespeichert
- secrets.enc is not portable between boxes - secrets.enc ist nicht zwischen Boxen austauschbar
## Reset behavior ## Reset-Verhalten
- WiFi reset: removes only wifi_profiles from secrets.enc - WLAN-Reset: nur wifi_profiles entfernen
- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` - Factory-Reset: `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` loeschen
## Checklist ## Checkliste
-`secrets.enc` exists after WiFi/Spotify set -`secrets.enc` existiert nach WiFi/Spotify set
-`box.json` contains `box_id` -`box.json` enthaelt `box_id`
-No secrets appear in `/status` -Keine Secrets in `/status`
## Troubleshooting ## Troubleshooting
- secrets.enc not created: - secrets.enc fehlt:
- Ensure you ran `wifi_add_profile` or `spotify_set_tokens`. - WiFi/Spotify set ausfuehren.
- secrets.enc unreadable after reset: - secrets.enc unlesbar nach Reset:
- Factory reset creates a new box_id and new key. - Neue box_id erzeugt neuen Key.
+31 -28
View File
@@ -1,40 +1,43 @@
# Setup WebGUI # Setup-WebGUI
## Purpose ## Zweck
Configure WiFi profiles in the virtual box without touching the API or CLI. WLAN-Profile in der virtuellen Box konfigurieren, ohne CLI oder API.
## Prerequisites ## Voraussetzungen
- Box running: `python3 box/main.py run` - Box laeuft: `python3 box/main.py run`
## Step-by-step ## Schritt-fuer-Schritt
1) Open the Setup UI: 1) Setup-UI oeffnen:
``` ```
http://127.0.0.1:9000/setup http://127.0.0.1:9000/setup
``` ```
2) Choose SSID and enter password. 2) SSID auswaehlen, Passwort eingeben.
3) Click "Verbinden". 3) "Verbinden" klicken.
4) Refresh `/status` to see `wifi_state` and `connected_ssid`. 4) Status pruefen:
```
curl http://127.0.0.1:8000/status
```
## Fields ## Felder
- SSID: drop-down from mock scan - SSID: Dropdown aus dem Mock-Scan
- Password: free text - Passwort: Freitext
## Expected results ## Erwartete Ergebnisse
- WiFi profile is saved (encrypted) - WLAN-Profil wird verschluesselt gespeichert
- `wifi_state` becomes `WIFI_ONLINE` - `wifi_state` wird `WIFI_ONLINE`
- `connected_ssid` matches selected SSID - `connected_ssid` passt zur Auswahl
## Checklist ## Checkliste
- ✅ Setup UI loads - ✅ Setup-UI laedt
- ✅ Submit stores a profile - ✅ Submit speichert Profil
-`/status` reflects `WIFI_ONLINE` -`/status` zeigt `WIFI_ONLINE`
## Troubleshooting ## Troubleshooting
- Setup UI not reachable: - Setup-UI nicht erreichbar:
- Ensure Box is running and port `9000` is free. - Port 9000 frei? Box laeuft?
- Submit does nothing: - Submit ohne Wirkung:
- Check Box logs for errors; secrets file must be writable. - Logs pruefen, `box/data/secrets.enc` muss schreibbar sein.
## Notes ## Hinweise
- Setup UI is write-enabled only when no profiles exist. - Setup-UI ist nur schreibend, wenn keine Profile existieren.
- When profiles exist, the page switches to read-only status. - Bei vorhandenen Profilen ist die Seite read-only.
+8 -8
View File
@@ -1,13 +1,13 @@
# Storage (Legacy Notes) # Storage (Legacy-Hinweis)
## Purpose ## Zweck
This file is kept for backward reference. Current storage documentation lives in `docs/security-storage.md`. Dieses Dokument bleibt als Legacy-Hinweis bestehen. Die aktuelle Doku liegt in `docs/security-storage.md`.
## Prerequisites ## Voraussetzungen
- None - Keine
## Step-by-step ## Schritt-fuer-Schritt
- See `docs/security-storage.md`. - Siehe `docs/security-storage.md`.
## Troubleshooting ## Troubleshooting
- If you ended up here from older links, update them to `docs/security-storage.md`. - Falls Links hierher zeigen, bitte aktualisieren.
+16 -16
View File
@@ -1,22 +1,22 @@
# Virtual Box Flow (IDE) # Virtuelle Box (IDE)
## Purpose ## Zweck
Describe the full, hardware-free box lifecycle used in development. Beschreibt den vollstaendigen, hardwarefreien Box-Flow fuer die IDE.
## Prerequisites ## Voraussetzungen
- Python 3 - Python 3
- Box started with `python3 box/main.py run` - Box gestartet mit `python3 box/main.py run`
## Step-by-step ## Schritt-fuer-Schritt
1) Box starts and creates `box/data/box.json` if missing. 1) Box erzeugt `box_id` in `box/data/box.json`.
2) WiFi state is derived from stored profiles: 2) WLAN-Status wird aus Profilen abgeleitet:
- none -> `WIFI_UNCONFIGURED` - keine Profile -> `WIFI_UNCONFIGURED`
3) Setup UI available at `http://127.0.0.1:9000/setup`. 3) Setup UI unter `http://127.0.0.1:9000/setup`.
4) After profile submission, box becomes `WIFI_ONLINE`. 4) Nach Submit wird `WIFI_ONLINE`.
5) API available at `http://127.0.0.1:8000`. 5) API unter `http://127.0.0.1:8000`.
## Troubleshooting ## Troubleshooting
- Setup UI not reachable: - Setup UI nicht erreichbar:
- Ensure port 9000 is free and Box is running. - Port 9000 frei? Box laeuft?
- API not reachable: - API nicht erreichbar:
- Ensure port 8000 is free and Box is running. - Port 8000 frei? Box laeuft?
+6 -1
View File
@@ -1,6 +1,7 @@
// src/api.js // src/api.js
const API_BASE = import.meta.env.VITE_BOX_API_URL || "http://10.10.10.23:8000"; const API_BASE = import.meta.env.VITE_BOX_API_URL || "http://10.10.10.23:8000";
const API_TOKEN = import.meta.env.VITE_BOX_API_TOKEN || "";
export async function getStatus() { export async function getStatus() {
const response = await fetch(`${API_BASE}/status`); const response = await fetch(`${API_BASE}/status`);
@@ -12,9 +13,13 @@ export async function getStatus() {
export async function sendCommand(command, payload = {}) { export async function sendCommand(command, payload = {}) {
console.log("GUI -> API", command, payload); console.log("GUI -> API", command, payload);
const headers = { "Content-Type": "application/json" };
if (API_TOKEN) {
headers["X-API-Token"] = API_TOKEN;
}
const response = await fetch(`${API_BASE}/command`, { const response = await fetch(`${API_BASE}/command`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers,
body: JSON.stringify({ command, payload }), body: JSON.stringify({ command, payload }),
}); });
if (!response.ok) { if (!response.ok) {
Binary file not shown.
+15
View File
@@ -0,0 +1,15 @@
{
"klangkiste-x1u8i4x5e4": {
"fingerprint": "ecfcaddfcdf73417c8b377cb807b006f7d0756ca717fda35f377e8d18b297c43",
"state": "UNPAIRED",
"first_seen": 1768244301,
"last_seen": 1768244591,
"firmware_version": "0.1.0",
"capabilities": {
"nfc": true,
"audio": true,
"spotify": true
},
"api_token": null
}
}
+168
View File
@@ -0,0 +1,168 @@
"""Minimal server API and GUI for pairing."""
from __future__ import annotations
import secrets
import time
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Form
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
import uvicorn
from storage import BoxRecord, BoxRegistry
DATA_DIR = Path(__file__).resolve().parent / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
registry = BoxRegistry(DATA_DIR)
app = FastAPI()
class AnnounceRequest(BaseModel):
box_id: str
fingerprint: str
firmware_version: str
capabilities: dict[str, Any]
class PollRequest(BaseModel):
box_id: str
class PairRequest(BaseModel):
box_id: str
def _now() -> int:
return int(time.time())
def _validate_box_id(value: str) -> bool:
if not value.startswith("klangkiste-"):
return False
suffix = value.replace("klangkiste-", "", 1)
if len(suffix) != 10:
return False
return all(c.isdigit() or ("a" <= c <= "z") for c in suffix)
@app.post("/api/boxes/announce")
def announce(req: AnnounceRequest) -> dict[str, Any]:
if not _validate_box_id(req.box_id):
raise HTTPException(status_code=400, detail="invalid box_id")
if not req.fingerprint:
raise HTTPException(status_code=400, detail="fingerprint required")
boxes = registry.load()
now = _now()
if req.box_id in boxes:
record = boxes[req.box_id]
if record.fingerprint and record.fingerprint != req.fingerprint:
raise HTTPException(status_code=400, detail="fingerprint mismatch")
record.last_seen = now
record.firmware_version = req.firmware_version
record.capabilities = req.capabilities
if record.state != "PAIRED":
record.state = "UNPAIRED"
else:
record = BoxRecord(
box_id=req.box_id,
fingerprint=req.fingerprint,
state="UNPAIRED",
first_seen=now,
last_seen=now,
firmware_version=req.firmware_version,
capabilities=req.capabilities,
)
boxes[req.box_id] = record
registry.save(boxes)
return {"ok": True}
@app.post("/api/boxes/poll-pairing")
def poll_pairing(req: PollRequest) -> dict[str, Any]:
boxes = registry.load()
record = boxes.get(req.box_id)
if not record:
raise HTTPException(status_code=404, detail="unknown box")
if record.state == "PAIRED" and record.api_token:
return {"paired": True, "api_token": record.api_token}
return {"paired": False}
@app.post("/api/boxes/pair")
def pair_box(req: PairRequest) -> dict[str, Any]:
boxes = registry.load()
record = boxes.get(req.box_id)
if not record:
raise HTTPException(status_code=404, detail="unknown box")
if record.state != "PAIRED":
record.state = "PAIRED"
record.api_token = secrets.token_hex(32)
boxes[req.box_id] = record
registry.save(boxes)
return {"paired": True, "api_token": record.api_token}
@app.get("/ui", response_class=HTMLResponse)
def ui() -> str:
boxes = _apply_offline(registry.load())
unpaired = [b for b in boxes.values() if b.state != "PAIRED"]
paired = [b for b in boxes.values() if b.state == "PAIRED"]
def _row(box: BoxRecord) -> str:
return (
f"<tr><td>{box.box_id}</td><td>{box.firmware_version}</td>"
f"<td>{box.last_seen}</td>"
f"<td><form method='post' action='/ui/pair'>"
f"<input type='hidden' name='box_id' value='{box.box_id}'/>"
f"<button type='submit'>Box hinzufuegen</button>"
f"</form></td></tr>"
)
unpaired_rows = "".join(_row(b) for b in unpaired) or "<tr><td colspan='4'>Keine</td></tr>"
paired_rows = "".join(
f"<tr><td>{b.box_id}</td><td>{b.firmware_version}</td><td>{b.last_seen}</td></tr>"
for b in paired
) or "<tr><td colspan='3'>Keine</td></tr>"
return f"""
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Klangkiste Server</title></head>
<body>
<h1>Neue Boxen (UNPAIRED)</h1>
<table border="1">
<tr><th>box_id</th><th>firmware</th><th>last_seen</th><th>action</th></tr>
{unpaired_rows}
</table>
<h1>Gepaarte Boxen</h1>
<table border="1">
<tr><th>box_id</th><th>firmware</th><th>last_seen</th></tr>
{paired_rows}
</table>
</body>
</html>
"""
def _apply_offline(boxes: dict[str, BoxRecord], threshold_seconds: int = 120) -> dict[str, BoxRecord]:
now = _now()
for record in boxes.values():
if now - record.last_seen > threshold_seconds:
record.state = "OFFLINE"
registry.save(boxes)
return boxes
@app.post("/ui/pair")
def ui_pair(box_id: str = Form(...)) -> RedirectResponse:
pair_box(PairRequest(box_id=box_id))
return RedirectResponse(url="/ui", status_code=303)
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=7000, log_level="info")
+3
View File
@@ -0,0 +1,3 @@
fastapi==0.115.6
uvicorn==0.30.6
pydantic==2.9.2
+60
View File
@@ -0,0 +1,60 @@
"""Server-side storage for box registry."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@dataclass
class BoxRecord:
box_id: str
fingerprint: str
state: str
first_seen: int
last_seen: int
firmware_version: str
capabilities: dict[str, Any]
api_token: str | None = None
class BoxRegistry:
def __init__(self, base_dir: Path) -> None:
self._path = base_dir / "boxes.json"
def load(self) -> dict[str, BoxRecord]:
if not self._path.exists():
return {}
raw = json.loads(self._path.read_text(encoding="utf-8"))
result: dict[str, BoxRecord] = {}
for box_id, data in raw.items():
if not isinstance(data, dict):
continue
result[box_id] = BoxRecord(
box_id=box_id,
fingerprint=str(data.get("fingerprint", "")),
state=str(data.get("state", "UNPAIRED")),
first_seen=int(data.get("first_seen", 0)),
last_seen=int(data.get("last_seen", 0)),
firmware_version=str(data.get("firmware_version", "")),
capabilities=dict(data.get("capabilities", {})),
api_token=data.get("api_token"),
)
return result
def save(self, records: dict[str, BoxRecord]) -> None:
data = {
box_id: {
"fingerprint": rec.fingerprint,
"state": rec.state,
"first_seen": rec.first_seen,
"last_seen": rec.last_seen,
"firmware_version": rec.firmware_version,
"capabilities": rec.capabilities,
"api_token": rec.api_token,
}
for box_id, rec in records.items()
}
self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")