Nice Work
This commit is contained in:
+97
-33
@@ -7,6 +7,7 @@ import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -17,6 +18,7 @@ from core.announce import AnnounceConfig, announce_loop, pairing_poll_loop
|
||||
from core.box_controller import BoxController
|
||||
from core.identity import generate_box_id
|
||||
from core.lifecycle import NetworkLifecycle
|
||||
from core.media_sync import MediaSyncClient
|
||||
from core.pairing import PairingManager
|
||||
from core.state import BoxState, RuntimeState
|
||||
from player.mock_player import MockPlayer
|
||||
@@ -25,7 +27,6 @@ from spotify.mock import MockSpotifyBackend
|
||||
from storage.box_store import BoxStore
|
||||
from storage.state_store import StateStore
|
||||
from storage.secret_store import SecretStore
|
||||
from storage.json_storage import JsonStorage
|
||||
from wifi.mock import MockWifiBackend
|
||||
|
||||
|
||||
@@ -34,11 +35,6 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
prog="klangkiste-box",
|
||||
description="Klangkiste Box CLI (Phase 1a, local simulation)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config/box_config.json",
|
||||
help="Path to local box config JSON (legacy seed)",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("status", help="Show current runtime status")
|
||||
@@ -53,6 +49,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
tick_parser.add_argument("seconds", type=int, help="Seconds to advance")
|
||||
|
||||
subparsers.add_parser("run", help="Start interactive session + HTTP API")
|
||||
subparsers.add_parser("factory-reset", help="Factory reset (delete all local data)")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -81,17 +78,16 @@ def _state_from_data(
|
||||
return state
|
||||
|
||||
|
||||
def _normalize_tag_paths(tags: dict[str, Any]) -> dict[str, Any]:
|
||||
normalized = {}
|
||||
for uid, tag in tags.items():
|
||||
if not isinstance(tag, dict):
|
||||
continue
|
||||
path = tag.get("path")
|
||||
if isinstance(path, str) and path.startswith("../media/"):
|
||||
tag = dict(tag)
|
||||
tag["path"] = path.replace("../media/", "media/", 1)
|
||||
normalized[uid] = tag
|
||||
return normalized
|
||||
def _prune_resume(state: RuntimeState, tags: dict[str, Any]) -> bool:
|
||||
if not isinstance(tags, dict):
|
||||
return False
|
||||
valid_uids = {uid for uid, tag in tags.items() if isinstance(tag, dict)}
|
||||
stale_uids = [uid for uid in state._resume.keys() if uid not in valid_uids]
|
||||
if not stale_uids:
|
||||
return False
|
||||
for uid in stale_uids:
|
||||
state._resume.pop(uid, None)
|
||||
return True
|
||||
|
||||
|
||||
def _print_status(state: RuntimeState) -> None:
|
||||
@@ -119,12 +115,10 @@ def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
config_path = Path(args.config)
|
||||
if not config_path.is_absolute():
|
||||
config_path = (Path(__file__).resolve().parent / config_path).resolve()
|
||||
base_dir = Path(__file__).resolve().parent / "data"
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
(base_dir / "media").mkdir(parents=True, exist_ok=True)
|
||||
media_root = Path(__file__).resolve().parent / "media"
|
||||
media_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
box_store = BoxStore(base_dir)
|
||||
box_meta = box_store.load()
|
||||
@@ -133,14 +127,12 @@ def main() -> int:
|
||||
if "settings" not in box_meta:
|
||||
box_meta["settings"] = {}
|
||||
if "tags" not in box_meta:
|
||||
legacy_storage = JsonStorage(str(config_path))
|
||||
legacy_config = legacy_storage.load_config()
|
||||
if isinstance(legacy_config.get("tags"), dict):
|
||||
box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {}))
|
||||
if isinstance(box_meta.get("tags"), dict):
|
||||
box_meta["tags"] = _normalize_tag_paths(box_meta.get("tags", {}))
|
||||
if "server_url" not in box_meta:
|
||||
box_meta["server_url"] = os.getenv("KLANGKISTE_SERVER_URL", "http://127.0.0.1:7000")
|
||||
box_meta["tags"] = {}
|
||||
env_server_url = os.getenv("KLANGKISTE_SERVER_URL")
|
||||
if env_server_url:
|
||||
box_meta["server_url"] = env_server_url
|
||||
elif "server_url" not in box_meta:
|
||||
box_meta["server_url"] = "http://127.0.0.1:5001"
|
||||
if "firmware_version" not in box_meta:
|
||||
box_meta["firmware_version"] = "0.1.0"
|
||||
if "capabilities" not in box_meta:
|
||||
@@ -151,18 +143,27 @@ def main() -> int:
|
||||
state_data = state_store.load()
|
||||
|
||||
state = _state_from_data(box_meta, state_data)
|
||||
resume_pruned = _prune_resume(state, box_meta.get("tags", {}))
|
||||
secrets = SecretStore(base_dir, state.box_id)
|
||||
secrets.ensure_secret_seed()
|
||||
wifi_backend = MockWifiBackend(state_store, secrets)
|
||||
spotify_backend = MockSpotifyBackend(secrets)
|
||||
lifecycle = NetworkLifecycle(wifi_backend)
|
||||
pairing_manager = PairingManager(state_store, secrets)
|
||||
media_sync = MediaSyncClient(
|
||||
server_url=str(box_meta.get("server_url")),
|
||||
box_id=state.box_id,
|
||||
token_provider=pairing_manager.get_api_token,
|
||||
)
|
||||
controller = BoxController(
|
||||
state=state,
|
||||
player=MockPlayer(),
|
||||
config=box_meta,
|
||||
config_path=str(box_store.path),
|
||||
media_sync=media_sync,
|
||||
)
|
||||
if resume_pruned:
|
||||
_persist_resume(controller, state_store)
|
||||
|
||||
if args.command == "status":
|
||||
_print_status(controller.state)
|
||||
@@ -186,7 +187,16 @@ def main() -> int:
|
||||
_persist_resume(controller, state_store)
|
||||
return 0
|
||||
|
||||
if args.command == "factory-reset":
|
||||
_factory_reset(controller, box_store, state_store, secrets, media_root)
|
||||
print("Factory reset completed. Restart required.")
|
||||
return 0
|
||||
|
||||
if args.command == "run":
|
||||
announce_interval = _parse_int_env("KLANGKISTE_ANNOUNCE_INTERVAL", 30)
|
||||
poll_interval = _parse_int_env("KLANGKISTE_POLL_INTERVAL", 10)
|
||||
api_port = _parse_int_env("KLANGKISTE_API_PORT", 8000)
|
||||
setup_port = _parse_int_env("KLANGKISTE_SETUP_PORT", 9000)
|
||||
try:
|
||||
asyncio.run(
|
||||
_run_with_api(
|
||||
@@ -201,6 +211,11 @@ def main() -> int:
|
||||
server_url=str(box_meta.get("server_url")),
|
||||
firmware_version=str(box_meta.get("firmware_version")),
|
||||
capabilities=dict(box_meta.get("capabilities", {})),
|
||||
api_port=api_port,
|
||||
setup_port=setup_port,
|
||||
media_root=str(media_root.resolve()),
|
||||
announce_interval_seconds=announce_interval,
|
||||
polling_interval_seconds=poll_interval,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -218,6 +233,19 @@ def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]:
|
||||
}
|
||||
|
||||
|
||||
def _parse_int_env(name: str, default_value: int) -> int:
|
||||
raw = os.getenv(name)
|
||||
if not raw:
|
||||
return default_value
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError:
|
||||
return default_value
|
||||
if value <= 0:
|
||||
return default_value
|
||||
return value
|
||||
|
||||
|
||||
def _stdin_is_foreground() -> bool:
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
@@ -237,6 +265,9 @@ async def _run_with_api(
|
||||
secrets: SecretStore,
|
||||
announce_config: AnnounceConfig,
|
||||
) -> None:
|
||||
base_dir = Path(__file__).resolve().parent / "data"
|
||||
media_root = Path(__file__).resolve().parent / "media"
|
||||
box_store = BoxStore(base_dir)
|
||||
app = create_app(
|
||||
controller,
|
||||
lifecycle,
|
||||
@@ -244,12 +275,13 @@ async def _run_with_api(
|
||||
spotify_backend,
|
||||
pairing_manager,
|
||||
lambda: _persist_resume(controller, storage),
|
||||
lambda: _factory_reset(controller, box_store, storage, secrets, media_root),
|
||||
)
|
||||
api_server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
app,
|
||||
host="0.0.0.0",
|
||||
port=8000,
|
||||
port=announce_config.api_port,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
)
|
||||
@@ -259,7 +291,7 @@ async def _run_with_api(
|
||||
uvicorn.Config(
|
||||
setup_app,
|
||||
host="0.0.0.0",
|
||||
port=9000,
|
||||
port=announce_config.setup_port,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
)
|
||||
@@ -285,7 +317,17 @@ async def _run_with_api(
|
||||
|
||||
try:
|
||||
if _stdin_is_foreground():
|
||||
await _run_session(controller, storage)
|
||||
await _run_session(
|
||||
controller,
|
||||
storage,
|
||||
lambda: _factory_reset(
|
||||
controller,
|
||||
box_store,
|
||||
storage,
|
||||
secrets,
|
||||
media_root,
|
||||
),
|
||||
)
|
||||
api_server.should_exit = True
|
||||
setup_server.should_exit = True
|
||||
await api_task
|
||||
@@ -309,7 +351,7 @@ async def _run_with_api(
|
||||
|
||||
|
||||
async def _run_session(
|
||||
controller: BoxController, storage: StateStore
|
||||
controller: BoxController, storage: StateStore, factory_reset: callable
|
||||
) -> None:
|
||||
print("Klangkiste Box - Interactive Mode")
|
||||
print("Type 'help' for commands.")
|
||||
@@ -339,6 +381,10 @@ async def _run_session(
|
||||
if command == "status":
|
||||
_print_status(controller.state)
|
||||
continue
|
||||
if command == "factory-reset":
|
||||
factory_reset()
|
||||
print("Factory reset completed. Restart required.")
|
||||
break
|
||||
if command == "nfc":
|
||||
if len(args) != 1:
|
||||
print("error: usage: nfc <UID>")
|
||||
@@ -414,6 +460,23 @@ def _persist_resume(
|
||||
storage.save(data)
|
||||
|
||||
|
||||
def _factory_reset(
|
||||
controller: BoxController,
|
||||
box_store: BoxStore,
|
||||
state_store: StateStore,
|
||||
secrets: SecretStore,
|
||||
media_root: Path,
|
||||
) -> None:
|
||||
controller.factory_reset()
|
||||
if box_store.path.exists():
|
||||
box_store.path.unlink()
|
||||
if state_store.path.exists():
|
||||
state_store.path.unlink()
|
||||
secrets.clear_all()
|
||||
shutil.rmtree(media_root, ignore_errors=True)
|
||||
media_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _print_help() -> None:
|
||||
print("Commands:")
|
||||
print(" nfc <UID> simulate NFC tag detection")
|
||||
@@ -425,6 +488,7 @@ def _print_help() -> None:
|
||||
print(" vol-up | lauter increase volume")
|
||||
print(" vol-down | leiser decrease volume")
|
||||
print(" status show current status")
|
||||
print(" factory-reset delete all local data (restart required)")
|
||||
print(" help show this help")
|
||||
print(" exit | quit leave interactive mode")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user