Nice Work
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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.
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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": []
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i
|
||||
gAAAAABpZgUAtNvwWTJeaG5AVPuEFOUC321L1H9BVoQXpKCCAW209SvVYspQHMjZ3RamiRlwu9sr8UZA5zo0JBaNxSom2ZzgDUlUxpsa5zlL7ZHG9WGWIr1QFSBjJeBGeSTCu7gT4vu_3T-N_NWip2oaQUDY0VS8IHQ2damKibeV6JQJFqC2MJDVQKKbyGq25OtNsdvuYe7VDUbnA-Khs0TZJP0eSCeTlDwNGmrUPVDn8ec7X7-Y3prgV6tkIHvcpKE-XgXCc4uqievI0nCdWu0gp1HfXydpP-EQsrAN6ZJR264vdgIoq-s=
|
||||
+3
-11
@@ -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
@@ -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")
|
||||
|
||||
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "box",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user