This commit is contained in:
2026-01-12 15:25:34 +00:00
parent 83bd89a25a
commit fc5a281e85
85 changed files with 3748 additions and 73 deletions
+214 -29
View File
@@ -3,12 +3,28 @@
from __future__ import annotations
import argparse
import asyncio
import contextlib
import os
import sys
from pathlib import Path
from typing import Any
import uvicorn
from api.http import create_app
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 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 wifi.mock import MockWifiBackend
def build_parser() -> argparse.ArgumentParser:
@@ -19,7 +35,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--config",
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)
@@ -34,17 +50,24 @@ def build_parser() -> argparse.ArgumentParser:
tick_parser = subparsers.add_parser("tick", help="Simulate playback time")
tick_parser.add_argument("seconds", type=int, help="Seconds to advance")
subparsers.add_parser("run", help="Start interactive session")
subparsers.add_parser("run", help="Start interactive session + HTTP API")
return parser
def _state_from_config(config: dict[str, Any]) -> RuntimeState:
box_id = str(config.get("box_id", "unknown"))
settings = config.get("settings", {}) if isinstance(config.get("settings"), dict) else {}
def _state_from_data(
box_meta: dict[str, Any], state_data: dict[str, Any]
) -> RuntimeState:
box_id = str(box_meta.get("box_id", "unknown"))
settings = box_meta.get("settings", {}) if isinstance(box_meta.get("settings"), dict) else {}
default_volume = settings.get("default_volume", 0)
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):
for uid, data in resume_raw.items():
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
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:
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 "-"
@@ -74,20 +110,48 @@ def _print_status(state: RuntimeState) -> None:
print(f"duration={current_duration}")
print(f"current_media={current}")
print(f"volume={state.volume}")
print(f"max_volume={state.max_volume}")
def main() -> int:
parser = build_parser()
args = parser.parse_args()
storage = JsonStorage(args.config)
config = storage.load_config()
state = _state_from_config(config)
config_path = Path(args.config)
if not config_path.is_absolute():
config_path = (Path(__file__).resolve().parent / config_path).resolve()
base_dir = Path(__file__).resolve().parent / "data"
base_dir.mkdir(parents=True, exist_ok=True)
(base_dir / "media").mkdir(parents=True, exist_ok=True)
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(
state=state,
player=MockPlayer(),
config=config,
config_path=args.config,
config=box_meta,
config_path=str(box_store.path),
)
if args.command == "status":
@@ -97,26 +161,31 @@ def main() -> int:
if args.command == "simulate-nfc":
controller.handle_nfc_on(args.uid)
_print_status(controller.state)
config["resume"] = _resume_to_json(controller.state)
storage.save_config(config)
_persist_resume(controller, state_store)
return 0
if args.command == "simulate-nfc-off":
controller.handle_nfc_off(args.uid)
_print_status(controller.state)
config["resume"] = _resume_to_json(controller.state)
storage.save_config(config)
_persist_resume(controller, state_store)
return 0
if args.command == "tick":
controller.tick(args.seconds)
_print_status(controller.state)
config["resume"] = _resume_to_json(controller.state)
storage.save_config(config)
_persist_resume(controller, state_store)
return 0
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
@@ -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("Type 'help' for commands.")
while True:
try:
raw = input("> ").strip()
raw = await asyncio.to_thread(input, "> ")
raw = raw.strip()
except asyncio.CancelledError:
break
except EOFError:
print()
break
@@ -145,6 +287,9 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
args = parts[1:]
if command in {"exit", "quit"}:
if controller.state.active_uid is not None:
controller.handle_nfc_off(controller.state.active_uid)
_persist_resume(controller, storage)
break
if command == "help":
_print_help()
@@ -158,7 +303,7 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
continue
controller.handle_nfc_on(args[0])
_print_status(controller.state)
_persist_resume(controller, storage, config)
_persist_resume(controller, storage)
continue
if command == "nfc-off":
if len(args) != 1:
@@ -166,7 +311,7 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
continue
controller.handle_nfc_off(args[0])
_print_status(controller.state)
_persist_resume(controller, storage, config)
_persist_resume(controller, storage)
continue
if command == "tick":
if len(args) != 1:
@@ -179,17 +324,52 @@ def _run_session(controller: BoxController, storage: JsonStorage, config: dict[s
continue
controller.tick(seconds)
_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
print(f"error: unknown command '{command}'")
def _persist_resume(
controller: BoxController, storage: JsonStorage, config: dict[str, Any]
async def _auto_tick_loop(
controller: BoxController,
storage: StateStore,
stop_event: asyncio.Event,
) -> None:
config["resume"] = _resume_to_json(controller.state)
storage.save_config(config)
while not stop_event.is_set():
await asyncio.sleep(1)
if controller.state.state == BoxState.PLAYING:
controller.tick(1)
_persist_resume(controller, storage)
def _persist_resume(
controller: BoxController, storage: StateStore
) -> None:
data = storage.load()
data["resume"] = _resume_to_json(controller.state)
storage.save(data)
def _print_help() -> None:
@@ -197,6 +377,11 @@ def _print_help() -> None:
print(" nfc <UID> simulate NFC tag detection")
print(" nfc-off <UID> simulate NFC tag removal")
print(" tick <seconds> simulate time progression")
print(" play | pause toggle PLAYING/PAUSED")
print(" next | vor jump to next file")
print(" prev | zurueck jump to previous file")
print(" vol-up | lauter increase volume")
print(" vol-down | leiser decrease volume")
print(" status show current status")
print(" help show this help")
print(" exit | quit leave interactive mode")