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
View File
View File
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
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: