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 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
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
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()
+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"
}
}
+44 -2
View File
@@ -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,6 +187,7 @@ def main() -> int:
return 0
if args.command == "run":
try:
asyncio.run(
_run_with_api(
controller,
@@ -184,8 +195,17 @@ def main() -> int:
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
+1
View File
@@ -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
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)
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)
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() {
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 ""
+34 -34
View File
@@ -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`
+346 -334
View File
@@ -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":"<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
- 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.
+41 -27
View File
@@ -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: <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.
+36 -31
View File
@@ -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.
+19 -19
View File
@@ -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 <UID>
@@ -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.
+59 -60
View File
@@ -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.
+31 -29
View File
@@ -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.
+27 -24
View File
@@ -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=<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.
+22 -22
View File
@@ -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.
+34 -37
View File
@@ -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.
+38 -37
View File
@@ -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.
+31 -28
View File
@@ -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.
+8 -8
View File
@@ -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.
+16 -16
View File
@@ -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?
+6 -1
View File
@@ -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) {
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")