Nice Work
This commit is contained in:
+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
|
||||
@@ -0,0 +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)
|
||||
Reference in New Issue
Block a user