Upload
This commit is contained in:
Binary file not shown.
+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"
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
"""HTTP API (FastAPI) for the Box."""
|
||||||
|
|
||||||
|
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 core.lifecycle import NetworkLifecycle
|
||||||
|
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,
|
||||||
|
persist_state: 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,
|
||||||
|
"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,
|
||||||
|
"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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/command")
|
||||||
|
def command(request: CommandRequest) -> dict[str, Any]:
|
||||||
|
cmd = request.command
|
||||||
|
payload = request.payload or {}
|
||||||
|
|
||||||
|
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()
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail="unknown command")
|
||||||
|
|
||||||
|
persist_state()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
return app
|
||||||
@@ -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
|
||||||
@@ -24,12 +24,20 @@
|
|||||||
"UID_5": {
|
"UID_5": {
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"path": "../media/book_5"
|
"path": "../media/book_5"
|
||||||
|
},
|
||||||
|
"UID_6": {
|
||||||
|
"type": "folder",
|
||||||
|
"path": "../media/book_6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"resume": {
|
"resume": {
|
||||||
"UID_1": {
|
"UID_1": {
|
||||||
"file_index": 0,
|
"file_index": 0,
|
||||||
"position": 50
|
"position": 12
|
||||||
|
},
|
||||||
|
"UID_2": {
|
||||||
|
"file_index": 1,
|
||||||
|
"position": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Binary file not shown.
Binary file not shown.
+137
-14
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from core.state import BoxState, RuntimeState
|
from core.state import BoxState, RuntimeState
|
||||||
from player.base_player import BasePlayer
|
from player.base_player import BasePlayer
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
|
||||||
class BoxController:
|
class BoxController:
|
||||||
@@ -31,10 +32,20 @@ class BoxController:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def handle_nfc_on(self, uid: str) -> 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
|
||||||
tag = self._resolve_tag(uid)
|
tag = self._resolve_tag(uid)
|
||||||
if tag is None:
|
if tag is None:
|
||||||
print(f"error: uid '{uid}' not found in config tags")
|
print(f"error: uid '{uid}' not found in config tags")
|
||||||
|
self._trigger_error("uid_unmapped")
|
||||||
return None
|
return None
|
||||||
|
self._state.last_error = None
|
||||||
|
|
||||||
playlist = self._build_playlist(tag)
|
playlist = self._build_playlist(tag)
|
||||||
if not playlist:
|
if not playlist:
|
||||||
@@ -43,15 +54,24 @@ class BoxController:
|
|||||||
print(
|
print(
|
||||||
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
|
f"error: no playable files found for uid '{uid}' at path '{resolved}'"
|
||||||
)
|
)
|
||||||
|
self._trigger_error("no_playable_files")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if self._state.active_uid == uid and uid in self._state._resume:
|
if uid in self._state._resume:
|
||||||
file_index, position = self._state._resume[uid]
|
file_index, position = self._state._resume[uid]
|
||||||
else:
|
else:
|
||||||
file_index, position = 0, 0
|
file_index, position = 0, 0
|
||||||
|
|
||||||
if file_index < 0 or file_index >= len(playlist):
|
if file_index < 0 or file_index >= len(playlist):
|
||||||
file_index, position = 0, 0
|
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]
|
media_ref = playlist[file_index]
|
||||||
duration = self._duration_for(media_ref)
|
duration = self._duration_for(media_ref)
|
||||||
@@ -75,10 +95,11 @@ class BoxController:
|
|||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._player.stop()
|
self._player.stop()
|
||||||
self._state.current_media = None
|
self._clear_playback_state()
|
||||||
self._state.state = BoxState.IDLE
|
|
||||||
|
|
||||||
def handle_nfc_off(self, uid: str) -> None:
|
def handle_nfc_off(self, uid: str) -> None:
|
||||||
|
if self._state.state == BoxState.ERROR:
|
||||||
|
return None
|
||||||
if self._state.active_uid != uid:
|
if self._state.active_uid != uid:
|
||||||
return None
|
return None
|
||||||
self._store_progress(uid)
|
self._store_progress(uid)
|
||||||
@@ -96,6 +117,8 @@ class BoxController:
|
|||||||
return tag
|
return tag
|
||||||
|
|
||||||
def tick(self, seconds: int) -> None:
|
def tick(self, seconds: int) -> None:
|
||||||
|
if self._state.state == BoxState.ERROR:
|
||||||
|
return None
|
||||||
if self._state.state != BoxState.PLAYING:
|
if self._state.state != BoxState.PLAYING:
|
||||||
return None
|
return None
|
||||||
if self._state.position is None or self._state.file_index is None:
|
if self._state.position is None or self._state.file_index is None:
|
||||||
@@ -124,12 +147,14 @@ class BoxController:
|
|||||||
position = 0
|
position = 0
|
||||||
|
|
||||||
if file_index >= len(self._state._playlist):
|
if file_index >= len(self._state._playlist):
|
||||||
self._state.file_index = len(self._state._playlist) - 1
|
last_index = len(self._state._playlist) - 1
|
||||||
self._state.position = duration
|
last_media = self._state._playlist[last_index]
|
||||||
self._state.current_media = None
|
last_duration = self._duration_for(last_media)
|
||||||
self._state.current_file = None
|
self._state.file_index = last_index
|
||||||
self._state.current_duration = None
|
self._state.position = last_duration
|
||||||
self._state.state = BoxState.IDLE
|
if self._state.active_uid:
|
||||||
|
self._store_progress(self._state.active_uid)
|
||||||
|
self.stop()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
self._state.file_index = file_index
|
self._state.file_index = file_index
|
||||||
@@ -146,9 +171,17 @@ class BoxController:
|
|||||||
path = self._resolve_path(path_value)
|
path = self._resolve_path(path_value)
|
||||||
if not path.exists() or not path.is_dir():
|
if not path.exists() or not path.is_dir():
|
||||||
return []
|
return []
|
||||||
files = [p for p in path.iterdir() if p.is_file()]
|
return [str(p) for p in self._collect_files(path)]
|
||||||
files_sorted = sorted(files, key=lambda p: p.name)
|
|
||||||
return [str(p) for p in files_sorted]
|
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:
|
def _resolve_path(self, path_value: str) -> Path:
|
||||||
path = Path(path_value)
|
path = Path(path_value)
|
||||||
@@ -164,4 +197,94 @@ class BoxController:
|
|||||||
def _duration_for(self, media_ref: str) -> int:
|
def _duration_for(self, media_ref: str) -> int:
|
||||||
name = Path(media_ref).name
|
name = Path(media_ref).name
|
||||||
durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220}
|
durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220}
|
||||||
return durations.get(name, 0)
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Compatibility re-export for BoxController."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from core.box_controller import BoxController
|
||||||
|
|
||||||
|
__all__ = ["BoxController"]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""Box identity generation and management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
|
||||||
|
PREFIX = "klangkiste-"
|
||||||
|
ALPHABET = string.ascii_lowercase + string.digits
|
||||||
|
SUFFIX_LENGTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
def generate_box_id() -> str:
|
||||||
|
suffix = "".join(secrets.choice(ALPHABET) for _ in range(SUFFIX_LENGTH))
|
||||||
|
return f"{PREFIX}{suffix}"
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -11,6 +11,7 @@ class BoxState(str, Enum):
|
|||||||
IDLE = "IDLE"
|
IDLE = "IDLE"
|
||||||
PLAYING = "PLAYING"
|
PLAYING = "PLAYING"
|
||||||
PAUSED = "PAUSED"
|
PAUSED = "PAUSED"
|
||||||
|
ERROR = "ERROR"
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -26,5 +27,7 @@ class RuntimeState:
|
|||||||
current_duration: Optional[int] = None
|
current_duration: Optional[int] = None
|
||||||
file_index: Optional[int] = None
|
file_index: Optional[int] = None
|
||||||
position: Optional[int] = None
|
position: Optional[int] = None
|
||||||
|
last_error: Optional[str] = None
|
||||||
_resume: dict[str, tuple[int, int]] = field(default_factory=dict)
|
_resume: dict[str, tuple[int, int]] = field(default_factory=dict)
|
||||||
_playlist: list[str] = field(default_factory=list)
|
_playlist: list[str] = field(default_factory=list)
|
||||||
|
max_volume: int = 100
|
||||||
|
|||||||
+214
-29
@@ -3,12 +3,28 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
from api.http import create_app
|
||||||
from core.box_controller import BoxController
|
from core.box_controller import BoxController
|
||||||
from core.state import RuntimeState
|
from core.identity import generate_box_id
|
||||||
|
from core.lifecycle import NetworkLifecycle
|
||||||
|
from core.state import BoxState, RuntimeState
|
||||||
from player.mock_player import MockPlayer
|
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 storage.json_storage import JsonStorage
|
from storage.json_storage import JsonStorage
|
||||||
|
from wifi.mock import MockWifiBackend
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
@@ -19,7 +35,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--config",
|
"--config",
|
||||||
default="config/box_config.json",
|
default="config/box_config.json",
|
||||||
help="Path to local box config JSON",
|
help=\"Path to local box config JSON (legacy seed)\",
|
||||||
)
|
)
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
@@ -34,17 +50,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
tick_parser = subparsers.add_parser("tick", help="Simulate playback time")
|
tick_parser = subparsers.add_parser("tick", help="Simulate playback time")
|
||||||
tick_parser.add_argument("seconds", type=int, help="Seconds to advance")
|
tick_parser.add_argument("seconds", type=int, help="Seconds to advance")
|
||||||
|
|
||||||
subparsers.add_parser("run", help="Start interactive session")
|
subparsers.add_parser("run", help="Start interactive session + HTTP API")
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def _state_from_config(config: dict[str, Any]) -> RuntimeState:
|
def _state_from_data(
|
||||||
box_id = str(config.get("box_id", "unknown"))
|
box_meta: dict[str, Any], state_data: dict[str, Any]
|
||||||
settings = config.get("settings", {}) if isinstance(config.get("settings"), dict) else {}
|
) -> 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)
|
default_volume = settings.get("default_volume", 0)
|
||||||
state = RuntimeState(box_id=box_id, volume=int(default_volume))
|
max_volume = settings.get("max_volume", 100)
|
||||||
|
state = RuntimeState(
|
||||||
|
box_id=box_id,
|
||||||
|
volume=int(default_volume),
|
||||||
|
max_volume=int(max_volume),
|
||||||
|
)
|
||||||
|
|
||||||
resume_raw = config.get("resume", {})
|
resume_raw = state_data.get("resume", {})
|
||||||
if isinstance(resume_raw, dict):
|
if isinstance(resume_raw, dict):
|
||||||
for uid, data in resume_raw.items():
|
for uid, data in resume_raw.items():
|
||||||
if not isinstance(uid, str) or not isinstance(data, dict):
|
if not isinstance(uid, str) or not isinstance(data, dict):
|
||||||
@@ -56,6 +79,19 @@ def _state_from_config(config: dict[str, Any]) -> RuntimeState:
|
|||||||
return state
|
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 _print_status(state: RuntimeState) -> None:
|
def _print_status(state: RuntimeState) -> None:
|
||||||
current = state.current_media if state.current_media is not None else "-"
|
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_file = state.current_file if state.current_file is not None else "-"
|
||||||
@@ -74,20 +110,48 @@ def _print_status(state: RuntimeState) -> None:
|
|||||||
print(f"duration={current_duration}")
|
print(f"duration={current_duration}")
|
||||||
print(f"current_media={current}")
|
print(f"current_media={current}")
|
||||||
print(f"volume={state.volume}")
|
print(f"volume={state.volume}")
|
||||||
|
print(f"max_volume={state.max_volume}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
storage = JsonStorage(args.config)
|
config_path = Path(args.config)
|
||||||
config = storage.load_config()
|
if not config_path.is_absolute():
|
||||||
state = _state_from_config(config)
|
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)
|
||||||
|
|
||||||
|
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:
|
||||||
|
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", {}))
|
||||||
|
box_store.save(box_meta)
|
||||||
|
|
||||||
|
state_store = StateStore(base_dir)
|
||||||
|
state_data = state_store.load()
|
||||||
|
|
||||||
|
state = _state_from_data(box_meta, state_data)
|
||||||
|
secrets = SecretStore(base_dir, state.box_id)
|
||||||
|
wifi_backend = MockWifiBackend(state_store, secrets)
|
||||||
|
spotify_backend = MockSpotifyBackend(secrets)
|
||||||
|
lifecycle = NetworkLifecycle(wifi_backend)
|
||||||
controller = BoxController(
|
controller = BoxController(
|
||||||
state=state,
|
state=state,
|
||||||
player=MockPlayer(),
|
player=MockPlayer(),
|
||||||
config=config,
|
config=box_meta,
|
||||||
config_path=args.config,
|
config_path=str(box_store.path),
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.command == "status":
|
if args.command == "status":
|
||||||
@@ -97,26 +161,31 @@ def main() -> int:
|
|||||||
if args.command == "simulate-nfc":
|
if args.command == "simulate-nfc":
|
||||||
controller.handle_nfc_on(args.uid)
|
controller.handle_nfc_on(args.uid)
|
||||||
_print_status(controller.state)
|
_print_status(controller.state)
|
||||||
config["resume"] = _resume_to_json(controller.state)
|
_persist_resume(controller, state_store)
|
||||||
storage.save_config(config)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "simulate-nfc-off":
|
if args.command == "simulate-nfc-off":
|
||||||
controller.handle_nfc_off(args.uid)
|
controller.handle_nfc_off(args.uid)
|
||||||
_print_status(controller.state)
|
_print_status(controller.state)
|
||||||
config["resume"] = _resume_to_json(controller.state)
|
_persist_resume(controller, state_store)
|
||||||
storage.save_config(config)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "tick":
|
if args.command == "tick":
|
||||||
controller.tick(args.seconds)
|
controller.tick(args.seconds)
|
||||||
_print_status(controller.state)
|
_print_status(controller.state)
|
||||||
config["resume"] = _resume_to_json(controller.state)
|
_persist_resume(controller, state_store)
|
||||||
storage.save_config(config)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
_run_session(controller, storage, config)
|
asyncio.run(
|
||||||
|
_run_with_api(
|
||||||
|
controller,
|
||||||
|
state_store,
|
||||||
|
lifecycle,
|
||||||
|
wifi_backend,
|
||||||
|
spotify_backend,
|
||||||
|
)
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
@@ -129,12 +198,85 @@ def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _run_session(controller: BoxController, storage: JsonStorage, config: dict[str, Any]) -> None:
|
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,
|
||||||
|
) -> None:
|
||||||
|
app = create_app(
|
||||||
|
controller,
|
||||||
|
lifecycle,
|
||||||
|
wifi_backend,
|
||||||
|
spotify_backend,
|
||||||
|
lambda: _persist_resume(controller, storage),
|
||||||
|
)
|
||||||
|
api_server = uvicorn.Server(
|
||||||
|
uvicorn.Config(
|
||||||
|
app,
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=8000,
|
||||||
|
log_level="warning",
|
||||||
|
access_log=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
setup_app = create_setup_app(lifecycle, wifi_backend)
|
||||||
|
setup_server = uvicorn.Server(
|
||||||
|
uvicorn.Config(
|
||||||
|
setup_app,
|
||||||
|
host="127.0.0.1",
|
||||||
|
port=9000,
|
||||||
|
log_level="warning",
|
||||||
|
access_log=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
tick_task = asyncio.create_task(
|
||||||
|
_auto_tick_loop(controller, storage, 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)
|
||||||
|
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
|
||||||
|
setup_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await setup_task
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_session(
|
||||||
|
controller: BoxController, storage: StateStore
|
||||||
|
) -> None:
|
||||||
print("Klangkiste Box - Interactive Mode")
|
print("Klangkiste Box - Interactive Mode")
|
||||||
print("Type 'help' for commands.")
|
print("Type 'help' for commands.")
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
raw = input("> ").strip()
|
raw = await asyncio.to_thread(input, "> ")
|
||||||
|
raw = raw.strip()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
except EOFError:
|
except EOFError:
|
||||||
print()
|
print()
|
||||||
break
|
break
|
||||||
@@ -145,6 +287,9 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
|
|||||||
args = parts[1:]
|
args = parts[1:]
|
||||||
|
|
||||||
if command in {"exit", "quit"}:
|
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
|
break
|
||||||
if command == "help":
|
if command == "help":
|
||||||
_print_help()
|
_print_help()
|
||||||
@@ -158,7 +303,7 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
|
|||||||
continue
|
continue
|
||||||
controller.handle_nfc_on(args[0])
|
controller.handle_nfc_on(args[0])
|
||||||
_print_status(controller.state)
|
_print_status(controller.state)
|
||||||
_persist_resume(controller, storage, config)
|
_persist_resume(controller, storage)
|
||||||
continue
|
continue
|
||||||
if command == "nfc-off":
|
if command == "nfc-off":
|
||||||
if len(args) != 1:
|
if len(args) != 1:
|
||||||
@@ -166,7 +311,7 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
|
|||||||
continue
|
continue
|
||||||
controller.handle_nfc_off(args[0])
|
controller.handle_nfc_off(args[0])
|
||||||
_print_status(controller.state)
|
_print_status(controller.state)
|
||||||
_persist_resume(controller, storage, config)
|
_persist_resume(controller, storage)
|
||||||
continue
|
continue
|
||||||
if command == "tick":
|
if command == "tick":
|
||||||
if len(args) != 1:
|
if len(args) != 1:
|
||||||
@@ -179,17 +324,52 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
|
|||||||
continue
|
continue
|
||||||
controller.tick(seconds)
|
controller.tick(seconds)
|
||||||
_print_status(controller.state)
|
_print_status(controller.state)
|
||||||
_persist_resume(controller, storage, config)
|
_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
|
continue
|
||||||
|
|
||||||
print(f"error: unknown command '{command}'")
|
print(f"error: unknown command '{command}'")
|
||||||
|
|
||||||
|
|
||||||
def _persist_resume(
|
async def _auto_tick_loop(
|
||||||
controller: BoxController, storage: JsonStorage, config: dict[str, Any]
|
controller: BoxController,
|
||||||
|
storage: StateStore,
|
||||||
|
stop_event: asyncio.Event,
|
||||||
) -> None:
|
) -> None:
|
||||||
config["resume"] = _resume_to_json(controller.state)
|
while not stop_event.is_set():
|
||||||
storage.save_config(config)
|
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 _print_help() -> None:
|
def _print_help() -> None:
|
||||||
@@ -197,6 +377,11 @@ def _print_help() -> None:
|
|||||||
print(" nfc <UID> simulate NFC tag detection")
|
print(" nfc <UID> simulate NFC tag detection")
|
||||||
print(" nfc-off <UID> simulate NFC tag removal")
|
print(" nfc-off <UID> simulate NFC tag removal")
|
||||||
print(" tick <seconds> simulate time progression")
|
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(" status show current status")
|
||||||
print(" help show this help")
|
print(" help show this help")
|
||||||
print(" exit | quit leave interactive mode")
|
print(" exit | quit leave interactive mode")
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn==0.30.6
|
||||||
|
pydantic==2.9.2
|
||||||
|
cryptography==43.0.3
|
||||||
@@ -41,6 +41,9 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"server_reachable": {
|
||||||
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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,57 @@
|
|||||||
|
"""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 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)
|
||||||
@@ -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,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)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "🚀 Starting Klangkiste (Box + GUI)"
|
||||||
|
|
||||||
|
# Ensure both processes are stopped on exit or Ctrl+C.
|
||||||
|
cleanup() {
|
||||||
|
echo ""
|
||||||
|
echo "🛑 Stopping Klangkiste..."
|
||||||
|
if [[ -n "${GUI_PID:-}" ]]; then
|
||||||
|
kill -TERM "$GUI_PID" 2>/dev/null || true
|
||||||
|
wait "$GUI_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup INT TERM EXIT
|
||||||
|
|
||||||
|
# GUI starten (Vite)
|
||||||
|
echo "▶️ Starting GUI..."
|
||||||
|
(
|
||||||
|
cd gui
|
||||||
|
npm run dev
|
||||||
|
) &
|
||||||
|
|
||||||
|
GUI_PID=$!
|
||||||
|
|
||||||
|
# Box starten (FastAPI + Run-Modus)
|
||||||
|
echo "▶️ Starting Box..."
|
||||||
|
cd box
|
||||||
|
python3 main.py run
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ GUI PID: $GUI_PID"
|
||||||
|
echo "🛑 Press Ctrl+C to stop everything"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Klangkiste Dev Playbook
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This playbook documents the "virtual box" development environment. It is a complete, hardware-free simulation that runs entirely inside the repo and lets you test setup, playback, and API flows end-to-end.
|
||||||
|
|
||||||
|
Docs live in the repo-level `docs/` to keep all dev guidance in one predictable place for both Box and GUI contributors.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Python 3
|
||||||
|
- Node.js + npm
|
||||||
|
- From repo root
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
Quickstart (3-5 minutes):
|
||||||
|
1) Start everything:
|
||||||
|
```
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
2) Open the WiFi setup UI:
|
||||||
|
```
|
||||||
|
http://127.0.0.1:9000/setup
|
||||||
|
```
|
||||||
|
3) Add a WiFi profile and submit.
|
||||||
|
4) Check status:
|
||||||
|
```
|
||||||
|
curl http://127.0.0.1:8000/status
|
||||||
|
```
|
||||||
|
5) Start playback:
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"nfc_on","payload":{"uid":"UID_1"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Box not starting:
|
||||||
|
- Ensure dependencies are installed and ports 8000/9000 are free.
|
||||||
|
- GUI cannot reach API:
|
||||||
|
- Check `VITE_BOX_API_URL` and Box logs.
|
||||||
|
|
||||||
|
## What Is Mock vs Real
|
||||||
|
- Mock: WiFi, IP, Spotify, audio, hardware. These are logical simulations only.
|
||||||
|
- Real: Box process, playback state machine, API, CLI, resume logic, media traversal.
|
||||||
|
|
||||||
|
## Link Index
|
||||||
|
- Dev workflow: `docs/dev-workflow.md`
|
||||||
|
- Box lifecycle: `docs/box-lifecycle.md`
|
||||||
|
- Setup WebGUI: `docs/setup-webgui.md`
|
||||||
|
- HTTP API contract: `docs/api.md`
|
||||||
|
- GUI control panel: `docs/gui-control-panel.md`
|
||||||
|
- Playback testing: `docs/playback-testing.md`
|
||||||
|
- Offline scenarios: `docs/offline-scenarios.md`
|
||||||
|
- Security & storage: `docs/security-storage.md`
|
||||||
|
- CLI reference: `docs/cli.md`
|
||||||
|
- Virtual box notes: `docs/virtual-box.md`
|
||||||
|
- Legacy storage notes: `docs/storage.md`
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
# Box HTTP API
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Defines the stable contract between the GUI/CLI and the Box.
|
||||||
|
The Box is authoritative; the client only sends commands and reads status.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Box running on `http://127.0.0.1:8000`
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
### Status
|
||||||
|
```
|
||||||
|
curl http://127.0.0.1:8000/status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command (example)
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"nfc_on","payload":{"uid":"UID_1"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## GET /status
|
||||||
|
Response fields:
|
||||||
|
- `box_id`: unique device ID
|
||||||
|
- `wifi_state`: `WIFI_UNCONFIGURED | WIFI_CONNECTING | WIFI_ONLINE | WIFI_OFFLINE`
|
||||||
|
- `ip_address`: simulated IP (`192.168.4.1` or `127.0.0.1`)
|
||||||
|
- `connected_ssid`: connected SSID or `null`
|
||||||
|
- `wifi_profiles_count`: number of stored profiles
|
||||||
|
- `spotify_status`: `NOT_CONFIGURED | READY`
|
||||||
|
- `playback_state`:
|
||||||
|
- `state`: `IDLE | PLAYING | PAUSED`
|
||||||
|
- `active_uid`
|
||||||
|
- `playback_root`
|
||||||
|
- `current_file`
|
||||||
|
- `file_index`
|
||||||
|
- `position`
|
||||||
|
- `duration`
|
||||||
|
- `volume`
|
||||||
|
- `last_error`
|
||||||
|
|
||||||
|
Example response:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"box_id": "klangkiste-a9f3k7m2q8",
|
||||||
|
"wifi_state": "WIFI_ONLINE",
|
||||||
|
"ip_address": "127.0.0.1",
|
||||||
|
"connected_ssid": "HomeWiFi",
|
||||||
|
"wifi_profiles_count": 1,
|
||||||
|
"spotify_status": "READY",
|
||||||
|
"playback_state": {
|
||||||
|
"state": "PLAYING",
|
||||||
|
"active_uid": "UID_1",
|
||||||
|
"playback_root": "media/book_1",
|
||||||
|
"current_file": "/root/klangkiste/box/data/media/book_1/01.mp3",
|
||||||
|
"file_index": 0,
|
||||||
|
"position": 42,
|
||||||
|
"duration": 180,
|
||||||
|
"volume": 40,
|
||||||
|
"last_error": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## POST /command
|
||||||
|
Request body:
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"command": "nfc_on",
|
||||||
|
"payload": { "uid": "UID_1" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported commands:
|
||||||
|
- `nfc_on` `{ uid }`
|
||||||
|
- `nfc_off` `{ uid }`
|
||||||
|
- `play_pause`
|
||||||
|
- `next`
|
||||||
|
- `prev`
|
||||||
|
- `volume_up`
|
||||||
|
- `volume_down`
|
||||||
|
- `vol_up` (alias)
|
||||||
|
- `vol_down` (alias)
|
||||||
|
- `stop`
|
||||||
|
- `trigger_error` `{ type }`
|
||||||
|
- `wifi_add_profile` `{ ssid, password, priority? }`
|
||||||
|
- `wifi_reset`
|
||||||
|
- `spotify_set_tokens` `{ access_token, refresh_token, expires_at, account_id? }`
|
||||||
|
- `spotify_clear`
|
||||||
|
|
||||||
|
Example: add WiFi profile
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: set Spotify tokens
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ `GET /status` returns JSON
|
||||||
|
- ✅ `POST /command` returns `{ "ok": true }`
|
||||||
|
- ✅ No secrets appear in `/status`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- 400 responses:
|
||||||
|
- Check required fields in the payload.
|
||||||
|
- CORS errors:
|
||||||
|
- Ensure Box API is running on the expected host/port.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Box Lifecycle
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Defines the virtual box lifecycle: identity, WiFi states, and restart behavior.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Box started with `python3 box/main.py run`
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
### Box identity
|
||||||
|
- Generated on first start if `box/data/box.json` has no `box_id`.
|
||||||
|
- Format: `klangkiste-<10 chars a-z0-9>`
|
||||||
|
- Stored in `box/data/box.json`
|
||||||
|
|
||||||
|
### WiFi states (logical)
|
||||||
|
- `WIFI_UNCONFIGURED`: no profiles stored
|
||||||
|
- `WIFI_CONNECTING`: reserved for future; not emitted in mock
|
||||||
|
- `WIFI_ONLINE`: profile exists and mock is "connected"
|
||||||
|
- `WIFI_OFFLINE`: profile exists but not connected
|
||||||
|
|
||||||
|
### IP model (informational)
|
||||||
|
- Setup/AP mode: `192.168.4.1`
|
||||||
|
- Normal mode: `127.0.0.1`
|
||||||
|
|
||||||
|
### Restart behavior
|
||||||
|
- Box reloads `box_id`, resume state, and WiFi profiles from `box/data/`
|
||||||
|
- Playback resume and WiFi status are preserved
|
||||||
|
|
||||||
|
### Resets
|
||||||
|
- WiFi reset: clears WiFi profiles only (`wifi_reset` command)
|
||||||
|
- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc`
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ After first boot, `box/data/box.json` contains `box_id`
|
||||||
|
- ✅ `GET /status` returns `wifi_state` and `ip_address`
|
||||||
|
- ✅ WiFi reset clears profiles, `wifi_state=WIFI_UNCONFIGURED`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- `wifi_state` not changing:
|
||||||
|
- Add a WiFi profile via setup UI or API.
|
||||||
|
- Missing `box_id`:
|
||||||
|
- Ensure `box/data/box.json` is writable.
|
||||||
+39
-29
@@ -1,45 +1,55 @@
|
|||||||
# Klangkiste Box CLI
|
# Box CLI Reference
|
||||||
|
|
||||||
This document lists all currently available CLI interactions (Phase 1a).
|
## Purpose
|
||||||
|
List all CLI interactions available in the virtual box.
|
||||||
|
|
||||||
## Commands
|
## Prerequisites
|
||||||
|
- Box code available in `box/`
|
||||||
|
|
||||||
### status
|
## Step-by-step
|
||||||
Show the current runtime status.
|
### One-shot commands
|
||||||
|
Status:
|
||||||
Example:
|
|
||||||
```
|
```
|
||||||
python box/main.py status
|
python3 box/main.py status
|
||||||
```
|
```
|
||||||
|
|
||||||
### simulate-nfc <uid>
|
Simulate NFC:
|
||||||
Simulate NFC tag detection. Resolves the UID to a playback object from `box/config/box_config.json` and starts playback.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
```
|
||||||
python box/main.py simulate-nfc CHIP_1
|
python3 box/main.py simulate-nfc UID_1
|
||||||
|
python3 box/main.py simulate-nfc-off UID_1
|
||||||
```
|
```
|
||||||
|
|
||||||
### simulate-nfc-off <uid>
|
Manual tick:
|
||||||
Simulate NFC tag removal. Stops playback and stores progress for that UID.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
```
|
||||||
python box/main.py simulate-nfc-off CHIP_1
|
python3 box/main.py tick 30
|
||||||
```
|
```
|
||||||
|
|
||||||
### tick <seconds>
|
### Interactive run mode
|
||||||
Simulate playback time progression. Advances position and moves to next file when a file ends.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
```
|
||||||
python box/main.py tick 90
|
python3 box/main.py run
|
||||||
|
```
|
||||||
|
Commands inside run mode:
|
||||||
|
```
|
||||||
|
status
|
||||||
|
nfc <UID>
|
||||||
|
nfc-off <UID>
|
||||||
|
tick <seconds>
|
||||||
|
play | pause
|
||||||
|
next | vor
|
||||||
|
prev | zurueck
|
||||||
|
vol-up | lauter
|
||||||
|
vol-down | leiser
|
||||||
|
help
|
||||||
|
exit | quit
|
||||||
```
|
```
|
||||||
|
|
||||||
### run
|
## Checklist
|
||||||
Start an interactive session that keeps state in memory. Use `help` inside the session for available commands.
|
- ✅ `status` prints runtime state
|
||||||
|
- ✅ `simulate-nfc` starts playback
|
||||||
|
- ✅ `run` accepts interactive commands
|
||||||
|
|
||||||
Example:
|
## Troubleshooting
|
||||||
```
|
- Commands not found:
|
||||||
python box/main.py run
|
- Ensure you run from repo root or use full path to `box/main.py`.
|
||||||
```
|
- No playback:
|
||||||
|
- Check tags in `box/data/box.json` and media under `box/data/media`.
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Dev Workflow
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
How to start/stop the virtual box, how to access logs, and how to point the GUI to the API.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Python 3
|
||||||
|
- Node.js + npm
|
||||||
|
- Run from repo root
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
### Start everything (recommended)
|
||||||
|
```
|
||||||
|
./dev.sh
|
||||||
|
```
|
||||||
|
- GUI runs in the background
|
||||||
|
- Box runs in the foreground (you can type CLI commands)
|
||||||
|
|
||||||
|
### Start separately
|
||||||
|
Box only:
|
||||||
|
```
|
||||||
|
python3 box/main.py run
|
||||||
|
```
|
||||||
|
GUI only:
|
||||||
|
```
|
||||||
|
cd gui
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ports / Hosts
|
||||||
|
- Box API: `http://127.0.0.1:8000`
|
||||||
|
- Setup UI: `http://127.0.0.1:9000/setup`
|
||||||
|
- GUI: `http://127.0.0.1:5174`
|
||||||
|
|
||||||
|
## API URL for GUI
|
||||||
|
Default is `http://localhost:8000`. Override with:
|
||||||
|
```
|
||||||
|
VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
- Box logs appear in the same terminal where you run `python3 box/main.py run`.
|
||||||
|
- GUI logs appear in the browser console.
|
||||||
|
|
||||||
|
## Stop / Cleanup
|
||||||
|
- Press `Ctrl+C` in the terminal where the box is running.
|
||||||
|
- `dev.sh` will also stop the GUI process.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ Run `./dev.sh`
|
||||||
|
- ✅ Box shows interactive prompt
|
||||||
|
- ✅ GUI loads in browser
|
||||||
|
- ✅ `curl http://127.0.0.1:8000/status` returns JSON
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Port already in use:
|
||||||
|
- 8000 or 9000: stop other processes, or change ports in `box/main.py`.
|
||||||
|
- CORS error in browser:
|
||||||
|
- Ensure API is running; CORS is already enabled in the Box API.
|
||||||
|
- GUI shows "Failed to fetch":
|
||||||
|
- Check `VITE_BOX_API_URL` and Box API port.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# GUI Control Panel
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
How to use the Vite-based GUI to control and observe the virtual box.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Box running on `http://127.0.0.1:8000`
|
||||||
|
- GUI started (`npm run dev` or `./dev.sh`)
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
1) Open GUI:
|
||||||
|
```
|
||||||
|
http://127.0.0.1:5174
|
||||||
|
```
|
||||||
|
2) Click buttons to send commands.
|
||||||
|
3) Observe status updates (polling + refresh after command).
|
||||||
|
|
||||||
|
## Controls (current in GUI)
|
||||||
|
- Play / Pause
|
||||||
|
- Next
|
||||||
|
- Prev
|
||||||
|
- Vol +
|
||||||
|
- Vol -
|
||||||
|
- NFC UID_1 ON
|
||||||
|
- NFC UID_1 OFF
|
||||||
|
- Refresh Status
|
||||||
|
|
||||||
|
## Controls (available via API only)
|
||||||
|
- WiFi add/reset
|
||||||
|
- Spotify set/clear
|
||||||
|
|
||||||
|
Use API for those:
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected results
|
||||||
|
- After any button click, status refreshes within 1 second.
|
||||||
|
- `last_error` is visible if an error occurs.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ GUI loads and shows API base URL
|
||||||
|
- ✅ Status updates every 1s
|
||||||
|
- ✅ Clicking a button updates `/status`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- GUI shows "Failed to fetch":
|
||||||
|
- Check `VITE_BOX_API_URL`.
|
||||||
|
- Verify Box API is running.
|
||||||
|
- Buttons have no effect:
|
||||||
|
- Check the API command names in `docs/api.md`.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Offline Scenarios
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Simulate online/offline behavior in the virtual box and verify status fields.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Box running on `http://127.0.0.1:8000`
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
### WiFi offline (no profiles)
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"wifi_reset"}'
|
||||||
|
```
|
||||||
|
Expected:
|
||||||
|
- `wifi_state = WIFI_UNCONFIGURED`
|
||||||
|
- `connected_ssid = null`
|
||||||
|
|
||||||
|
### WiFi online
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}'
|
||||||
|
```
|
||||||
|
Expected:
|
||||||
|
- `wifi_state = WIFI_ONLINE`
|
||||||
|
- `connected_ssid = HomeWiFi`
|
||||||
|
|
||||||
|
### Spotify unavailable
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"spotify_clear"}'
|
||||||
|
```
|
||||||
|
Expected:
|
||||||
|
- `spotify_status = NOT_CONFIGURED`
|
||||||
|
|
||||||
|
### Spotify ready
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"spotify_set_tokens","payload":{"access_token":"a","refresh_token":"b","expires_at":123}}'
|
||||||
|
```
|
||||||
|
Expected:
|
||||||
|
- `spotify_status = READY`
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ WiFi reset shows UNCONFIGURED
|
||||||
|
- ✅ WiFi add profile shows ONLINE
|
||||||
|
- ✅ Spotify clear shows NOT_CONFIGURED
|
||||||
|
- ✅ Spotify set shows READY
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Status not changing:
|
||||||
|
- Ensure `/status` is polled after sending commands.
|
||||||
|
- Check that Box is running and secrets file is writable.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Playback Testing
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Test NFC playback, recursive media order, resume, and playback controls.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Box running: `python3 box/main.py run`
|
||||||
|
- Media in `box/data/media/`
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
### NFC simulation
|
||||||
|
Start playback:
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"nfc_on","payload":{"uid":"UID_1"}}'
|
||||||
|
```
|
||||||
|
Stop playback:
|
||||||
|
```
|
||||||
|
curl -X POST http://127.0.0.1:8000/command \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"command":"nfc_off","payload":{"uid":"UID_1"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resume tests
|
||||||
|
1) `nfc_on UID_1`
|
||||||
|
2) Wait or use `tick` in CLI
|
||||||
|
3) `nfc_off UID_1`
|
||||||
|
4) `nfc_on UID_1` again
|
||||||
|
Expected: position resumes from saved state.
|
||||||
|
|
||||||
|
### Tag switching
|
||||||
|
1) `nfc_on UID_1`
|
||||||
|
2) `nfc_on UID_2`
|
||||||
|
3) `nfc_on UID_1`
|
||||||
|
Expected: UID_1 resumes from previous position.
|
||||||
|
|
||||||
|
### Recursive order
|
||||||
|
- Order is depth-first, folders first, lexicographic.
|
||||||
|
- Change folder names or nesting to see ordering changes.
|
||||||
|
|
||||||
|
### End of media behavior
|
||||||
|
- When the last file ends:
|
||||||
|
- Playback stops
|
||||||
|
- Position saved at end
|
||||||
|
- State becomes IDLE
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
- `next`: file_index + 1
|
||||||
|
- `prev`: if position > 3s then position=0, else file_index - 1
|
||||||
|
- `play_pause`: toggle PLAYING/PAUSED
|
||||||
|
- `volume_up` / `volume_down`: change volume within 0..max
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ Resume works for same UID
|
||||||
|
- ✅ Tag switching preserves per-UID resume
|
||||||
|
- ✅ Recursive order matches folder structure
|
||||||
|
- ✅ End-of-media stops playback and saves
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- No playback starts:
|
||||||
|
- Check tags in `box/data/box.json`.
|
||||||
|
- Ensure files exist under `box/data/media`.
|
||||||
|
- Duration is 0:
|
||||||
|
- Filenames not matched; mock uses hash-based durations for unknown names.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Security & Storage
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Explain where data lives, what is encrypted, and how keys are derived.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Box running at least once to create `box/data/`
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
1) Start the box:
|
||||||
|
```
|
||||||
|
python3 box/main.py run
|
||||||
|
```
|
||||||
|
2) Inspect data files:
|
||||||
|
```
|
||||||
|
ls -la box/data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data layout
|
||||||
|
```
|
||||||
|
box/data/
|
||||||
|
├── box.json # box_id, settings, tags (plaintext)
|
||||||
|
├── state.json # resume, wifi state (plaintext)
|
||||||
|
├── media/ # audio files
|
||||||
|
└── secrets.enc # encrypted secrets (JSON inside)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What is plaintext
|
||||||
|
- box_id
|
||||||
|
- box name
|
||||||
|
- volume
|
||||||
|
- resume positions
|
||||||
|
- playback state
|
||||||
|
- media paths
|
||||||
|
- UI state
|
||||||
|
|
||||||
|
## What is encrypted
|
||||||
|
- WiFi profiles
|
||||||
|
- Spotify tokens
|
||||||
|
- Server tokens (future)
|
||||||
|
|
||||||
|
## Key derivation
|
||||||
|
- Key = PBKDF2-HMAC-SHA256(box_id + STATIC_SALT)
|
||||||
|
- Key is never stored on disk
|
||||||
|
- secrets.enc is not portable between boxes
|
||||||
|
|
||||||
|
## Reset behavior
|
||||||
|
- WiFi reset: removes only wifi_profiles from secrets.enc
|
||||||
|
- Factory reset: delete `box/data/box.json`, `box/data/state.json`, `box/data/secrets.enc`
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ `secrets.enc` exists after WiFi/Spotify set
|
||||||
|
- ✅ `box.json` contains `box_id`
|
||||||
|
- ✅ No secrets appear in `/status`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- secrets.enc not created:
|
||||||
|
- Ensure you ran `wifi_add_profile` or `spotify_set_tokens`.
|
||||||
|
- secrets.enc unreadable after reset:
|
||||||
|
- Factory reset creates a new box_id and new key.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Setup WebGUI
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Configure WiFi profiles in the virtual box without touching the API or CLI.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Box running: `python3 box/main.py run`
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
1) Open the Setup UI:
|
||||||
|
```
|
||||||
|
http://127.0.0.1:9000/setup
|
||||||
|
```
|
||||||
|
2) Choose SSID and enter password.
|
||||||
|
3) Click "Verbinden".
|
||||||
|
4) Refresh `/status` to see `wifi_state` and `connected_ssid`.
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
- SSID: drop-down from mock scan
|
||||||
|
- Password: free text
|
||||||
|
|
||||||
|
## Expected results
|
||||||
|
- WiFi profile is saved (encrypted)
|
||||||
|
- `wifi_state` becomes `WIFI_ONLINE`
|
||||||
|
- `connected_ssid` matches selected SSID
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- ✅ Setup UI loads
|
||||||
|
- ✅ Submit stores a profile
|
||||||
|
- ✅ `/status` reflects `WIFI_ONLINE`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Setup UI not reachable:
|
||||||
|
- Ensure Box is running and port `9000` is free.
|
||||||
|
- Submit does nothing:
|
||||||
|
- Check Box logs for errors; secrets file must be writable.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Setup UI is write-enabled only when no profiles exist.
|
||||||
|
- When profiles exist, the page switches to read-only status.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Storage (Legacy Notes)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This file is kept for backward reference. Current storage documentation lives in `docs/security-storage.md`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- None
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
- See `docs/security-storage.md`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- If you ended up here from older links, update them to `docs/security-storage.md`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Virtual Box Flow (IDE)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Describe the full, hardware-free box lifecycle used in development.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Python 3
|
||||||
|
- Box started with `python3 box/main.py run`
|
||||||
|
|
||||||
|
## Step-by-step
|
||||||
|
1) Box starts and creates `box/data/box.json` if missing.
|
||||||
|
2) WiFi state is derived from stored profiles:
|
||||||
|
- none -> `WIFI_UNCONFIGURED`
|
||||||
|
3) Setup UI available at `http://127.0.0.1:9000/setup`.
|
||||||
|
4) After profile submission, box becomes `WIFI_ONLINE`.
|
||||||
|
5) API available at `http://127.0.0.1:8000`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- Setup UI not reachable:
|
||||||
|
- Ensure port 9000 is free and Box is running.
|
||||||
|
- API not reachable:
|
||||||
|
- Ensure port 8000 is free and Box is running.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>gui</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1337
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "gui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.5.24"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { getStatus, sendCommand } from "./api.js";
|
||||||
|
|
||||||
|
const status = ref({});
|
||||||
|
const logs = ref([]);
|
||||||
|
const error = ref(null);
|
||||||
|
const apiBase = import.meta.env.VITE_BOX_API_URL || "http://10.10.10.23:8000";
|
||||||
|
let pollId = null;
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
status.value = await getStatus();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err?.message || "Failed to fetch status";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runCommand(command, payload = {}) {
|
||||||
|
error.value = null;
|
||||||
|
logs.value.unshift({
|
||||||
|
at: new Date().toLocaleTimeString(),
|
||||||
|
command,
|
||||||
|
payload,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await sendCommand(command, payload);
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err?.message || "Failed to send command";
|
||||||
|
logs.value.unshift({
|
||||||
|
at: new Date().toLocaleTimeString(),
|
||||||
|
command: "error",
|
||||||
|
payload: { message: error.value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await refresh();
|
||||||
|
pollId = setInterval(refresh, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollId) {
|
||||||
|
clearInterval(pollId);
|
||||||
|
pollId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>Klangkiste – Control Panel</h1>
|
||||||
|
|
||||||
|
<p>API: {{ apiBase }}</p>
|
||||||
|
<p v-if="error">Error: {{ error }}</p>
|
||||||
|
<p v-if="status.playback_state?.last_error">
|
||||||
|
last_error: {{ status.playback_state.last_error }}
|
||||||
|
</p>
|
||||||
|
<pre>{{ status }}</pre>
|
||||||
|
<h2>GUI Log</h2>
|
||||||
|
<pre>{{ logs }}</pre>
|
||||||
|
|
||||||
|
<button @click="runCommand('play_pause')">Play / Pause</button>
|
||||||
|
<button @click="runCommand('next')">Next</button>
|
||||||
|
<button @click="runCommand('prev')">Prev</button>
|
||||||
|
<button @click="runCommand('volume_up')">Vol +</button>
|
||||||
|
<button @click="runCommand('volume_down')">Vol -</button>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<button @click="runCommand('nfc_on', { uid: 'UID_1' })">
|
||||||
|
NFC UID_1 ON
|
||||||
|
</button>
|
||||||
|
<button @click="runCommand('nfc_off', { uid: 'UID_1' })">
|
||||||
|
NFC UID_1 OFF
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<button @click="refresh">Refresh Status</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// src/api.js
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_BOX_API_URL || "http://10.10.10.23:8000";
|
||||||
|
|
||||||
|
export async function getStatus() {
|
||||||
|
const response = await fetch(`${API_BASE}/status`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Status request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendCommand(command, payload = {}) {
|
||||||
|
console.log("GUI -> API", command, payload);
|
||||||
|
const response = await fetch(`${API_BASE}/command`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ command, payload }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Command request failed (${response.status})`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
msg: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1>{{ msg }}</h1>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button type="button" @click="count++">count is {{ count }}</button>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test HMR
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Check out
|
||||||
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
|
>create-vue</a
|
||||||
|
>, the official Vue + Vite starter
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Learn more about IDE Support for Vue in the
|
||||||
|
<a
|
||||||
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
|
target="_blank"
|
||||||
|
>Vue Docs Scaling up Guide</a
|
||||||
|
>.
|
||||||
|
</p>
|
||||||
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.read-the-docs {
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import './style.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #646cff;
|
||||||
|
text-decoration: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #535bf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 0.6em 1.2em;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.25s;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border-color: #646cff;
|
||||||
|
}
|
||||||
|
button:focus,
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 4px auto -webkit-focus-ring-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
color: #213547;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: #747bff;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: [{ find: "@", replacement: "/src" }],
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig
|
||||||
|
port: 5174 // optional, Standardport für Vite
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user