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:
|
||||
|
||||
+141
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Compatibility re-export for BoxController."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.box_controller import BoxController
|
||||
|
||||
__all__ = ["BoxController"]
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
gAAAAABpZpDxBsn-LkdKkkUukRVKtBoRtjK6EprtWis8WxJlqDqdybx_9ZtdZtPVcVEdFEgIWtmXJKRk9uAHVAFTAtnDiE_fxLRt4LHc3D7roHMehd853E4Dxb_DtWfLQ7XOYQ9Zsij1uF1w08v-WFiTfb4zOWq_usPeidr0Mo0BGD7V_R3LP9Vv1m61pZA--M8kwrMlHxiOGyn1QHGTagizuuk2IKuWwKmg_BeHfBeBndQuU-q6uaHfu9c68iAu1YU44mAemuYk3px4q31zEjgyqmJPVO7Tn93CgltVPGCYx7VhHhxfmbU=
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pairing": {
|
||||
"state": "PAIRED"
|
||||
},
|
||||
"resume": {}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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())
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "box",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"])
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
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
Reference in New Issue
Block a user