Upload
This commit is contained in:
Binary file not shown.
Binary file not shown.
+30
-2
@@ -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.
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"wifi": {
|
||||
"connected_ssid": null
|
||||
},
|
||||
"resume": {
|
||||
"UID_1": {
|
||||
"file_index": 1,
|
||||
"position": 79
|
||||
}
|
||||
},
|
||||
"pairing": {
|
||||
"state": "PAIRING_PENDING"
|
||||
}
|
||||
}
|
||||
+44
-2
@@ -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
|
||||
|
||||
@@ -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.
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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")
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn==0.30.6
|
||||
pydantic==2.9.2
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user