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