Nice Work

This commit is contained in:
2026-01-13 19:44:02 +00:00
parent b503b372b4
commit 0718377b27
144 changed files with 15587 additions and 2132 deletions
Binary file not shown.
+82
View File
@@ -27,6 +27,7 @@ def create_app(
spotify_backend: SpotifyBackend,
pairing_manager: PairingManager,
persist_state: Callable[[], None],
factory_reset: Callable[[], None],
) -> FastAPI:
app = FastAPI()
app.add_middleware(
@@ -48,6 +49,11 @@ def create_app(
"connected_ssid": snapshot.connected_ssid,
"wifi_profiles_count": snapshot.wifi_profiles_count,
"spotify_status": spotify_backend.status,
"last_nfc": {
"uid": state.last_nfc_uid,
"known": state.last_nfc_known,
},
"last_nfc_at": state.last_nfc_at,
"playback_state": {
"state": state.state.value,
"active_uid": state.active_uid,
@@ -59,8 +65,47 @@ def create_app(
"volume": state.volume,
"last_error": state.last_error,
},
"blocked_tags": controller.config.get("blocked_tags", []),
}
@app.get("/local-tags")
def local_tags(http_request: Request) -> dict[str, Any]:
if not _is_authorized(pairing_manager, http_request):
raise HTTPException(status_code=403, detail="pairing required")
tags = controller.config.get("tags", {})
result = []
if isinstance(tags, dict):
for uid, tag in tags.items():
if not isinstance(tag, dict):
continue
path_value = tag.get("path")
if not isinstance(path_value, str):
continue
root = controller._resolve_path(path_value)
files = []
total_size = 0
if root.exists() and root.is_dir():
for file_path in root.rglob("*"):
if not file_path.is_file():
continue
rel_path = file_path.relative_to(root).as_posix()
files.append(rel_path)
try:
total_size += file_path.stat().st_size
except OSError:
continue
result.append(
{
"uid": uid,
"path": path_value,
"media_exists": bool(files),
"file_count": len(files),
"total_size": total_size,
"files": files,
}
)
return {"tags": result}
@app.post("/command")
def command(request: CommandRequest, http_request: Request) -> dict[str, Any]:
cmd = request.command
@@ -124,6 +169,42 @@ def create_app(
spotify_backend.set_tokens(access_token, refresh_token, expires_at, account_id)
elif cmd == "spotify_clear":
spotify_backend.clear()
elif cmd == "unpair":
pairing_manager.unpair(reset_state=True)
elif cmd == "tag_assign":
uid = payload.get("uid")
media_path = payload.get("media_path")
if not isinstance(uid, str) or not isinstance(media_path, str):
raise HTTPException(status_code=400, detail="uid and media_path required")
controller.set_tag(uid, media_path)
elif cmd == "tag_remove":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.remove_tag(uid)
elif cmd == "tag_block":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.block_tag(uid)
elif cmd == "tag_unblock":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.unblock_tag(uid)
elif cmd == "export_tag":
uid = payload.get("uid")
target_folder = payload.get("target_folder")
if not isinstance(uid, str) or not isinstance(target_folder, str):
raise HTTPException(status_code=400, detail="uid and target_folder required")
if "/" in target_folder or "\\" in target_folder or not target_folder.strip():
raise HTTPException(status_code=400, detail="invalid target_folder")
ok = controller.export_tag_media(uid, target_folder.strip())
if not ok:
raise HTTPException(status_code=500, detail="export failed")
elif cmd == "factory_reset":
factory_reset()
return {"ok": True, "restart_required": True}
else:
raise HTTPException(status_code=400, detail="unknown command")
@@ -139,6 +220,7 @@ def _requires_pairing(command: str) -> bool:
"wifi_reset",
"spotify_set_tokens",
"spotify_clear",
"factory_reset",
}
return command not in allowed_without_pairing
-43
View File
@@ -1,43 +0,0 @@
{
"box_id": "box-0001",
"settings": {
"max_volume": 80,
"default_volume": 40
},
"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"
}
},
"resume": {
"UID_1": {
"file_index": 0,
"position": 12
},
"UID_2": {
"file_index": 1,
"position": 0
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -2
View File
@@ -18,6 +18,9 @@ class AnnounceConfig:
server_url: str
firmware_version: str
capabilities: dict[str, Any]
api_port: int
setup_port: int
media_root: str
announce_interval_seconds: int = 30
polling_interval_seconds: int = 10
@@ -33,6 +36,9 @@ def build_payload(
"fingerprint": fingerprint,
"firmware_version": config.firmware_version,
"capabilities": config.capabilities,
"api_port": config.api_port,
"setup_port": config.setup_port,
"media_root": config.media_root,
}
@@ -67,8 +73,9 @@ async def announce_loop(
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)
response = await asyncio.to_thread(_post_json, announce_url, payload)
if response and response.get("force_unpair") is True:
pairing.unpair(reset_state=True)
await asyncio.sleep(config.announce_interval_seconds)
+175 -10
View File
@@ -2,11 +2,15 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import time
import shutil
from core.state import BoxState, RuntimeState
from player.base_player import BasePlayer
from core.media_sync import MediaSyncClient
import hashlib
@@ -17,11 +21,17 @@ class BoxController:
player: BasePlayer,
config: dict[str, Any],
config_path: str,
media_sync: MediaSyncClient | None = None,
) -> None:
self._state = state
self._player = player
self._config = config
self._config_path = Path(config_path)
self._media_sync = media_sync
@property
def config(self) -> dict[str, Any]:
return self._config
@property
def state(self) -> RuntimeState:
@@ -40,10 +50,24 @@ class BoxController:
print("error: server not reachable")
self._trigger_error("server_unreachable")
return None
blocked = self._config.get("blocked_tags")
if isinstance(blocked, list) and uid in blocked:
self._state.last_nfc_uid = uid
self._state.last_nfc_known = True
self._state.last_nfc_at = int(time.time())
self._trigger_error("tag_blocked")
return None
tag = self._resolve_tag(uid)
resolve_error = None
if tag is None:
tag, resolve_error = self._resolve_tag_from_server(uid)
self._state.last_nfc_uid = uid
self._state.last_nfc_known = tag is not None or resolve_error == "tag_blocked"
self._state.last_nfc_at = int(time.time())
if resolve_error == "tag_blocked":
self._trigger_error("tag_blocked")
return None
if tag is None:
print(f"error: uid '{uid}' not found in config tags")
self._trigger_error("uid_unmapped")
return None
self._state.last_error = None
@@ -51,11 +75,22 @@ class BoxController:
if not playlist:
path_value = tag.get("path", "-")
resolved = self._resolve_path(path_value)
print(
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
)
self._trigger_error("no_playable_files")
return None
if self._request_media_sync(uid):
playlist = self._build_playlist(tag)
if playlist:
self._state.last_error = None
else:
print(
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
)
self._trigger_error("no_playable_files")
return None
else:
print(
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
)
self._trigger_error("no_playable_files")
return None
if uid in self._state._resume:
file_index, position = self._state._resume[uid]
@@ -100,11 +135,81 @@ class BoxController:
def handle_nfc_off(self, uid: str) -> None:
if self._state.state == BoxState.ERROR:
return None
if self._state.last_nfc_uid == uid:
self._state.last_nfc_uid = None
self._state.last_nfc_known = None
self._state.last_nfc_at = None
if self._state.active_uid != uid:
return None
self._store_progress(uid)
self.stop()
def set_tag(self, uid: str, media_path: str) -> None:
tags = self._config.get("tags")
if not isinstance(tags, dict):
tags = {}
self._config["tags"] = tags
tags[uid] = {"type": "folder", "path": media_path}
if uid in self._state._resume:
self._state._resume.pop(uid, None)
self._state._resume[uid] = (0, 0)
if self._state.last_nfc_uid == uid:
self._state.last_nfc_known = True
self._save_config()
if not self._build_playlist(tags[uid]):
self._request_media_sync(uid)
def _remove_tag_data(self, uid: str) -> None:
tags = self._config.get("tags")
if not isinstance(tags, dict):
return None
removed = None
if uid in tags:
removed = tags.pop(uid)
self._state._resume.pop(uid, None)
if self._state.active_uid == uid:
self.stop()
if self._state.last_nfc_uid == uid:
self._state.last_nfc_uid = None
self._state.last_nfc_known = None
self._state.last_nfc_at = None
if isinstance(removed, dict):
path_value = removed.get("path")
if isinstance(path_value, str):
media_root = (self._config_path.parent.parent / "media").resolve()
resolved = self._resolve_path(path_value)
if media_root in resolved.parents:
shutil.rmtree(resolved, ignore_errors=True)
def remove_tag(self, uid: str) -> None:
self._remove_tag_data(uid)
blocked = self._config.get("blocked_tags")
if isinstance(blocked, list) and uid in blocked:
blocked = [value for value in blocked if value != uid]
self._config["blocked_tags"] = blocked
self._save_config()
def block_tag(self, uid: str) -> None:
blocked = self._config.get("blocked_tags")
if not isinstance(blocked, list):
blocked = []
if uid not in blocked:
blocked.append(uid)
self._config["blocked_tags"] = blocked
if self._state.active_uid == uid or self._state.last_nfc_uid == uid:
self._trigger_error("tag_blocked")
self._remove_tag_data(uid)
self._save_config()
def unblock_tag(self, uid: str) -> None:
blocked = self._config.get("blocked_tags")
if not isinstance(blocked, list):
return None
if uid in blocked:
blocked = [value for value in blocked if value != uid]
self._config["blocked_tags"] = blocked
self._save_config()
def _resolve_tag(self, uid: str) -> dict[str, Any] | None:
tags = self._config.get("tags", {})
if not isinstance(tags, dict):
@@ -116,6 +221,56 @@ class BoxController:
return None
return tag
def _save_config(self) -> None:
data = dict(self._config)
self._config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def _request_media_sync(self, uid: str) -> bool:
if not self._media_sync:
return False
ok = self._media_sync.request_tag_media(uid)
if ok:
print(f"info: media sync requested for uid '{uid}'")
else:
print(f"error: media sync request failed for uid '{uid}'")
return ok
def _resolve_tag_from_server(self, uid: str) -> tuple[dict[str, Any] | None, str | None]:
if not self._media_sync:
return None, None
media_path, error = self._media_sync.resolve_tag(uid)
if error:
return None, error
if not media_path:
return None, None
self.set_tag(uid, f"media/{uid}")
return self._resolve_tag(uid), None
def export_tag_media(self, uid: str, target_folder: str) -> bool:
if not self._media_sync:
return False
tag = self._resolve_tag(uid)
if not tag:
return False
path_value = tag.get("path")
if not isinstance(path_value, str):
return False
root = self._resolve_path(path_value)
if not root.exists() or not root.is_dir():
return False
for file_path in root.rglob("*"):
if not file_path.is_file():
continue
rel_path = file_path.relative_to(root).as_posix()
try:
data = file_path.read_bytes()
except OSError:
return False
if not self._media_sync.upload_media_file(target_folder, rel_path, data):
return False
return True
def tick(self, seconds: int) -> None:
if self._state.state == BoxState.ERROR:
return None
@@ -186,7 +341,10 @@ class BoxController:
def _resolve_path(self, path_value: str) -> Path:
path = Path(path_value)
if not path.is_absolute():
return (self._config_path.parent / path).resolve()
media_root = self._config_path.parent.parent / "media"
if path_value.startswith("media/"):
return (media_root / path_value.replace("media/", "", 1)).resolve()
return (media_root / path).resolve()
return path
def _store_progress(self, uid: str) -> None:
@@ -199,8 +357,6 @@ class BoxController:
durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220}
if name in durations:
return durations[name]
if not name.lower().endswith(".mp3"):
return 0
digest = hashlib.md5(name.encode("utf-8")).hexdigest()
seed = int(digest[:8], 16)
return 120 + (seed % 241)
@@ -288,3 +444,12 @@ class BoxController:
def trigger_error(self, code: str) -> None:
self._trigger_error(code)
def factory_reset(self) -> None:
self.stop()
self._state._resume.clear()
self._state.last_error = None
self._state.last_nfc_uid = None
self._state.last_nfc_known = None
self._state.last_nfc_at = None
self._config.clear()
+111
View File
@@ -0,0 +1,111 @@
"""Client to request media sync from the server."""
from __future__ import annotations
import json
import base64
from dataclasses import dataclass
from typing import Callable
from urllib import request, error
@dataclass
class MediaSyncClient:
server_url: str
box_id: str
token_provider: Callable[[], str | None]
timeout_seconds: int = 5
def request_tag_media(self, uid: str) -> bool:
token = self.token_provider()
if not token:
return False
endpoint = f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-sync"
payload = json.dumps({"uid": uid}).encode("utf-8")
req = request.Request(
endpoint,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"X-API-Token": token,
},
)
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
return 200 <= response.status < 300
except error.HTTPError:
return False
except error.URLError:
return False
def resolve_tag(self, uid: str) -> tuple[str | None, str | None]:
token = self.token_provider()
if not token:
return None, None
endpoint = (
f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/resolve-tag"
)
payload = json.dumps({"uid": uid}).encode("utf-8")
req = request.Request(
endpoint,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"X-API-Token": token,
},
)
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
body = response.read().decode("utf-8")
except error.HTTPError as http_error:
try:
detail = json.loads(http_error.read().decode("utf-8")).get("detail")
except Exception:
detail = None
if detail == "tag_blocked":
return None, "tag_blocked"
return None, None
except error.URLError:
return None, None
try:
data = json.loads(body)
except json.JSONDecodeError:
return None, None
media_path = data.get("media_path")
if isinstance(media_path, str) and media_path:
return media_path, None
return None, None
def upload_media_file(self, target_folder: str, rel_path: str, data: bytes) -> bool:
token = self.token_provider()
if not token:
return False
endpoint = (
f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-import"
)
payload = json.dumps(
{
"target_folder": target_folder,
"rel_path": rel_path,
"content_base64": base64.b64encode(data).decode("ascii"),
}
).encode("utf-8")
req = request.Request(
endpoint,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"X-API-Token": token,
},
)
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
return 200 <= response.status < 300
except error.HTTPError:
return False
except error.URLError:
return False
+9
View File
@@ -48,6 +48,15 @@ class PairingManager:
self._secret_store.save_api_token(token)
self.set_state(PairingState.PAIRED)
def unpair(self, reset_state: bool = False) -> None:
self._secret_store.clear_api_token()
self.set_state(PairingState.UNPAIRED)
if reset_state:
data = self._state_store.load()
data["pairing"] = {"state": PairingState.UNPAIRED.name}
data["resume"] = {}
self._state_store.save(data)
def _load(self) -> None:
data = self._state_store.load()
pairing = data.get("pairing") if isinstance(data.get("pairing"), dict) else {}
+3
View File
@@ -28,6 +28,9 @@ class RuntimeState:
file_index: Optional[int] = None
position: Optional[int] = None
last_error: Optional[str] = None
last_nfc_uid: Optional[str] = None
last_nfc_known: Optional[bool] = None
last_nfc_at: Optional[int] = None
_resume: dict[str, tuple[int, int]] = field(default_factory=dict)
_playlist: list[str] = field(default_factory=list)
max_volume: int = 100
+5 -29
View File
@@ -1,37 +1,13 @@
{
"box_id": "klangkiste-x1u8i4x5e4",
"box_id": "klangkiste-4w8u0c0y1i",
"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",
"tags": {},
"server_url": "http://10.10.10.23:5001",
"firmware_version": "0.1.0",
"capabilities": {
"nfc": true,
"audio": true,
"spotify": true
}
},
"blocked_tags": []
}
View File
View File
View File
View File
View File
View File
View File
+1 -1
View File
@@ -1 +1 @@
gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i
gAAAAABpZgUAtNvwWTJeaG5AVPuEFOUC321L1H9BVoQXpKCCAW209SvVYspQHMjZ3RamiRlwu9sr8UZA5zo0JBaNxSom2ZzgDUlUxpsa5zlL7ZHG9WGWIr1QFSBjJeBGeSTCu7gT4vu_3T-N_NWip2oaQUDY0VS8IHQ2damKibeV6JQJFqC2MJDVQKKbyGq25OtNsdvuYe7VDUbnA-Khs0TZJP0eSCeTlDwNGmrUPVDn8ec7X7-Y3prgV6tkIHvcpKE-XgXCc4uqievI0nCdWu0gp1HfXydpP-EQsrAN6ZJR264vdgIoq-s=
+3 -11
View File
@@ -1,14 +1,6 @@
{
"wifi": {
"connected_ssid": null
},
"resume": {
"UID_1": {
"file_index": 1,
"position": 79
}
},
"pairing": {
"state": "PAIRING_PENDING"
}
"state": "PAIRED"
},
"resume": {}
}
+97 -33
View File
@@ -7,6 +7,7 @@ import asyncio
import contextlib
import os
import sys
import shutil
from pathlib import Path
from typing import Any
@@ -17,6 +18,7 @@ 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.media_sync import MediaSyncClient
from core.pairing import PairingManager
from core.state import BoxState, RuntimeState
from player.mock_player import MockPlayer
@@ -25,7 +27,6 @@ from spotify.mock import MockSpotifyBackend
from storage.box_store import BoxStore
from storage.state_store import StateStore
from storage.secret_store import SecretStore
from storage.json_storage import JsonStorage
from wifi.mock import MockWifiBackend
@@ -34,11 +35,6 @@ def build_parser() -> argparse.ArgumentParser:
prog="klangkiste-box",
description="Klangkiste Box CLI (Phase 1a, local simulation)",
)
parser.add_argument(
"--config",
default="config/box_config.json",
help="Path to local box config JSON (legacy seed)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("status", help="Show current runtime status")
@@ -53,6 +49,7 @@ def build_parser() -> argparse.ArgumentParser:
tick_parser.add_argument("seconds", type=int, help="Seconds to advance")
subparsers.add_parser("run", help="Start interactive session + HTTP API")
subparsers.add_parser("factory-reset", help="Factory reset (delete all local data)")
return parser
@@ -81,17 +78,16 @@ def _state_from_data(
return state
def _normalize_tag_paths(tags: dict[str, Any]) -> dict[str, Any]:
normalized = {}
for uid, tag in tags.items():
if not isinstance(tag, dict):
continue
path = tag.get("path")
if isinstance(path, str) and path.startswith("../media/"):
tag = dict(tag)
tag["path"] = path.replace("../media/", "media/", 1)
normalized[uid] = tag
return normalized
def _prune_resume(state: RuntimeState, tags: dict[str, Any]) -> bool:
if not isinstance(tags, dict):
return False
valid_uids = {uid for uid, tag in tags.items() if isinstance(tag, dict)}
stale_uids = [uid for uid in state._resume.keys() if uid not in valid_uids]
if not stale_uids:
return False
for uid in stale_uids:
state._resume.pop(uid, None)
return True
def _print_status(state: RuntimeState) -> None:
@@ -119,12 +115,10 @@ def main() -> int:
parser = build_parser()
args = parser.parse_args()
config_path = Path(args.config)
if not config_path.is_absolute():
config_path = (Path(__file__).resolve().parent / config_path).resolve()
base_dir = Path(__file__).resolve().parent / "data"
base_dir.mkdir(parents=True, exist_ok=True)
(base_dir / "media").mkdir(parents=True, exist_ok=True)
media_root = Path(__file__).resolve().parent / "media"
media_root.mkdir(parents=True, exist_ok=True)
box_store = BoxStore(base_dir)
box_meta = box_store.load()
@@ -133,14 +127,12 @@ def main() -> int:
if "settings" not in box_meta:
box_meta["settings"] = {}
if "tags" not in box_meta:
legacy_storage = JsonStorage(str(config_path))
legacy_config = legacy_storage.load_config()
if isinstance(legacy_config.get("tags"), dict):
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")
box_meta["tags"] = {}
env_server_url = os.getenv("KLANGKISTE_SERVER_URL")
if env_server_url:
box_meta["server_url"] = env_server_url
elif "server_url" not in box_meta:
box_meta["server_url"] = "http://127.0.0.1:5001"
if "firmware_version" not in box_meta:
box_meta["firmware_version"] = "0.1.0"
if "capabilities" not in box_meta:
@@ -151,18 +143,27 @@ def main() -> int:
state_data = state_store.load()
state = _state_from_data(box_meta, state_data)
resume_pruned = _prune_resume(state, box_meta.get("tags", {}))
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)
media_sync = MediaSyncClient(
server_url=str(box_meta.get("server_url")),
box_id=state.box_id,
token_provider=pairing_manager.get_api_token,
)
controller = BoxController(
state=state,
player=MockPlayer(),
config=box_meta,
config_path=str(box_store.path),
media_sync=media_sync,
)
if resume_pruned:
_persist_resume(controller, state_store)
if args.command == "status":
_print_status(controller.state)
@@ -186,7 +187,16 @@ def main() -> int:
_persist_resume(controller, state_store)
return 0
if args.command == "factory-reset":
_factory_reset(controller, box_store, state_store, secrets, media_root)
print("Factory reset completed. Restart required.")
return 0
if args.command == "run":
announce_interval = _parse_int_env("KLANGKISTE_ANNOUNCE_INTERVAL", 30)
poll_interval = _parse_int_env("KLANGKISTE_POLL_INTERVAL", 10)
api_port = _parse_int_env("KLANGKISTE_API_PORT", 8000)
setup_port = _parse_int_env("KLANGKISTE_SETUP_PORT", 9000)
try:
asyncio.run(
_run_with_api(
@@ -201,6 +211,11 @@ def main() -> int:
server_url=str(box_meta.get("server_url")),
firmware_version=str(box_meta.get("firmware_version")),
capabilities=dict(box_meta.get("capabilities", {})),
api_port=api_port,
setup_port=setup_port,
media_root=str(media_root.resolve()),
announce_interval_seconds=announce_interval,
polling_interval_seconds=poll_interval,
),
)
)
@@ -218,6 +233,19 @@ def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]:
}
def _parse_int_env(name: str, default_value: int) -> int:
raw = os.getenv(name)
if not raw:
return default_value
try:
value = int(raw)
except ValueError:
return default_value
if value <= 0:
return default_value
return value
def _stdin_is_foreground() -> bool:
try:
if not sys.stdin.isatty():
@@ -237,6 +265,9 @@ async def _run_with_api(
secrets: SecretStore,
announce_config: AnnounceConfig,
) -> None:
base_dir = Path(__file__).resolve().parent / "data"
media_root = Path(__file__).resolve().parent / "media"
box_store = BoxStore(base_dir)
app = create_app(
controller,
lifecycle,
@@ -244,12 +275,13 @@ async def _run_with_api(
spotify_backend,
pairing_manager,
lambda: _persist_resume(controller, storage),
lambda: _factory_reset(controller, box_store, storage, secrets, media_root),
)
api_server = uvicorn.Server(
uvicorn.Config(
app,
host="0.0.0.0",
port=8000,
port=announce_config.api_port,
log_level="warning",
access_log=False,
)
@@ -259,7 +291,7 @@ async def _run_with_api(
uvicorn.Config(
setup_app,
host="0.0.0.0",
port=9000,
port=announce_config.setup_port,
log_level="warning",
access_log=False,
)
@@ -285,7 +317,17 @@ async def _run_with_api(
try:
if _stdin_is_foreground():
await _run_session(controller, storage)
await _run_session(
controller,
storage,
lambda: _factory_reset(
controller,
box_store,
storage,
secrets,
media_root,
),
)
api_server.should_exit = True
setup_server.should_exit = True
await api_task
@@ -309,7 +351,7 @@ async def _run_with_api(
async def _run_session(
controller: BoxController, storage: StateStore
controller: BoxController, storage: StateStore, factory_reset: callable
) -> None:
print("Klangkiste Box - Interactive Mode")
print("Type 'help' for commands.")
@@ -339,6 +381,10 @@ async def _run_session(
if command == "status":
_print_status(controller.state)
continue
if command == "factory-reset":
factory_reset()
print("Factory reset completed. Restart required.")
break
if command == "nfc":
if len(args) != 1:
print("error: usage: nfc <UID>")
@@ -414,6 +460,23 @@ def _persist_resume(
storage.save(data)
def _factory_reset(
controller: BoxController,
box_store: BoxStore,
state_store: StateStore,
secrets: SecretStore,
media_root: Path,
) -> None:
controller.factory_reset()
if box_store.path.exists():
box_store.path.unlink()
if state_store.path.exists():
state_store.path.unlink()
secrets.clear_all()
shutil.rmtree(media_root, ignore_errors=True)
media_root.mkdir(parents=True, exist_ok=True)
def _print_help() -> None:
print("Commands:")
print(" nfc <UID> simulate NFC tag detection")
@@ -425,6 +488,7 @@ def _print_help() -> None:
print(" vol-up | lauter increase volume")
print(" vol-down | leiser decrease volume")
print(" status show current status")
print(" factory-reset delete all local data (restart required)")
print(" help show this help")
print(" exit | quit leave interactive mode")
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+6
View File
@@ -0,0 +1,6 @@
{
"name": "box",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
Binary file not shown.
+6
View File
@@ -65,6 +65,12 @@ class SecretStore:
return token
return None
def clear_api_token(self) -> None:
data = self.load_secrets()
if "api_token" in data:
data.pop("api_token")
self.save_secrets(data)
def clear_wifi_secrets(self) -> None:
data = self.load_secrets()
if "wifi_profiles" in data:
+141
View File
@@ -0,0 +1,141 @@
"""HTTP API for the Klangkiste Box (FastAPI)."""
from __future__ import annotations
from typing import Any, Callable
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from core.box_controller import BoxController
from storage.box_storage import BoxStorage
from wifi.manager import WifiManager
from spotify.manager import SpotifyManager
class CommandRequest(BaseModel):
command: str
payload: dict[str, Any] = {}
def create_app(
controller: BoxController,
persist_resume: Callable[[], None],
wifi_manager: WifiManager,
spotify_manager: SpotifyManager,
storage: BoxStorage,
) -> FastAPI:
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/status")
def status() -> dict[str, Any]:
state = controller.state
return {
"box_id": state.box_id,
"network_status": wifi_manager.network_status,
"connected_ssid": wifi_manager.connected_ssid,
"wifi_profiles_count": wifi_manager.profiles_count,
"spotify_status": spotify_manager.status,
"server_status": _server_status(storage),
"playback_status": {
"state": state.state.value,
"active_uid": state.active_uid,
"playback_root": state.playback_path,
"current_file": state.current_media,
"file_index": state.file_index,
"position": state.position,
"duration": state.current_duration,
"volume": state.volume,
"last_error": state.last_error,
},
}
@app.post("/command")
def command(request: CommandRequest) -> dict[str, Any]:
cmd = request.command
payload = request.payload or {}
print(f"API command: {cmd} payload={payload}")
if cmd == "nfc_on":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.handle_nfc_on(uid)
elif cmd == "nfc_off":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.handle_nfc_off(uid)
elif cmd == "play_pause":
controller.play_pause()
elif cmd == "next":
controller.next_track()
elif cmd == "prev":
controller.prev_track()
elif cmd == "vol_up":
controller.volume_up()
elif cmd == "vol_down":
controller.volume_down()
elif cmd == "stop":
if controller.state.active_uid:
controller.handle_nfc_off(controller.state.active_uid)
else:
controller.stop()
elif cmd == "trigger_error":
error_type = payload.get("type")
if not isinstance(error_type, str):
raise HTTPException(status_code=400, detail="type is required")
controller.trigger_error(error_type)
elif cmd == "wifi_add_profile":
ssid = payload.get("ssid")
password = payload.get("password")
priority = payload.get("priority", 0)
if not isinstance(ssid, str) or not isinstance(password, str):
raise HTTPException(status_code=400, detail="ssid and password required")
if not isinstance(priority, int):
priority = 0
wifi_manager.add_profile(ssid, password, priority)
elif cmd == "wifi_remove_profile":
ssid = payload.get("ssid")
if not isinstance(ssid, str):
raise HTTPException(status_code=400, detail="ssid is required")
wifi_manager.remove_profile(ssid)
elif cmd == "wifi_reset":
wifi_manager.reset()
elif cmd == "spotify_set_tokens":
access_token = payload.get("access_token")
refresh_token = payload.get("refresh_token")
expires_at = payload.get("expires_at")
account_id = payload.get("account_id")
if not isinstance(access_token, str) or not isinstance(refresh_token, str):
raise HTTPException(status_code=400, detail="tokens are required")
if not isinstance(expires_at, int):
raise HTTPException(status_code=400, detail="expires_at is required")
if account_id is not None and not isinstance(account_id, str):
raise HTTPException(status_code=400, detail="account_id must be string")
spotify_manager.set_tokens(access_token, refresh_token, expires_at, account_id)
elif cmd == "spotify_clear":
spotify_manager.clear()
else:
raise HTTPException(status_code=400, detail="unknown command")
persist_resume()
return {"ok": True}
return app
def _server_status(storage: BoxStorage) -> str:
box_meta = storage.load_box_meta()
if box_meta.get("server_reachable") is False:
return "UNREACHABLE"
if box_meta.get("server_reachable") is True:
return "OK"
return "UNKNOWN"
+237
View File
@@ -0,0 +1,237 @@
"""HTTP API (FastAPI) for the Box."""
from __future__ import annotations
from typing import Any, Callable
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
class CommandRequest(BaseModel):
command: str
payload: dict[str, Any] = {}
def create_app(
controller: BoxController,
lifecycle: NetworkLifecycle,
wifi_backend: WifiBackend,
spotify_backend: SpotifyBackend,
pairing_manager: PairingManager,
persist_state: Callable[[], None],
factory_reset: Callable[[], None],
) -> FastAPI:
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/status")
def status() -> dict[str, Any]:
snapshot = lifecycle.snapshot()
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,
"wifi_profiles_count": snapshot.wifi_profiles_count,
"spotify_status": spotify_backend.status,
"last_nfc": {
"uid": state.last_nfc_uid,
"known": state.last_nfc_known,
},
"last_nfc_at": state.last_nfc_at,
"playback_state": {
"state": state.state.value,
"active_uid": state.active_uid,
"playback_root": state.playback_path,
"current_file": state.current_media,
"file_index": state.file_index,
"position": state.position,
"duration": state.current_duration,
"volume": state.volume,
"last_error": state.last_error,
},
"blocked_tags": controller.config.get("blocked_tags", []),
}
@app.get("/local-tags")
def local_tags(http_request: Request) -> dict[str, Any]:
if not _is_authorized(pairing_manager, http_request):
raise HTTPException(status_code=403, detail="pairing required")
tags = controller.config.get("tags", {})
result = []
if isinstance(tags, dict):
for uid, tag in tags.items():
if not isinstance(tag, dict):
continue
path_value = tag.get("path")
if not isinstance(path_value, str):
continue
root = controller._resolve_path(path_value)
files = []
total_size = 0
if root.exists() and root.is_dir():
for file_path in root.rglob("*"):
if not file_path.is_file():
continue
rel_path = file_path.relative_to(root).as_posix()
files.append(rel_path)
try:
total_size += file_path.stat().st_size
except OSError:
continue
result.append(
{
"uid": uid,
"path": path_value,
"media_exists": bool(files),
"file_count": len(files),
"total_size": total_size,
"files": files,
}
)
return {"tags": result}
@app.post("/command")
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):
raise HTTPException(status_code=400, detail="uid is required")
controller.handle_nfc_on(uid)
elif cmd == "nfc_off":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.handle_nfc_off(uid)
elif cmd == "play_pause":
controller.play_pause()
elif cmd == "next":
controller.next_track()
elif cmd == "prev":
controller.prev_track()
elif cmd in {"vol_up", "volume_up"}:
controller.volume_up()
elif cmd in {"vol_down", "volume_down"}:
controller.volume_down()
elif cmd == "stop":
if controller.state.active_uid:
controller.handle_nfc_off(controller.state.active_uid)
else:
controller.stop()
elif cmd == "trigger_error":
error_type = payload.get("type")
if not isinstance(error_type, str):
raise HTTPException(status_code=400, detail="type is required")
controller.trigger_error(error_type)
elif cmd == "wifi_add_profile":
ssid = payload.get("ssid")
password = payload.get("password")
priority = payload.get("priority", 0)
if not isinstance(ssid, str) or not isinstance(password, str):
raise HTTPException(status_code=400, detail="ssid and password required")
if not isinstance(priority, int):
priority = 0
wifi_backend.add_profile(ssid, password, priority)
elif cmd == "wifi_reset":
wifi_backend.reset()
elif cmd == "spotify_set_tokens":
access_token = payload.get("access_token")
refresh_token = payload.get("refresh_token")
expires_at = payload.get("expires_at")
account_id = payload.get("account_id")
if not isinstance(access_token, str) or not isinstance(refresh_token, str):
raise HTTPException(status_code=400, detail="tokens are required")
if not isinstance(expires_at, int):
raise HTTPException(status_code=400, detail="expires_at is required")
if account_id is not None and not isinstance(account_id, str):
raise HTTPException(status_code=400, detail="account_id must be string")
spotify_backend.set_tokens(access_token, refresh_token, expires_at, account_id)
elif cmd == "spotify_clear":
spotify_backend.clear()
elif cmd == "unpair":
pairing_manager.unpair(reset_state=True)
elif cmd == "tag_assign":
uid = payload.get("uid")
media_path = payload.get("media_path")
if not isinstance(uid, str) or not isinstance(media_path, str):
raise HTTPException(status_code=400, detail="uid and media_path required")
controller.set_tag(uid, media_path)
elif cmd == "tag_remove":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.remove_tag(uid)
elif cmd == "tag_block":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.block_tag(uid)
elif cmd == "tag_unblock":
uid = payload.get("uid")
if not isinstance(uid, str):
raise HTTPException(status_code=400, detail="uid is required")
controller.unblock_tag(uid)
elif cmd == "export_tag":
uid = payload.get("uid")
target_folder = payload.get("target_folder")
if not isinstance(uid, str) or not isinstance(target_folder, str):
raise HTTPException(status_code=400, detail="uid and target_folder required")
if "/" in target_folder or "\\" in target_folder or not target_folder.strip():
raise HTTPException(status_code=400, detail="invalid target_folder")
ok = controller.export_tag_media(uid, target_folder.strip())
if not ok:
raise HTTPException(status_code=500, detail="export failed")
elif cmd == "factory_reset":
factory_reset()
return {"ok": True, "restart_required": True}
else:
raise HTTPException(status_code=400, detail="unknown command")
persist_state()
return {"ok": True}
return app
def _requires_pairing(command: str) -> bool:
allowed_without_pairing = {
"wifi_add_profile",
"wifi_reset",
"spotify_set_tokens",
"spotify_clear",
"factory_reset",
}
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
+16
View File
@@ -0,0 +1,16 @@
"""Audio backend interface (placeholder)."""
from __future__ import annotations
from typing import Protocol
class AudioBackend(Protocol):
def play(self, media_ref: str) -> None:
raise NotImplementedError
def pause(self) -> None:
raise NotImplementedError
def stop(self) -> None:
raise NotImplementedError
+19
View File
@@ -0,0 +1,19 @@
"""Mock audio backend (no real playback)."""
from __future__ import annotations
from audio.base import AudioBackend
class MockAudioBackend(AudioBackend):
def __init__(self) -> None:
self._current: str | None = None
def play(self, media_ref: str) -> None:
self._current = media_ref
def pause(self) -> None:
return None
def stop(self) -> None:
self._current = None
+100
View File
@@ -0,0 +1,100 @@
"""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]
api_port: int
setup_port: int
media_root: str
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,
"api_port": config.api_port,
"setup_port": config.setup_port,
"media_root": config.media_root,
}
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():
response = await asyncio.to_thread(_post_json, announce_url, payload)
if response and response.get("force_unpair") is True:
pairing.unpair(reset_state=True)
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)
+455
View File
@@ -0,0 +1,455 @@
"""Central orchestration placeholder for the Box runtime."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
import time
import shutil
from core.state import BoxState, RuntimeState
from player.base_player import BasePlayer
from core.media_sync import MediaSyncClient
import hashlib
class BoxController:
def __init__(
self,
state: RuntimeState,
player: BasePlayer,
config: dict[str, Any],
config_path: str,
media_sync: MediaSyncClient | None = None,
) -> None:
self._state = state
self._player = player
self._config = config
self._config_path = Path(config_path)
self._media_sync = media_sync
@property
def config(self) -> dict[str, Any]:
return self._config
@property
def state(self) -> RuntimeState:
return self._state
def start(self) -> None:
"""Start the main loop (not implemented in Phase 1a)."""
return None
def handle_nfc_on(self, uid: str) -> None:
if self._state.state == BoxState.ERROR:
return None
if self._state.active_uid == uid and self._state.state != BoxState.IDLE:
return None
if self._config.get("server_reachable") is False:
print("error: server not reachable")
self._trigger_error("server_unreachable")
return None
blocked = self._config.get("blocked_tags")
if isinstance(blocked, list) and uid in blocked:
self._state.last_nfc_uid = uid
self._state.last_nfc_known = True
self._state.last_nfc_at = int(time.time())
self._trigger_error("tag_blocked")
return None
tag = self._resolve_tag(uid)
resolve_error = None
if tag is None:
tag, resolve_error = self._resolve_tag_from_server(uid)
self._state.last_nfc_uid = uid
self._state.last_nfc_known = tag is not None or resolve_error == "tag_blocked"
self._state.last_nfc_at = int(time.time())
if resolve_error == "tag_blocked":
self._trigger_error("tag_blocked")
return None
if tag is None:
return None
self._state.last_error = None
playlist = self._build_playlist(tag)
if not playlist:
path_value = tag.get("path", "-")
resolved = self._resolve_path(path_value)
if self._request_media_sync(uid):
playlist = self._build_playlist(tag)
if playlist:
self._state.last_error = None
else:
print(
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
)
self._trigger_error("no_playable_files")
return None
else:
print(
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
)
self._trigger_error("no_playable_files")
return None
if uid in self._state._resume:
file_index, position = self._state._resume[uid]
else:
file_index, position = 0, 0
if file_index < 0 or file_index >= len(playlist):
print(
f"error: stored progress points outside playlist for uid '{uid}'"
)
self._trigger_error("invalid_resume")
return None
if self._state.active_uid and self._state.active_uid != uid:
self._store_progress(self._state.active_uid)
self.stop()
media_ref = playlist[file_index]
duration = self._duration_for(media_ref)
self._state.active_uid = uid
self._state.playback_type = tag.get("type")
self._state.playback_path = tag.get("path")
self._state.current_file = Path(media_ref).name
self._state.current_duration = duration
self._state.file_index = file_index
self._state.position = position
self._state._playlist = playlist
self._player.play(media_ref)
self._state.current_media = media_ref
self._state.state = BoxState.PLAYING
def pause(self) -> None:
self._player.pause()
if self._state.state == BoxState.PLAYING:
self._state.state = BoxState.PAUSED
def stop(self) -> None:
self._player.stop()
self._clear_playback_state()
def handle_nfc_off(self, uid: str) -> None:
if self._state.state == BoxState.ERROR:
return None
if self._state.last_nfc_uid == uid:
self._state.last_nfc_uid = None
self._state.last_nfc_known = None
self._state.last_nfc_at = None
if self._state.active_uid != uid:
return None
self._store_progress(uid)
self.stop()
def set_tag(self, uid: str, media_path: str) -> None:
tags = self._config.get("tags")
if not isinstance(tags, dict):
tags = {}
self._config["tags"] = tags
tags[uid] = {"type": "folder", "path": media_path}
if uid in self._state._resume:
self._state._resume.pop(uid, None)
self._state._resume[uid] = (0, 0)
if self._state.last_nfc_uid == uid:
self._state.last_nfc_known = True
self._save_config()
if not self._build_playlist(tags[uid]):
self._request_media_sync(uid)
def _remove_tag_data(self, uid: str) -> None:
tags = self._config.get("tags")
if not isinstance(tags, dict):
return None
removed = None
if uid in tags:
removed = tags.pop(uid)
self._state._resume.pop(uid, None)
if self._state.active_uid == uid:
self.stop()
if self._state.last_nfc_uid == uid:
self._state.last_nfc_uid = None
self._state.last_nfc_known = None
self._state.last_nfc_at = None
if isinstance(removed, dict):
path_value = removed.get("path")
if isinstance(path_value, str):
media_root = (self._config_path.parent.parent / "media").resolve()
resolved = self._resolve_path(path_value)
if media_root in resolved.parents:
shutil.rmtree(resolved, ignore_errors=True)
def remove_tag(self, uid: str) -> None:
self._remove_tag_data(uid)
blocked = self._config.get("blocked_tags")
if isinstance(blocked, list) and uid in blocked:
blocked = [value for value in blocked if value != uid]
self._config["blocked_tags"] = blocked
self._save_config()
def block_tag(self, uid: str) -> None:
blocked = self._config.get("blocked_tags")
if not isinstance(blocked, list):
blocked = []
if uid not in blocked:
blocked.append(uid)
self._config["blocked_tags"] = blocked
if self._state.active_uid == uid or self._state.last_nfc_uid == uid:
self._trigger_error("tag_blocked")
self._remove_tag_data(uid)
self._save_config()
def unblock_tag(self, uid: str) -> None:
blocked = self._config.get("blocked_tags")
if not isinstance(blocked, list):
return None
if uid in blocked:
blocked = [value for value in blocked if value != uid]
self._config["blocked_tags"] = blocked
self._save_config()
def _resolve_tag(self, uid: str) -> dict[str, Any] | None:
tags = self._config.get("tags", {})
if not isinstance(tags, dict):
return None
tag = tags.get(uid)
if not isinstance(tag, dict):
return None
if "type" not in tag or "path" not in tag:
return None
return tag
def _save_config(self) -> None:
data = dict(self._config)
self._config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def _request_media_sync(self, uid: str) -> bool:
if not self._media_sync:
return False
ok = self._media_sync.request_tag_media(uid)
if ok:
print(f"info: media sync requested for uid '{uid}'")
else:
print(f"error: media sync request failed for uid '{uid}'")
return ok
def _resolve_tag_from_server(self, uid: str) -> tuple[dict[str, Any] | None, str | None]:
if not self._media_sync:
return None, None
media_path, error = self._media_sync.resolve_tag(uid)
if error:
return None, error
if not media_path:
return None, None
self.set_tag(uid, f"media/{uid}")
return self._resolve_tag(uid), None
def export_tag_media(self, uid: str, target_folder: str) -> bool:
if not self._media_sync:
return False
tag = self._resolve_tag(uid)
if not tag:
return False
path_value = tag.get("path")
if not isinstance(path_value, str):
return False
root = self._resolve_path(path_value)
if not root.exists() or not root.is_dir():
return False
for file_path in root.rglob("*"):
if not file_path.is_file():
continue
rel_path = file_path.relative_to(root).as_posix()
try:
data = file_path.read_bytes()
except OSError:
return False
if not self._media_sync.upload_media_file(target_folder, rel_path, data):
return False
return True
def tick(self, seconds: int) -> None:
if self._state.state == BoxState.ERROR:
return None
if self._state.state != BoxState.PLAYING:
return None
if self._state.position is None or self._state.file_index is None:
return None
if not self._state._playlist:
return None
if seconds <= 0:
return None
remaining = seconds
file_index = self._state.file_index
position = self._state.position
while remaining > 0 and file_index < len(self._state._playlist):
media_ref = self._state._playlist[file_index]
duration = self._duration_for(media_ref)
if duration <= 0:
break
time_left = duration - position
if remaining < time_left:
position += remaining
remaining = 0
else:
remaining -= time_left
file_index += 1
position = 0
if file_index >= len(self._state._playlist):
last_index = len(self._state._playlist) - 1
last_media = self._state._playlist[last_index]
last_duration = self._duration_for(last_media)
self._state.file_index = last_index
self._state.position = last_duration
if self._state.active_uid:
self._store_progress(self._state.active_uid)
self.stop()
return None
self._state.file_index = file_index
self._state.position = position
media_ref = self._state._playlist[file_index]
self._state.current_media = media_ref
self._state.current_file = Path(media_ref).name
self._state.current_duration = self._duration_for(media_ref)
def _build_playlist(self, tag: dict[str, Any]) -> list[str]:
path_value = tag.get("path")
if not isinstance(path_value, str):
return []
path = self._resolve_path(path_value)
if not path.exists() or not path.is_dir():
return []
return [str(p) for p in self._collect_files(path)]
def _collect_files(self, root: Path) -> list[Path]:
entries = list(root.iterdir())
dirs = sorted([p for p in entries if p.is_dir()], key=lambda p: p.name)
files = sorted([p for p in entries if p.is_file()], key=lambda p: p.name)
result: list[Path] = []
for directory in dirs:
result.extend(self._collect_files(directory))
result.extend(files)
return result
def _resolve_path(self, path_value: str) -> Path:
path = Path(path_value)
if not path.is_absolute():
media_root = self._config_path.parent.parent / "media"
if path_value.startswith("media/"):
return (media_root / path_value.replace("media/", "", 1)).resolve()
return (media_root / path).resolve()
return path
def _store_progress(self, uid: str) -> None:
file_index = self._state.file_index if self._state.file_index is not None else 0
position = self._state.position if self._state.position is not None else 0
self._state._resume[uid] = (file_index, position)
def _duration_for(self, media_ref: str) -> int:
name = Path(media_ref).name
durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220}
if name in durations:
return durations[name]
digest = hashlib.md5(name.encode("utf-8")).hexdigest()
seed = int(digest[:8], 16)
return 120 + (seed % 241)
def play_pause(self) -> None:
if self._state.state in {BoxState.IDLE, BoxState.ERROR}:
return None
if self._state.state == BoxState.PLAYING:
self._state.state = BoxState.PAUSED
return None
if self._state.state == BoxState.PAUSED:
self._state.state = BoxState.PLAYING
return None
def next_track(self) -> None:
if self._state.state in {BoxState.IDLE, BoxState.ERROR}:
return None
if not self._state._playlist:
return None
if self._state.file_index is None:
return None
next_index = self._state.file_index + 1
if next_index >= len(self._state._playlist):
if self._state.active_uid:
self._store_progress(self._state.active_uid)
self.stop()
return None
self._jump_to_index(next_index, 0)
self._state.state = BoxState.PLAYING
def prev_track(self, threshold_seconds: int = 3) -> None:
if self._state.state in {BoxState.IDLE, BoxState.ERROR}:
return None
if self._state.file_index is None or self._state.position is None:
return None
if self._state.position > threshold_seconds:
self._state.position = 0
return None
prev_index = max(self._state.file_index - 1, 0)
self._jump_to_index(prev_index, 0)
self._state.state = BoxState.PLAYING
def volume_up(self, step: int = 5) -> None:
if self._state.state == BoxState.ERROR:
return None
self._state.volume = min(self._state.volume + step, self._state.max_volume)
def volume_down(self, step: int = 5) -> None:
if self._state.state == BoxState.ERROR:
return None
self._state.volume = max(self._state.volume - step, 0)
def _jump_to_index(self, file_index: int, position: int) -> None:
if not self._state._playlist:
return None
if file_index < 0 or file_index >= len(self._state._playlist):
return None
media_ref = self._state._playlist[file_index]
self._state.file_index = file_index
self._state.position = position
self._state.current_media = media_ref
self._state.current_file = Path(media_ref).name
self._state.current_duration = self._duration_for(media_ref)
self._player.play(media_ref)
def _trigger_error(self, code: str) -> None:
if self._state.state in {BoxState.PLAYING, BoxState.PAUSED}:
self._player.pause()
self._state.state = BoxState.ERROR
self._state.last_error = code
self._player.play(f"system:error:{code}")
self._clear_playback_state()
def _clear_playback_state(self) -> None:
self._state.state = BoxState.IDLE
self._state.active_uid = None
self._state.playback_type = None
self._state.playback_path = None
self._state.current_media = None
self._state.current_file = None
self._state.current_duration = None
self._state.file_index = None
self._state.position = None
self._state._playlist = []
def trigger_error(self, code: str) -> None:
self._trigger_error(code)
def factory_reset(self) -> None:
self.stop()
self._state._resume.clear()
self._state.last_error = None
self._state.last_nfc_uid = None
self._state.last_nfc_known = None
self._state.last_nfc_at = None
self._config.clear()
+7
View File
@@ -0,0 +1,7 @@
"""Compatibility re-export for BoxController."""
from __future__ import annotations
from core.box_controller import BoxController
__all__ = ["BoxController"]
+30
View File
@@ -0,0 +1,30 @@
"""Box identity generation and management."""
from __future__ import annotations
import hashlib
import secrets
import string
PREFIX = "klangkiste-"
LETTERS = string.ascii_lowercase
DIGITS = string.digits
SUFFIX_LENGTH = 10
STATIC_APP_SALT = "klangkiste-static-app-salt-v1"
def generate_box_id() -> str:
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()
+52
View File
@@ -0,0 +1,52 @@
"""Network lifecycle state machine (logical)."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from wifi.base import WifiBackend
class WifiState(str, Enum):
WIFI_UNCONFIGURED = "WIFI_UNCONFIGURED"
WIFI_CONNECTING = "WIFI_CONNECTING"
WIFI_ONLINE = "WIFI_ONLINE"
WIFI_OFFLINE = "WIFI_OFFLINE"
@dataclass
class NetworkSnapshot:
wifi_state: WifiState
ip_address: str
connected_ssid: str | None
wifi_profiles_count: int
class NetworkLifecycle:
def __init__(self, wifi_backend: WifiBackend) -> None:
self._wifi = wifi_backend
self._wifi_state = WifiState.WIFI_UNCONFIGURED
self._ip_address = "192.168.4.1"
self.refresh()
def refresh(self) -> None:
if self._wifi.profiles_count == 0:
self._wifi_state = WifiState.WIFI_UNCONFIGURED
self._ip_address = "192.168.4.1"
return
if self._wifi.connected_ssid:
self._wifi_state = WifiState.WIFI_ONLINE
self._ip_address = "127.0.0.1"
return
self._wifi_state = WifiState.WIFI_OFFLINE
self._ip_address = "127.0.0.1"
def snapshot(self) -> NetworkSnapshot:
self.refresh()
return NetworkSnapshot(
wifi_state=self._wifi_state,
ip_address=self._ip_address,
connected_ssid=self._wifi.connected_ssid,
wifi_profiles_count=self._wifi.profiles_count,
)
+111
View File
@@ -0,0 +1,111 @@
"""Client to request media sync from the server."""
from __future__ import annotations
import json
import base64
from dataclasses import dataclass
from typing import Callable
from urllib import request, error
@dataclass
class MediaSyncClient:
server_url: str
box_id: str
token_provider: Callable[[], str | None]
timeout_seconds: int = 5
def request_tag_media(self, uid: str) -> bool:
token = self.token_provider()
if not token:
return False
endpoint = f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-sync"
payload = json.dumps({"uid": uid}).encode("utf-8")
req = request.Request(
endpoint,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"X-API-Token": token,
},
)
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
return 200 <= response.status < 300
except error.HTTPError:
return False
except error.URLError:
return False
def resolve_tag(self, uid: str) -> tuple[str | None, str | None]:
token = self.token_provider()
if not token:
return None, None
endpoint = (
f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/resolve-tag"
)
payload = json.dumps({"uid": uid}).encode("utf-8")
req = request.Request(
endpoint,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"X-API-Token": token,
},
)
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
body = response.read().decode("utf-8")
except error.HTTPError as http_error:
try:
detail = json.loads(http_error.read().decode("utf-8")).get("detail")
except Exception:
detail = None
if detail == "tag_blocked":
return None, "tag_blocked"
return None, None
except error.URLError:
return None, None
try:
data = json.loads(body)
except json.JSONDecodeError:
return None, None
media_path = data.get("media_path")
if isinstance(media_path, str) and media_path:
return media_path, None
return None, None
def upload_media_file(self, target_folder: str, rel_path: str, data: bytes) -> bool:
token = self.token_provider()
if not token:
return False
endpoint = (
f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-import"
)
payload = json.dumps(
{
"target_folder": target_folder,
"rel_path": rel_path,
"content_base64": base64.b64encode(data).decode("ascii"),
}
).encode("utf-8")
req = request.Request(
endpoint,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"X-API-Token": token,
},
)
try:
with request.urlopen(req, timeout=self.timeout_seconds) as response:
return 200 <= response.status < 300
except error.HTTPError:
return False
except error.URLError:
return False
+70
View File
@@ -0,0 +1,70 @@
"""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 unpair(self, reset_state: bool = False) -> None:
self._secret_store.clear_api_token()
self.set_state(PairingState.UNPAIRED)
if reset_state:
data = self._state_store.load()
data["pairing"] = {"state": PairingState.UNPAIRED.name}
data["resume"] = {}
self._state_store.save(data)
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)
+36
View File
@@ -0,0 +1,36 @@
"""State model for the Box runtime (placeholder)."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class BoxState(str, Enum):
IDLE = "IDLE"
PLAYING = "PLAYING"
PAUSED = "PAUSED"
ERROR = "ERROR"
@dataclass
class RuntimeState:
box_id: str
state: BoxState = BoxState.IDLE
current_media: Optional[str] = None
volume: int = 0
active_uid: Optional[str] = None
playback_type: Optional[str] = None
playback_path: Optional[str] = None
current_file: Optional[str] = None
current_duration: Optional[int] = None
file_index: Optional[int] = None
position: Optional[int] = None
last_error: Optional[str] = None
last_nfc_uid: Optional[str] = None
last_nfc_known: Optional[bool] = None
last_nfc_at: Optional[int] = None
_resume: dict[str, tuple[int, int]] = field(default_factory=dict)
_playlist: list[str] = field(default_factory=list)
max_volume: int = 100
View File
+12
View File
@@ -0,0 +1,12 @@
{
"box_id": "klangkiste-c1b9h2f3j5",
"settings": {},
"tags": {},
"server_url": "http://10.10.10.23:5001",
"firmware_version": "0.1.0",
"capabilities": {
"nfc": true,
"audio": true,
"spotify": true
}
}
+1
View File
@@ -0,0 +1 @@
gAAAAABpZpDxBsn-LkdKkkUukRVKtBoRtjK6EprtWis8WxJlqDqdybx_9ZtdZtPVcVEdFEgIWtmXJKRk9uAHVAFTAtnDiE_fxLRt4LHc3D7roHMehd853E4Dxb_DtWfLQ7XOYQ9Zsij1uF1w08v-WFiTfb4zOWq_usPeidr0Mo0BGD7V_R3LP9Vv1m61pZA--M8kwrMlHxiOGyn1QHGTagizuuk2IKuWwKmg_BeHfBeBndQuU-q6uaHfu9c68iAu1YU44mAemuYk3px4q31zEjgyqmJPVO7Tn93CgltVPGCYx7VhHhxfmbU=
+6
View File
@@ -0,0 +1,6 @@
{
"pairing": {
"state": "PAIRED"
},
"resume": {}
}
+10
View File
@@ -0,0 +1,10 @@
"""Base button interface."""
from __future__ import annotations
from typing import Protocol
class BaseButtons(Protocol):
def poll(self) -> str | None:
raise NotImplementedError
+10
View File
@@ -0,0 +1,10 @@
"""Base NFC reader interface."""
from __future__ import annotations
from typing import Protocol
class BaseNFC(Protocol):
def read_tag(self) -> str | None:
raise NotImplementedError
+20
View File
@@ -0,0 +1,20 @@
"""Mock buttons for Phase 1a."""
from __future__ import annotations
from collections import deque
from hardware.base_buttons import BaseButtons
class MockButtons(BaseButtons):
def __init__(self) -> None:
self._queue: deque[str] = deque()
def enqueue(self, button_id: str) -> None:
self._queue.append(button_id)
def poll(self) -> str | None:
if not self._queue:
return None
return self._queue.popleft()
+18
View File
@@ -0,0 +1,18 @@
"""Mock NFC reader for Phase 1a."""
from __future__ import annotations
from hardware.base_nfc import BaseNFC
class MockNFC(BaseNFC):
def __init__(self) -> None:
self._next_uid: str | None = None
def set_next_uid(self, uid: str | None) -> None:
self._next_uid = uid
def read_tag(self) -> str | None:
uid = self._next_uid
self._next_uid = None
return uid
+497
View File
@@ -0,0 +1,497 @@
"""CLI entrypoint for the Box runtime (Phase 1a)."""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import os
import sys
import shutil
from pathlib import Path
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.media_sync import MediaSyncClient
from core.pairing import PairingManager
from core.state import BoxState, RuntimeState
from player.mock_player import MockPlayer
from setup.webui import create_setup_app
from spotify.mock import MockSpotifyBackend
from storage.box_store import BoxStore
from storage.state_store import StateStore
from storage.secret_store import SecretStore
from wifi.mock import MockWifiBackend
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="klangkiste-box",
description="Klangkiste Box CLI (Phase 1a, local simulation)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("status", help="Show current runtime status")
nfc_on = subparsers.add_parser("simulate-nfc", help="Simulate NFC tag detection")
nfc_on.add_argument("uid", help="NFC UID")
nfc_off = subparsers.add_parser("simulate-nfc-off", help="Simulate NFC tag removal")
nfc_off.add_argument("uid", help="NFC UID")
tick_parser = subparsers.add_parser("tick", help="Simulate playback time")
tick_parser.add_argument("seconds", type=int, help="Seconds to advance")
subparsers.add_parser("run", help="Start interactive session + HTTP API")
subparsers.add_parser("factory-reset", help="Factory reset (delete all local data)")
return parser
def _state_from_data(
box_meta: dict[str, Any], state_data: dict[str, Any]
) -> RuntimeState:
box_id = str(box_meta.get("box_id", "unknown"))
settings = box_meta.get("settings", {}) if isinstance(box_meta.get("settings"), dict) else {}
default_volume = settings.get("default_volume", 0)
max_volume = settings.get("max_volume", 100)
state = RuntimeState(
box_id=box_id,
volume=int(default_volume),
max_volume=int(max_volume),
)
resume_raw = state_data.get("resume", {})
if isinstance(resume_raw, dict):
for uid, data in resume_raw.items():
if not isinstance(uid, str) or not isinstance(data, dict):
continue
file_index = data.get("file_index")
position = data.get("position")
if isinstance(file_index, int) and isinstance(position, int):
state._resume[uid] = (file_index, position)
return state
def _prune_resume(state: RuntimeState, tags: dict[str, Any]) -> bool:
if not isinstance(tags, dict):
return False
valid_uids = {uid for uid, tag in tags.items() if isinstance(tag, dict)}
stale_uids = [uid for uid in state._resume.keys() if uid not in valid_uids]
if not stale_uids:
return False
for uid in stale_uids:
state._resume.pop(uid, None)
return True
def _print_status(state: RuntimeState) -> None:
current = state.current_media if state.current_media is not None else "-"
current_file = state.current_file if state.current_file is not None else "-"
current_duration = state.current_duration if state.current_duration is not None else "-"
active_uid = state.active_uid if state.active_uid is not None else "-"
playback = state.playback_path if state.playback_path is not None else "-"
file_index = state.file_index if state.file_index is not None else "-"
position = state.position if state.position is not None else "-"
print(f"box_id={state.box_id}")
print(f"state={state.state.value}")
print(f"active_uid={active_uid}")
print(f"playback={playback}")
print(f"current_file={current_file}")
print(f"file_index={file_index}")
print(f"position={position}")
print(f"duration={current_duration}")
print(f"current_media={current}")
print(f"volume={state.volume}")
print(f"max_volume={state.max_volume}")
def main() -> int:
parser = build_parser()
args = parser.parse_args()
base_dir = Path(__file__).resolve().parent / "data"
base_dir.mkdir(parents=True, exist_ok=True)
media_root = Path(__file__).resolve().parent / "media"
media_root.mkdir(parents=True, exist_ok=True)
box_store = BoxStore(base_dir)
box_meta = box_store.load()
if "box_id" not in box_meta:
box_meta["box_id"] = generate_box_id()
if "settings" not in box_meta:
box_meta["settings"] = {}
if "tags" not in box_meta:
box_meta["tags"] = {}
env_server_url = os.getenv("KLANGKISTE_SERVER_URL")
if env_server_url:
box_meta["server_url"] = env_server_url
elif "server_url" not in box_meta:
box_meta["server_url"] = "http://127.0.0.1:5001"
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)
state_data = state_store.load()
state = _state_from_data(box_meta, state_data)
resume_pruned = _prune_resume(state, box_meta.get("tags", {}))
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)
media_sync = MediaSyncClient(
server_url=str(box_meta.get("server_url")),
box_id=state.box_id,
token_provider=pairing_manager.get_api_token,
)
controller = BoxController(
state=state,
player=MockPlayer(),
config=box_meta,
config_path=str(box_store.path),
media_sync=media_sync,
)
if resume_pruned:
_persist_resume(controller, state_store)
if args.command == "status":
_print_status(controller.state)
return 0
if args.command == "simulate-nfc":
controller.handle_nfc_on(args.uid)
_print_status(controller.state)
_persist_resume(controller, state_store)
return 0
if args.command == "simulate-nfc-off":
controller.handle_nfc_off(args.uid)
_print_status(controller.state)
_persist_resume(controller, state_store)
return 0
if args.command == "tick":
controller.tick(args.seconds)
_print_status(controller.state)
_persist_resume(controller, state_store)
return 0
if args.command == "factory-reset":
_factory_reset(controller, box_store, state_store, secrets, media_root)
print("Factory reset completed. Restart required.")
return 0
if args.command == "run":
announce_interval = _parse_int_env("KLANGKISTE_ANNOUNCE_INTERVAL", 30)
poll_interval = _parse_int_env("KLANGKISTE_POLL_INTERVAL", 10)
api_port = _parse_int_env("KLANGKISTE_API_PORT", 8000)
setup_port = _parse_int_env("KLANGKISTE_SETUP_PORT", 9000)
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", {})),
api_port=api_port,
setup_port=setup_port,
media_root=str(media_root.resolve()),
announce_interval_seconds=announce_interval,
polling_interval_seconds=poll_interval,
),
)
)
except KeyboardInterrupt:
return 0
return 0
return 0
def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]:
return {
uid: {"file_index": file_index, "position": position}
for uid, (file_index, position) in state._resume.items()
}
def _parse_int_env(name: str, default_value: int) -> int:
raw = os.getenv(name)
if not raw:
return default_value
try:
value = int(raw)
except ValueError:
return default_value
if value <= 0:
return default_value
return value
def _stdin_is_foreground() -> bool:
try:
if not sys.stdin.isatty():
return False
return os.getpgrp() == os.tcgetpgrp(sys.stdin.fileno())
except OSError:
return False
async def _run_with_api(
controller: BoxController,
storage: StateStore,
lifecycle: NetworkLifecycle,
wifi_backend: MockWifiBackend,
spotify_backend: MockSpotifyBackend,
pairing_manager: PairingManager,
secrets: SecretStore,
announce_config: AnnounceConfig,
) -> None:
base_dir = Path(__file__).resolve().parent / "data"
media_root = Path(__file__).resolve().parent / "media"
box_store = BoxStore(base_dir)
app = create_app(
controller,
lifecycle,
wifi_backend,
spotify_backend,
pairing_manager,
lambda: _persist_resume(controller, storage),
lambda: _factory_reset(controller, box_store, storage, secrets, media_root),
)
api_server = uvicorn.Server(
uvicorn.Config(
app,
host="0.0.0.0",
port=announce_config.api_port,
log_level="warning",
access_log=False,
)
)
setup_app = create_setup_app(lifecycle, wifi_backend)
setup_server = uvicorn.Server(
uvicorn.Config(
setup_app,
host="0.0.0.0",
port=announce_config.setup_port,
log_level="warning",
access_log=False,
)
)
stop_event = asyncio.Event()
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())
try:
if _stdin_is_foreground():
await _run_session(
controller,
storage,
lambda: _factory_reset(
controller,
box_store,
storage,
secrets,
media_root,
),
)
api_server.should_exit = True
setup_server.should_exit = True
await api_task
except asyncio.CancelledError:
api_server.should_exit = True
setup_server.should_exit = True
finally:
stop_event.set()
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
async def _run_session(
controller: BoxController, storage: StateStore, factory_reset: callable
) -> None:
print("Klangkiste Box - Interactive Mode")
print("Type 'help' for commands.")
while True:
try:
raw = await asyncio.to_thread(input, "> ")
raw = raw.strip()
except asyncio.CancelledError:
break
except EOFError:
print()
break
if not raw:
continue
parts = raw.split()
command = parts[0]
args = parts[1:]
if command in {"exit", "quit"}:
if controller.state.active_uid is not None:
controller.handle_nfc_off(controller.state.active_uid)
_persist_resume(controller, storage)
break
if command == "help":
_print_help()
continue
if command == "status":
_print_status(controller.state)
continue
if command == "factory-reset":
factory_reset()
print("Factory reset completed. Restart required.")
break
if command == "nfc":
if len(args) != 1:
print("error: usage: nfc <UID>")
continue
controller.handle_nfc_on(args[0])
_print_status(controller.state)
_persist_resume(controller, storage)
continue
if command == "nfc-off":
if len(args) != 1:
print("error: usage: nfc-off <UID>")
continue
controller.handle_nfc_off(args[0])
_print_status(controller.state)
_persist_resume(controller, storage)
continue
if command == "tick":
if len(args) != 1:
print("error: usage: tick <seconds>")
continue
try:
seconds = int(args[0])
except ValueError:
print("error: seconds must be an integer")
continue
controller.tick(seconds)
_print_status(controller.state)
_persist_resume(controller, storage)
continue
if command in {"play", "pause"}:
controller.play_pause()
_print_status(controller.state)
continue
if command in {"next", "vor"}:
controller.next_track()
_print_status(controller.state)
_persist_resume(controller, storage)
continue
if command in {"prev", "zurueck"}:
controller.prev_track()
_print_status(controller.state)
_persist_resume(controller, storage)
continue
if command in {"vol-up", "lauter"}:
controller.volume_up()
_print_status(controller.state)
continue
if command in {"vol-down", "leiser"}:
controller.volume_down()
_print_status(controller.state)
continue
print(f"error: unknown command '{command}'")
async def _auto_tick_loop(
controller: BoxController,
storage: StateStore,
stop_event: asyncio.Event,
) -> None:
while not stop_event.is_set():
await asyncio.sleep(1)
if controller.state.state == BoxState.PLAYING:
controller.tick(1)
_persist_resume(controller, storage)
def _persist_resume(
controller: BoxController, storage: StateStore
) -> None:
data = storage.load()
data["resume"] = _resume_to_json(controller.state)
storage.save(data)
def _factory_reset(
controller: BoxController,
box_store: BoxStore,
state_store: StateStore,
secrets: SecretStore,
media_root: Path,
) -> None:
controller.factory_reset()
if box_store.path.exists():
box_store.path.unlink()
if state_store.path.exists():
state_store.path.unlink()
secrets.clear_all()
shutil.rmtree(media_root, ignore_errors=True)
media_root.mkdir(parents=True, exist_ok=True)
def _print_help() -> None:
print("Commands:")
print(" nfc <UID> simulate NFC tag detection")
print(" nfc-off <UID> simulate NFC tag removal")
print(" tick <seconds> simulate time progression")
print(" play | pause toggle PLAYING/PAUSED")
print(" next | vor jump to next file")
print(" prev | zurueck jump to previous file")
print(" vol-up | lauter increase volume")
print(" vol-down | leiser decrease volume")
print(" status show current status")
print(" factory-reset delete all local data (restart required)")
print(" help show this help")
print(" exit | quit leave interactive mode")
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
{
"name": "box",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+19
View File
@@ -0,0 +1,19 @@
"""Base player interface for audio playback."""
from __future__ import annotations
from typing import Protocol
class BasePlayer(Protocol):
def play(self, media_ref: str) -> None:
raise NotImplementedError
def pause(self) -> None:
raise NotImplementedError
def stop(self) -> None:
raise NotImplementedError
def set_volume(self, volume: int) -> None:
raise NotImplementedError
+23
View File
@@ -0,0 +1,23 @@
"""Mock player for Phase 1a (no real audio)."""
from __future__ import annotations
from player.base_player import BasePlayer
class MockPlayer(BasePlayer):
def __init__(self) -> None:
self._current: str | None = None
self._volume: int = 0
def play(self, media_ref: str) -> None:
self._current = media_ref
def pause(self) -> None:
return None
def stop(self) -> None:
self._current = None
def set_volume(self, volume: int) -> None:
self._volume = volume
@@ -1,3 +1,5 @@
fastapi==0.115.6
uvicorn==0.30.6
pydantic==2.9.2
cryptography==43.0.3
python-multipart==0.0.9
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://klangkiste.local/schemas/box_config.schema.json",
"title": "BoxConfig",
"type": "object",
"required": ["box_id", "settings", "tags"],
"properties": {
"box_id": {"type": "string"},
"settings": {
"type": "object",
"required": ["max_volume", "default_volume"],
"properties": {
"max_volume": {"type": "integer", "minimum": 0, "maximum": 100},
"default_volume": {"type": "integer", "minimum": 0, "maximum": 100}
},
"additionalProperties": false
},
"tags": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["type", "path"],
"properties": {
"type": {
"type": "string",
"enum": ["folder"]
},
"path": {"type": "string"}
},
"additionalProperties": false
}
},
"resume": {
"type": "object",
"additionalProperties": {
"type": "object",
"required": ["file_index", "position"],
"properties": {
"file_index": {"type": "integer", "minimum": 0},
"position": {"type": "integer", "minimum": 0}
},
"additionalProperties": false
}
},
"server_reachable": {
"type": "boolean"
}
},
"additionalProperties": false
}
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://klangkiste.local/schemas/box_status.schema.json",
"title": "BoxStatus",
"type": "object",
"required": ["box_id", "state", "current_media", "volume"],
"properties": {
"box_id": {"type": "string"},
"state": {"type": "string", "enum": ["IDLE", "PLAYING", "PAUSED"]},
"current_media": {"type": ["string", "null"]},
"volume": {"type": "integer", "minimum": 0, "maximum": 100}
},
"additionalProperties": false
}
+71
View File
@@ -0,0 +1,71 @@
"""Setup Web UI for WiFi configuration (mock)."""
from __future__ import annotations
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse
from core.lifecycle import NetworkLifecycle, WifiState
from wifi.base import WifiBackend
def create_setup_app(lifecycle: NetworkLifecycle, wifi_backend: WifiBackend) -> FastAPI:
app = FastAPI()
@app.get("/setup", response_class=HTMLResponse)
def setup_page() -> str:
snapshot = lifecycle.snapshot()
networks = wifi_backend.scan_available_networks()
options = "".join(f"<option value='{n}'>{n}</option>" for n in networks)
status = snapshot.wifi_state.value
connected = snapshot.connected_ssid or "-"
if snapshot.wifi_state != WifiState.WIFI_UNCONFIGURED:
return _render_readonly(status, connected)
return _render_form(status, connected, options)
@app.post("/setup", response_class=HTMLResponse)
def setup_submit(ssid: str = Form(...), password: str = Form("")) -> str:
wifi_backend.add_profile(ssid=ssid, password=password, priority=10)
snapshot = lifecycle.snapshot()
status = snapshot.wifi_state.value
connected = snapshot.connected_ssid or "-"
return _render_readonly(status, connected)
return app
def _render_form(status: str, connected: str, options: str) -> str:
return f"""
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Klangkiste Setup</title></head>
<body>
<h1>WLAN Setup</h1>
<p>Status: {status}</p>
<p>Connected: {connected}</p>
<form method="post" action="/setup">
<label>SSID</label>
<select name="ssid">{options}</select>
<br />
<label>Password</label>
<input type="password" name="password" />
<br />
<button type="submit">Verbinden</button>
</form>
</body>
</html>
"""
def _render_readonly(status: str, connected: str) -> str:
return f"""
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Klangkiste Setup</title></head>
<body>
<h1>WLAN Status</h1>
<p>Status: {status}</p>
<p>Connected: {connected}</p>
</body>
</html>
"""
+23
View File
@@ -0,0 +1,23 @@
"""Spotify backend interface (logical)."""
from __future__ import annotations
from typing import Protocol
class SpotifyBackend(Protocol):
def set_tokens(
self,
access_token: str,
refresh_token: str,
expires_at: int,
account_id: str | None = None,
) -> None:
raise NotImplementedError
def clear(self) -> None:
raise NotImplementedError
@property
def status(self) -> str:
raise NotImplementedError
+54
View File
@@ -0,0 +1,54 @@
"""Logical Spotify token manager (mock, no server dependency)."""
from __future__ import annotations
from typing import Any
from storage.secrets_store import SecretsStore
class SpotifyManager:
def __init__(self, secrets: SecretsStore) -> None:
self._secrets = secrets
self._tokens: dict[str, Any] = {}
self._load()
def set_tokens(
self,
access_token: str,
refresh_token: str,
expires_at: int,
account_id: str | None = None,
) -> None:
self._tokens = {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at,
}
if account_id is not None:
self._tokens["account_id"] = account_id
self._save()
def clear(self) -> None:
self._tokens = {}
secrets = self._secrets.load_secrets()
if "spotify" in secrets:
secrets.pop("spotify")
self._secrets.save_secrets(secrets)
@property
def status(self) -> str:
if not self._tokens:
return "NOT_CONFIGURED"
return "READY"
def _load(self) -> None:
secrets = self._secrets.load_secrets()
tokens = secrets.get("spotify")
if isinstance(tokens, dict):
self._tokens = dict(tokens)
def _save(self) -> None:
secrets = self._secrets.load_secrets()
secrets["spotify"] = dict(self._tokens)
self._secrets.save_secrets(secrets)
+55
View File
@@ -0,0 +1,55 @@
"""Mock Spotify backend (logical)."""
from __future__ import annotations
from typing import Any
from storage.secret_store import SecretStore
from spotify.base import SpotifyBackend
class MockSpotifyBackend(SpotifyBackend):
def __init__(self, secret_store: SecretStore) -> None:
self._secret_store = secret_store
self._tokens: dict[str, Any] = {}
self._load()
def set_tokens(
self,
access_token: str,
refresh_token: str,
expires_at: int,
account_id: str | None = None,
) -> None:
self._tokens = {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_at": expires_at,
}
if account_id is not None:
self._tokens["account_id"] = account_id
self._save()
def clear(self) -> None:
self._tokens = {}
secrets = self._secret_store.load_secrets()
if "spotify" in secrets:
secrets.pop("spotify")
self._secret_store.save_secrets(secrets)
@property
def status(self) -> str:
if not self._tokens:
return "NOT_CONFIGURED"
return "READY"
def _load(self) -> None:
secrets = self._secret_store.load_secrets()
tokens = secrets.get("spotify")
if isinstance(tokens, dict):
self._tokens = dict(tokens)
def _save(self) -> None:
secrets = self._secret_store.load_secrets()
secrets["spotify"] = dict(self._tokens)
self._secret_store.save_secrets(secrets)
+52
View File
@@ -0,0 +1,52 @@
"""Persistent storage for Box metadata and runtime state."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
class BoxStorage:
def __init__(self, base_dir: str | None = None) -> None:
default_dir = Path(__file__).resolve().parents[1] / "data"
self._base_dir = Path(base_dir) if base_dir else default_dir
self._box_path = self._base_dir / "box.json"
self._state_path = self._base_dir / "state.json"
self._media_dir = self._base_dir / "media"
@property
def base_dir(self) -> Path:
return self._base_dir
@property
def media_dir(self) -> Path:
return self._media_dir
@property
def box_path(self) -> Path:
return self._box_path
@property
def state_path(self) -> Path:
return self._state_path
def ensure_layout(self) -> None:
self._base_dir.mkdir(parents=True, exist_ok=True)
self._media_dir.mkdir(parents=True, exist_ok=True)
def load_box_meta(self) -> dict[str, Any]:
if not self._box_path.exists():
return {}
return json.loads(self._box_path.read_text(encoding="utf-8"))
def save_box_meta(self, data: dict[str, Any]) -> None:
self._box_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def load_state(self) -> dict[str, Any]:
if not self._state_path.exists():
return {}
return json.loads(self._state_path.read_text(encoding="utf-8"))
def save_state(self, data: dict[str, Any]) -> None:
self._state_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
+34
View File
@@ -0,0 +1,34 @@
"""Box metadata storage (app-level, within box/data)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from core.identity import generate_box_id
class BoxStore:
def __init__(self, base_dir: Path) -> None:
self._base_dir = base_dir
self._path = self._base_dir / "box.json"
@property
def path(self) -> Path:
return self._path
def load(self) -> dict[str, Any]:
if not self._path.exists():
return {}
return json.loads(self._path.read_text(encoding="utf-8"))
def save(self, data: dict[str, Any]) -> None:
self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")
def ensure_box_id(self) -> str:
data = self.load()
if "box_id" not in data:
data["box_id"] = generate_box_id()
self.save(data)
return str(data["box_id"])
+21
View File
@@ -0,0 +1,21 @@
"""JSON file storage placeholder for Phase 1a."""
from __future__ import annotations
import json
from pathlib import Path
from storage.storage import Storage
class JsonStorage(Storage):
def __init__(self, path: str) -> None:
self._path = Path(path)
def load_config(self) -> dict:
if not self._path.exists():
return {}
return json.loads(self._path.read_text(encoding="utf-8"))
def save_config(self, config: dict) -> None:
self._path.write_text(json.dumps(config, indent=2), encoding="utf-8")
+98
View File
@@ -0,0 +1,98 @@
"""Encrypted secret storage for sensitive Box data (app-level)."""
from __future__ import annotations
import base64
import json
from hashlib import pbkdf2_hmac
from pathlib import Path
from typing import Any
from cryptography.fernet import Fernet, InvalidToken
STATIC_SALT = b"klangkiste-static-salt-v1"
KDF_ITERATIONS = 200_000
class SecretStore:
def __init__(self, base_dir: Path, box_id: str) -> None:
self._path = base_dir / "secrets.enc"
self._box_id = box_id
def load_secrets(self) -> dict[str, Any]:
if not self._path.exists():
return {}
token = self._path.read_bytes()
fernet = Fernet(self._derive_key())
try:
payload = fernet.decrypt(token)
except InvalidToken:
return {}
return json.loads(payload.decode("utf-8"))
def save_secrets(self, data: dict[str, Any]) -> None:
fernet = Fernet(self._derive_key())
payload = json.dumps(data, indent=2).encode("utf-8")
token = fernet.encrypt(payload)
self._path.write_bytes(token)
def ensure_secret_seed(self) -> str:
data = self.load_secrets()
seed = data.get("secret_seed")
if isinstance(seed, str) and seed:
return seed
seed_value = self._generate_seed()
data["secret_seed"] = seed_value
self.save_secrets(data)
return seed_value
def get_secret_seed(self) -> str | None:
data = self.load_secrets()
seed = data.get("secret_seed")
if isinstance(seed, str) and seed:
return seed
return None
def save_api_token(self, token: str) -> None:
data = self.load_secrets()
data["api_token"] = token
self.save_secrets(data)
def get_api_token(self) -> str | None:
data = self.load_secrets()
token = data.get("api_token")
if isinstance(token, str) and token:
return token
return None
def clear_api_token(self) -> None:
data = self.load_secrets()
if "api_token" in data:
data.pop("api_token")
self.save_secrets(data)
def clear_wifi_secrets(self) -> None:
data = self.load_secrets()
if "wifi_profiles" in data:
data.pop("wifi_profiles")
self.save_secrets(data)
def clear_all(self) -> None:
if self._path.exists():
self._path.unlink()
def _derive_key(self) -> bytes:
raw = pbkdf2_hmac(
"sha256",
self._box_id.encode("utf-8"),
STATIC_SALT,
KDF_ITERATIONS,
dklen=32,
)
return base64.urlsafe_b64encode(raw)
@staticmethod
def _generate_seed() -> str:
import secrets
return secrets.token_hex(32)
+57
View File
@@ -0,0 +1,57 @@
"""Encrypted secret storage for sensitive Box data."""
from __future__ import annotations
import base64
import json
from hashlib import pbkdf2_hmac
from pathlib import Path
from typing import Any
from cryptography.fernet import Fernet, InvalidToken
STATIC_SALT = b"klangkiste-static-salt-v1"
KDF_ITERATIONS = 200_000
class SecretsStore:
def __init__(self, base_dir: Path, box_id: str) -> None:
self._path = base_dir / "secrets.enc"
self._box_id = box_id
def load_secrets(self) -> dict[str, Any]:
if not self._path.exists():
return {}
token = self._path.read_bytes()
fernet = Fernet(self._derive_key())
try:
payload = fernet.decrypt(token)
except InvalidToken:
return {}
return json.loads(payload.decode("utf-8"))
def save_secrets(self, data: dict[str, Any]) -> None:
fernet = Fernet(self._derive_key())
payload = json.dumps(data, indent=2).encode("utf-8")
token = fernet.encrypt(payload)
self._path.write_bytes(token)
def clear_wifi_secrets(self) -> None:
data = self.load_secrets()
if "wifi" in data:
data.pop("wifi")
self.save_secrets(data)
def clear_all(self) -> None:
if self._path.exists():
self._path.unlink()
def _derive_key(self) -> bytes:
raw = pbkdf2_hmac(
"sha256",
self._box_id.encode("utf-8"),
STATIC_SALT,
KDF_ITERATIONS,
dklen=32,
)
return base64.urlsafe_b64encode(raw)
+24
View File
@@ -0,0 +1,24 @@
"""Runtime state storage (resume, wifi state)."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
class StateStore:
def __init__(self, base_dir: Path) -> None:
self._path = base_dir / "state.json"
@property
def path(self) -> Path:
return self._path
def load(self) -> dict[str, Any]:
if not self._path.exists():
return {}
return json.loads(self._path.read_text(encoding="utf-8"))
def save(self, data: dict[str, Any]) -> None:
self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")
+13
View File
@@ -0,0 +1,13 @@
"""Storage interface for Box configuration and state."""
from __future__ import annotations
from typing import Protocol
class Storage(Protocol):
def load_config(self) -> dict:
raise NotImplementedError
def save_config(self, config: dict) -> None:
raise NotImplementedError
+45
View File
@@ -0,0 +1,45 @@
"""WiFi backend interface (logical, no OS integration)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass
class WifiProfile:
ssid: str
password: str
priority: int = 0
class WifiBackend(Protocol):
def scan_available_networks(self) -> list[str]:
raise NotImplementedError
def add_profile(self, ssid: str, password: str, priority: int = 0) -> None:
raise NotImplementedError
def remove_profile(self, ssid: str) -> None:
raise NotImplementedError
def reset(self) -> None:
raise NotImplementedError
def select_best_profile(self) -> WifiProfile | None:
raise NotImplementedError
def connect(self, profile: WifiProfile) -> None:
raise NotImplementedError
@property
def network_status(self) -> str:
raise NotImplementedError
@property
def connected_ssid(self) -> str | None:
raise NotImplementedError
@property
def profiles_count(self) -> int:
raise NotImplementedError
+115
View File
@@ -0,0 +1,115 @@
"""Logical WiFi manager (mock, no OS integration)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from storage.box_storage import BoxStorage
from storage.secrets_store import SecretsStore
@dataclass
class WifiProfile:
ssid: str
password: str
priority: int = 0
class WifiManager:
def __init__(self, storage: BoxStorage, secrets: SecretsStore) -> None:
self._storage = storage
self._secrets = secrets
self._profiles: list[WifiProfile] = []
self._connected_ssid: str | None = None
self._load_state()
def scan_available_networks(self) -> list[str]:
return []
def add_profile(self, ssid: str, password: str, priority: int = 0) -> None:
self._profiles = [p for p in self._profiles if p.ssid != ssid]
self._profiles.append(WifiProfile(ssid=ssid, password=password, priority=priority))
self._save_profiles()
self._auto_connect()
def remove_profile(self, ssid: str) -> None:
self._profiles = [p for p in self._profiles if p.ssid != ssid]
if self._connected_ssid == ssid:
self._connected_ssid = None
self._save_profiles()
self._save_state()
self._auto_connect()
def reset(self) -> None:
self._profiles = []
self._connected_ssid = None
self._secrets.clear_wifi_secrets()
self._save_state()
def select_best_profile(self) -> WifiProfile | None:
if not self._profiles:
return None
return sorted(self._profiles, key=lambda p: (-p.priority, p.ssid))[0]
def connect(self, profile: WifiProfile) -> None:
self._connected_ssid = profile.ssid
self._save_state()
@property
def network_status(self) -> str:
if not self._profiles:
return "NOT_CONFIGURED"
if self._connected_ssid:
return "CONNECTED"
return "DISCONNECTED"
@property
def connected_ssid(self) -> str | None:
return self._connected_ssid
@property
def profiles_count(self) -> int:
return len(self._profiles)
def _auto_connect(self) -> None:
profile = self.select_best_profile()
if profile is not None:
self.connect(profile)
def _load_state(self) -> None:
secrets = self._secrets.load_secrets()
profiles_raw = secrets.get("wifi_profiles", [])
if isinstance(profiles_raw, list):
for entry in profiles_raw:
if not isinstance(entry, dict):
continue
ssid = entry.get("ssid")
password = entry.get("password")
priority = entry.get("priority", 0)
if isinstance(ssid, str) and isinstance(password, str):
if not isinstance(priority, int):
priority = 0
self._profiles.append(
WifiProfile(ssid=ssid, password=password, priority=priority)
)
state = self._storage.load_state()
wifi_state = state.get("wifi", {}) if isinstance(state.get("wifi"), dict) else {}
connected = wifi_state.get("connected_ssid")
if isinstance(connected, str):
self._connected_ssid = connected
self._auto_connect()
def _save_profiles(self) -> None:
secrets = self._secrets.load_secrets()
secrets["wifi_profiles"] = [
{"ssid": p.ssid, "password": p.password, "priority": p.priority}
for p in self._profiles
]
self._secrets.save_secrets(secrets)
def _save_state(self) -> None:
state = self._storage.load_state()
state["wifi"] = {"connected_ssid": self._connected_ssid}
self._storage.save_state(state)
+109
View File
@@ -0,0 +1,109 @@
"""Mock WiFi backend (logical simulation)."""
from __future__ import annotations
from typing import Any
from storage.state_store import StateStore
from storage.secret_store import SecretStore
from wifi.base import WifiBackend, WifiProfile
class MockWifiBackend(WifiBackend):
def __init__(self, state_store: StateStore, secret_store: SecretStore) -> None:
self._state_store = state_store
self._secret_store = secret_store
self._profiles: list[WifiProfile] = []
self._connected_ssid: str | None = None
self._available_networks = ["HomeWiFi", "Hotspot"]
self._load()
def scan_available_networks(self) -> list[str]:
return list(self._available_networks)
def add_profile(self, ssid: str, password: str, priority: int = 0) -> None:
self._profiles = [p for p in self._profiles if p.ssid != ssid]
self._profiles.append(WifiProfile(ssid=ssid, password=password, priority=priority))
self._save_profiles()
self._auto_connect()
def remove_profile(self, ssid: str) -> None:
self._profiles = [p for p in self._profiles if p.ssid != ssid]
if self._connected_ssid == ssid:
self._connected_ssid = None
self._save_profiles()
self._save_state()
self._auto_connect()
def reset(self) -> None:
self._profiles = []
self._connected_ssid = None
self._secret_store.clear_wifi_secrets()
self._save_state()
def select_best_profile(self) -> WifiProfile | None:
if not self._profiles:
return None
return sorted(self._profiles, key=lambda p: (-p.priority, p.ssid))[0]
def connect(self, profile: WifiProfile) -> None:
self._connected_ssid = profile.ssid
self._save_state()
@property
def network_status(self) -> str:
if not self._profiles:
return "WIFI_UNCONFIGURED"
if self._connected_ssid:
return "WIFI_ONLINE"
return "WIFI_OFFLINE"
@property
def connected_ssid(self) -> str | None:
return self._connected_ssid
@property
def profiles_count(self) -> int:
return len(self._profiles)
def _auto_connect(self) -> None:
profile = self.select_best_profile()
if profile is not None:
self.connect(profile)
def _load(self) -> None:
secrets = self._secret_store.load_secrets()
profiles_raw = secrets.get("wifi_profiles", [])
if isinstance(profiles_raw, list):
for entry in profiles_raw:
if not isinstance(entry, dict):
continue
ssid = entry.get("ssid")
password = entry.get("password")
priority = entry.get("priority", 0)
if isinstance(ssid, str) and isinstance(password, str):
if not isinstance(priority, int):
priority = 0
self._profiles.append(
WifiProfile(ssid=ssid, password=password, priority=priority)
)
state = self._state_store.load()
wifi_state = state.get("wifi", {}) if isinstance(state.get("wifi"), dict) else {}
connected = wifi_state.get("connected_ssid")
if isinstance(connected, str):
self._connected_ssid = connected
self._auto_connect()
def _save_profiles(self) -> None:
secrets = self._secret_store.load_secrets()
secrets["wifi_profiles"] = [
{"ssid": p.ssid, "password": p.password, "priority": p.priority}
for p in self._profiles
]
self._secret_store.save_secrets(secrets)
def _save_state(self) -> None:
state = self._state_store.load()
state["wifi"] = {"connected_ssid": self._connected_ssid}
self._state_store.save(state)
Executable
+1264
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More