From d4b8dcd4558b9a0b49cffea991d2c8ac79c50b2f Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Mon, 12 Jan 2026 19:03:50 +0000 Subject: [PATCH] Upload --- box/api/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 133 bytes box/api/__pycache__/http.cpython-312.pyc | Bin 0 -> 7131 bytes box/api/http.py | 32 +- box/core/__pycache__/announce.cpython-312.pyc | Bin 0 -> 4845 bytes box/core/__pycache__/identity.cpython-312.pyc | Bin 0 -> 1615 bytes .../__pycache__/lifecycle.cpython-312.pyc | Bin 0 -> 2566 bytes box/core/__pycache__/pairing.cpython-312.pyc | Bin 0 -> 3905 bytes box/core/announce.py | 93 +++ box/core/identity.py | 19 +- box/core/pairing.py | 61 ++ box/data/box.json | 37 + box/data/secrets.enc | 1 + box/data/state.json | 14 + box/main.py | 62 +- box/requirements.txt | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 135 bytes box/setup/__pycache__/webui.cpython-312.pyc | Bin 0 -> 3578 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 137 bytes box/spotify/__pycache__/base.cpython-312.pyc | Bin 0 -> 1155 bytes box/spotify/__pycache__/mock.cpython-312.pyc | Bin 0 -> 2749 bytes .../__pycache__/box_store.cpython-312.pyc | Bin 0 -> 2080 bytes .../__pycache__/secret_store.cpython-312.pyc | Bin 0 -> 4996 bytes .../__pycache__/state_store.cpython-312.pyc | Bin 0 -> 1629 bytes box/storage/secret_store.py | 35 + box/wifi/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 134 bytes box/wifi/__pycache__/base.cpython-312.pyc | Bin 0 -> 2435 bytes box/wifi/__pycache__/mock.cpython-312.pyc | Bin 0 -> 6876 bytes dev.sh | 19 +- docs/README.md | 68 +- docs/acceptance-tests.md | 680 +++++++++--------- docs/api.md | 68 +- docs/box-lifecycle.md | 67 +- docs/cli.md | 38 +- docs/debugging.md | 119 ++- docs/dev-workflow.md | 60 +- docs/gui-control-panel.md | 51 +- docs/offline-scenarios.md | 44 +- docs/playback-testing.md | 71 +- docs/security-storage.md | 75 +- docs/setup-webgui.md | 59 +- docs/storage.md | 16 +- docs/virtual-box.md | 32 +- gui/src/api.js | 7 +- server/__pycache__/storage.cpython-312.pyc | Bin 0 -> 3221 bytes server/data/boxes.json | 15 + server/main.py | 168 +++++ server/requirements.txt | 3 + server/storage.py | 60 ++ 48 files changed, 1352 insertions(+), 723 deletions(-) create mode 100644 box/api/__pycache__/__init__.cpython-312.pyc create mode 100644 box/api/__pycache__/http.cpython-312.pyc create mode 100644 box/core/__pycache__/announce.cpython-312.pyc create mode 100644 box/core/__pycache__/identity.cpython-312.pyc create mode 100644 box/core/__pycache__/lifecycle.cpython-312.pyc create mode 100644 box/core/__pycache__/pairing.cpython-312.pyc create mode 100644 box/core/announce.py create mode 100644 box/core/pairing.py create mode 100644 box/data/box.json create mode 100644 box/data/secrets.enc create mode 100644 box/data/state.json create mode 100644 box/setup/__pycache__/__init__.cpython-312.pyc create mode 100644 box/setup/__pycache__/webui.cpython-312.pyc create mode 100644 box/spotify/__pycache__/__init__.cpython-312.pyc create mode 100644 box/spotify/__pycache__/base.cpython-312.pyc create mode 100644 box/spotify/__pycache__/mock.cpython-312.pyc create mode 100644 box/storage/__pycache__/box_store.cpython-312.pyc create mode 100644 box/storage/__pycache__/secret_store.cpython-312.pyc create mode 100644 box/storage/__pycache__/state_store.cpython-312.pyc create mode 100644 box/wifi/__pycache__/__init__.cpython-312.pyc create mode 100644 box/wifi/__pycache__/base.cpython-312.pyc create mode 100644 box/wifi/__pycache__/mock.cpython-312.pyc create mode 100644 server/__pycache__/storage.cpython-312.pyc create mode 100644 server/data/boxes.json create mode 100644 server/main.py create mode 100644 server/requirements.txt create mode 100644 server/storage.py diff --git a/box/api/__pycache__/__init__.cpython-312.pyc b/box/api/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fa834d83f74880a4af4c1ddb12ae4d754f26fd98 GIT binary patch literal 133 zcmX@j%ge<81QTDSWP<3&AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrJ`SypI@S%os*cC zo}F1-lB%DSU!k8^kf|RZpP83g5+AQuQ2C3)CO1E&G$+-rh!v=b5r~UHjE~HWjEqIh GKo$Tyx*f~_ literal 0 HcmV?d00001 diff --git a/box/api/__pycache__/http.cpython-312.pyc b/box/api/__pycache__/http.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fbb87b86ac0e56aa4ae4f04b6722f2c94245917 GIT binary patch literal 7131 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 aH9%2tMuD{uqH8zpZuY?I4lirC;{O3{8f}vR literal 0 HcmV?d00001 diff --git a/box/api/http.py b/box/api/http.py index 512af00..7ff81d8 100644 --- a/box/api/http.py +++ b/box/api/http.py @@ -4,12 +4,13 @@ from __future__ import annotations from typing import Any, Callable -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from core.box_controller import BoxController from core.lifecycle import NetworkLifecycle +from core.pairing import PairingManager, PairingState from spotify.base import SpotifyBackend from wifi.base import WifiBackend @@ -24,6 +25,7 @@ def create_app( lifecycle: NetworkLifecycle, wifi_backend: WifiBackend, spotify_backend: SpotifyBackend, + pairing_manager: PairingManager, persist_state: Callable[[], None], ) -> FastAPI: app = FastAPI() @@ -40,6 +42,7 @@ def create_app( state = controller.state return { "box_id": state.box_id, + "pairing_state": pairing_manager.state.value, "wifi_state": snapshot.wifi_state.value, "ip_address": snapshot.ip_address, "connected_ssid": snapshot.connected_ssid, @@ -59,10 +62,13 @@ def create_app( } @app.post("/command") - def command(request: CommandRequest) -> dict[str, Any]: + def command(request: CommandRequest, http_request: Request) -> dict[str, Any]: cmd = request.command payload = request.payload or {} + if _requires_pairing(cmd) and not _is_authorized(pairing_manager, http_request): + raise HTTPException(status_code=403, detail="pairing required") + if cmd == "nfc_on": uid = payload.get("uid") if not isinstance(uid, str): @@ -125,3 +131,25 @@ def create_app( return {"ok": True} return app + + +def _requires_pairing(command: str) -> bool: + allowed_without_pairing = { + "wifi_add_profile", + "wifi_reset", + "spotify_set_tokens", + "spotify_clear", + } + return command not in allowed_without_pairing + + +def _is_authorized(pairing: PairingManager, http_request: Request) -> bool: + if not pairing.is_paired(): + return False + token = pairing.get_api_token() + if not token: + return False + header = http_request.headers.get("X-API-Token") + if not header: + return False + return header == token diff --git a/box/core/__pycache__/announce.cpython-312.pyc b/box/core/__pycache__/announce.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4d77b6d72116d24bb08b9d20407386f22930c418 GIT binary patch literal 4845 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 literal 0 HcmV?d00001 diff --git a/box/core/__pycache__/identity.cpython-312.pyc b/box/core/__pycache__/identity.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cec55bd9a9f2ac096ed3f57663d0cc08ddade62a GIT binary patch literal 1615 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 dict[str, Any]: + fingerprint = compute_fingerprint(box_id, secret_seed) + return { + "box_id": box_id, + "fingerprint": fingerprint, + "firmware_version": config.firmware_version, + "capabilities": config.capabilities, + } + + +def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any] | None: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read().decode("utf-8") + except Exception: + return None + try: + return json.loads(body) + except json.JSONDecodeError: + return None + + +async def announce_loop( + box_id: str, + secrets: SecretStore, + pairing: PairingManager, + config: AnnounceConfig, + stop_event: asyncio.Event, +) -> None: + secret_seed = secrets.ensure_secret_seed() + payload = build_payload(box_id, secret_seed, config) + announce_url = f"{config.server_url}/api/boxes/announce" + + while not stop_event.is_set(): + if not pairing.is_paired(): + await asyncio.to_thread(_post_json, announce_url, payload) + await asyncio.sleep(config.announce_interval_seconds) + + +async def pairing_poll_loop( + box_id: str, + pairing: PairingManager, + config: AnnounceConfig, + stop_event: asyncio.Event, +) -> None: + poll_url = f"{config.server_url}/api/boxes/poll-pairing" + payload = {"box_id": box_id} + while not stop_event.is_set(): + if pairing.is_paired(): + await asyncio.sleep(config.polling_interval_seconds) + continue + pairing.set_state(PairingState.PAIRING_PENDING) + response = await asyncio.to_thread(_post_json, poll_url, payload) + if response and response.get("paired") is True: + token = response.get("api_token") + if isinstance(token, str) and token: + pairing.save_api_token(token) + await asyncio.sleep(config.polling_interval_seconds) diff --git a/box/core/identity.py b/box/core/identity.py index c640038..8bfd141 100644 --- a/box/core/identity.py +++ b/box/core/identity.py @@ -2,14 +2,29 @@ from __future__ import annotations +import hashlib import secrets import string PREFIX = "klangkiste-" -ALPHABET = string.ascii_lowercase + string.digits +LETTERS = string.ascii_lowercase +DIGITS = string.digits SUFFIX_LENGTH = 10 +STATIC_APP_SALT = "klangkiste-static-app-salt-v1" def generate_box_id() -> str: - suffix = "".join(secrets.choice(ALPHABET) for _ in range(SUFFIX_LENGTH)) + start_with_letter = secrets.choice([True, False]) + suffix_chars = [] + for i in range(SUFFIX_LENGTH): + if (i % 2 == 0) == start_with_letter: + suffix_chars.append(secrets.choice(LETTERS)) + else: + suffix_chars.append(secrets.choice(DIGITS)) + suffix = "".join(suffix_chars) return f"{PREFIX}{suffix}" + + +def compute_fingerprint(box_id: str, secret_seed: str) -> str: + payload = f"{box_id}{secret_seed}{STATIC_APP_SALT}".encode("utf-8") + return hashlib.sha256(payload).hexdigest() diff --git a/box/core/pairing.py b/box/core/pairing.py new file mode 100644 index 0000000..147b448 --- /dev/null +++ b/box/core/pairing.py @@ -0,0 +1,61 @@ +"""Pairing state management for the Box.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from storage.state_store import StateStore +from storage.secret_store import SecretStore + + +class PairingState(str, Enum): + UNPAIRED = "UNPAIRED" + PAIRING_PENDING = "PAIRING_PENDING" + PAIRED = "PAIRED" + + +@dataclass +class PairingSnapshot: + state: PairingState + + +class PairingManager: + def __init__(self, state_store: StateStore, secret_store: SecretStore) -> None: + self._state_store = state_store + self._secret_store = secret_store + self._state = PairingState.UNPAIRED + self._load() + + @property + def state(self) -> PairingState: + return self._state + + def set_state(self, state: PairingState) -> None: + self._state = state + self._save() + + def snapshot(self) -> PairingSnapshot: + return PairingSnapshot(state=self._state) + + def is_paired(self) -> bool: + return self._state == PairingState.PAIRED + + def get_api_token(self) -> str | None: + return self._secret_store.get_api_token() + + def save_api_token(self, token: str) -> None: + self._secret_store.save_api_token(token) + self.set_state(PairingState.PAIRED) + + def _load(self) -> None: + data = self._state_store.load() + pairing = data.get("pairing") if isinstance(data.get("pairing"), dict) else {} + state_raw = pairing.get("state") + if isinstance(state_raw, str) and state_raw in PairingState.__members__: + self._state = PairingState[state_raw] + + def _save(self) -> None: + data = self._state_store.load() + data["pairing"] = {"state": self._state.name} + self._state_store.save(data) diff --git a/box/data/box.json b/box/data/box.json new file mode 100644 index 0000000..1cf280e --- /dev/null +++ b/box/data/box.json @@ -0,0 +1,37 @@ +{ + "box_id": "klangkiste-x1u8i4x5e4", + "settings": {}, + "tags": { + "UID_1": { + "type": "folder", + "path": "media/book_1" + }, + "UID_2": { + "type": "folder", + "path": "media/book_2" + }, + "UID_3": { + "type": "folder", + "path": "media/book_3" + }, + "UID_4": { + "type": "folder", + "path": "media/book_4" + }, + "UID_5": { + "type": "folder", + "path": "media/book_5" + }, + "UID_6": { + "type": "folder", + "path": "media/book_6" + } + }, + "server_url": "http://127.0.0.1:7000", + "firmware_version": "0.1.0", + "capabilities": { + "nfc": true, + "audio": true, + "spotify": true + } +} \ No newline at end of file diff --git a/box/data/secrets.enc b/box/data/secrets.enc new file mode 100644 index 0000000..df8bbbd --- /dev/null +++ b/box/data/secrets.enc @@ -0,0 +1 @@ +gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i \ No newline at end of file diff --git a/box/data/state.json b/box/data/state.json new file mode 100644 index 0000000..139df98 --- /dev/null +++ b/box/data/state.json @@ -0,0 +1,14 @@ +{ + "wifi": { + "connected_ssid": null + }, + "resume": { + "UID_1": { + "file_index": 1, + "position": 79 + } + }, + "pairing": { + "state": "PAIRING_PENDING" + } +} \ No newline at end of file diff --git a/box/main.py b/box/main.py index 5c89000..cce2774 100644 --- a/box/main.py +++ b/box/main.py @@ -13,9 +13,11 @@ from typing import Any import uvicorn from api.http import create_app +from core.announce import AnnounceConfig, announce_loop, pairing_poll_loop from core.box_controller import BoxController from core.identity import generate_box_id from core.lifecycle import NetworkLifecycle +from core.pairing import PairingManager from core.state import BoxState, RuntimeState from player.mock_player import MockPlayer from setup.webui import create_setup_app @@ -35,7 +37,7 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument( "--config", default="config/box_config.json", - help=\"Path to local box config JSON (legacy seed)\", + help="Path to local box config JSON (legacy seed)", ) subparsers = parser.add_subparsers(dest="command", required=True) @@ -137,6 +139,12 @@ def main() -> int: box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {})) if isinstance(box_meta.get("tags"), dict): box_meta["tags"] = _normalize_tag_paths(box_meta.get("tags", {})) + if "server_url" not in box_meta: + box_meta["server_url"] = os.getenv("KLANGKISTE_SERVER_URL", "http://127.0.0.1:7000") + if "firmware_version" not in box_meta: + box_meta["firmware_version"] = "0.1.0" + if "capabilities" not in box_meta: + box_meta["capabilities"] = {"nfc": True, "audio": True, "spotify": True} box_store.save(box_meta) state_store = StateStore(base_dir) @@ -144,9 +152,11 @@ def main() -> int: state = _state_from_data(box_meta, state_data) secrets = SecretStore(base_dir, state.box_id) + secrets.ensure_secret_seed() wifi_backend = MockWifiBackend(state_store, secrets) spotify_backend = MockSpotifyBackend(secrets) lifecycle = NetworkLifecycle(wifi_backend) + pairing_manager = PairingManager(state_store, secrets) controller = BoxController( state=state, player=MockPlayer(), @@ -177,15 +187,25 @@ def main() -> int: return 0 if args.command == "run": - asyncio.run( - _run_with_api( - controller, - state_store, - lifecycle, - wifi_backend, - spotify_backend, + try: + asyncio.run( + _run_with_api( + controller, + state_store, + lifecycle, + wifi_backend, + spotify_backend, + pairing_manager, + secrets, + AnnounceConfig( + server_url=str(box_meta.get("server_url")), + firmware_version=str(box_meta.get("firmware_version")), + capabilities=dict(box_meta.get("capabilities", {})), + ), + ) ) - ) + except KeyboardInterrupt: + return 0 return 0 return 0 @@ -213,12 +233,16 @@ async def _run_with_api( lifecycle: NetworkLifecycle, wifi_backend: MockWifiBackend, spotify_backend: MockSpotifyBackend, + pairing_manager: PairingManager, + secrets: SecretStore, + announce_config: AnnounceConfig, ) -> None: app = create_app( controller, lifecycle, wifi_backend, spotify_backend, + pairing_manager, lambda: _persist_resume(controller, storage), ) api_server = uvicorn.Server( @@ -234,7 +258,7 @@ async def _run_with_api( setup_server = uvicorn.Server( uvicorn.Config( setup_app, - host="127.0.0.1", + host="0.0.0.0", port=9000, log_level="warning", access_log=False, @@ -244,6 +268,18 @@ async def _run_with_api( tick_task = asyncio.create_task( _auto_tick_loop(controller, storage, stop_event) ) + announce_task = asyncio.create_task( + announce_loop( + controller.state.box_id, + secrets=secrets, + pairing=pairing_manager, + config=announce_config, + stop_event=stop_event, + ) + ) + poll_task = asyncio.create_task( + pairing_poll_loop(controller.state.box_id, pairing_manager, announce_config, stop_event) + ) api_task = asyncio.create_task(api_server.serve()) setup_task = asyncio.create_task(setup_server.serve()) @@ -261,6 +297,12 @@ async def _run_with_api( tick_task.cancel() with contextlib.suppress(asyncio.CancelledError): await tick_task + announce_task.cancel() + poll_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await announce_task + with contextlib.suppress(asyncio.CancelledError): + await poll_task setup_task.cancel() with contextlib.suppress(asyncio.CancelledError): await setup_task diff --git a/box/requirements.txt b/box/requirements.txt index ac8d625..9a98b0c 100644 --- a/box/requirements.txt +++ b/box/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.115.6 uvicorn==0.30.6 pydantic==2.9.2 cryptography==43.0.3 +python-multipart==0.0.9 diff --git a/box/setup/__pycache__/__init__.cpython-312.pyc b/box/setup/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73eb57132b3d34de7f0058ae14250f2707a9c76c GIT binary patch literal 135 zcmX@j%ge<81QTDSWP<3&AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrKVq$pI@S%os*cC zo}F1-lB%DSU!h-|T2fk|A0MBYmst`YuUAm{i^C>2KczG$)vkyYsErYbi$RQ!%#4hT IMa)1J0FXx>#Q*>R literal 0 HcmV?d00001 diff --git a/box/setup/__pycache__/webui.cpython-312.pyc b/box/setup/__pycache__/webui.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b4a7343eb097ecdfd006e3a09154b4902c35d347 GIT binary patch literal 3578 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 Zlzt+2k4fv8Y@9mFwDs=gF9KVU`x{0+4B7wy literal 0 HcmV?d00001 diff --git a/box/spotify/__pycache__/mock.cpython-312.pyc b/box/spotify/__pycache__/mock.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4cefddb86b5785e397c212ca93792462d9e3fe56 GIT binary patch literal 2749 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 GDg6&U&I|4U literal 0 HcmV?d00001 diff --git a/box/storage/__pycache__/state_store.cpython-312.pyc b/box/storage/__pycache__/state_store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..164d091f46b8dad42bf003df040a329df5621dad GIT binary patch literal 1629 zcmb7EJ!~9B6rS0CuYXS97{Vc=#Uvu@ICn||E)c>rfQU{AcNd}6`ey9xd2jca*-Lz< z6e(Od1xo2eLJB38Q&3XU)7VOo!-_~q5rsP|7YWG~@6GNdHUc50c=PR>H}B`0_x9(> zNteLdI%LthM#xkA7%ig!boeQNZNf;CFv_%;cC@BO5$mzu)L}PbqhmHrr88rzV>fL| zH1Y{y)+S;0E_lY(bo#pQ3@(1zOR}iLJ(-0W%2b4F+`AyS>~;8pcOzPjiZj1jos3| zX)=fDaAL7BX25RuP2bJkWlU`uGUKf>d36CXM4-d7%KJ^q=wO_XnM1P)le6cRlG^?-2VY0R!$&A-m(v5ma4D66g zfM2}U?dMJqL`jqd!C?AjmbH-*vLSK`#4Yl8X7;;@{h5U=;~RH~`L!s@av@fwQ~_*5 z3GLCziG3NkZ-_C_>`H zfTCE9YjMhD_#Tf`=@~_(mpP0V8>mFSIzt7b;#w zt3?p-Uq=dJ*vz-o}StI?6*RB_uT#Yd-Dg?+J3e6qrF$HJ*{4VxaeEEG|KavO*o1BemdZ#>e_)I-#jZCShj;`-X+;U+NhZ#~rqn}h2>0?EhfY3{1j!Vcpa|Fb0B~+XX1`~+dXh*_np~q+5Pst z_V;Ig2p_$*_|yA)&g?JF=Zc;NWPm*>42)_Cp7lhM8;vyK5d59dNr{iASk%g{%1kVH z@PGLVF29W?=TRtQ4DLHPib>evL6Eya&`DV@Mm!M&U-!beI1xAxxziPCmy4_~@VUjC zDCR)y5S5-PR@kUTf!!tF1+htn8Z|E+S;U%p2BE_4uxl7F}wD5s9{juB* z0+zOllvaCCC!{2Bj)Y>|#u<&Hm6D09--V7AI7LPLc| str: + data = self.load_secrets() + seed = data.get("secret_seed") + if isinstance(seed, str) and seed: + return seed + seed_value = self._generate_seed() + data["secret_seed"] = seed_value + self.save_secrets(data) + return seed_value + + def get_secret_seed(self) -> str | None: + data = self.load_secrets() + seed = data.get("secret_seed") + if isinstance(seed, str) and seed: + return seed + return None + + def save_api_token(self, token: str) -> None: + data = self.load_secrets() + data["api_token"] = token + self.save_secrets(data) + + def get_api_token(self) -> str | None: + data = self.load_secrets() + token = data.get("api_token") + if isinstance(token, str) and token: + return token + return None + def clear_wifi_secrets(self) -> None: data = self.load_secrets() if "wifi_profiles" in data: @@ -55,3 +84,9 @@ class SecretStore: dklen=32, ) return base64.urlsafe_b64encode(raw) + + @staticmethod + def _generate_seed() -> str: + import secrets + + return secrets.token_hex(32) diff --git a/box/wifi/__pycache__/__init__.cpython-312.pyc b/box/wifi/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..df302be527688c2c8a84f938767c235b9247c106 GIT binary patch literal 134 zcmX@j%ge<81ko>2GC}lX5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!Qq?cY&o9x>&PmKm z&(16^N!3rvuh1{gOv}`dkI&4@EQycTE2#X%VUwGmQks)$SHud`#R$a3AjU^#Mn=XW HW*`dyNf;gq literal 0 HcmV?d00001 diff --git a/box/wifi/__pycache__/base.cpython-312.pyc b/box/wifi/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34c4c60cb62a34cbfd0eb2dc66de7f23bd07b2b3 GIT binary patch literal 2435 zcmb_e-HRMW5bvIuot^!>-0fXX5<_+`h&O6(6+}UT4}u?ga1DqAR%J3b-FFN(JH2+# zChp=xFemuv+kyYVaes<0KKKv^1wr$Wx6_RXd9tc!c5fChA(%AmZ>p=RyQ;dXtM`XS zqe@_Wf1ibyEkb_B$!wG|f=+J$aX=X96GoX4(Sgx7DAHzR4y?YVX)7uX?7mG2w+!+= zVWoY->?0%F<0$)(ep$=PAal5VWM=HXGp@MR$?Mm{_rmtJAMEj%wZk~!a>oyNdovQd zVc`Ee`~%~UY0FhB8w$X7UVu1S%IKtw_AhW_Nn z>GV~Y9uQ9Y6hbsUfxx)Qj4Qy^l&L8Qla+vP<7`}ZowRm6+zDaloiO65sS=r5N`)+S zhG6xUkW6CmsR@AqPliH<$++j1QpfXRf51I2t$N--uu+6`-SfT}`B5%e^1MPoo+sADL1*z<4tVdQT^5@Mb} z-uBew5~emcz4ZvCW!Pcr0Nf_OG&?^!9etDmT%!4SjH7ZLwB&ODt|iYSjfs+Y8Fi`^ z?$d^md0O)s^M+ZiP1cLv6$Goe_yPhZHOQ|XkZ@M;d&H3yJTXo@QMh_$y(#%X+~jiy z&J`V4l$5mCQrEShRbJ zf)4d0-v4{%X*o~s!nv0N5y#-tq*e4HXSSbp{LQ@lJq2xaq$UelUX)z|+j;H(QK7oydvKKM_ml1TeKLgvd6nKFc#mS=WMVU4`x7~A%)RsIM$#_zUpdWt< zkHP0I)&1XfvYU;aBD>z2!gele2>AM>R!_tnI)QEiS^?SeH2~MpDn)v-g7t7MYX|MG z+WBIABD(SaXEAP>Z#1cbce6$L-sp(ikPWnnO;s)-Jdbb@p@V=)moFe}0^pM-hFm6N z`4aLjBXkk4F=o#SHd(nggEK&=&j9wx)S#t}2R6X;6gakt-TVtxrY2CwNIe1XPt?l{ zqs^XMNz0zcM36nSJ0nOCJhT$}AW3WU{|8)2?6s+#jE7;oEAd}X_Izs37o&OTT*)5n zx8<9lhefB}05~=&r9YFm?vagqr1L8|5B?gpe+-kJJthDUh*3Mn>4W+Oy7ArS-vmC| G*?$2wXa@%X literal 0 HcmV?d00001 diff --git a/box/wifi/__pycache__/mock.cpython-312.pyc b/box/wifi/__pycache__/mock.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73fff4eac3a0d29568810ec9e33d68a756c03d96 GIT binary patch literal 6876 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%0AR(-@F?f{{}nEWZ(b* literal 0 HcmV?d00001 diff --git a/dev.sh b/dev.sh index a7586a2..f41678b 100755 --- a/dev.sh +++ b/dev.sh @@ -7,6 +7,10 @@ echo "🚀 Starting Klangkiste (Box + GUI)" cleanup() { echo "" echo "🛑 Stopping Klangkiste..." + if [[ -n "${SERVER_PID:-}" ]]; then + kill -TERM "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi if [[ -n "${GUI_PID:-}" ]]; then kill -TERM "$GUI_PID" 2>/dev/null || true wait "$GUI_PID" 2>/dev/null || true @@ -14,11 +18,21 @@ cleanup() { } trap cleanup INT TERM EXIT +# Server starten (FastAPI + GUI) +echo "▶️ Starting Server..." +( + cd server + python3 main.py +) & + +SERVER_PID=$! + # GUI starten (Vite) -echo "▶️ Starting GUI..." +GUI_PORT="${KLANGKISTE_GUI_PORT:-5174}" +echo "▶️ Starting GUI on port ${GUI_PORT}..." ( cd gui - npm run dev + npm run dev -- --host 0.0.0.0 --port "${GUI_PORT}" --strictPort ) & GUI_PID=$! @@ -29,6 +43,7 @@ cd box python3 main.py run echo "" +echo "✅ Server PID: $SERVER_PID" echo "✅ GUI PID: $GUI_PID" echo "🛑 Press Ctrl+C to stop everything" echo "" diff --git a/docs/README.md b/docs/README.md index 3901845..2b7fc48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,31 +1,31 @@ -# Klangkiste Dev Playbook +# Klangkiste Dev-Playbook -## Purpose -This playbook documents the "virtual box" development environment. It is a complete, hardware-free simulation that runs entirely inside the repo and lets you test setup, playback, and API flows end-to-end. +## Zweck +Dieses Playbook dokumentiert die "virtuelle Box"-Entwicklungsumgebung. Es ist eine vollstaendige, hardwarefreie Simulation, die komplett im Repo laeuft und Setup-, Playback- und API-Flows end-to-end testbar macht. -Docs live in the repo-level `docs/` to keep all dev guidance in one predictable place for both Box and GUI contributors. +Die Doku liegt bewusst im Repo-weiten `docs/`-Ordner, damit Box- und GUI-Entwickler einen gemeinsamen Einstiegspunkt haben. -## Prerequisites +## Voraussetzungen - Python 3 - Node.js + npm -- From repo root +- Aus dem Repo-Root ausfuehren -## Step-by-step -Quickstart (3-5 minutes): -1) Start everything: +## Schritt-fuer-Schritt +Quickstart (3-5 Minuten): +1) Alles starten: ``` ./dev.sh ``` -2) Open the WiFi setup UI: +2) Setup-WebGUI oeffnen: ``` http://127.0.0.1:9000/setup ``` -3) Add a WiFi profile and submit. -4) Check status: +3) WLAN-Profil eintragen und speichern. +4) Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -5) Start playback: +5) Playback starten: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ @@ -33,26 +33,26 @@ curl -X POST http://127.0.0.1:8000/command \ ``` ## Troubleshooting -- Box not starting: - - Ensure dependencies are installed and ports 8000/9000 are free. -- GUI cannot reach API: - - Check `VITE_BOX_API_URL` and Box logs. +- Box startet nicht: + - Pruefe Abhaengigkeiten und ob Ports 8000/9000 frei sind. +- GUI erreicht API nicht: + - Pruefe `VITE_BOX_API_URL` und Box-Logs. -## What Is Mock vs Real -- Mock: WiFi, IP, Spotify, audio, hardware. These are logical simulations only. -- Real: Box process, playback state machine, API, CLI, resume logic, media traversal. +## Was ist Mock vs. real +- Mock: WLAN, IP, Spotify, Audio, Hardware. Diese Backends sind logische Simulationen. +- Real: Box-Prozess, Playback-State-Machine, API, CLI, Resume-Logik, Media-Traversal. -## Link Index -- Dev workflow: `docs/dev-workflow.md` -- Box lifecycle: `docs/box-lifecycle.md` -- Setup WebGUI: `docs/setup-webgui.md` -- HTTP API contract: `docs/api.md` -- GUI control panel: `docs/gui-control-panel.md` -- Playback testing: `docs/playback-testing.md` -- Offline scenarios: `docs/offline-scenarios.md` -- Security & storage: `docs/security-storage.md` -- CLI reference: `docs/cli.md` -- Acceptance tests: `docs/acceptance-tests.md` -- Debugging guide: `docs/debugging.md` -- Virtual box notes: `docs/virtual-box.md` -- Legacy storage notes: `docs/storage.md` +## Link-Index +- Dev-Workflow: `docs/dev-workflow.md` +- Box-Lifecycle: `docs/box-lifecycle.md` +- Setup-WebGUI: `docs/setup-webgui.md` +- HTTP-API Vertrag: `docs/api.md` +- GUI Control Panel: `docs/gui-control-panel.md` +- Playback-Tests: `docs/playback-testing.md` +- Offline-Szenarien: `docs/offline-scenarios.md` +- Security & Storage: `docs/security-storage.md` +- CLI-Referenz: `docs/cli.md` +- Acceptance-Tests: `docs/acceptance-tests.md` +- Debugging-Guide: `docs/debugging.md` +- Virtual-Box-Notizen: `docs/virtual-box.md` +- Legacy-Storage-Notizen: `docs/storage.md` diff --git a/docs/acceptance-tests.md b/docs/acceptance-tests.md index e047c89..c75e82c 100644 --- a/docs/acceptance-tests.md +++ b/docs/acceptance-tests.md @@ -1,560 +1,572 @@ -# Acceptance Tests +# Acceptance-Tests -## Purpose -Comprehensive test catalog derived from the current codebase. Each test is runnable in Simulation and described for Real Box hardware. Behaviors not implemented are explicitly marked. +## Zweck +Konsistenter Testkatalog basierend auf dem aktuellen Code. Alle Tests trennen Simulation und Hardware. Nicht implementierte Punkte sind explizit markiert. -## Prerequisites -- Repo root +## Voraussetzungen +- Repo-Root - Python 3 -- Box API running for Simulation: `python3 box/main.py run` +- Box API fuer Simulation: `python3 box/main.py run` - GUI optional: `cd gui && npm run dev` -## Step-by-step -Use the tests below in order or individually. +## Schritt-fuer-Schritt +Die Tests koennen einzeln oder der Reihe nach ausgefuehrt werden. --- -## Test A: Box-Start & Identitaet (box_id) +# Lokale Box-Tests (L1-L10) -1) Goal -- Verify box_id is created once and persists. +## Test L1: Box-Start & box_id -2) Preconditions -- Simulation: `box/data/box.json` may be missing. -- Real Box: storage is empty or fresh install. +1) Ziel +- box_id wird einmalig erzeugt und bleibt stabil. -3) Procedure – Simulation / Emulation -- Start Box: +2) Voraussetzungen +- Simulation: `box/data/box.json` kann fehlen. +- Hardware: frischer Start. + +3) Durchfuehrung – Simulation / Emulation +- Box starten: ``` python3 box/main.py run ``` -- Check box_id in API: +- Status lesen: ``` curl http://127.0.0.1:8000/status ``` -- Inspect file: +- Datei pruefen: ``` cat box/data/box.json ``` -4) Procedure – Real Box (Hardware) -- Power on device. -- Open status UI (if available) or API endpoint. +4) Durchfuehrung – Reale Box (Hardware) +- Box einschalten. +- Statusseite/API pruefen (sofern erreichbar). -5) Expected Result -- `/status` contains `box_id` with format `klangkiste-<10 chars a-z0-9>`. -- `box/data/box.json` contains `box_id` and is stable across restarts. +5) Erwartetes Ergebnis +- `box_id` im Format `klangkiste-<10 chars a-z0-9>`. +- `box/data/box.json` bleibt stabil ueber Neustarts. -6) Deviations / Troubleshooting -- `box_id` missing: check write permissions for `box/data/`. -- `box_id` changes: indicates manual deletion of `box/data/box.json`. +6) Abweichungen / Fehlersuche +- `box_id` fehlt: Schreibrechte in `box/data/` pruefen. --- -## Test B: WLAN-Ersteinrichtung (Setup WebGUI) +## Test L2: WLAN-Ersteinrichtung (Setup WebGUI) -1) Goal -- Configure WiFi profile via setup UI and reach WIFI_ONLINE. +1) Ziel +- WLAN-Profil per Setup UI anlegen. -2) Preconditions -- No WiFi profiles stored. +2) Voraussetzungen +- Keine WLAN-Profile gespeichert. -3) Procedure – Simulation / Emulation -- Reset WiFi profiles: +3) Durchfuehrung – Simulation / Emulation +- WLAN reset: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_reset"}' ``` -- Open Setup UI: +- Setup UI oeffnen: ``` http://127.0.0.1:9000/setup ``` -- Choose SSID and enter password, click "Verbinden". -- Check status: +- SSID waehlen, Passwort eingeben, speichern. +- Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -4) Procedure – Real Box (Hardware) -- Power on device. -- Connect to device AP SSID (not implemented in code). -- Open setup page and submit WiFi credentials. +4) Durchfuehrung – Reale Box (Hardware) +- Nicht testbar mit aktuellem Code: kein echter AP/WLAN-Stack. -5) Expected Result +5) Erwartetes Ergebnis - `wifi_state = WIFI_ONLINE` -- `connected_ssid` matches selected SSID -- `wifi_profiles_count` increments +- `connected_ssid` gesetzt -6) Deviations / Troubleshooting -- Setup UI unreachable: port 9000 occupied or Box not running. -- Not testable with current code: AP SSID and real WiFi connect. +6) Abweichungen / Fehlersuche +- Setup UI nicht erreichbar: Port 9000 frei, Box laeuft. --- -## Test C: Persistenz & Neustart +## Test L3: Persistenz & Neustart -1) Goal -- Ensure resume state and WiFi profiles persist across restarts. +1) Ziel +- Resume und WLAN-Profile bleiben ueber Neustart erhalten. -2) Preconditions -- At least one WiFi profile stored. -- Playback started once. +2) Voraussetzungen +- WLAN-Profil vorhanden. +- Playback einmal gestartet. -3) Procedure – Simulation / Emulation -- Add WiFi profile (if needed): +3) Durchfuehrung – Simulation / Emulation +- WLAN-Profil setzen: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' ``` -- Start playback: +- Playback starten: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' ``` -- Stop playback: +- Playback stoppen: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_off","payload":{"uid":"UID_1"}}' ``` -- Restart Box process. -- Check status: +- Box-Prozess neu starten. +- Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -4) Procedure – Real Box (Hardware) -- Start playback via NFC. -- Power cycle device. -- Verify WiFi profile and resume state persist. +4) Durchfuehrung – Reale Box (Hardware) +- Nicht testbar mit aktuellem Code: kein echter WLAN-Stack. -5) Expected Result -- `box/data/state.json` contains `resume`. -- `box/data/secrets.enc` exists and contains profiles (encrypted). -- After restart, `wifi_state` is not UNCONFIGURED. +5) Erwartetes Ergebnis +- `box/data/state.json` enthaelt Resume. +- `box/data/secrets.enc` existiert. -6) Deviations / Troubleshooting -- Resume missing: check `box/data/state.json`. -- WiFi profiles missing: check `box/data/secrets.enc` creation. +6) Abweichungen / Fehlersuche +- Resume fehlt: `box/data/state.json` pruefen. --- -## Test D: HTTP API Status & Commands +## Test L4: HTTP API Status & Commands -1) Goal -- Verify /status and /command are functional. +1) Ziel +- /status und /command liefern Antworten und aendern Status. -2) Preconditions -- Box running. +2) Voraussetzungen +- Box laeuft. -3) Procedure – Simulation / Emulation +3) Durchfuehrung – Simulation / Emulation - Status: ``` curl http://127.0.0.1:8000/status ``` -- Command: +- Command (vor Pairing -> 403 erwartet): ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"play_pause"}' ``` -4) Procedure – Real Box (Hardware) -- Same as above once API is reachable in real network. +4) Durchfuehrung – Reale Box (Hardware) +- Gleich wie Simulation, sofern API erreichbar. -5) Expected Result -- HTTP 200 responses. -- `/status` fields updated after commands. +5) Erwartetes Ergebnis +- Vor Pairing: HTTP 403 fuer gesperrte Commands. +- Nach Pairing (mit Token): HTTP 200 und Status aktualisiert. -6) Deviations / Troubleshooting -- CORS errors: ensure API is running. -- 400 errors: check payload requirements. +6) Abweichungen / Fehlersuche +- 400: Payload pruefen. --- -## Test E: GUI-Steuerung +## Test L5: GUI-Steuerung -1) Goal -- Verify GUI can control playback and display status. +1) Ziel +- GUI steuert Box und zeigt Live-Status. -2) Preconditions -- Box running on 127.0.0.1:8000. -- GUI running on 127.0.0.1:5174. +2) Voraussetzungen +- GUI laeuft auf 127.0.0.1:5174. -3) Procedure – Simulation / Emulation -- Open GUI: -``` -http://127.0.0.1:5174 -``` -- Click Play / Pause, Next, Prev, Vol +, Vol -. -- Click NFC UID_1 ON/OFF. -- Watch status refresh (polling every 1s). +3) Durchfuehrung – Simulation / Emulation +- GUI oeffnen, Buttons klicken. +- Status aendert sich innerhalb von 1s. -4) Procedure – Real Box (Hardware) -- Use GUI from another device pointing to Box IP. +4) Durchfuehrung – Reale Box (Hardware) +- GUI auf Box-IP ausrichten (spaeter). -5) Expected Result -- `/status` changes after each button. -- GUI shows updated status and last_error if present. +5) Erwartetes Ergebnis +- `/status` aendert sich nach Commands. -6) Deviations / Troubleshooting -- GUI shows "Failed to fetch": verify VITE_BOX_API_URL. +6) Abweichungen / Fehlersuche +- GUI "Failed to fetch": `VITE_BOX_API_URL` pruefen. --- -## Test F: Playback & NFC +## Test L6: Playback & NFC -1) Goal -- Verify NFC starts playback from tags. +1) Ziel +- NFC startet Playback. -2) Preconditions -- Tags exist in `box/data/box.json`. -- Media exists under `box/data/media`. +2) Voraussetzungen +- Tags in `box/data/box.json`. +- Medien in `box/data/media`. -3) Procedure – Simulation / Emulation -- Start playback: +3) Durchfuehrung – Simulation / Emulation ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' ``` -- Observe `/status`: -``` -curl http://127.0.0.1:8000/status -``` -4) Procedure – Real Box (Hardware) -- Place NFC tag mapped to UID_1. +4) Durchfuehrung – Reale Box (Hardware) +- NFC-Tag auflegen (spaeter). -5) Expected Result +5) Erwartetes Ergebnis - `playback_state.state = PLAYING` -- `active_uid = UID_1` -6) Deviations / Troubleshooting -- No playback: check tags in `box/data/box.json` and files in `box/data/media`. +6) Abweichungen / Fehlersuche +- Tag fehlt: `box/data/box.json` pruefen. --- -## Test G: Resume-Logik +## Test L7: Resume-Logik -1) Goal -- Resume picks up last file index and position per UID. +1) Ziel +- Resume pro UID funktioniert. -2) Preconditions -- Playback started at least once for UID_1. +2) Voraussetzungen +- Playback einmal gestartet. -3) Procedure – Simulation / Emulation -- Start playback, wait ~5s. -- Stop with `nfc_off`. -- Start again with `nfc_on`. +3) Durchfuehrung – Simulation / Emulation +- `nfc_on UID_1`, warten, `nfc_off UID_1`, dann `nfc_on UID_1`. -4) Procedure – Real Box (Hardware) -- Place NFC tag, remove, place again. +4) Durchfuehrung – Reale Box (Hardware) +- NFC auflegen/abnehmen (spaeter). -5) Expected Result -- Resume uses stored `file_index` and `position`. -- `box/data/state.json` contains resume for UID_1. +5) Erwartetes Ergebnis +- Resume nutzt gespeicherte Position. -6) Deviations / Troubleshooting -- Resume resets to 0: check `state.json` and path changes. +6) Abweichungen / Fehlersuche +- Resume = 0: `box/data/state.json` pruefen. --- -## Test H: Ordner- & Rekursionslogik +## Test L8: Rekursion & Ordnerstruktur -1) Goal -- Verify recursive ordering (DFS, folders first, lexicographic). +1) Ziel +- Rekursive Reihenfolge (DFS) ist korrekt. -2) Preconditions -- Nested media structure in `box/data/media`. +2) Voraussetzungen +- Verschachtelte Ordner in `box/data/media`. -3) Procedure – Simulation / Emulation -- Create nested structure in `box/data/media/book_6/CD1/...`. -- Point a tag to `media/book_6` in `box/data/box.json`. -- Start playback with `nfc_on`. +3) Durchfuehrung – Simulation / Emulation +- Tag auf `media/book_6` setzen, `nfc_on`. -4) Procedure – Real Box (Hardware) -- Copy nested folders to the device media path. -- Start playback via NFC. +4) Durchfuehrung – Reale Box (Hardware) +- Medienstruktur auf Box kopieren (spaeter). -5) Expected Result -- Files play in DFS order: subfolders sorted, then files. +5) Erwartetes Ergebnis +- DFS-Reihenfolge, lexikografisch. -6) Deviations / Troubleshooting -- Ordering unexpected: check folder names and lexicographic sort. +6) Abweichungen / Fehlersuche +- Reihenfolge falsch: Ordnernamen pruefen. --- -## Test I: Offline-Szenarien (Box intern) +## Test L9: Offline-Szenarien (Box intern) -1) Goal -- Verify WiFi and Spotify states in offline/online flows. +1) Ziel +- WiFi/Spotify-Status reagieren auf Commands. -2) Preconditions -- Box running. +2) Voraussetzungen +- Box laeuft. -3) Procedure – Simulation / Emulation -- WiFi reset: -``` -curl -X POST http://127.0.0.1:8000/command \ - -H "Content-Type: application/json" \ - -d '{"command":"wifi_reset"}' -``` -- Spotify clear: -``` -curl -X POST http://127.0.0.1:8000/command \ - -H "Content-Type: application/json" \ - -d '{"command":"spotify_clear"}' -``` +3) Durchfuehrung – Simulation / Emulation +- `wifi_reset` und `spotify_clear` ausfuehren. -4) Procedure – Real Box (Hardware) -- Not testable with current code: real network loss and Spotify connectivity. +4) Durchfuehrung – Reale Box (Hardware) +- Nicht testbar mit aktuellem Code. -5) Expected Result +5) Erwartetes Ergebnis - `wifi_state = WIFI_UNCONFIGURED` - `spotify_status = NOT_CONFIGURED` -6) Deviations / Troubleshooting -- Status unchanged: ensure you read `/status` after command. +6) Abweichungen / Fehlersuche +- Status aendert sich nicht: `/status` pruefen. --- -## Test J: Fehlerfaelle (Box intern) +## Test L10: Fehlerfaelle (Box intern) -1) Goal -- Verify error sound and error reporting. +1) Ziel +- Fehler setzen `last_error`. -2) Preconditions -- Box running. +2) Voraussetzungen +- Box laeuft. -3) Procedure – Simulation / Emulation -- Trigger error: +3) Durchfuehrung – Simulation / Emulation ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"trigger_error","payload":{"type":"uid_unmapped"}}' ``` -- Check `/status` for `last_error`. - -4) Procedure – Real Box (Hardware) -- Not testable with current code: actual error sound output. - -5) Expected Result -- `playback_state.last_error` equals error type. -- Box returns to `IDLE` after error. - -6) Deviations / Troubleshooting -- No error shown: verify `/status` and that errors are not cleared automatically. - ---- - -## Test K: Announce -> neue Box erscheint im Server - -1) Ziel -- Box meldet sich beim Server und erscheint als UNPAIRED / neue Box. - -2) Voraussetzungen -- Simulation: Server-Komponente vorhanden und laeuft. -- Real Box: Server erreichbar im Netzwerk. - -3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code: Box sendet keine Announce-Requests. - -4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code: Announce-Logik ist nicht implementiert. - -5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. - -6) Abweichungen / Fehlersuche -- Wenn implementiert: Pruefe Server-Logs, API-Endpunkte und Box-Statusfelder. - ---- - -## Test L: Mehrere Boxen gleichzeitig - -1) Ziel -- Mehrere Boxen werden als separate, unbekannte Boxen erkannt. - -2) Voraussetzungen -- Simulation: Mehrere Box-Prozesse mit unterschiedlichen `box_id`. - -3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code: Announce/Server-Erkennung fehlt. 4) Durchfuehrung – Reale Box (Hardware) - Nicht testbar mit aktuellem Code. 5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. +- `playback_state.last_error` gesetzt. 6) Abweichungen / Fehlersuche -- Wenn implementiert: Pruefe Duplikate anhand `box_id` und `fingerprint`. +- Kein Fehler sichtbar: `/status` pruefen. --- -## Test M: Pairing-Freigabe ueber Server-GUI +# Server/Pairing-Tests (A-J) + +## Test A: Box-Erststart & Identitaet 1) Ziel -- Box wird nach Freigabe gepairt und erhaelt API-Token. +- box_id wird einmalig erzeugt. 2) Voraussetzungen -- Simulation: Server-GUI und Pairing-Endpunkte implementiert. +- Box gestartet. 3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code: Pairing-Flow fehlt. +- Siehe Test L1. 4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code. +- Siehe Test L1. 5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. +- box_id stabil. 6) Abweichungen / Fehlersuche -- Wenn implementiert: Pruefe Token-Speicherung in `box/data/secrets.enc`. +- Siehe Test L1. --- -## Test N: Verhalten vor Pairing (keine Steuerung erlaubt) +## Test B: Announce -> neue Box erscheint im Server 1) Ziel -- Vor Pairing sind Steuerbefehle blockiert. +- Box meldet sich beim Server und erscheint als UNPAIRED. 2) Voraussetzungen -- Server-Integration und Auth-Checks vorhanden. - -3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code: kein Auth/Pairing implementiert. - -4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code. - -5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. - -6) Abweichungen / Fehlersuche -- Wenn implementiert: Pruefe API-Responses (401/403) und Server-Logs. - ---- - -## Test O: Verhalten nach Pairing - -1) Ziel -- Nach Pairing sind Steuerbefehle erlaubt und Box reagiert. - -2) Voraussetzungen -- Pairing-Flow implementiert. - -3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code. - -4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code. - -5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. - -6) Abweichungen / Fehlersuche -- Wenn implementiert: Pruefe Token in `secrets.enc` und Server-GUI Status. - ---- - -## Test P: Neustart vor/nach Pairing - -1) Ziel -- Box behaelt Pairing-Status und Token ueber Neustarts. - -2) Voraussetzungen -- Pairing implementiert. - -3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code. - -4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code. - -5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. - -6) Abweichungen / Fehlersuche -- Wenn implementiert: Pruefe `secrets.enc` vor/nach Neustart. - ---- - -## Test Q: Factory Reset -> neue Identitaet - -1) Ziel -- Factory Reset loescht box_id und erzeugt neue Identitaet beim naechsten Start. - -2) Voraussetzungen -- Box laeuft einmalig und `box/data/` existiert. - -3) Durchfuehrung – Simulation / Emulation -- Stoppe Box. -- Loesche folgende Dateien: +- Server laeuft: ``` -rm -f box/data/box.json box/data/state.json box/data/secrets.enc +python3 server/main.py ``` -- Starte Box neu: + +3) Durchfuehrung – Simulation / Emulation +- Box starten: ``` python3 box/main.py run ``` -- Pruefe `/status`. +- 30-60s warten (Announce-Intervall). +- Server-GUI oeffnen: +``` +http://127.0.0.1:7000/ui +``` +- Optional: Server-Storage pruefen: +``` +cat server/data/boxes.json +``` 4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code: kein Factory-Reset-Mechanismus implementiert. +- Box einschalten. +- Server-GUI oeffnen, Box sollte erscheinen. 5) Erwartetes Ergebnis -- Neue `box_id` erscheint in `/status`. +- Box erscheint als UNPAIRED in der Server-GUI. +- `server/data/boxes.json` enthaelt `box_id` und `fingerprint`. 6) Abweichungen / Fehlersuche -- `box_id` bleibt gleich: Dateien wurden nicht geloescht. +- Keine Box sichtbar: Server-URL in `box/data/box.json` pruefen. +- Server nicht erreichbar: Server-Prozess/Port 7000 pruefen. --- -## Test R: Offline / Server nicht erreichbar +## Test C: Mehrere Boxen gleichzeitig 1) Ziel -- Box laeuft weiter, wenn Server nicht erreichbar ist. +- Mehrere Boxen werden separat erkannt. 2) Voraussetzungen -- Server-Integration implementiert. +- Mehrere Boxen mit unterschiedlichen `box_id`. 3) Durchfuehrung – Simulation / Emulation -- Nicht testbar mit aktuellem Code: keine Server-Kommunikation. +- Nicht testbar mit aktuellem Code: Box nutzt festen `box/data/`-Pfad und kann nicht mehrfach parallel laufen. 4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code. +- Zwei physische Boxen einschalten. +- Server-GUI zeigt beide UNPAIRED an. 5) Erwartetes Ergebnis -- Nicht testbar mit aktuellem Code. +- Zwei Eintraege mit unterschiedlichen `box_id`. 6) Abweichungen / Fehlersuche -- Wenn implementiert: pruefe lokale Funktionen (Playback, NFC) laufen weiter. +- Duplikate: `box_id` doppelt? Factory-Reset pruefen. --- -## Test S: Simulation vs. Hardware – Unterschiede +## Test D: Pairing-Freigabe ueber Server-GUI 1) Ziel -- Dokumentiere, was real vs. mock ist. +- Pairing liefert API-Token und Box wird PAIRED. + +2) Voraussetzungen +- Box erscheint als UNPAIRED in `/ui`. + +3) Durchfuehrung – Simulation / Emulation +- Server-GUI oeffnen: +``` +http://127.0.0.1:7000/ui +``` +- Button "Box hinzufuegen" klicken. +- API-Token via Server-API abrufen: +``` +curl -X POST http://127.0.0.1:7000/api/boxes/pair -H "Content-Type: application/json" -d '{"box_id":""}' +``` + +4) Durchfuehrung – Reale Box (Hardware) +- Gleiches Vorgehen ueber Server-GUI. + +5) Erwartetes Ergebnis +- Box erscheint in der gepaarten Liste. +- `api_token` wird an die Box ausgeliefert. + +6) Abweichungen / Fehlersuche +- Pairing bleibt aus: `server/data/boxes.json` pruefen. + +--- + +## Test E: Verhalten vor Pairing (keine Steuerung erlaubt) + +1) Ziel +- Vor Pairing werden Steuerbefehle blockiert. + +2) Voraussetzungen +- Box UNPAIRED. + +3) Durchfuehrung – Simulation / Emulation +``` +curl -i -X POST http://127.0.0.1:8000/command -H "Content-Type: application/json" -d '{"command":"play_pause"}' +``` + +4) Durchfuehrung – Reale Box (Hardware) +- Gleicher Request gegen Box-IP. + +5) Erwartetes Ergebnis +- HTTP 403 "pairing required". + +6) Abweichungen / Fehlersuche +- Wenn 200: Pairing-Status und API-Token pruefen. + +--- + +## Test F: Verhalten nach Pairing + +1) Ziel +- Nach Pairing sind Commands erlaubt (mit Token). + +2) Voraussetzungen +- Box PAIRED und api_token vorhanden. + +3) Durchfuehrung – Simulation / Emulation +``` +curl -i -X POST http://127.0.0.1:8000/command -H "Content-Type: application/json" -H "X-API-Token: " -d '{"command":"play_pause"}' +``` + +4) Durchfuehrung – Reale Box (Hardware) +- Gleiches Vorgehen gegen Box-IP. + +5) Erwartetes Ergebnis +- HTTP 200, Status aktualisiert. + +6) Abweichungen / Fehlersuche +- 403: Token pruefen, Pairing-Status pruefen. + +--- + +## Test G: Neustart vor/nach Pairing + +1) Ziel +- Pairing-Status und Token bleiben ueber Neustart erhalten. + +2) Voraussetzungen +- Box PAIRED. + +3) Durchfuehrung – Simulation / Emulation +- Box-Prozess stoppen, neu starten. +- Mit Token Command senden. + +4) Durchfuehrung – Reale Box (Hardware) +- Power cycle. + +5) Erwartetes Ergebnis +- Box bleibt PAIRED, Token weiterhin gueltig. + +6) Abweichungen / Fehlersuche +- Token fehlt: `box/data/secrets.enc` pruefen. + +--- + +## Test H: Factory Reset -> neue Identitaet + +1) Ziel +- Factory Reset erzeugt neue Identitaet. + +2) Voraussetzungen +- Box einmal gestartet. + +3) Durchfuehrung – Simulation / Emulation +- Box stoppen. +- Dateien loeschen: +``` +rm -f box/data/box.json box/data/state.json box/data/secrets.enc +``` +- Box neu starten. + +4) Durchfuehrung – Reale Box (Hardware) +- Nicht testbar mit aktuellem Code: kein Reset-Mechanismus. + +5) Erwartetes Ergebnis +- Neue `box_id`. + +6) Abweichungen / Fehlersuche +- `box_id` bleibt gleich: Dateien nicht geloescht. + +--- + +## Test I: Offline / Server nicht erreichbar + +1) Ziel +- Box laeuft weiter ohne Server. + +2) Voraussetzungen +- Box laeuft, Server gestoppt. + +3) Durchfuehrung – Simulation / Emulation +- Server stoppen. +- Playback starten (mit Token falls gepairt). + +4) Durchfuehrung – Reale Box (Hardware) +- Server vom Netz trennen. + +5) Erwartetes Ergebnis +- Lokale Playback-Logik laeuft weiter. +- Server-GUI zeigt Box ggf. als OFFLINE nach Timeout. + +6) Abweichungen / Fehlersuche +- Box stoppt: lokale Logs pruefen. + +--- + +## Test J: Simulation vs. Hardware – Unterschiede + +1) Ziel +- Unterschiede klar dokumentieren. 2) Voraussetzungen - Box laeuft in Simulation. 3) Durchfuehrung – Simulation / Emulation -- Beachte: WiFi/IP/Spotify/Audio sind Mock. +- WiFi/IP/Spotify/Audio sind Mock. - Setup UI und API funktionieren lokal. 4) Durchfuehrung – Reale Box (Hardware) -- Nicht testbar mit aktuellem Code: Hardware-Backends fehlen. +- Echte Backends notwendig (nicht implementiert). 5) Erwartetes Ergebnis - Simulation laeuft ohne Hardware. -- Hardware-Backends koennen spaeter ersetzt werden. 6) Abweichungen / Fehlersuche -- Wenn Hardware genutzt wird, fehlen Implementierungen. +- Hardware-Backends fehlen. diff --git a/docs/api.md b/docs/api.md index 8149284..822121c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,19 +1,19 @@ # Box HTTP API -## Purpose -Defines the stable contract between the GUI/CLI and the Box. -The Box is authoritative; the client only sends commands and reads status. +## Zweck +Stabiler Vertrag zwischen GUI/CLI und Box. +Die Box ist autoritativ, der Client sendet nur Commands und liest Status. -## Prerequisites -- Box running on `http://127.0.0.1:8000` +## Voraussetzungen +- Box laeuft auf `http://127.0.0.1:8000` -## Step-by-step +## Schritt-fuer-Schritt ### Status ``` curl http://127.0.0.1:8000/status ``` -### Command (example) +### Command (Beispiel) ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ @@ -21,12 +21,13 @@ curl -X POST http://127.0.0.1:8000/command \ ``` ## GET /status -Response fields: -- `box_id`: unique device ID +Felder: +- `box_id`: eindeutige Box-ID +- `pairing_state`: `UNPAIRED | PAIRING_PENDING | PAIRED` - `wifi_state`: `WIFI_UNCONFIGURED | WIFI_CONNECTING | WIFI_ONLINE | WIFI_OFFLINE` -- `ip_address`: simulated IP (`192.168.4.1` or `127.0.0.1`) -- `connected_ssid`: connected SSID or `null` -- `wifi_profiles_count`: number of stored profiles +- `ip_address`: simulierte IP (`192.168.4.1` oder `127.0.0.1`) +- `connected_ssid`: verbundene SSID oder `null` +- `wifi_profiles_count`: Anzahl gespeicherter Profile - `spotify_status`: `NOT_CONFIGURED | READY` - `playback_state`: - `state`: `IDLE | PLAYING | PAUSED` @@ -39,10 +40,11 @@ Response fields: - `volume` - `last_error` -Example response: +Beispielantwort: ``` { "box_id": "klangkiste-a9f3k7m2q8", + "pairing_state": "UNPAIRED", "wifi_state": "WIFI_ONLINE", "ip_address": "127.0.0.1", "connected_ssid": "HomeWiFi", @@ -63,7 +65,7 @@ Example response: ``` ## POST /command -Request body: +Request Body: ``` { "command": "nfc_on", @@ -71,7 +73,7 @@ Request body: } ``` -Supported commands: +Unterstuetzte Commands: - `nfc_on` `{ uid }` - `nfc_off` `{ uid }` - `play_pause` @@ -79,8 +81,8 @@ Supported commands: - `prev` - `volume_up` - `volume_down` -- `vol_up` (alias) -- `vol_down` (alias) +- `vol_up` (Alias) +- `vol_down` (Alias) - `stop` - `trigger_error` `{ type }` - `wifi_add_profile` `{ ssid, password, priority? }` @@ -88,27 +90,39 @@ Supported commands: - `spotify_set_tokens` `{ access_token, refresh_token, expires_at, account_id? }` - `spotify_clear` -Example: add WiFi profile +## Auth fuer Commands +- Vor Pairing sind nur diese Commands erlaubt: + - `wifi_add_profile`, `wifi_reset`, `spotify_set_tokens`, `spotify_clear` +- Alle anderen Commands erfordern `X-API-Token` im Header. + +Beispiel mit Token:\n``` +curl -X POST http://127.0.0.1:8000/command \\ + -H "Content-Type: application/json" \\ + -H "X-API-Token: " \\ + -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' +``` + +Beispiel: WLAN-Profil hinzufuegen ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' ``` -Example: set Spotify tokens +Beispiel: Spotify Tokens setzen ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' ``` -## Checklist -- ✅ `GET /status` returns JSON -- ✅ `POST /command` returns `{ "ok": true }` -- ✅ No secrets appear in `/status` +## Checkliste +- ✅ `GET /status` liefert JSON +- ✅ `POST /command` liefert `{ "ok": true }` +- ✅ Keine Secrets in `/status` ## Troubleshooting -- 400 responses: - - Check required fields in the payload. -- CORS errors: - - Ensure Box API is running on the expected host/port. +- 400 Responses: + - Payload pruefen (Pflichtfelder). +- CORS-Fehler: + - API muss laufen und erreichbar sein. diff --git a/docs/box-lifecycle.md b/docs/box-lifecycle.md index a45aad6..e69fb30 100644 --- a/docs/box-lifecycle.md +++ b/docs/box-lifecycle.md @@ -1,42 +1,47 @@ -# Box Lifecycle +# Box-Lifecycle -## Purpose -Defines the virtual box lifecycle: identity, WiFi states, and restart behavior. +## Zweck +Definiert Identitaet, WLAN-Status und Neustartverhalten der virtuellen Box. -## Prerequisites -- Box started with `python3 box/main.py run` +## Voraussetzungen +- Box gestartet mit `python3 box/main.py run` -## Step-by-step -### Box identity -- Generated on first start if `box/data/box.json` has no `box_id`. -- Format: `klangkiste-<10 chars a-z0-9>` -- Stored in `box/data/box.json` +## Schritt-fuer-Schritt +### Box-Identitaet +- Wird beim ersten Start erzeugt, wenn `box/data/box.json` keine `box_id` enthaelt. +- Format: `klangkiste-<10 Zeichen a-z0-9>` +- Persistiert in `box/data/box.json` -### WiFi states (logical) -- `WIFI_UNCONFIGURED`: no profiles stored -- `WIFI_CONNECTING`: reserved for future; not emitted in mock -- `WIFI_ONLINE`: profile exists and mock is "connected" -- `WIFI_OFFLINE`: profile exists but not connected +### WLAN-Zustaende (logisch) +- `WIFI_UNCONFIGURED`: keine Profile gespeichert +- `WIFI_CONNECTING`: reserviert fuer spaeter (im Mock nicht aktiv) +- `WIFI_ONLINE`: Profil vorhanden und verbunden +- `WIFI_OFFLINE`: Profil vorhanden, aber nicht verbunden -### IP model (informational) -- Setup/AP mode: `192.168.4.1` -- Normal mode: `127.0.0.1` +### IP-Modell (informativ) +- Setup/AP-Modus: `192.168.4.1` +- Normalbetrieb: `127.0.0.1` -### Restart behavior -- Box reloads `box_id`, resume state, and WiFi profiles from `box/data/` -- Playback resume and WiFi status are preserved +### Neustartverhalten +- Box laedt `box_id`, Resume-State und WLAN-Profile aus `box/data/` +- Resume und WLAN-Status bleiben erhalten ### Resets -- WiFi reset: clears WiFi profiles only (`wifi_reset` command) -- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` +- WLAN-Reset: nur WLAN-Profile loeschen (`wifi_reset`) +- Factory-Reset: `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` loeschen -## Checklist -- ✅ After first boot, `box/data/box.json` contains `box_id` -- ✅ `GET /status` returns `wifi_state` and `ip_address` -- ✅ WiFi reset clears profiles, `wifi_state=WIFI_UNCONFIGURED` +### Pairing-Status +- `UNPAIRED`: Box ist nicht gekoppelt +- `PAIRING_PENDING`: Box pollt Pairing-Status beim Server +- `PAIRED`: Box ist gekoppelt, API-Token vorhanden + +## Checkliste +- ✅ Nach erstem Start existiert `box/data/box.json` mit `box_id` +- ✅ `GET /status` liefert `wifi_state` und `ip_address` +- ✅ WLAN-Reset setzt `wifi_state=WIFI_UNCONFIGURED` ## Troubleshooting -- `wifi_state` not changing: - - Add a WiFi profile via setup UI or API. -- Missing `box_id`: - - Ensure `box/data/box.json` is writable. +- `wifi_state` aendert sich nicht: + - WLAN-Profil via Setup UI oder API hinzufuegen. +- `box_id` fehlt: + - Schreibrechte fuer `box/data/box.json` pruefen. diff --git a/docs/cli.md b/docs/cli.md index 7305359..8673d72 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,34 +1,34 @@ -# Box CLI Reference +# CLI-Referenz -## Purpose -List all CLI interactions available in the virtual box. +## Zweck +Alle CLI-Interaktionen der virtuellen Box. -## Prerequisites -- Box code available in `box/` +## Voraussetzungen +- Box-Code in `box/` -## Step-by-step -### One-shot commands +## Schritt-fuer-Schritt +### One-shot Commands Status: ``` python3 box/main.py status ``` -Simulate NFC: +NFC simulieren: ``` python3 box/main.py simulate-nfc UID_1 python3 box/main.py simulate-nfc-off UID_1 ``` -Manual tick: +Manuelles Tick: ``` python3 box/main.py tick 30 ``` -### Interactive run mode +### Interaktiver Run-Modus ``` python3 box/main.py run ``` -Commands inside run mode: +Commands im Run-Modus: ``` status nfc @@ -43,13 +43,13 @@ help exit | quit ``` -## Checklist -- ✅ `status` prints runtime state -- ✅ `simulate-nfc` starts playback -- ✅ `run` accepts interactive commands +## Checkliste +- ✅ `status` gibt Runtime-State aus +- ✅ `simulate-nfc` startet Playback +- ✅ `run` akzeptiert interaktive Commands ## Troubleshooting -- Commands not found: - - Ensure you run from repo root or use full path to `box/main.py`. -- No playback: - - Check tags in `box/data/box.json` and media under `box/data/media`. +- Commands nicht gefunden: + - Aus Repo-Root ausfuehren oder `box/main.py` voll referenzieren. +- Kein Playback: + - Tags in `box/data/box.json` und Medien in `box/data/media` pruefen. diff --git a/docs/debugging.md b/docs/debugging.md index 4a1611b..35288a0 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -1,99 +1,98 @@ -# Debugging Guide +# Debugging-Guide -## Purpose -Fast diagnosis guide: "If X happens, check Y" with separate paths for Simulation and Real Box. +## Zweck +Schnelle Diagnose: "Wenn X passiert, pruefe Y" getrennt nach Simulation und Hardware. -## Prerequisites -- Box running (simulation or device) -- Access to `/status` and data files +## Voraussetzungen +- Box laeuft (Simulation oder Hardware) +- Zugriff auf `/status` und Datenfiles -## Step-by-step -Use the sections below by symptom. +## Schritt-fuer-Schritt +Nutze die Sektionen nach Symptomen. ## Simulation (IDE) -### If GUI shows "Failed to fetch" -- Check API is running: +### GUI zeigt "Failed to fetch" +- API pruefen: ``` curl http://127.0.0.1:8000/status ``` -- Verify GUI API base: - - `VITE_BOX_API_URL` or default `http://localhost:8000` +- GUI API-Base pruefen: + - `VITE_BOX_API_URL` oder Default `http://localhost:8000` -### If playback does not start -- Check tags: +### Playback startet nicht +- Tags pruefen: ``` cat box/data/box.json ``` -- Check media files: +- Medien pruefen: ``` ls -la box/data/media ``` -- Check `/status`: +- Status pruefen: ``` curl http://127.0.0.1:8000/status ``` -### If resume does not persist -- Check state file: +### Resume bleibt leer +- State pruefen: ``` cat box/data/state.json ``` -- Ensure playback was stopped via `nfc_off` or end-of-media. +- Playback mit `nfc_off` beenden. -### If setup UI not reachable -- Check port 9000 free. -- Ensure Box is running. +### Setup UI nicht erreichbar +- Port 9000 frei? +- Box laeuft? -### If box not recognized by server -- Not testable with current code: no announce/pairing implemented. +### Box wird nicht erkannt (Server) +- Server-URL in `box/data/box.json` pruefen (`server_url`). +- Server laeuft auf Port 7000? +- `server/data/boxes.json` pruefen. -### If box appears multiple times -- Not testable with current code: no announce/pairing implemented. +### Box erscheint mehrfach +- `box_id` in `box/data/box.json` pruefen. +- Factory-Reset nur gezielt ausfuehren. -### If fingerprint changes -- Not testable with current code: fingerprint not implemented. +### Fingerprint aendert sich +- `secret_seed` in `box/data/secrets.enc` darf sich nicht aendern. +- `box/data/box.json` darf nicht geloescht werden. -### If pairing hangs -- Not testable with current code: pairing flow not implemented. +### Pairing haengt +- `server/data/boxes.json` pruefen: state, api_token. +- Box-Status: `pairing_state` in `/status` pruefen. -## Real Box (Hardware) -### If device not reachable in browser -- Ensure it is powered on and connected to the same network. -- Check the IP shown by your router. +## Reale Box (Hardware) +### Box nicht im Browser erreichbar +- Stromversorgung ok? +- Im gleichen Netzwerk? +- Router-IP pruefen. -### If NFC does nothing -- Confirm tag UID exists in `box.json`. -- Confirm NFC hardware is working (not implemented in code). +### NFC reagiert nicht +- UID in `box.json` vorhanden? +- NFC-Hardware (nicht implementiert) pruefen. -### If audio is silent -- Audio backend is mock in current code. -- Not testable with current code. +### Audio bleibt stumm +- Audio-Backend ist Mock im aktuellen Code. +- Nicht testbar mit aktuellem Code. -### If box not recognized by server -- Not testable with current code: no announce/pairing implemented. +### Box wird nicht erkannt / erscheint mehrfach / Fingerprint wechselt / Pairing haengt +- Server-GUI (`/ui`) pruefen. +- Box-IP/Server-IP/VLAN pruefen. +- Box-Logs pruefen. -### If box appears multiple times -- Not testable with current code: no announce/pairing implemented. +## Files +- `box/data/box.json`: Identitaet, Tags, Settings +- `box/data/state.json`: Resume und WiFi-State +- `box/data/secrets.enc`: verschluesselte Secrets -### If fingerprint changes -- Not testable with current code: fingerprint not implemented. - -### If pairing hangs -- Not testable with current code: pairing flow not implemented. - -## Files to inspect -- `box/data/box.json`: identity, tags, settings -- `box/data/state.json`: resume and wifi state -- `box/data/secrets.enc`: encrypted secrets - -## Status fields to watch +## Statusfelder - `wifi_state`, `ip_address`, `connected_ssid` - `playback_state.state`, `playback_state.active_uid` - `playback_state.position`, `playback_state.file_index` - `playback_state.last_error` ## Troubleshooting -- 400 from `/command`: - - Check required payload fields in `docs/api.md`. -- No change after command: - - Poll `/status` after sending. +- 400 von `/command`: + - Payload pruefen (Pflichtfelder). +- Keine Aenderung nach Command: + - `/status` danach erneut abrufen. diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index 4655323..7d517a5 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -1,22 +1,22 @@ -# Dev Workflow +# Dev-Workflow -## Purpose -How to start/stop the virtual box, how to access logs, and how to point the GUI to the API. +## Zweck +Start/Stop der virtuellen Box, Logs, und GUI-API-Konfiguration. -## Prerequisites +## Voraussetzungen - Python 3 - Node.js + npm -- Run from repo root +- Aus dem Repo-Root ausfuehren -## Step-by-step -### Start everything (recommended) +## Schritt-fuer-Schritt +### Alles starten (empfohlen) ``` ./dev.sh ``` -- GUI runs in the background -- Box runs in the foreground (you can type CLI commands) +- GUI laeuft im Hintergrund. +- Box laeuft im Vordergrund und akzeptiert CLI-Kommandos. -### Start separately +### Komponenten separat starten Box only: ``` python3 box/main.py run @@ -28,34 +28,36 @@ npm run dev ``` ## Ports / Hosts +- Server API + GUI: `http://127.0.0.1:7000` (GUI unter `/ui`) - Box API: `http://127.0.0.1:8000` - Setup UI: `http://127.0.0.1:9000/setup` - GUI: `http://127.0.0.1:5174` -## API URL for GUI -Default is `http://localhost:8000`. Override with: +## API-URL fuer die GUI +Default ist `http://localhost:8000`. Override: ``` VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev ``` -## Logs -- Box logs appear in the same terminal where you run `python3 box/main.py run`. -- GUI logs appear in the browser console. +## Logs ansehen +- Server-Logs: Terminal mit `python3 server/main.py` +- Box-Logs: Terminal mit `python3 box/main.py run` +- GUI-Logs: Browser-Konsole -## Stop / Cleanup -- Press `Ctrl+C` in the terminal where the box is running. -- `dev.sh` will also stop the GUI process. +## Sauber beenden +- `Ctrl+C` im Box-Terminal. +- `dev.sh` beendet die GUI automatisch. -## Checklist -- ✅ Run `./dev.sh` -- ✅ Box shows interactive prompt -- ✅ GUI loads in browser -- ✅ `curl http://127.0.0.1:8000/status` returns JSON +## Checkliste +- ✅ `./dev.sh` gestartet +- ✅ Box-CLI Prompt sichtbar +- ✅ GUI im Browser erreichbar +- ✅ `curl http://127.0.0.1:8000/status` liefert JSON ## Troubleshooting -- Port already in use: - - 8000 or 9000: stop other processes, or change ports in `box/main.py`. -- CORS error in browser: - - Ensure API is running; CORS is already enabled in the Box API. -- GUI shows "Failed to fetch": - - Check `VITE_BOX_API_URL` and Box API port. +- Port belegt: + - 8000/9000: andere Prozesse beenden oder Ports in `box/main.py` aendern. +- CORS-Fehler: + - API muss laufen und erreichbar sein. +- GUI zeigt "Failed to fetch": + - `VITE_BOX_API_URL` pruefen. diff --git a/docs/gui-control-panel.md b/docs/gui-control-panel.md index 33a828d..ef2c30f 100644 --- a/docs/gui-control-panel.md +++ b/docs/gui-control-panel.md @@ -1,21 +1,21 @@ # GUI Control Panel -## Purpose -How to use the Vite-based GUI to control and observe the virtual box. +## Zweck +Nutzung des GUI Control Panels zur Steuerung und Beobachtung der virtuellen Box. -## Prerequisites -- Box running on `http://127.0.0.1:8000` -- GUI started (`npm run dev` or `./dev.sh`) +## Voraussetzungen +- Box laeuft auf `http://127.0.0.1:8000` +- GUI gestartet (`npm run dev` oder `./dev.sh`) + - Bei gepaarter Box: `VITE_BOX_API_TOKEN` gesetzt -## Step-by-step -1) Open GUI: +## Schritt-fuer-Schritt +1) GUI oeffnen: ``` http://127.0.0.1:5174 ``` -2) Click buttons to send commands. -3) Observe status updates (polling + refresh after command). +2) Buttons klicken, Status beobachten. -## Controls (current in GUI) +## Controls (im GUI vorhanden) - Play / Pause - Next - Prev @@ -25,29 +25,32 @@ http://127.0.0.1:5174 - NFC UID_1 OFF - Refresh Status -## Controls (available via API only) +## Controls (nur API) - WiFi add/reset - Spotify set/clear -Use API for those: +Beispiel via API: ``` + +## Auth-Token fuer GUI +- Nach Pairing: Token ueber Server-API oder Server-GUI ermitteln.\n- GUI mit Token starten:\n```\nVITE_BOX_API_TOKEN= npm run dev\n``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' ``` -## Expected results -- After any button click, status refreshes within 1 second. -- `last_error` is visible if an error occurs. +## Erwartete Ergebnisse +- Nach jedem Klick wird der Status innerhalb von 1s aktualisiert. +- `last_error` wird angezeigt, wenn gesetzt. -## Checklist -- ✅ GUI loads and shows API base URL -- ✅ Status updates every 1s -- ✅ Clicking a button updates `/status` +## Checkliste +- ✅ GUI laedt und zeigt API-URL +- ✅ Status aktualisiert sich automatisch +- ✅ Buttons aendern `/status` ## Troubleshooting -- GUI shows "Failed to fetch": - - Check `VITE_BOX_API_URL`. - - Verify Box API is running. -- Buttons have no effect: - - Check the API command names in `docs/api.md`. +- GUI zeigt "Failed to fetch": + - `VITE_BOX_API_URL` pruefen. + - Box API muss laufen. +- Buttons ohne Wirkung: + - API-Command-Namen in `docs/api.md` pruefen. diff --git a/docs/offline-scenarios.md b/docs/offline-scenarios.md index 09046c2..22ec19c 100644 --- a/docs/offline-scenarios.md +++ b/docs/offline-scenarios.md @@ -1,57 +1,57 @@ -# Offline Scenarios +# Offline-Szenarien -## Purpose -Simulate online/offline behavior in the virtual box and verify status fields. +## Zweck +Offline/Online-Verhalten in der virtuellen Box pruefen. -## Prerequisites -- Box running on `http://127.0.0.1:8000` +## Voraussetzungen +- Box laeuft auf `http://127.0.0.1:8000` -## Step-by-step -### WiFi offline (no profiles) +## Schritt-fuer-Schritt +### WLAN offline (keine Profile) ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_reset"}' ``` -Expected: +Erwartung: - `wifi_state = WIFI_UNCONFIGURED` - `connected_ssid = null` -### WiFi online +### WLAN online ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' ``` -Expected: +Erwartung: - `wifi_state = WIFI_ONLINE` - `connected_ssid = HomeWiFi` -### Spotify unavailable +### Spotify nicht konfiguriert ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"spotify_clear"}' ``` -Expected: +Erwartung: - `spotify_status = NOT_CONFIGURED` -### Spotify ready +### Spotify bereit ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}' ``` -Expected: +Erwartung: - `spotify_status = READY` -## Checklist -- ✅ WiFi reset shows UNCONFIGURED -- ✅ WiFi add profile shows ONLINE -- ✅ Spotify clear shows NOT_CONFIGURED -- ✅ Spotify set shows READY +## Checkliste +- ✅ WLAN-Reset zeigt UNCONFIGURED +- ✅ WLAN-Profil zeigt ONLINE +- ✅ Spotify clear zeigt NOT_CONFIGURED +- ✅ Spotify set zeigt READY ## Troubleshooting -- Status not changing: - - Ensure `/status` is polled after sending commands. - - Check that Box is running and secrets file is writable. +- Status aendert sich nicht: + - `/status` nach Commands abfragen. + - Schreibrechte auf `box/data/secrets.enc` pruefen. diff --git a/docs/playback-testing.md b/docs/playback-testing.md index 20b97ca..ee6af5b 100644 --- a/docs/playback-testing.md +++ b/docs/playback-testing.md @@ -1,65 +1,62 @@ -# Playback Testing +# Playback-Tests -## Purpose -Test NFC playback, recursive media order, resume, and playback controls. +## Zweck +Tests fuer NFC-Playback, Rekursion, Resume und Buttons. -## Prerequisites -- Box running: `python3 box/main.py run` -- Media in `box/data/media/` +## Voraussetzungen +- Box laeuft: `python3 box/main.py run` +- Medien unter `box/data/media/` -## Step-by-step -### NFC simulation -Start playback: +## Schritt-fuer-Schritt +### NFC-Simulation +Start: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' ``` -Stop playback: +Stop: ``` curl -X POST http://127.0.0.1:8000/command \ -H "Content-Type: application/json" \ -d '{"command":"nfc_off","payload":{"uid":"UID_1"}}' ``` -### Resume tests +### Resume 1) `nfc_on UID_1` -2) Wait or use `tick` in CLI +2) Warten (Auto-Tick) 3) `nfc_off UID_1` -4) `nfc_on UID_1` again -Expected: position resumes from saved state. +4) `nfc_on UID_1` +Erwartung: Resume nutzt gespeicherte Position. -### Tag switching +### Tag-Wechsel 1) `nfc_on UID_1` 2) `nfc_on UID_2` 3) `nfc_on UID_1` -Expected: UID_1 resumes from previous position. +Erwartung: UID_1 resumed. -### Recursive order -- Order is depth-first, folders first, lexicographic. -- Change folder names or nesting to see ordering changes. +### Rekursive Reihenfolge +- Ordner werden DFS traversiert (Unterordner zuerst, dann Dateien). +- Sortierung lexikografisch. -### End of media behavior -- When the last file ends: - - Playback stops - - Position saved at end - - State becomes IDLE +### Ende-des-Mediums +- Letzte Datei endet -> Playback stoppt, Position wird am Ende gespeichert. ### Buttons - `next`: file_index + 1 -- `prev`: if position > 3s then position=0, else file_index - 1 -- `play_pause`: toggle PLAYING/PAUSED -- `volume_up` / `volume_down`: change volume within 0..max +- `prev`: wenn position > 3s -> position=0, sonst file_index - 1 +- `play_pause`: toggelt PLAYING/PAUSED +- `volume_up` / `volume_down`: innerhalb 0..max -## Checklist -- ✅ Resume works for same UID -- ✅ Tag switching preserves per-UID resume -- ✅ Recursive order matches folder structure -- ✅ End-of-media stops playback and saves +## Checkliste +- ✅ Resume pro UID +- ✅ Tag-Wechsel speichert und resumed +- ✅ Rekursion entspricht Ordnerstruktur +- ✅ Ende-des-Mediums stoppt und speichert ## Troubleshooting -- No playback starts: - - Check tags in `box/data/box.json`. - - Ensure files exist under `box/data/media`. -- Duration is 0: - - Filenames not matched; mock uses hash-based durations for unknown names. +- Kein Playback: + - Tags in `box/data/box.json` pruefen. + - Dateien in `box/data/media` pruefen. +- Duration = 0: + - Unbekannte Dateinamen -> Hash-Dauer, nicht 0. diff --git a/docs/security-storage.md b/docs/security-storage.md index ac681f1..30d5a80 100644 --- a/docs/security-storage.md +++ b/docs/security-storage.md @@ -1,60 +1,61 @@ # Security & Storage -## Purpose -Explain where data lives, what is encrypted, and how keys are derived. +## Zweck +Beschreibt Datenablage, Verschluesselung und Key-Ableitung. -## Prerequisites -- Box running at least once to create `box/data/` +## Voraussetzungen +- Box mindestens einmal gestartet -## Step-by-step -1) Start the box: +## Schritt-fuer-Schritt +1) Box starten: ``` python3 box/main.py run ``` -2) Inspect data files: +2) Datenordner pruefen: ``` ls -la box/data ``` -## Data layout +## Datenstruktur ``` box/data/ -├── box.json # box_id, settings, tags (plaintext) -├── state.json # resume, wifi state (plaintext) -├── media/ # audio files -└── secrets.enc # encrypted secrets (JSON inside) +├── box.json # box_id, settings, tags (Klartext) +├── state.json # resume, wifi state (Klartext) +├── media/ # Audiodateien +└── secrets.enc # verschluesselte Secrets ``` -## What is plaintext +## Klartext - box_id -- box name -- volume -- resume positions -- playback state -- media paths -- UI state +- Box-Name +- Lautstaerke +- Resume-Positionen +- Playback-State +- Media-Pfade +- UI-Zustaende -## What is encrypted -- WiFi profiles -- Spotify tokens -- Server tokens (future) +## Verschluesselt +- WLAN-Profile +- Spotify Tokens +- Server Tokens (api_token) +- secret_seed (Fingerprint-Seed) -## Key derivation +## Key-Ableitung - Key = PBKDF2-HMAC-SHA256(box_id + STATIC_SALT) -- Key is never stored on disk -- secrets.enc is not portable between boxes +- Key wird nie gespeichert +- secrets.enc ist nicht zwischen Boxen austauschbar -## Reset behavior -- WiFi reset: removes only wifi_profiles from secrets.enc -- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` +## Reset-Verhalten +- WLAN-Reset: nur wifi_profiles entfernen +- Factory-Reset: `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc` loeschen -## Checklist -- ✅ `secrets.enc` exists after WiFi/Spotify set -- ✅ `box.json` contains `box_id` -- ✅ No secrets appear in `/status` +## Checkliste +- ✅ `secrets.enc` existiert nach WiFi/Spotify set +- ✅ `box.json` enthaelt `box_id` +- ✅ Keine Secrets in `/status` ## Troubleshooting -- secrets.enc not created: - - Ensure you ran `wifi_add_profile` or `spotify_set_tokens`. -- secrets.enc unreadable after reset: - - Factory reset creates a new box_id and new key. +- secrets.enc fehlt: + - WiFi/Spotify set ausfuehren. +- secrets.enc unlesbar nach Reset: + - Neue box_id erzeugt neuen Key. diff --git a/docs/setup-webgui.md b/docs/setup-webgui.md index dfdf0b7..7ad096c 100644 --- a/docs/setup-webgui.md +++ b/docs/setup-webgui.md @@ -1,40 +1,43 @@ -# Setup WebGUI +# Setup-WebGUI -## Purpose -Configure WiFi profiles in the virtual box without touching the API or CLI. +## Zweck +WLAN-Profile in der virtuellen Box konfigurieren, ohne CLI oder API. -## Prerequisites -- Box running: `python3 box/main.py run` +## Voraussetzungen +- Box laeuft: `python3 box/main.py run` -## Step-by-step -1) Open the Setup UI: +## Schritt-fuer-Schritt +1) Setup-UI oeffnen: ``` http://127.0.0.1:9000/setup ``` -2) Choose SSID and enter password. -3) Click "Verbinden". -4) Refresh `/status` to see `wifi_state` and `connected_ssid`. +2) SSID auswaehlen, Passwort eingeben. +3) "Verbinden" klicken. +4) Status pruefen: +``` +curl http://127.0.0.1:8000/status +``` -## Fields -- SSID: drop-down from mock scan -- Password: free text +## Felder +- SSID: Dropdown aus dem Mock-Scan +- Passwort: Freitext -## Expected results -- WiFi profile is saved (encrypted) -- `wifi_state` becomes `WIFI_ONLINE` -- `connected_ssid` matches selected SSID +## Erwartete Ergebnisse +- WLAN-Profil wird verschluesselt gespeichert +- `wifi_state` wird `WIFI_ONLINE` +- `connected_ssid` passt zur Auswahl -## Checklist -- ✅ Setup UI loads -- ✅ Submit stores a profile -- ✅ `/status` reflects `WIFI_ONLINE` +## Checkliste +- ✅ Setup-UI laedt +- ✅ Submit speichert Profil +- ✅ `/status` zeigt `WIFI_ONLINE` ## Troubleshooting -- Setup UI not reachable: - - Ensure Box is running and port `9000` is free. -- Submit does nothing: - - Check Box logs for errors; secrets file must be writable. +- Setup-UI nicht erreichbar: + - Port 9000 frei? Box laeuft? +- Submit ohne Wirkung: + - Logs pruefen, `box/data/secrets.enc` muss schreibbar sein. -## Notes -- Setup UI is write-enabled only when no profiles exist. -- When profiles exist, the page switches to read-only status. +## Hinweise +- Setup-UI ist nur schreibend, wenn keine Profile existieren. +- Bei vorhandenen Profilen ist die Seite read-only. diff --git a/docs/storage.md b/docs/storage.md index f1eb546..e9db25a 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -1,13 +1,13 @@ -# Storage (Legacy Notes) +# Storage (Legacy-Hinweis) -## Purpose -This file is kept for backward reference. Current storage documentation lives in `docs/security-storage.md`. +## Zweck +Dieses Dokument bleibt als Legacy-Hinweis bestehen. Die aktuelle Doku liegt in `docs/security-storage.md`. -## Prerequisites -- None +## Voraussetzungen +- Keine -## Step-by-step -- See `docs/security-storage.md`. +## Schritt-fuer-Schritt +- Siehe `docs/security-storage.md`. ## Troubleshooting -- If you ended up here from older links, update them to `docs/security-storage.md`. +- Falls Links hierher zeigen, bitte aktualisieren. diff --git a/docs/virtual-box.md b/docs/virtual-box.md index f67296b..6b358fe 100644 --- a/docs/virtual-box.md +++ b/docs/virtual-box.md @@ -1,22 +1,22 @@ -# Virtual Box Flow (IDE) +# Virtuelle Box (IDE) -## Purpose -Describe the full, hardware-free box lifecycle used in development. +## Zweck +Beschreibt den vollstaendigen, hardwarefreien Box-Flow fuer die IDE. -## Prerequisites +## Voraussetzungen - Python 3 -- Box started with `python3 box/main.py run` +- Box gestartet mit `python3 box/main.py run` -## Step-by-step -1) Box starts and creates `box/data/box.json` if missing. -2) WiFi state is derived from stored profiles: - - none -> `WIFI_UNCONFIGURED` -3) Setup UI available at `http://127.0.0.1:9000/setup`. -4) After profile submission, box becomes `WIFI_ONLINE`. -5) API available at `http://127.0.0.1:8000`. +## Schritt-fuer-Schritt +1) Box erzeugt `box_id` in `box/data/box.json`. +2) WLAN-Status wird aus Profilen abgeleitet: + - keine Profile -> `WIFI_UNCONFIGURED` +3) Setup UI unter `http://127.0.0.1:9000/setup`. +4) Nach Submit wird `WIFI_ONLINE`. +5) API unter `http://127.0.0.1:8000`. ## Troubleshooting -- Setup UI not reachable: - - Ensure port 9000 is free and Box is running. -- API not reachable: - - Ensure port 8000 is free and Box is running. +- Setup UI nicht erreichbar: + - Port 9000 frei? Box laeuft? +- API nicht erreichbar: + - Port 8000 frei? Box laeuft? diff --git a/gui/src/api.js b/gui/src/api.js index 344c157..63cca30 100644 --- a/gui/src/api.js +++ b/gui/src/api.js @@ -1,6 +1,7 @@ // src/api.js const API_BASE = import.meta.env.VITE_BOX_API_URL || "http://10.10.10.23:8000"; +const API_TOKEN = import.meta.env.VITE_BOX_API_TOKEN || ""; export async function getStatus() { const response = await fetch(`${API_BASE}/status`); @@ -12,9 +13,13 @@ export async function getStatus() { export async function sendCommand(command, payload = {}) { console.log("GUI -> API", command, payload); + const headers = { "Content-Type": "application/json" }; + if (API_TOKEN) { + headers["X-API-Token"] = API_TOKEN; + } const response = await fetch(`${API_BASE}/command`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ command, payload }), }); if (!response.ok) { diff --git a/server/__pycache__/storage.cpython-312.pyc b/server/__pycache__/storage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef876c2ec03255a33eba3c97b1ac3fac7ec46e9d GIT binary patch literal 3221 zcma(TOKcn0@$HXX{$>4a%A&MV%POh zdGmQY^X9YjO(YT|u>Lcn>3{GO@+}^`B{UJHK>@i*RFWbpqq12h$EH{YaW2cHIN13t zpA%AoOB1rboImAvabH%<1yTWqXaY-qMO6PaqKbFe=Dl}X-$?~sUI2JOkCzIqbIDNQ z*m;e9s?iBkS2fAB@>E&Uq{Tdy7V>Kn)s}SAqU)32;YCTt2`Pq=w-igy8|ECG1FB*v z>8xU!aNs{wtc$R7Gsb$l^<{VCng^z_0LV?EkrV@-vLAzg8mF?Sfa+r0KE}J409@Zb z=HKUvE*1c8(8WS2UJb)3f~WORGU^20$*+B+rSnvEIB>x6fj6?QI-y0~SkmY+)eXxL zOz=c=IK+dCIyEiX)HK5hfIls?3&^=ug=#XqkqKTGqN<*@egk*DCV`vtPB^VBD+_v7 zw{*>%D+GZf6{Wenp*aC%S(mN+CCx|*jws89lG9|_3CeOVudZYf56klJR+Mau6O-kR z_mySZ4gP#d8UV?Y(J7kGTT_>^im`MFqM}WiZsev~L7QA&ry>ZtAOwi{27o`3e}!XL z&)SE2t9-8=ie7!+?&`VvfgS6wHplLT(hADU@zq0NR8g4oSf@@LM-Ykx`ifm|P?> za^1B~k}NzY#7#3NGiKg^#1iCX%rMsBO)b0Vhzp9T$tna6?~^1NhYcTEH*`yu3%wnm zpY*8sMKcQE3bFeV+u_~5*EaY+2VJkz4A8w51H`rQBHRu(l8ndc*fXuv?jRAb=uB28 z=ZV!SaY8b_PN^pTf?EU6ORCIW_D;(_Z!a-ADX~Q+gW-N=c~+bu^Sgxn7JMjiCEkj7 zVnyD+5meuEI)}YGiwA$l-_g=q zZ(zMlCvbqy%lru*;1A}3yIx>hNGH$w^8(4R_eOUkcd$}n71^TO^lnsnz|WIpbnc0; zVl7Tgf5|waW~B2fG%SkobqEdPCVo8k;mqtu@1CI;KZ>bO(MH;Z0Hf!&jigBNx8th1 zicRr@B*QcTl8#SXgSKKiJXV_{Wb=w@IsvLFs%&X%&@OdL%b8A4H+2I#r;*ki9-Cpa z>tM<{+>&NdOtGXuhgzS?I%0PKwFFvswpSZVrKR!{7zg3OCy}{YX zlN&yJaJVvCp8fpn_aq#MUO%_--e+fOJ&7&#?`IzLB<-#vwZu>C!-?A9@HT&2yd~}= zzGAD=$?D*#T7P_NtTJ96-#)SPes$nPwST&HbZE<}td-Yx!uOQw@O1U)&uRm3SLI^) z;`Z6$`F`EU%!HND9q@F7xQ~6YTx`>c6g`$50aD~+SiFCYDDwjUL z{J1aF6@1i74h??h+v4gyB+_FKk5(?1FWd3KO1vDmhej$!*{~B*WxhObj~(9|o8BFp zwxw70q}O+)*ISH7F|jZDh=`GBor4=U_(ue``VkU{YzD6f_r%1mnD|1iWXhSH-h0Y} zq3N$r+!qu75YPVDh=Zhwnf2G1qwgGpN9XA9=5&by={C_lO9UL1d_8J!;-NeD`8fmbM zFwyXJ3;jQgM}%`sQye9eVaG4aYCheZ=N4DMR+#4~jswjQhW008&7@dVjvt4YtiI6l z&stx`){g!+JE&{H(^fqZPc=uy)AW}hhm~%kb9Ihkn17O2|4l}}C83986)tU int: + return int(time.time()) + + +def _validate_box_id(value: str) -> bool: + if not value.startswith("klangkiste-"): + return False + suffix = value.replace("klangkiste-", "", 1) + if len(suffix) != 10: + return False + return all(c.isdigit() or ("a" <= c <= "z") for c in suffix) + + +@app.post("/api/boxes/announce") +def announce(req: AnnounceRequest) -> dict[str, Any]: + if not _validate_box_id(req.box_id): + raise HTTPException(status_code=400, detail="invalid box_id") + if not req.fingerprint: + raise HTTPException(status_code=400, detail="fingerprint required") + + boxes = registry.load() + now = _now() + if req.box_id in boxes: + record = boxes[req.box_id] + if record.fingerprint and record.fingerprint != req.fingerprint: + raise HTTPException(status_code=400, detail="fingerprint mismatch") + record.last_seen = now + record.firmware_version = req.firmware_version + record.capabilities = req.capabilities + if record.state != "PAIRED": + record.state = "UNPAIRED" + else: + record = BoxRecord( + box_id=req.box_id, + fingerprint=req.fingerprint, + state="UNPAIRED", + first_seen=now, + last_seen=now, + firmware_version=req.firmware_version, + capabilities=req.capabilities, + ) + boxes[req.box_id] = record + registry.save(boxes) + return {"ok": True} + + +@app.post("/api/boxes/poll-pairing") +def poll_pairing(req: PollRequest) -> dict[str, Any]: + boxes = registry.load() + record = boxes.get(req.box_id) + if not record: + raise HTTPException(status_code=404, detail="unknown box") + if record.state == "PAIRED" and record.api_token: + return {"paired": True, "api_token": record.api_token} + return {"paired": False} + + +@app.post("/api/boxes/pair") +def pair_box(req: PairRequest) -> dict[str, Any]: + boxes = registry.load() + record = boxes.get(req.box_id) + if not record: + raise HTTPException(status_code=404, detail="unknown box") + if record.state != "PAIRED": + record.state = "PAIRED" + record.api_token = secrets.token_hex(32) + boxes[req.box_id] = record + registry.save(boxes) + return {"paired": True, "api_token": record.api_token} + + +@app.get("/ui", response_class=HTMLResponse) +def ui() -> str: + boxes = _apply_offline(registry.load()) + unpaired = [b for b in boxes.values() if b.state != "PAIRED"] + paired = [b for b in boxes.values() if b.state == "PAIRED"] + + def _row(box: BoxRecord) -> str: + return ( + f"{box.box_id}{box.firmware_version}" + f"{box.last_seen}" + f"
" + f"" + f"" + f"
" + ) + + unpaired_rows = "".join(_row(b) for b in unpaired) or "Keine" + paired_rows = "".join( + f"{b.box_id}{b.firmware_version}{b.last_seen}" + for b in paired + ) or "Keine" + + return f""" + + + Klangkiste Server + +

