diff --git a/box/api/__pycache__/__init__.cpython-312.pyc b/box/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..fa834d8 Binary files /dev/null and b/box/api/__pycache__/__init__.cpython-312.pyc differ diff --git a/box/api/__pycache__/http.cpython-312.pyc b/box/api/__pycache__/http.cpython-312.pyc new file mode 100644 index 0000000..1fbb87b Binary files /dev/null and b/box/api/__pycache__/http.cpython-312.pyc differ diff --git a/box/api/http.py b/box/api/http.py index 512af00..7ff81d8 100644 --- a/box/api/http.py +++ b/box/api/http.py @@ -4,12 +4,13 @@ from __future__ import annotations from typing import Any, Callable -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from core.box_controller import BoxController from core.lifecycle import NetworkLifecycle +from core.pairing import PairingManager, PairingState from spotify.base import SpotifyBackend from wifi.base import WifiBackend @@ -24,6 +25,7 @@ def create_app( lifecycle: NetworkLifecycle, wifi_backend: WifiBackend, spotify_backend: SpotifyBackend, + pairing_manager: PairingManager, persist_state: Callable[[], None], ) -> FastAPI: app = FastAPI() @@ -40,6 +42,7 @@ def create_app( state = controller.state return { "box_id": state.box_id, + "pairing_state": pairing_manager.state.value, "wifi_state": snapshot.wifi_state.value, "ip_address": snapshot.ip_address, "connected_ssid": snapshot.connected_ssid, @@ -59,10 +62,13 @@ def create_app( } @app.post("/command") - def command(request: CommandRequest) -> dict[str, Any]: + def command(request: CommandRequest, http_request: Request) -> dict[str, Any]: cmd = request.command 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": uid = payload.get("uid") if not isinstance(uid, str): @@ -125,3 +131,25 @@ def create_app( return {"ok": True} 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 diff --git a/box/core/__pycache__/announce.cpython-312.pyc b/box/core/__pycache__/announce.cpython-312.pyc new file mode 100644 index 0000000..4d77b6d Binary files /dev/null and b/box/core/__pycache__/announce.cpython-312.pyc differ diff --git a/box/core/__pycache__/identity.cpython-312.pyc b/box/core/__pycache__/identity.cpython-312.pyc new file mode 100644 index 0000000..cec55bd Binary files /dev/null and b/box/core/__pycache__/identity.cpython-312.pyc differ diff --git a/box/core/__pycache__/lifecycle.cpython-312.pyc b/box/core/__pycache__/lifecycle.cpython-312.pyc new file mode 100644 index 0000000..1760676 Binary files /dev/null and b/box/core/__pycache__/lifecycle.cpython-312.pyc differ diff --git a/box/core/__pycache__/pairing.cpython-312.pyc b/box/core/__pycache__/pairing.cpython-312.pyc new file mode 100644 index 0000000..095cc6f Binary files /dev/null and b/box/core/__pycache__/pairing.cpython-312.pyc differ diff --git a/box/core/announce.py b/box/core/announce.py new file mode 100644 index 0000000..5884b0d --- /dev/null +++ b/box/core/announce.py @@ -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) diff --git a/box/core/identity.py b/box/core/identity.py index c640038..8bfd141 100644 --- a/box/core/identity.py +++ b/box/core/identity.py @@ -2,14 +2,29 @@ from __future__ import annotations +import hashlib import secrets import string PREFIX = "klangkiste-" -ALPHABET = string.ascii_lowercase + string.digits +LETTERS = string.ascii_lowercase +DIGITS = string.digits SUFFIX_LENGTH = 10 +STATIC_APP_SALT = "klangkiste-static-app-salt-v1" 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}" + + +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() diff --git a/box/core/pairing.py b/box/core/pairing.py new file mode 100644 index 0000000..147b448 --- /dev/null +++ b/box/core/pairing.py @@ -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) diff --git a/box/data/box.json b/box/data/box.json new file mode 100644 index 0000000..1cf280e --- /dev/null +++ b/box/data/box.json @@ -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 + } +} \ No newline at end of file diff --git a/box/data/secrets.enc b/box/data/secrets.enc new file mode 100644 index 0000000..df8bbbd --- /dev/null +++ b/box/data/secrets.enc @@ -0,0 +1 @@ +gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i \ No newline at end of file diff --git a/box/data/state.json b/box/data/state.json new file mode 100644 index 0000000..139df98 --- /dev/null +++ b/box/data/state.json @@ -0,0 +1,14 @@ +{ + "wifi": { + "connected_ssid": null + }, + "resume": { + "UID_1": { + "file_index": 1, + "position": 79 + } + }, + "pairing": { + "state": "PAIRING_PENDING" + } +} \ No newline at end of file diff --git a/box/main.py b/box/main.py index 5c89000..cce2774 100644 --- a/box/main.py +++ b/box/main.py @@ -13,9 +13,11 @@ from typing import Any import uvicorn from api.http import create_app +from core.announce import AnnounceConfig, announce_loop, pairing_poll_loop from core.box_controller import BoxController from core.identity import generate_box_id from core.lifecycle import NetworkLifecycle +from core.pairing import PairingManager from core.state import BoxState, RuntimeState from player.mock_player import MockPlayer from setup.webui import create_setup_app @@ -35,7 +37,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--config", 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) @@ -137,6 +139,12 @@ def main() -> int: box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {})) if isinstance(box_meta.get("tags"), dict): 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) state_store = StateStore(base_dir) @@ -144,9 +152,11 @@ def main() -> int: state = _state_from_data(box_meta, state_data) secrets = SecretStore(base_dir, state.box_id) + secrets.ensure_secret_seed() wifi_backend = MockWifiBackend(state_store, secrets) spotify_backend = MockSpotifyBackend(secrets) lifecycle = NetworkLifecycle(wifi_backend) + pairing_manager = PairingManager(state_store, secrets) controller = BoxController( state=state, player=MockPlayer(), @@ -177,15 +187,25 @@ def main() -> int: return 0 if args.command == "run": - asyncio.run( - _run_with_api( - controller, - state_store, - lifecycle, - wifi_backend, - spotify_backend, + try: + asyncio.run( + _run_with_api( + controller, + state_store, + lifecycle, + 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 @@ -213,12 +233,16 @@ async def _run_with_api( lifecycle: NetworkLifecycle, wifi_backend: MockWifiBackend, spotify_backend: MockSpotifyBackend, + pairing_manager: PairingManager, + secrets: SecretStore, + announce_config: AnnounceConfig, ) -> None: app = create_app( controller, lifecycle, wifi_backend, spotify_backend, + pairing_manager, lambda: _persist_resume(controller, storage), ) api_server = uvicorn.Server( @@ -234,7 +258,7 @@ async def _run_with_api( setup_server = uvicorn.Server( uvicorn.Config( setup_app, - host="127.0.0.1", + host="0.0.0.0", port=9000, log_level="warning", access_log=False, @@ -244,6 +268,18 @@ async def _run_with_api( tick_task = asyncio.create_task( _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()) setup_task = asyncio.create_task(setup_server.serve()) @@ -261,6 +297,12 @@ async def _run_with_api( tick_task.cancel() with contextlib.suppress(asyncio.CancelledError): 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() with contextlib.suppress(asyncio.CancelledError): await setup_task diff --git a/box/requirements.txt b/box/requirements.txt index ac8d625..9a98b0c 100644 --- a/box/requirements.txt +++ b/box/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.115.6 uvicorn==0.30.6 pydantic==2.9.2 cryptography==43.0.3 +python-multipart==0.0.9 diff --git a/box/setup/__pycache__/__init__.cpython-312.pyc b/box/setup/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..73eb571 Binary files /dev/null and b/box/setup/__pycache__/__init__.cpython-312.pyc differ diff --git a/box/setup/__pycache__/webui.cpython-312.pyc b/box/setup/__pycache__/webui.cpython-312.pyc new file mode 100644 index 0000000..b4a7343 Binary files /dev/null and b/box/setup/__pycache__/webui.cpython-312.pyc differ diff --git a/box/spotify/__pycache__/__init__.cpython-312.pyc b/box/spotify/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..dad4d76 Binary files /dev/null and b/box/spotify/__pycache__/__init__.cpython-312.pyc differ diff --git a/box/spotify/__pycache__/base.cpython-312.pyc b/box/spotify/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000..256250b Binary files /dev/null and b/box/spotify/__pycache__/base.cpython-312.pyc differ diff --git a/box/spotify/__pycache__/mock.cpython-312.pyc b/box/spotify/__pycache__/mock.cpython-312.pyc new file mode 100644 index 0000000..4cefddb Binary files /dev/null and b/box/spotify/__pycache__/mock.cpython-312.pyc differ diff --git a/box/storage/__pycache__/box_store.cpython-312.pyc b/box/storage/__pycache__/box_store.cpython-312.pyc new file mode 100644 index 0000000..d560e64 Binary files /dev/null and b/box/storage/__pycache__/box_store.cpython-312.pyc differ diff --git a/box/storage/__pycache__/secret_store.cpython-312.pyc b/box/storage/__pycache__/secret_store.cpython-312.pyc new file mode 100644 index 0000000..4d8bbe4 Binary files /dev/null and b/box/storage/__pycache__/secret_store.cpython-312.pyc differ diff --git a/box/storage/__pycache__/state_store.cpython-312.pyc b/box/storage/__pycache__/state_store.cpython-312.pyc new file mode 100644 index 0000000..164d091 Binary files /dev/null and b/box/storage/__pycache__/state_store.cpython-312.pyc differ diff --git a/box/storage/secret_store.py b/box/storage/secret_store.py index 4b3f1c7..ff83b22 100644 --- a/box/storage/secret_store.py +++ b/box/storage/secret_store.py @@ -36,6 +36,35 @@ class SecretStore: token = fernet.encrypt(payload) 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: data = self.load_secrets() if "wifi_profiles" in data: @@ -55,3 +84,9 @@ class SecretStore: dklen=32, ) return base64.urlsafe_b64encode(raw) + + @staticmethod + def _generate_seed() -> str: + import secrets + + return secrets.token_hex(32) diff --git a/box/wifi/__pycache__/__init__.cpython-312.pyc b/box/wifi/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..df302be Binary files /dev/null and b/box/wifi/__pycache__/__init__.cpython-312.pyc differ diff --git a/box/wifi/__pycache__/base.cpython-312.pyc b/box/wifi/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000..34c4c60 Binary files /dev/null and b/box/wifi/__pycache__/base.cpython-312.pyc differ diff --git a/box/wifi/__pycache__/mock.cpython-312.pyc b/box/wifi/__pycache__/mock.cpython-312.pyc new file mode 100644 index 0000000..73fff4e Binary files /dev/null and b/box/wifi/__pycache__/mock.cpython-312.pyc differ diff --git a/dev.sh b/dev.sh index a7586a2..f41678b 100755 --- a/dev.sh +++ b/dev.sh @@ -7,6 +7,10 @@ echo "🚀 Starting Klangkiste (Box + GUI)" cleanup() { echo "" 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 kill -TERM "$GUI_PID" 2>/dev/null || true wait "$GUI_PID" 2>/dev/null || true @@ -14,11 +18,21 @@ cleanup() { } trap cleanup INT TERM EXIT +# Server starten (FastAPI + GUI) +echo "▶️ Starting Server..." +( + cd server + python3 main.py +) & + +SERVER_PID=$! + # GUI starten (Vite) -echo "▶️ Starting GUI..." +GUI_PORT="${KLANGKISTE_GUI_PORT:-5174}" +echo "▶️ Starting GUI on port ${GUI_PORT}..." ( cd gui - npm run dev + npm run dev -- --host 0.0.0.0 --port "${GUI_PORT}" --strictPort ) & GUI_PID=$! @@ -29,6 +43,7 @@ cd box python3 main.py run echo "" +echo "✅ Server PID: $SERVER_PID" echo "✅ GUI PID: $GUI_PID" echo "🛑 Press Ctrl+C to stop everything" echo "" diff --git a/docs/README.md b/docs/README.md index 3901845..2b7fc48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,31 +1,31 @@ -# Klangkiste Dev Playbook +# Klangkiste Dev-Playbook -## Purpose -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. +## Zweck +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 - Node.js + npm -- From repo root +- Aus dem Repo-Root ausfuehren -## Step-by-step -Quickstart (3-5 minutes): -1) Start everything: +## Schritt-fuer-Schritt +Quickstart (3-5 Minuten): +1) Alles starten: ``` ./dev.sh ``` -2) Open the WiFi setup UI: +2) Setup-WebGUI oeffnen: ``` http://127.0.0.1:9000/setup ``` -3) Add a WiFi profile and submit. -4) Check status: +3) WLAN-Profil eintragen und speichern. +4) Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -5) Start playback: +5) Playback starten: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ @@ -33,26 +33,26 @@ curl -X POST http://127.0.0.1:8000/command \ ``` ## Troubleshooting -- Box not starting: - - Ensure dependencies are installed and ports 8000/9000 are free. -- GUI cannot reach API: - - Check `VITE_BOX_API_URL` and Box logs. +- Box startet nicht: + - Pruefe Abhaengigkeiten und ob Ports 8000/9000 frei sind. +- GUI erreicht API nicht: + - Pruefe `VITE_BOX_API_URL` und Box-Logs. -## What Is Mock vs Real -- Mock: WiFi, IP, Spotify, audio, hardware. These are logical simulations only. -- Real: Box process, playback state machine, API, CLI, resume logic, media traversal. +## Was ist Mock vs. real +- Mock: WLAN, IP, Spotify, Audio, Hardware. Diese Backends sind logische Simulationen. +- Real: Box-Prozess, Playback-State-Machine, API, CLI, Resume-Logik, Media-Traversal. -## Link Index -- Dev workflow: `docs/dev-workflow.md` -- Box lifecycle: `docs/box-lifecycle.md` -- Setup WebGUI: `docs/setup-webgui.md` -- HTTP API contract: `docs/api.md` -- GUI control panel: `docs/gui-control-panel.md` -- Playback testing: `docs/playback-testing.md` -- Offline scenarios: `docs/offline-scenarios.md` -- Security & storage: `docs/security-storage.md` -- CLI reference: `docs/cli.md` -- Acceptance tests: `docs/acceptance-tests.md` -- Debugging guide: `docs/debugging.md` -- Virtual box notes: `docs/virtual-box.md` -- Legacy storage notes: `docs/storage.md` +## Link-Index +- Dev-Workflow: `docs/dev-workflow.md` +- Box-Lifecycle: `docs/box-lifecycle.md` +- Setup-WebGUI: `docs/setup-webgui.md` +- HTTP-API Vertrag: `docs/api.md` +- GUI Control Panel: `docs/gui-control-panel.md` +- Playback-Tests: `docs/playback-testing.md` +- Offline-Szenarien: `docs/offline-scenarios.md` +- Security & Storage: `docs/security-storage.md` +- CLI-Referenz: `docs/cli.md` +- Acceptance-Tests: `docs/acceptance-tests.md` +- Debugging-Guide: `docs/debugging.md` +- Virtual-Box-Notizen: `docs/virtual-box.md` +- Legacy-Storage-Notizen: `docs/storage.md` diff --git a/docs/acceptance-tests.md b/docs/acceptance-tests.md index e047c89..c75e82c 100644 --- a/docs/acceptance-tests.md +++ b/docs/acceptance-tests.md @@ -1,560 +1,572 @@ -# Acceptance Tests +# Acceptance-Tests -## Purpose -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. +## Zweck +Konsistenter Testkatalog basierend auf dem aktuellen Code. Alle Tests trennen Simulation und Hardware. Nicht implementierte Punkte sind explizit markiert. -## Prerequisites -- Repo root +## Voraussetzungen +- Repo-Root - 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` -## Step-by-step -Use the tests below in order or individually. +## Schritt-fuer-Schritt +Die Tests koennen einzeln oder der Reihe nach ausgefuehrt werden. --- -## Test A: Box-Start & Identitaet (box_id) +# Lokale Box-Tests (L1-L10) -1) Goal -- Verify box_id is created once and persists. +## Test L1: Box-Start & box_id -2) Preconditions -- Simulation: `box/data/box.json` may be missing. -- Real Box: storage is empty or fresh install. +1) Ziel +- box_id wird einmalig erzeugt und bleibt stabil. -3) Procedure – Simulation / Emulation -- Start Box: +2) Voraussetzungen +- Simulation: `box/data/box.json` kann fehlen. +- Hardware: frischer Start. + +3) Durchfuehrung – Simulation / Emulation +- Box starten: ``` python3 box/main.py run ``` -- Check box_id in API: +- Status lesen: ``` curl http://127.0.0.1:8000/status ``` -- Inspect file: +- Datei pruefen: ``` cat box/data/box.json ``` -4) Procedure – Real Box (Hardware) -- Power on device. -- Open status UI (if available) or API endpoint. +4) Durchfuehrung – Reale Box (Hardware) +- Box einschalten. +- Statusseite/API pruefen (sofern erreichbar). -5) Expected Result -- `/status` contains `box_id` with format `klangkiste-<10 chars a-z0-9>`. -- `box/data/box.json` contains `box_id` and is stable across restarts. +5) Erwartetes Ergebnis +- `box_id` im Format `klangkiste-<10 chars a-z0-9>`. +- `box/data/box.json` bleibt stabil ueber Neustarts. -6) Deviations / Troubleshooting -- `box_id` missing: check write permissions for `box/data/`. -- `box_id` changes: indicates manual deletion of `box/data/box.json`. +6) Abweichungen / Fehlersuche +- `box_id` fehlt: Schreibrechte in `box/data/` pruefen. --- -## Test B: WLAN-Ersteinrichtung (Setup WebGUI) +## Test L2: WLAN-Ersteinrichtung (Setup WebGUI) -1) Goal -- Configure WiFi profile via setup UI and reach WIFI_ONLINE. +1) Ziel +- WLAN-Profil per Setup UI anlegen. -2) Preconditions -- No WiFi profiles stored. +2) Voraussetzungen +- Keine WLAN-Profile gespeichert. -3) Procedure – Simulation / Emulation -- Reset WiFi profiles: +3) Durchfuehrung – Simulation / Emulation +- WLAN reset: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_reset"}' ``` -- Open Setup UI: +- Setup UI oeffnen: ``` http://127.0.0.1:9000/setup ``` -- Choose SSID and enter password, click "Verbinden". -- Check status: +- SSID waehlen, Passwort eingeben, speichern. +- Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -4) Procedure – Real Box (Hardware) -- Power on device. -- Connect to device AP SSID (not implemented in code). -- Open setup page and submit WiFi credentials. +4) Durchfuehrung – Reale Box (Hardware) +- Nicht testbar mit aktuellem Code: kein echter AP/WLAN-Stack. -5) Expected Result +5) Erwartetes Ergebnis - `wifi_state = WIFI_ONLINE` -- `connected_ssid` matches selected SSID -- `wifi_profiles_count` increments +- `connected_ssid` gesetzt -6) Deviations / Troubleshooting -- Setup UI unreachable: port 9000 occupied or Box not running. -- Not testable with current code: AP SSID and real WiFi connect. +6) Abweichungen / Fehlersuche +- Setup UI nicht erreichbar: Port 9000 frei, Box laeuft. --- -## Test C: Persistenz & Neustart +## Test L3: Persistenz & Neustart -1) Goal -- Ensure resume state and WiFi profiles persist across restarts. +1) Ziel +- Resume und WLAN-Profile bleiben ueber Neustart erhalten. -2) Preconditions -- At least one WiFi profile stored. -- Playback started once. +2) Voraussetzungen +- WLAN-Profil vorhanden. +- Playback einmal gestartet. -3) Procedure – Simulation / Emulation -- Add WiFi profile (if needed): +3) Durchfuehrung – Simulation / Emulation +- WLAN-Profil setzen: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -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 \ -H "Content-Type: application/json" \ -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' ``` -- Stop playback: +- Playback stoppen: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_off","payload":{"uid":"UID_1"}}' ``` -- Restart Box process. -- Check status: +- Box-Prozess neu starten. +- Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -4) Procedure – Real Box (Hardware) -- Start playback via NFC. -- Power cycle device. -- Verify WiFi profile and resume state persist. +4) Durchfuehrung – Reale Box (Hardware) +- Nicht testbar mit aktuellem Code: kein echter WLAN-Stack. -5) Expected Result -- `box/data/state.json` contains `resume`. -- `box/data/secrets.enc` exists and contains profiles (encrypted). -- After restart, `wifi_state` is not UNCONFIGURED. +5) Erwartetes Ergebnis +- `box/data/state.json` enthaelt Resume. +- `box/data/secrets.enc` existiert. -6) Deviations / Troubleshooting -- Resume missing: check `box/data/state.json`. -- WiFi profiles missing: check `box/data/secrets.enc` creation. +6) Abweichungen / Fehlersuche +- Resume fehlt: `box/data/state.json` pruefen. --- -## Test D: HTTP API Status & Commands +## Test L4: HTTP API Status & Commands -1) Goal -- Verify /status and /command are functional. +1) Ziel +- /status und /command liefern Antworten und aendern Status. -2) Preconditions -- Box running. +2) Voraussetzungen +- Box laeuft. -3) Procedure – Simulation / Emulation +3) Durchfuehrung – Simulation / Emulation - 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 \ -H "Content-Type: application/json" \ -d '{"command":"play_pause"}' ``` -4) Procedure – Real Box (Hardware) -- Same as above once API is reachable in real network. +4) Durchfuehrung – Reale Box (Hardware) +- Gleich wie Simulation, sofern API erreichbar. -5) Expected Result -- HTTP 200 responses. -- `/status` fields updated after commands. +5) Erwartetes Ergebnis +- Vor Pairing: HTTP 403 fuer gesperrte Commands. +- Nach Pairing (mit Token): HTTP 200 und Status aktualisiert. -6) Deviations / Troubleshooting -- CORS errors: ensure API is running. -- 400 errors: check payload requirements. +6) Abweichungen / Fehlersuche +- 400: Payload pruefen. --- -## Test E: GUI-Steuerung +## Test L5: GUI-Steuerung -1) Goal -- Verify GUI can control playback and display status. +1) Ziel +- GUI steuert Box und zeigt Live-Status. -2) Preconditions -- Box running on 127.0.0.1:8000. -- GUI running on 127.0.0.1:5174. +2) Voraussetzungen +- GUI laeuft auf 127.0.0.1:5174. -3) Procedure – Simulation / Emulation -- Open GUI: -``` -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). +3) Durchfuehrung – Simulation / Emulation +- GUI oeffnen, Buttons klicken. +- Status aendert sich innerhalb von 1s. -4) Procedure – Real Box (Hardware) -- Use GUI from another device pointing to Box IP. +4) Durchfuehrung – Reale Box (Hardware) +- GUI auf Box-IP ausrichten (spaeter). -5) Expected Result -- `/status` changes after each button. -- GUI shows updated status and last_error if present. +5) Erwartetes Ergebnis +- `/status` aendert sich nach Commands. -6) Deviations / Troubleshooting -- GUI shows "Failed to fetch": verify VITE_BOX_API_URL. +6) Abweichungen / Fehlersuche +- GUI "Failed to fetch": `VITE_BOX_API_URL` pruefen. --- -## Test F: Playback & NFC +## Test L6: Playback & NFC -1) Goal -- Verify NFC starts playback from tags. +1) Ziel +- NFC startet Playback. -2) Preconditions -- Tags exist in `box/data/box.json`. -- Media exists under `box/data/media`. +2) Voraussetzungen +- Tags in `box/data/box.json`. +- Medien in `box/data/media`. -3) Procedure – Simulation / Emulation -- Start playback: +3) Durchfuehrung – Simulation / Emulation ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' ``` -- Observe `/status`: -``` -curl http://127.0.0.1:8000/status -``` -4) Procedure – Real Box (Hardware) -- Place NFC tag mapped to UID_1. +4) Durchfuehrung – Reale Box (Hardware) +- NFC-Tag auflegen (spaeter). -5) Expected Result +5) Erwartetes Ergebnis - `playback_state.state = PLAYING` -- `active_uid = UID_1` -6) Deviations / Troubleshooting -- No playback: check tags in `box/data/box.json` and files in `box/data/media`. +6) Abweichungen / Fehlersuche +- Tag fehlt: `box/data/box.json` pruefen. --- -## Test G: Resume-Logik +## Test L7: Resume-Logik -1) Goal -- Resume picks up last file index and position per UID. +1) Ziel +- Resume pro UID funktioniert. -2) Preconditions -- Playback started at least once for UID_1. +2) Voraussetzungen +- Playback einmal gestartet. -3) Procedure – Simulation / Emulation -- Start playback, wait ~5s. -- Stop with `nfc_off`. -- Start again with `nfc_on`. +3) Durchfuehrung – Simulation / Emulation +- `nfc_on UID_1`, warten, `nfc_off UID_1`, dann `nfc_on UID_1`. -4) Procedure – Real Box (Hardware) -- Place NFC tag, remove, place again. +4) Durchfuehrung – Reale Box (Hardware) +- NFC auflegen/abnehmen (spaeter). -5) Expected Result -- Resume uses stored `file_index` and `position`. -- `box/data/state.json` contains resume for UID_1. +5) Erwartetes Ergebnis +- Resume nutzt gespeicherte Position. -6) Deviations / Troubleshooting -- Resume resets to 0: check `state.json` and path changes. +6) Abweichungen / Fehlersuche +- Resume = 0: `box/data/state.json` pruefen. --- -## Test H: Ordner- & Rekursionslogik +## Test L8: Rekursion & Ordnerstruktur -1) Goal -- Verify recursive ordering (DFS, folders first, lexicographic). +1) Ziel +- Rekursive Reihenfolge (DFS) ist korrekt. -2) Preconditions -- Nested media structure in `box/data/media`. +2) Voraussetzungen +- Verschachtelte Ordner in `box/data/media`. -3) Procedure – Simulation / Emulation -- Create nested structure in `box/data/media/book_6/CD1/...`. -- Point a tag to `media/book_6` in `box/data/box.json`. -- Start playback with `nfc_on`. +3) Durchfuehrung – Simulation / Emulation +- Tag auf `media/book_6` setzen, `nfc_on`. -4) Procedure – Real Box (Hardware) -- Copy nested folders to the device media path. -- Start playback via NFC. +4) Durchfuehrung – Reale Box (Hardware) +- Medienstruktur auf Box kopieren (spaeter). -5) Expected Result -- Files play in DFS order: subfolders sorted, then files. +5) Erwartetes Ergebnis +- DFS-Reihenfolge, lexikografisch. -6) Deviations / Troubleshooting -- Ordering unexpected: check folder names and lexicographic sort. +6) Abweichungen / Fehlersuche +- Reihenfolge falsch: Ordnernamen pruefen. --- -## Test I: Offline-Szenarien (Box intern) +## Test L9: Offline-Szenarien (Box intern) -1) Goal -- Verify WiFi and Spotify states in offline/online flows. +1) Ziel +- WiFi/Spotify-Status reagieren auf Commands. -2) Preconditions -- Box running. +2) Voraussetzungen +- Box laeuft. -3) Procedure – Simulation / Emulation -- WiFi reset: -``` -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"}' -``` +3) Durchfuehrung – Simulation / Emulation +- `wifi_reset` und `spotify_clear` ausfuehren. -4) Procedure – Real Box (Hardware) -- Not testable with current code: real network loss and Spotify connectivity. +4) Durchfuehrung – Reale Box (Hardware) +- Nicht testbar mit aktuellem Code. -5) Expected Result +5) Erwartetes Ergebnis - `wifi_state = WIFI_UNCONFIGURED` - `spotify_status = NOT_CONFIGURED` -6) Deviations / Troubleshooting -- Status unchanged: ensure you read `/status` after command. +6) Abweichungen / Fehlersuche +- Status aendert sich nicht: `/status` pruefen. --- -## Test J: Fehlerfaelle (Box intern) +## Test L10: Fehlerfaelle (Box intern) -1) Goal -- Verify error sound and error reporting. +1) Ziel +- Fehler setzen `last_error`. -2) Preconditions -- Box running. +2) Voraussetzungen +- Box laeuft. -3) Procedure – Simulation / Emulation -- Trigger error: +3) Durchfuehrung – Simulation / Emulation ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -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) - Nicht testbar mit aktuellem Code. 5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. +- `playback_state.last_error` gesetzt. 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 -- Box wird nach Freigabe gepairt und erhaelt API-Token. +- box_id wird einmalig erzeugt. 2) Voraussetzungen -- Simulation: Server-GUI und Pairing-Endpunkte implementiert. +- Box gestartet. 3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code: Pairing-Flow fehlt. +- Siehe Test L1. 4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code. +- Siehe Test L1. 5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. +- box_id stabil. 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 -- Vor Pairing sind Steuerbefehle blockiert. +- Box meldet sich beim Server und erscheint als UNPAIRED. 2) Voraussetzungen -- Server-Integration und Auth-Checks vorhanden. - -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: +- Server laeuft: ``` -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 ``` -- 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) -- Nicht testbar mit aktuellem Code: kein Factory-Reset-Mechanismus implementiert. +- Box einschalten. +- Server-GUI oeffnen, Box sollte erscheinen. 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 -- `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 -- Box laeuft weiter, wenn Server nicht erreichbar ist. +- Mehrere Boxen werden separat erkannt. 2) Voraussetzungen -- Server-Integration implementiert. +- Mehrere Boxen mit unterschiedlichen `box_id`. 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) -- Nicht testbar mit aktuellem Code. +- Zwei physische Boxen einschalten. +- Server-GUI zeigt beide UNPAIRED an. 5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. +- Zwei Eintraege mit unterschiedlichen `box_id`. 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 -- 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":""}' +``` + +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: " -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 - Box laeuft in Simulation. 3) Durchfuehrung – Simulation / Emulation -- Beachte: WiFi/IP/Spotify/Audio sind Mock. +- WiFi/IP/Spotify/Audio sind Mock. - Setup UI und API funktionieren lokal. 4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code: Hardware-Backends fehlen. +- Echte Backends notwendig (nicht implementiert). 5) Erwartetes Ergebnis - Simulation laeuft ohne Hardware. -- Hardware-Backends koennen spaeter ersetzt werden. 6) Abweichungen / Fehlersuche -- Wenn Hardware genutzt wird, fehlen Implementierungen. +- Hardware-Backends fehlen. diff --git a/docs/api.md b/docs/api.md index 8149284..822121c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,19 +1,19 @@ # Box HTTP API -## Purpose -Defines the stable contract between the GUI/CLI and the Box. -The Box is authoritative; the client only sends commands and reads status. +## Zweck +Stabiler Vertrag zwischen GUI/CLI und Box. +Die Box ist autoritativ, der Client sendet nur Commands und liest Status. -## Prerequisites -- Box running on `http://127.0.0.1:8000` +## Voraussetzungen +- Box laeuft auf `http://127.0.0.1:8000` -## Step-by-step +## Schritt-fuer-Schritt ### Status ``` curl http://127.0.0.1:8000/status ``` -### Command (example) +### Command (Beispiel) ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ @@ -21,12 +21,13 @@ curl -X POST http://127.0.0.1:8000/command \ ``` ## GET /status -Response fields: -- `box_id`: unique device ID +Felder: +- `box_id`: eindeutige Box-ID +- `pairing_state`: `UNPAIRED | PAIRING_PENDING | PAIRED` - `wifi_state`: `WIFI_UNCONFIGURED | WIFI_CONNECTING | WIFI_ONLINE | WIFI_OFFLINE` -- `ip_address`: simulated IP (`192.168.4.1` or `127.0.0.1`) -- `connected_ssid`: connected SSID or `null` -- `wifi_profiles_count`: number of stored profiles +- `ip_address`: simulierte IP (`192.168.4.1` oder `127.0.0.1`) +- `connected_ssid`: verbundene SSID oder `null` +- `wifi_profiles_count`: Anzahl gespeicherter Profile - `spotify_status`: `NOT_CONFIGURED | READY` - `playback_state`: - `state`: `IDLE | PLAYING | PAUSED` @@ -39,10 +40,11 @@ Response fields: - `volume` - `last_error` -Example response: +Beispielantwort: ``` { "box_id": "klangkiste-a9f3k7m2q8", + "pairing_state": "UNPAIRED", "wifi_state": "WIFI_ONLINE", "ip_address": "127.0.0.1", "connected_ssid": "HomeWiFi", @@ -63,7 +65,7 @@ Example response: ``` ## POST /command -Request body: +Request Body: ``` { "command": "nfc_on", @@ -71,7 +73,7 @@ Request body: } ``` -Supported commands: +Unterstuetzte Commands: - `nfc_on` `{ uid }` - `nfc_off` `{ uid }` - `play_pause` @@ -79,8 +81,8 @@ Supported commands: - `prev` - `volume_up` - `volume_down` -- `vol_up` (alias) -- `vol_down` (alias) +- `vol_up` (Alias) +- `vol_down` (Alias) - `stop` - `trigger_error` `{ type }` - `wifi_add_profile` `{ ssid, password, priority? }` @@ -88,27 +90,39 @@ Supported commands: - `spotify_set_tokens` `{ access_token, refresh_token, expires_at, account_id? }` - `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: " \\ + -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' +``` + +Beispiel: WLAN-Profil hinzufuegen ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -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 \ -H "Content-Type: application/json" \ -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' ``` -## Checklist -- ✅ `GET /status` returns JSON -- ✅ `POST /command` returns `{ "ok": true }` -- ✅ No secrets appear in `/status` +## Checkliste +- ✅ `GET /status` liefert JSON +- ✅ `POST /command` liefert `{ "ok": true }` +- ✅ Keine Secrets in `/status` ## Troubleshooting -- 400 responses: - - Check required fields in the payload. -- CORS errors: - - Ensure Box API is running on the expected host/port. +- 400 Responses: + - Payload pruefen (Pflichtfelder). +- CORS-Fehler: + - API muss laufen und erreichbar sein. diff --git a/docs/box-lifecycle.md b/docs/box-lifecycle.md index a45aad6..e69fb30 100644 --- a/docs/box-lifecycle.md +++ b/docs/box-lifecycle.md @@ -1,42 +1,47 @@ -# Box Lifecycle +# Box-Lifecycle -## Purpose -Defines the virtual box lifecycle: identity, WiFi states, and restart behavior. +## Zweck +Definiert Identitaet, WLAN-Status und Neustartverhalten der virtuellen Box. -## Prerequisites -- Box started with `python3 box/main.py run` +## Voraussetzungen +- Box gestartet mit `python3 box/main.py run` -## Step-by-step -### Box identity -- Generated on first start if `box/data/box.json` has no `box_id`. -- Format: `klangkiste-<10 chars a-z0-9>` -- Stored in `box/data/box.json` +## Schritt-fuer-Schritt +### Box-Identitaet +- Wird beim ersten Start erzeugt, wenn `box/data/box.json` keine `box_id` enthaelt. +- Format: `klangkiste-<10 Zeichen a-z0-9>` +- Persistiert in `box/data/box.json` -### WiFi states (logical) -- `WIFI_UNCONFIGURED`: no profiles stored -- `WIFI_CONNECTING`: reserved for future; not emitted in mock -- `WIFI_ONLINE`: profile exists and mock is "connected" -- `WIFI_OFFLINE`: profile exists but not connected +### WLAN-Zustaende (logisch) +- `WIFI_UNCONFIGURED`: keine Profile gespeichert +- `WIFI_CONNECTING`: reserviert fuer spaeter (im Mock nicht aktiv) +- `WIFI_ONLINE`: Profil vorhanden und verbunden +- `WIFI_OFFLINE`: Profil vorhanden, aber nicht verbunden -### IP model (informational) -- Setup/AP mode: `192.168.4.1` -- Normal mode: `127.0.0.1` +### IP-Modell (informativ) +- Setup/AP-Modus: `192.168.4.1` +- Normalbetrieb: `127.0.0.1` -### Restart behavior -- Box reloads `box_id`, resume state, and WiFi profiles from `box/data/` -- Playback resume and WiFi status are preserved +### Neustartverhalten +- Box laedt `box_id`, Resume-State und WLAN-Profile aus `box/data/` +- Resume und WLAN-Status bleiben erhalten ### Resets -- WiFi reset: clears WiFi profiles only (`wifi_reset` command) -- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` +- WLAN-Reset: nur WLAN-Profile loeschen (`wifi_reset`) +- Factory-Reset: `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` loeschen -## Checklist -- ✅ After first boot, `box/data/box.json` contains `box_id` -- ✅ `GET /status` returns `wifi_state` and `ip_address` -- ✅ WiFi reset clears profiles, `wifi_state=WIFI_UNCONFIGURED` +### Pairing-Status +- `UNPAIRED`: Box ist nicht gekoppelt +- `PAIRING_PENDING`: Box pollt Pairing-Status beim Server +- `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 -- `wifi_state` not changing: - - Add a WiFi profile via setup UI or API. -- Missing `box_id`: - - Ensure `box/data/box.json` is writable. +- `wifi_state` aendert sich nicht: + - WLAN-Profil via Setup UI oder API hinzufuegen. +- `box_id` fehlt: + - Schreibrechte fuer `box/data/box.json` pruefen. diff --git a/docs/cli.md b/docs/cli.md index 7305359..8673d72 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,34 +1,34 @@ -# Box CLI Reference +# CLI-Referenz -## Purpose -List all CLI interactions available in the virtual box. +## Zweck +Alle CLI-Interaktionen der virtuellen Box. -## Prerequisites -- Box code available in `box/` +## Voraussetzungen +- Box-Code in `box/` -## Step-by-step -### One-shot commands +## Schritt-fuer-Schritt +### One-shot Commands Status: ``` python3 box/main.py status ``` -Simulate NFC: +NFC simulieren: ``` python3 box/main.py simulate-nfc UID_1 python3 box/main.py simulate-nfc-off UID_1 ``` -Manual tick: +Manuelles Tick: ``` python3 box/main.py tick 30 ``` -### Interactive run mode +### Interaktiver Run-Modus ``` python3 box/main.py run ``` -Commands inside run mode: +Commands im Run-Modus: ``` status nfc @@ -43,13 +43,13 @@ help exit | quit ``` -## Checklist -- ✅ `status` prints runtime state -- ✅ `simulate-nfc` starts playback -- ✅ `run` accepts interactive commands +## Checkliste +- ✅ `status` gibt Runtime-State aus +- ✅ `simulate-nfc` startet Playback +- ✅ `run` akzeptiert interaktive Commands ## Troubleshooting -- Commands not found: - - Ensure you run from repo root or use full path to `box/main.py`. -- No playback: - - Check tags in `box/data/box.json` and media under `box/data/media`. +- Commands nicht gefunden: + - Aus Repo-Root ausfuehren oder `box/main.py` voll referenzieren. +- Kein Playback: + - Tags in `box/data/box.json` und Medien in `box/data/media` pruefen. diff --git a/docs/debugging.md b/docs/debugging.md index 4a1611b..35288a0 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -1,99 +1,98 @@ -# Debugging Guide +# Debugging-Guide -## Purpose -Fast diagnosis guide: "If X happens, check Y" with separate paths for Simulation and Real Box. +## Zweck +Schnelle Diagnose: "Wenn X passiert, pruefe Y" getrennt nach Simulation und Hardware. -## Prerequisites -- Box running (simulation or device) -- Access to `/status` and data files +## Voraussetzungen +- Box laeuft (Simulation oder Hardware) +- Zugriff auf `/status` und Datenfiles -## Step-by-step -Use the sections below by symptom. +## Schritt-fuer-Schritt +Nutze die Sektionen nach Symptomen. ## Simulation (IDE) -### If GUI shows "Failed to fetch" -- Check API is running: +### GUI zeigt "Failed to fetch" +- API pruefen: ``` curl http://127.0.0.1:8000/status ``` -- Verify GUI API base: - - `VITE_BOX_API_URL` or default `http://localhost:8000` +- GUI API-Base pruefen: + - `VITE_BOX_API_URL` oder Default `http://localhost:8000` -### If playback does not start -- Check tags: +### Playback startet nicht +- Tags pruefen: ``` cat box/data/box.json ``` -- Check media files: +- Medien pruefen: ``` ls -la box/data/media ``` -- Check `/status`: +- Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -### If resume does not persist -- Check state file: +### Resume bleibt leer +- State pruefen: ``` 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 -- Check port 9000 free. -- Ensure Box is running. +### Setup UI nicht erreichbar +- Port 9000 frei? +- Box laeuft? -### If box not recognized by server -- Not testable with current code: no announce/pairing implemented. +### Box wird nicht erkannt (Server) +- 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 -- Not testable with current code: no announce/pairing implemented. +### Box erscheint mehrfach +- `box_id` in `box/data/box.json` pruefen. +- Factory-Reset nur gezielt ausfuehren. -### If fingerprint changes -- Not testable with current code: fingerprint not implemented. +### Fingerprint aendert sich +- `secret_seed` in `box/data/secrets.enc` darf sich nicht aendern. +- `box/data/box.json` darf nicht geloescht werden. -### If pairing hangs -- Not testable with current code: pairing flow not implemented. +### Pairing haengt +- `server/data/boxes.json` pruefen: state, api_token. +- Box-Status: `pairing_state` in `/status` pruefen. -## Real Box (Hardware) -### If device not reachable in browser -- Ensure it is powered on and connected to the same network. -- Check the IP shown by your router. +## Reale Box (Hardware) +### Box nicht im Browser erreichbar +- Stromversorgung ok? +- Im gleichen Netzwerk? +- Router-IP pruefen. -### If NFC does nothing -- Confirm tag UID exists in `box.json`. -- Confirm NFC hardware is working (not implemented in code). +### NFC reagiert nicht +- UID in `box.json` vorhanden? +- NFC-Hardware (nicht implementiert) pruefen. -### If audio is silent -- Audio backend is mock in current code. -- Not testable with current code. +### Audio bleibt stumm +- Audio-Backend ist Mock im aktuellen Code. +- Nicht testbar mit aktuellem Code. -### If box not recognized by server -- Not testable with current code: no announce/pairing implemented. +### Box wird nicht erkannt / erscheint mehrfach / Fingerprint wechselt / Pairing haengt +- Server-GUI (`/ui`) pruefen. +- Box-IP/Server-IP/VLAN pruefen. +- Box-Logs pruefen. -### If box appears multiple times -- Not testable with current code: no announce/pairing implemented. +## Files +- `box/data/box.json`: Identitaet, Tags, Settings +- `box/data/state.json`: Resume und WiFi-State +- `box/data/secrets.enc`: verschluesselte Secrets -### If fingerprint changes -- 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 +## Statusfelder - `wifi_state`, `ip_address`, `connected_ssid` - `playback_state.state`, `playback_state.active_uid` - `playback_state.position`, `playback_state.file_index` - `playback_state.last_error` ## Troubleshooting -- 400 from `/command`: - - Check required payload fields in `docs/api.md`. -- No change after command: - - Poll `/status` after sending. +- 400 von `/command`: + - Payload pruefen (Pflichtfelder). +- Keine Aenderung nach Command: + - `/status` danach erneut abrufen. diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index 4655323..7d517a5 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -1,22 +1,22 @@ -# Dev Workflow +# Dev-Workflow -## Purpose -How to start/stop the virtual box, how to access logs, and how to point the GUI to the API. +## Zweck +Start/Stop der virtuellen Box, Logs, und GUI-API-Konfiguration. -## Prerequisites +## Voraussetzungen - Python 3 - Node.js + npm -- Run from repo root +- Aus dem Repo-Root ausfuehren -## Step-by-step -### Start everything (recommended) +## Schritt-fuer-Schritt +### Alles starten (empfohlen) ``` ./dev.sh ``` -- GUI runs in the background -- Box runs in the foreground (you can type CLI commands) +- GUI laeuft im Hintergrund. +- Box laeuft im Vordergrund und akzeptiert CLI-Kommandos. -### Start separately +### Komponenten separat starten Box only: ``` python3 box/main.py run @@ -28,34 +28,36 @@ npm run dev ``` ## Ports / Hosts +- Server API + GUI: `http://127.0.0.1:7000` (GUI unter `/ui`) - Box API: `http://127.0.0.1:8000` - Setup UI: `http://127.0.0.1:9000/setup` - GUI: `http://127.0.0.1:5174` -## API URL for GUI -Default is `http://localhost:8000`. Override with: +## API-URL fuer die GUI +Default ist `http://localhost:8000`. Override: ``` VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev ``` -## Logs -- Box logs appear in the same terminal where you run `python3 box/main.py run`. -- GUI logs appear in the browser console. +## Logs ansehen +- Server-Logs: Terminal mit `python3 server/main.py` +- Box-Logs: Terminal mit `python3 box/main.py run` +- GUI-Logs: Browser-Konsole -## Stop / Cleanup -- Press `Ctrl+C` in the terminal where the box is running. -- `dev.sh` will also stop the GUI process. +## Sauber beenden +- `Ctrl+C` im Box-Terminal. +- `dev.sh` beendet die GUI automatisch. -## Checklist -- ✅ Run `./dev.sh` -- ✅ Box shows interactive prompt -- ✅ GUI loads in browser -- ✅ `curl http://127.0.0.1:8000/status` returns JSON +## Checkliste +- ✅ `./dev.sh` gestartet +- ✅ Box-CLI Prompt sichtbar +- ✅ GUI im Browser erreichbar +- ✅ `curl http://127.0.0.1:8000/status` liefert JSON ## Troubleshooting -- Port already in use: - - 8000 or 9000: stop other processes, or change ports in `box/main.py`. -- CORS error in browser: - - Ensure API is running; CORS is already enabled in the Box API. -- GUI shows "Failed to fetch": - - Check `VITE_BOX_API_URL` and Box API port. +- Port belegt: + - 8000/9000: andere Prozesse beenden oder Ports in `box/main.py` aendern. +- CORS-Fehler: + - API muss laufen und erreichbar sein. +- GUI zeigt "Failed to fetch": + - `VITE_BOX_API_URL` pruefen. diff --git a/docs/gui-control-panel.md b/docs/gui-control-panel.md index 33a828d..ef2c30f 100644 --- a/docs/gui-control-panel.md +++ b/docs/gui-control-panel.md @@ -1,21 +1,21 @@ # GUI Control Panel -## Purpose -How to use the Vite-based GUI to control and observe the virtual box. +## Zweck +Nutzung des GUI Control Panels zur Steuerung und Beobachtung der virtuellen Box. -## Prerequisites -- Box running on `http://127.0.0.1:8000` -- GUI started (`npm run dev` or `./dev.sh`) +## Voraussetzungen +- Box laeuft auf `http://127.0.0.1:8000` +- GUI gestartet (`npm run dev` oder `./dev.sh`) + - Bei gepaarter Box: `VITE_BOX_API_TOKEN` gesetzt -## Step-by-step -1) Open GUI: +## Schritt-fuer-Schritt +1) GUI oeffnen: ``` http://127.0.0.1:5174 ``` -2) Click buttons to send commands. -3) Observe status updates (polling + refresh after command). +2) Buttons klicken, Status beobachten. -## Controls (current in GUI) +## Controls (im GUI vorhanden) - Play / Pause - Next - Prev @@ -25,29 +25,32 @@ http://127.0.0.1:5174 - NFC UID_1 OFF - Refresh Status -## Controls (available via API only) +## Controls (nur API) - WiFi add/reset - 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= npm run dev\n``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' ``` -## Expected results -- After any button click, status refreshes within 1 second. -- `last_error` is visible if an error occurs. +## Erwartete Ergebnisse +- Nach jedem Klick wird der Status innerhalb von 1s aktualisiert. +- `last_error` wird angezeigt, wenn gesetzt. -## Checklist -- ✅ GUI loads and shows API base URL -- ✅ Status updates every 1s -- ✅ Clicking a button updates `/status` +## Checkliste +- ✅ GUI laedt und zeigt API-URL +- ✅ Status aktualisiert sich automatisch +- ✅ Buttons aendern `/status` ## Troubleshooting -- GUI shows "Failed to fetch": - - Check `VITE_BOX_API_URL`. - - Verify Box API is running. -- Buttons have no effect: - - Check the API command names in `docs/api.md`. +- GUI zeigt "Failed to fetch": + - `VITE_BOX_API_URL` pruefen. + - Box API muss laufen. +- Buttons ohne Wirkung: + - API-Command-Namen in `docs/api.md` pruefen. diff --git a/docs/offline-scenarios.md b/docs/offline-scenarios.md index 09046c2..22ec19c 100644 --- a/docs/offline-scenarios.md +++ b/docs/offline-scenarios.md @@ -1,57 +1,57 @@ -# Offline Scenarios +# Offline-Szenarien -## Purpose -Simulate online/offline behavior in the virtual box and verify status fields. +## Zweck +Offline/Online-Verhalten in der virtuellen Box pruefen. -## Prerequisites -- Box running on `http://127.0.0.1:8000` +## Voraussetzungen +- Box laeuft auf `http://127.0.0.1:8000` -## Step-by-step -### WiFi offline (no profiles) +## Schritt-fuer-Schritt +### WLAN offline (keine Profile) ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_reset"}' ``` -Expected: +Erwartung: - `wifi_state = WIFI_UNCONFIGURED` - `connected_ssid = null` -### WiFi online +### WLAN online ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' ``` -Expected: +Erwartung: - `wifi_state = WIFI_ONLINE` - `connected_ssid = HomeWiFi` -### Spotify unavailable +### Spotify nicht konfiguriert ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"spotify_clear"}' ``` -Expected: +Erwartung: - `spotify_status = NOT_CONFIGURED` -### Spotify ready +### Spotify bereit ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' ``` -Expected: +Erwartung: - `spotify_status = READY` -## Checklist -- ✅ WiFi reset shows UNCONFIGURED -- ✅ WiFi add profile shows ONLINE -- ✅ Spotify clear shows NOT_CONFIGURED -- ✅ Spotify set shows READY +## Checkliste +- ✅ WLAN-Reset zeigt UNCONFIGURED +- ✅ WLAN-Profil zeigt ONLINE +- ✅ Spotify clear zeigt NOT_CONFIGURED +- ✅ Spotify set zeigt READY ## Troubleshooting -- Status not changing: - - Ensure `/status` is polled after sending commands. - - Check that Box is running and secrets file is writable. +- Status aendert sich nicht: + - `/status` nach Commands abfragen. + - Schreibrechte auf `box/data/secrets.enc` pruefen. diff --git a/docs/playback-testing.md b/docs/playback-testing.md index 20b97ca..ee6af5b 100644 --- a/docs/playback-testing.md +++ b/docs/playback-testing.md @@ -1,65 +1,62 @@ -# Playback Testing +# Playback-Tests -## Purpose -Test NFC playback, recursive media order, resume, and playback controls. +## Zweck +Tests fuer NFC-Playback, Rekursion, Resume und Buttons. -## Prerequisites -- Box running: `python3 box/main.py run` -- Media in `box/data/media/` +## Voraussetzungen +- Box laeuft: `python3 box/main.py run` +- Medien unter `box/data/media/` -## Step-by-step -### NFC simulation -Start playback: +## Schritt-fuer-Schritt +### NFC-Simulation +Start: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' ``` -Stop playback: +Stop: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_off","payload":{"uid":"UID_1"}}' ``` -### Resume tests +### Resume 1) `nfc_on UID_1` -2) Wait or use `tick` in CLI +2) Warten (Auto-Tick) 3) `nfc_off UID_1` -4) `nfc_on UID_1` again -Expected: position resumes from saved state. +4) `nfc_on UID_1` +Erwartung: Resume nutzt gespeicherte Position. -### Tag switching +### Tag-Wechsel 1) `nfc_on UID_1` 2) `nfc_on UID_2` 3) `nfc_on UID_1` -Expected: UID_1 resumes from previous position. +Erwartung: UID_1 resumed. -### Recursive order -- Order is depth-first, folders first, lexicographic. -- Change folder names or nesting to see ordering changes. +### Rekursive Reihenfolge +- Ordner werden DFS traversiert (Unterordner zuerst, dann Dateien). +- Sortierung lexikografisch. -### End of media behavior -- When the last file ends: - - Playback stops - - Position saved at end - - State becomes IDLE +### Ende-des-Mediums +- Letzte Datei endet -> Playback stoppt, Position wird am Ende gespeichert. ### Buttons - `next`: file_index + 1 -- `prev`: if position > 3s then position=0, else file_index - 1 -- `play_pause`: toggle PLAYING/PAUSED -- `volume_up` / `volume_down`: change volume within 0..max +- `prev`: wenn position > 3s -> position=0, sonst file_index - 1 +- `play_pause`: toggelt PLAYING/PAUSED +- `volume_up` / `volume_down`: innerhalb 0..max -## Checklist -- ✅ Resume works for same UID -- ✅ Tag switching preserves per-UID resume -- ✅ Recursive order matches folder structure -- ✅ End-of-media stops playback and saves +## Checkliste +- ✅ Resume pro UID +- ✅ Tag-Wechsel speichert und resumed +- ✅ Rekursion entspricht Ordnerstruktur +- ✅ Ende-des-Mediums stoppt und speichert ## Troubleshooting -- No playback starts: - - Check tags in `box/data/box.json`. - - Ensure files exist under `box/data/media`. -- Duration is 0: - - Filenames not matched; mock uses hash-based durations for unknown names. +- Kein Playback: + - Tags in `box/data/box.json` pruefen. + - Dateien in `box/data/media` pruefen. +- Duration = 0: + - Unbekannte Dateinamen -> Hash-Dauer, nicht 0. diff --git a/docs/security-storage.md b/docs/security-storage.md index ac681f1..30d5a80 100644 --- a/docs/security-storage.md +++ b/docs/security-storage.md @@ -1,60 +1,61 @@ # Security & Storage -## Purpose -Explain where data lives, what is encrypted, and how keys are derived. +## Zweck +Beschreibt Datenablage, Verschluesselung und Key-Ableitung. -## Prerequisites -- Box running at least once to create `box/data/` +## Voraussetzungen +- Box mindestens einmal gestartet -## Step-by-step -1) Start the box: +## Schritt-fuer-Schritt +1) Box starten: ``` python3 box/main.py run ``` -2) Inspect data files: +2) Datenordner pruefen: ``` ls -la box/data ``` -## Data layout +## Datenstruktur ``` box/data/ -├── box.json # box_id, settings, tags (plaintext) -├── state.json # resume, wifi state (plaintext) -├── media/ # audio files -└── secrets.enc # encrypted secrets (JSON inside) +├── box.json # box_id, settings, tags (Klartext) +├── state.json # resume, wifi state (Klartext) +├── media/ # Audiodateien +└── secrets.enc # verschluesselte Secrets ``` -## What is plaintext +## Klartext - box_id -- box name -- volume -- resume positions -- playback state -- media paths -- UI state +- Box-Name +- Lautstaerke +- Resume-Positionen +- Playback-State +- Media-Pfade +- UI-Zustaende -## What is encrypted -- WiFi profiles -- Spotify tokens -- Server tokens (future) +## Verschluesselt +- WLAN-Profile +- Spotify Tokens +- Server Tokens (api_token) +- secret_seed (Fingerprint-Seed) -## Key derivation +## Key-Ableitung - Key = PBKDF2-HMAC-SHA256(box_id + STATIC_SALT) -- Key is never stored on disk -- secrets.enc is not portable between boxes +- Key wird nie gespeichert +- secrets.enc ist nicht zwischen Boxen austauschbar -## Reset behavior -- WiFi reset: removes only wifi_profiles from secrets.enc -- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` +## Reset-Verhalten +- WLAN-Reset: nur wifi_profiles entfernen +- Factory-Reset: `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` loeschen -## Checklist -- ✅ `secrets.enc` exists after WiFi/Spotify set -- ✅ `box.json` contains `box_id` -- ✅ No secrets appear in `/status` +## Checkliste +- ✅ `secrets.enc` existiert nach WiFi/Spotify set +- ✅ `box.json` enthaelt `box_id` +- ✅ Keine Secrets in `/status` ## Troubleshooting -- secrets.enc not created: - - Ensure you ran `wifi_add_profile` or `spotify_set_tokens`. -- secrets.enc unreadable after reset: - - Factory reset creates a new box_id and new key. +- secrets.enc fehlt: + - WiFi/Spotify set ausfuehren. +- secrets.enc unlesbar nach Reset: + - Neue box_id erzeugt neuen Key. diff --git a/docs/setup-webgui.md b/docs/setup-webgui.md index dfdf0b7..7ad096c 100644 --- a/docs/setup-webgui.md +++ b/docs/setup-webgui.md @@ -1,40 +1,43 @@ -# Setup WebGUI +# Setup-WebGUI -## Purpose -Configure WiFi profiles in the virtual box without touching the API or CLI. +## Zweck +WLAN-Profile in der virtuellen Box konfigurieren, ohne CLI oder API. -## Prerequisites -- Box running: `python3 box/main.py run` +## Voraussetzungen +- Box laeuft: `python3 box/main.py run` -## Step-by-step -1) Open the Setup UI: +## Schritt-fuer-Schritt +1) Setup-UI oeffnen: ``` http://127.0.0.1:9000/setup ``` -2) Choose SSID and enter password. -3) Click "Verbinden". -4) Refresh `/status` to see `wifi_state` and `connected_ssid`. +2) SSID auswaehlen, Passwort eingeben. +3) "Verbinden" klicken. +4) Status pruefen: +``` +curl http://127.0.0.1:8000/status +``` -## Fields -- SSID: drop-down from mock scan -- Password: free text +## Felder +- SSID: Dropdown aus dem Mock-Scan +- Passwort: Freitext -## Expected results -- WiFi profile is saved (encrypted) -- `wifi_state` becomes `WIFI_ONLINE` -- `connected_ssid` matches selected SSID +## Erwartete Ergebnisse +- WLAN-Profil wird verschluesselt gespeichert +- `wifi_state` wird `WIFI_ONLINE` +- `connected_ssid` passt zur Auswahl -## Checklist -- ✅ Setup UI loads -- ✅ Submit stores a profile -- ✅ `/status` reflects `WIFI_ONLINE` +## Checkliste +- ✅ Setup-UI laedt +- ✅ Submit speichert Profil +- ✅ `/status` zeigt `WIFI_ONLINE` ## Troubleshooting -- Setup UI not reachable: - - Ensure Box is running and port `9000` is free. -- Submit does nothing: - - Check Box logs for errors; secrets file must be writable. +- Setup-UI nicht erreichbar: + - Port 9000 frei? Box laeuft? +- Submit ohne Wirkung: + - Logs pruefen, `box/data/secrets.enc` muss schreibbar sein. -## Notes -- Setup UI is write-enabled only when no profiles exist. -- When profiles exist, the page switches to read-only status. +## Hinweise +- Setup-UI ist nur schreibend, wenn keine Profile existieren. +- Bei vorhandenen Profilen ist die Seite read-only. diff --git a/docs/storage.md b/docs/storage.md index f1eb546..e9db25a 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -1,13 +1,13 @@ -# Storage (Legacy Notes) +# Storage (Legacy-Hinweis) -## Purpose -This file is kept for backward reference. Current storage documentation lives in `docs/security-storage.md`. +## Zweck +Dieses Dokument bleibt als Legacy-Hinweis bestehen. Die aktuelle Doku liegt in `docs/security-storage.md`. -## Prerequisites -- None +## Voraussetzungen +- Keine -## Step-by-step -- See `docs/security-storage.md`. +## Schritt-fuer-Schritt +- Siehe `docs/security-storage.md`. ## Troubleshooting -- If you ended up here from older links, update them to `docs/security-storage.md`. +- Falls Links hierher zeigen, bitte aktualisieren. diff --git a/docs/virtual-box.md b/docs/virtual-box.md index f67296b..6b358fe 100644 --- a/docs/virtual-box.md +++ b/docs/virtual-box.md @@ -1,22 +1,22 @@ -# Virtual Box Flow (IDE) +# Virtuelle Box (IDE) -## Purpose -Describe the full, hardware-free box lifecycle used in development. +## Zweck +Beschreibt den vollstaendigen, hardwarefreien Box-Flow fuer die IDE. -## Prerequisites +## Voraussetzungen - Python 3 -- Box started with `python3 box/main.py run` +- Box gestartet mit `python3 box/main.py run` -## Step-by-step -1) Box starts and creates `box/data/box.json` if missing. -2) WiFi state is derived from stored profiles: - - none -> `WIFI_UNCONFIGURED` -3) Setup UI available at `http://127.0.0.1:9000/setup`. -4) After profile submission, box becomes `WIFI_ONLINE`. -5) API available at `http://127.0.0.1:8000`. +## Schritt-fuer-Schritt +1) Box erzeugt `box_id` in `box/data/box.json`. +2) WLAN-Status wird aus Profilen abgeleitet: + - keine Profile -> `WIFI_UNCONFIGURED` +3) Setup UI unter `http://127.0.0.1:9000/setup`. +4) Nach Submit wird `WIFI_ONLINE`. +5) API unter `http://127.0.0.1:8000`. ## Troubleshooting -- Setup UI not reachable: - - Ensure port 9000 is free and Box is running. -- API not reachable: - - Ensure port 8000 is free and Box is running. +- Setup UI nicht erreichbar: + - Port 9000 frei? Box laeuft? +- API nicht erreichbar: + - Port 8000 frei? Box laeuft? diff --git a/gui/src/api.js b/gui/src/api.js index 344c157..63cca30 100644 --- a/gui/src/api.js +++ b/gui/src/api.js @@ -1,6 +1,7 @@ // src/api.js 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() { const response = await fetch(`${API_BASE}/status`); @@ -12,9 +13,13 @@ export async function getStatus() { export async function sendCommand(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`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ command, payload }), }); if (!response.ok) { diff --git a/server/__pycache__/storage.cpython-312.pyc b/server/__pycache__/storage.cpython-312.pyc new file mode 100644 index 0000000..ef876c2 Binary files /dev/null and b/server/__pycache__/storage.cpython-312.pyc differ diff --git a/server/data/boxes.json b/server/data/boxes.json new file mode 100644 index 0000000..df1a7c3 --- /dev/null +++ b/server/data/boxes.json @@ -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 + } +} \ No newline at end of file diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..482ab73 --- /dev/null +++ b/server/main.py @@ -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"{box.box_id}{box.firmware_version}" + f"{box.last_seen}" + f"
" + f"" + f"" + f"
" + ) + + unpaired_rows = "".join(_row(b) for b in unpaired) or "Keine" + paired_rows = "".join( + f"{b.box_id}{b.firmware_version}{b.last_seen}" + for b in paired + ) or "Keine" + + return f""" + + + Klangkiste Server + +

Neue Boxen (UNPAIRED)

+ + + {unpaired_rows} +
box_idfirmwarelast_seenaction
+

Gepaarte Boxen

+ + + {paired_rows} +
box_idfirmwarelast_seen
+ + +""" + + +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") diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..b240d3d --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn==0.30.6 +pydantic==2.9.2 diff --git a/server/storage.py b/server/storage.py new file mode 100644 index 0000000..1de0a94 --- /dev/null +++ b/server/storage.py @@ -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")