Neue Boxen (UNPAIRED)

+ + + {unpaired_rows} +
box_idfirmwarelast_seenaction
+

Gepaarte Boxen

+ + + {paired_rows} +
box_idfirmwarelast_seen
+ + +""" + + +def _apply_offline(boxes: dict[str, BoxRecord], threshold_seconds: int = 120) -> dict[str, BoxRecord]: + now = _now() + for record in boxes.values(): + if now - record.last_seen > threshold_seconds: + record.state = "OFFLINE" + registry.save(boxes) + return boxes + + +@app.post("/ui/pair") +def ui_pair(box_id: str = Form(...)) -> RedirectResponse: + pair_box(PairRequest(box_id=box_id)) + return RedirectResponse(url="/ui", status_code=303) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=7000, log_level="info") diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..b240d3d --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn==0.30.6 +pydantic==2.9.2 diff --git a/server/storage.py b/server/storage.py new file mode 100644 index 0000000..1de0a94 --- /dev/null +++ b/server/storage.py @@ -0,0 +1,60 @@ +"""Server-side storage for box registry.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass +class BoxRecord: + box_id: str + fingerprint: str + state: str + first_seen: int + last_seen: int + firmware_version: str + capabilities: dict[str, Any] + api_token: str | None = None + + +class BoxRegistry: + def __init__(self, base_dir: Path) -> None: + self._path = base_dir / "boxes.json" + + def load(self) -> dict[str, BoxRecord]: + if not self._path.exists(): + return {} + raw = json.loads(self._path.read_text(encoding="utf-8")) + result: dict[str, BoxRecord] = {} + for box_id, data in raw.items(): + if not isinstance(data, dict): + continue + result[box_id] = BoxRecord( + box_id=box_id, + fingerprint=str(data.get("fingerprint", "")), + state=str(data.get("state", "UNPAIRED")), + first_seen=int(data.get("first_seen", 0)), + last_seen=int(data.get("last_seen", 0)), + firmware_version=str(data.get("firmware_version", "")), + capabilities=dict(data.get("capabilities", {})), + api_token=data.get("api_token"), + ) + return result + + def save(self, records: dict[str, BoxRecord]) -> None: + data = { + box_id: { + "fingerprint": rec.fingerprint, + "state": rec.state, + "first_seen": rec.first_seen, + "last_seen": rec.last_seen, + "firmware_version": rec.firmware_version, + "capabilities": rec.capabilities, + "api_token": rec.api_token, + } + for box_id, rec in records.items() + } + self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")