commit 83bd89a25a12b562c205944115eee17bf88b041f Author: mboehmlaender Date: Sun Jan 11 21:24:50 2026 +0000 Initial project structure for Klangkiste diff --git a/README.md b/README.md new file mode 100644 index 0000000..97cea89 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Klangkiste 🎶📦 + +Klangkiste ist eine selbstgebaute, offlinefähige Kinder-Audiobox +(Phoniebox-Prinzip, aber vollständig eigenständig). + +## Ziel +- Mehrere Boxen +- Zentrale Verwaltung über Web-GUI (später) +- Jede Box arbeitet offlinefähig mit lokalem Medien-Cache + +## Status +Phase 1a – lokale Entwicklung ohne Hardware (Mocks) + +## Tech +- Python 3 +- Raspberry Pi Zero W / Zero 2 W (später) +- NFC, Buttons, Audio abstrahiert über Interfaces diff --git a/box/config/box_config.json b/box/config/box_config.json new file mode 100644 index 0000000..c2979df --- /dev/null +++ b/box/config/box_config.json @@ -0,0 +1,35 @@ +{ + "box_id": "box-0001", + "settings": { + "max_volume": 80, + "default_volume": 40 + }, + "tags": { + "UID_1": { + "type": "folder", + "path": "../media/book_1" + }, + "UID_2": { + "type": "folder", + "path": "../media/book_2" + }, + "UID_3": { + "type": "folder", + "path": "../media/book_3" + }, + "UID_4": { + "type": "folder", + "path": "../media/book_4" + }, + "UID_5": { + "type": "folder", + "path": "../media/book_5" + } + }, + "resume": { + "UID_1": { + "file_index": 0, + "position": 50 + } + } +} \ No newline at end of file diff --git a/box/core/__init__.py b/box/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/core/__pycache__/__init__.cpython-312.pyc b/box/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..367e96a Binary files /dev/null and b/box/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/box/core/__pycache__/box_controller.cpython-312.pyc b/box/core/__pycache__/box_controller.cpython-312.pyc new file mode 100644 index 0000000..f816184 Binary files /dev/null and b/box/core/__pycache__/box_controller.cpython-312.pyc differ diff --git a/box/core/__pycache__/state.cpython-312.pyc b/box/core/__pycache__/state.cpython-312.pyc new file mode 100644 index 0000000..b447ae9 Binary files /dev/null and b/box/core/__pycache__/state.cpython-312.pyc differ diff --git a/box/core/box_controller.py b/box/core/box_controller.py new file mode 100644 index 0000000..560dc30 --- /dev/null +++ b/box/core/box_controller.py @@ -0,0 +1,167 @@ +"""Central orchestration placeholder for the Box runtime.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from core.state import BoxState, RuntimeState +from player.base_player import BasePlayer + + +class BoxController: + def __init__( + self, + state: RuntimeState, + player: BasePlayer, + config: dict[str, Any], + config_path: str, + ) -> None: + self._state = state + self._player = player + self._config = config + self._config_path = Path(config_path) + + @property + def state(self) -> RuntimeState: + return self._state + + def start(self) -> None: + """Start the main loop (not implemented in Phase 1a).""" + return None + + def handle_nfc_on(self, uid: str) -> None: + tag = self._resolve_tag(uid) + if tag is None: + print(f"error: uid '{uid}' not found in config tags") + return None + + playlist = self._build_playlist(tag) + if not playlist: + path_value = tag.get("path", "-") + resolved = self._resolve_path(path_value) + print( + f"error: no playable files found for uid '{uid}' at path '{resolved}'" + ) + return None + + if self._state.active_uid == uid and uid in self._state._resume: + file_index, position = self._state._resume[uid] + else: + file_index, position = 0, 0 + + if file_index < 0 or file_index >= len(playlist): + file_index, position = 0, 0 + + media_ref = playlist[file_index] + duration = self._duration_for(media_ref) + self._state.active_uid = uid + self._state.playback_type = tag.get("type") + self._state.playback_path = tag.get("path") + self._state.current_file = Path(media_ref).name + self._state.current_duration = duration + self._state.file_index = file_index + self._state.position = position + self._state._playlist = playlist + + self._player.play(media_ref) + self._state.current_media = media_ref + self._state.state = BoxState.PLAYING + + def pause(self) -> None: + self._player.pause() + if self._state.state == BoxState.PLAYING: + self._state.state = BoxState.PAUSED + + def stop(self) -> None: + self._player.stop() + self._state.current_media = None + self._state.state = BoxState.IDLE + + def handle_nfc_off(self, uid: str) -> None: + if self._state.active_uid != uid: + return None + self._store_progress(uid) + self.stop() + + def _resolve_tag(self, uid: str) -> dict[str, Any] | None: + tags = self._config.get("tags", {}) + if not isinstance(tags, dict): + return None + tag = tags.get(uid) + if not isinstance(tag, dict): + return None + if "type" not in tag or "path" not in tag: + return None + return tag + + def tick(self, seconds: int) -> None: + if self._state.state != BoxState.PLAYING: + return None + if self._state.position is None or self._state.file_index is None: + return None + if not self._state._playlist: + return None + if seconds <= 0: + return None + + remaining = seconds + file_index = self._state.file_index + position = self._state.position + + while remaining > 0 and file_index < len(self._state._playlist): + media_ref = self._state._playlist[file_index] + duration = self._duration_for(media_ref) + if duration <= 0: + break + time_left = duration - position + if remaining < time_left: + position += remaining + remaining = 0 + else: + remaining -= time_left + file_index += 1 + position = 0 + + if file_index >= len(self._state._playlist): + self._state.file_index = len(self._state._playlist) - 1 + self._state.position = duration + self._state.current_media = None + self._state.current_file = None + self._state.current_duration = None + self._state.state = BoxState.IDLE + return None + + self._state.file_index = file_index + self._state.position = position + media_ref = self._state._playlist[file_index] + self._state.current_media = media_ref + self._state.current_file = Path(media_ref).name + self._state.current_duration = self._duration_for(media_ref) + + def _build_playlist(self, tag: dict[str, Any]) -> list[str]: + path_value = tag.get("path") + if not isinstance(path_value, str): + return [] + path = self._resolve_path(path_value) + if not path.exists() or not path.is_dir(): + return [] + files = [p for p in path.iterdir() if p.is_file()] + files_sorted = sorted(files, key=lambda p: p.name) + return [str(p) for p in files_sorted] + + def _resolve_path(self, path_value: str) -> Path: + path = Path(path_value) + if not path.is_absolute(): + return (self._config_path.parent / path).resolve() + return path + + def _store_progress(self, uid: str) -> None: + file_index = self._state.file_index if self._state.file_index is not None else 0 + position = self._state.position if self._state.position is not None else 0 + self._state._resume[uid] = (file_index, position) + + def _duration_for(self, media_ref: str) -> int: + name = Path(media_ref).name + durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220} + return durations.get(name, 0) diff --git a/box/core/state.py b/box/core/state.py new file mode 100644 index 0000000..38d8776 --- /dev/null +++ b/box/core/state.py @@ -0,0 +1,30 @@ +"""State model for the Box runtime (placeholder).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class BoxState(str, Enum): + IDLE = "IDLE" + PLAYING = "PLAYING" + PAUSED = "PAUSED" + + +@dataclass +class RuntimeState: + box_id: str + state: BoxState = BoxState.IDLE + current_media: Optional[str] = None + volume: int = 0 + active_uid: Optional[str] = None + playback_type: Optional[str] = None + playback_path: Optional[str] = None + current_file: Optional[str] = None + current_duration: Optional[int] = None + file_index: Optional[int] = None + position: Optional[int] = None + _resume: dict[str, tuple[int, int]] = field(default_factory=dict) + _playlist: list[str] = field(default_factory=list) diff --git a/box/hardware/__init__.py b/box/hardware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/hardware/base_buttons.py b/box/hardware/base_buttons.py new file mode 100644 index 0000000..46e7884 --- /dev/null +++ b/box/hardware/base_buttons.py @@ -0,0 +1,10 @@ +"""Base button interface.""" + +from __future__ import annotations + +from typing import Protocol + + +class BaseButtons(Protocol): + def poll(self) -> str | None: + raise NotImplementedError diff --git a/box/hardware/base_nfc.py b/box/hardware/base_nfc.py new file mode 100644 index 0000000..9969df2 --- /dev/null +++ b/box/hardware/base_nfc.py @@ -0,0 +1,10 @@ +"""Base NFC reader interface.""" + +from __future__ import annotations + +from typing import Protocol + + +class BaseNFC(Protocol): + def read_tag(self) -> str | None: + raise NotImplementedError diff --git a/box/hardware/mock_buttons.py b/box/hardware/mock_buttons.py new file mode 100644 index 0000000..84e0792 --- /dev/null +++ b/box/hardware/mock_buttons.py @@ -0,0 +1,20 @@ +"""Mock buttons for Phase 1a.""" + +from __future__ import annotations + +from collections import deque + +from hardware.base_buttons import BaseButtons + + +class MockButtons(BaseButtons): + def __init__(self) -> None: + self._queue: deque[str] = deque() + + def enqueue(self, button_id: str) -> None: + self._queue.append(button_id) + + def poll(self) -> str | None: + if not self._queue: + return None + return self._queue.popleft() diff --git a/box/hardware/mock_nfc.py b/box/hardware/mock_nfc.py new file mode 100644 index 0000000..7c7c98f --- /dev/null +++ b/box/hardware/mock_nfc.py @@ -0,0 +1,18 @@ +"""Mock NFC reader for Phase 1a.""" + +from __future__ import annotations + +from hardware.base_nfc import BaseNFC + + +class MockNFC(BaseNFC): + def __init__(self) -> None: + self._next_uid: str | None = None + + def set_next_uid(self, uid: str | None) -> None: + self._next_uid = uid + + def read_tag(self) -> str | None: + uid = self._next_uid + self._next_uid = None + return uid diff --git a/box/main.py b/box/main.py new file mode 100644 index 0000000..4a18906 --- /dev/null +++ b/box/main.py @@ -0,0 +1,206 @@ +"""CLI entrypoint for the Box runtime (Phase 1a).""" + +from __future__ import annotations + +import argparse +from typing import Any + +from core.box_controller import BoxController +from core.state import RuntimeState +from player.mock_player import MockPlayer +from storage.json_storage import JsonStorage + + +def build_parser() -> argparse.ArgumentParser: + 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", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("status", help="Show current runtime status") + + nfc_on = subparsers.add_parser("simulate-nfc", help="Simulate NFC tag detection") + nfc_on.add_argument("uid", help="NFC UID") + + nfc_off = subparsers.add_parser("simulate-nfc-off", help="Simulate NFC tag removal") + nfc_off.add_argument("uid", help="NFC UID") + + tick_parser = subparsers.add_parser("tick", help="Simulate playback time") + tick_parser.add_argument("seconds", type=int, help="Seconds to advance") + + subparsers.add_parser("run", help="Start interactive session") + 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 {} + default_volume = settings.get("default_volume", 0) + state = RuntimeState(box_id=box_id, volume=int(default_volume)) + + resume_raw = config.get("resume", {}) + if isinstance(resume_raw, dict): + for uid, data in resume_raw.items(): + if not isinstance(uid, str) or not isinstance(data, dict): + continue + file_index = data.get("file_index") + position = data.get("position") + if isinstance(file_index, int) and isinstance(position, int): + state._resume[uid] = (file_index, position) + return state + + +def _print_status(state: RuntimeState) -> None: + current = state.current_media if state.current_media is not None else "-" + current_file = state.current_file if state.current_file is not None else "-" + current_duration = state.current_duration if state.current_duration is not None else "-" + active_uid = state.active_uid if state.active_uid is not None else "-" + playback = state.playback_path if state.playback_path is not None else "-" + file_index = state.file_index if state.file_index is not None else "-" + position = state.position if state.position is not None else "-" + print(f"box_id={state.box_id}") + print(f"state={state.state.value}") + print(f"active_uid={active_uid}") + print(f"playback={playback}") + print(f"current_file={current_file}") + print(f"file_index={file_index}") + print(f"position={position}") + print(f"duration={current_duration}") + print(f"current_media={current}") + print(f"volume={state.volume}") + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + storage = JsonStorage(args.config) + config = storage.load_config() + state = _state_from_config(config) + controller = BoxController( + state=state, + player=MockPlayer(), + config=config, + config_path=args.config, + ) + + if args.command == "status": + _print_status(controller.state) + return 0 + + if args.command == "simulate-nfc": + controller.handle_nfc_on(args.uid) + _print_status(controller.state) + config["resume"] = _resume_to_json(controller.state) + storage.save_config(config) + 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) + 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) + return 0 + + if args.command == "run": + _run_session(controller, storage, config) + return 0 + + return 0 + + +def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]: + return { + uid: {"file_index": file_index, "position": position} + for uid, (file_index, position) in state._resume.items() + } + + +def _run_session(controller: BoxController, storage: JsonStorage, config: dict[str, Any]) -> None: + print("Klangkiste Box - Interactive Mode") + print("Type 'help' for commands.") + while True: + try: + raw = input("> ").strip() + except EOFError: + print() + break + if not raw: + continue + parts = raw.split() + command = parts[0] + args = parts[1:] + + if command in {"exit", "quit"}: + break + if command == "help": + _print_help() + continue + if command == "status": + _print_status(controller.state) + continue + if command == "nfc": + if len(args) != 1: + print("error: usage: nfc ") + continue + controller.handle_nfc_on(args[0]) + _print_status(controller.state) + _persist_resume(controller, storage, config) + continue + if command == "nfc-off": + if len(args) != 1: + print("error: usage: nfc-off ") + continue + controller.handle_nfc_off(args[0]) + _print_status(controller.state) + _persist_resume(controller, storage, config) + continue + if command == "tick": + if len(args) != 1: + print("error: usage: tick ") + continue + try: + seconds = int(args[0]) + except ValueError: + print("error: seconds must be an integer") + continue + controller.tick(seconds) + _print_status(controller.state) + _persist_resume(controller, storage, config) + continue + + print(f"error: unknown command '{command}'") + + +def _persist_resume( + controller: BoxController, storage: JsonStorage, config: dict[str, Any] +) -> None: + config["resume"] = _resume_to_json(controller.state) + storage.save_config(config) + + +def _print_help() -> None: + print("Commands:") + print(" nfc simulate NFC tag detection") + print(" nfc-off simulate NFC tag removal") + print(" tick simulate time progression") + print(" status show current status") + print(" help show this help") + print(" exit | quit leave interactive mode") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/box/media/.keep b/box/media/.keep new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_1/01.mp3 b/box/media/book_1/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_1/02.mp3 b/box/media/book_1/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_1/03.mp3 b/box/media/book_1/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_2/01.mp3 b/box/media/book_2/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_2/02.mp3 b/box/media/book_2/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_2/03.mp3 b/box/media/book_2/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_3/01.mp3 b/box/media/book_3/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_3/02.mp3 b/box/media/book_3/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_3/03.mp3 b/box/media/book_3/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_4/01.mp3 b/box/media/book_4/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_4/02.mp3 b/box/media/book_4/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_4/03.mp3 b/box/media/book_4/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_5/01.mp3 b/box/media/book_5/01.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_5/02.mp3 b/box/media/book_5/02.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/media/book_5/03.mp3 b/box/media/book_5/03.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/box/player/__init__.py b/box/player/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/player/__pycache__/__init__.cpython-312.pyc b/box/player/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ab3a646 Binary files /dev/null and b/box/player/__pycache__/__init__.cpython-312.pyc differ diff --git a/box/player/__pycache__/base_player.cpython-312.pyc b/box/player/__pycache__/base_player.cpython-312.pyc new file mode 100644 index 0000000..463c4bb Binary files /dev/null and b/box/player/__pycache__/base_player.cpython-312.pyc differ diff --git a/box/player/__pycache__/mock_player.cpython-312.pyc b/box/player/__pycache__/mock_player.cpython-312.pyc new file mode 100644 index 0000000..a8095a7 Binary files /dev/null and b/box/player/__pycache__/mock_player.cpython-312.pyc differ diff --git a/box/player/base_player.py b/box/player/base_player.py new file mode 100644 index 0000000..564d3bc --- /dev/null +++ b/box/player/base_player.py @@ -0,0 +1,19 @@ +"""Base player interface for audio playback.""" + +from __future__ import annotations + +from typing import Protocol + + +class BasePlayer(Protocol): + def play(self, media_ref: str) -> None: + raise NotImplementedError + + def pause(self) -> None: + raise NotImplementedError + + def stop(self) -> None: + raise NotImplementedError + + def set_volume(self, volume: int) -> None: + raise NotImplementedError diff --git a/box/player/mock_player.py b/box/player/mock_player.py new file mode 100644 index 0000000..74ea0ea --- /dev/null +++ b/box/player/mock_player.py @@ -0,0 +1,23 @@ +"""Mock player for Phase 1a (no real audio).""" + +from __future__ import annotations + +from player.base_player import BasePlayer + + +class MockPlayer(BasePlayer): + def __init__(self) -> None: + self._current: str | None = None + self._volume: int = 0 + + def play(self, media_ref: str) -> None: + self._current = media_ref + + def pause(self) -> None: + return None + + def stop(self) -> None: + self._current = None + + def set_volume(self, volume: int) -> None: + self._volume = volume diff --git a/box/schemas/box_config.schema.json b/box/schemas/box_config.schema.json new file mode 100644 index 0000000..dc50b56 --- /dev/null +++ b/box/schemas/box_config.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klangkiste.local/schemas/box_config.schema.json", + "title": "BoxConfig", + "type": "object", + "required": ["box_id", "settings", "tags"], + "properties": { + "box_id": {"type": "string"}, + "settings": { + "type": "object", + "required": ["max_volume", "default_volume"], + "properties": { + "max_volume": {"type": "integer", "minimum": 0, "maximum": 100}, + "default_volume": {"type": "integer", "minimum": 0, "maximum": 100} + }, + "additionalProperties": false + }, + "tags": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["type", "path"], + "properties": { + "type": { + "type": "string", + "enum": ["folder"] + }, + "path": {"type": "string"} + }, + "additionalProperties": false + } + }, + "resume": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["file_index", "position"], + "properties": { + "file_index": {"type": "integer", "minimum": 0}, + "position": {"type": "integer", "minimum": 0} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/box/schemas/box_status.schema.json b/box/schemas/box_status.schema.json new file mode 100644 index 0000000..9a54e9e --- /dev/null +++ b/box/schemas/box_status.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klangkiste.local/schemas/box_status.schema.json", + "title": "BoxStatus", + "type": "object", + "required": ["box_id", "state", "current_media", "volume"], + "properties": { + "box_id": {"type": "string"}, + "state": {"type": "string", "enum": ["IDLE", "PLAYING", "PAUSED"]}, + "current_media": {"type": ["string", "null"]}, + "volume": {"type": "integer", "minimum": 0, "maximum": 100} + }, + "additionalProperties": false +} diff --git a/box/storage/__init__.py b/box/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/box/storage/__pycache__/__init__.cpython-312.pyc b/box/storage/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8310490 Binary files /dev/null and b/box/storage/__pycache__/__init__.cpython-312.pyc differ diff --git a/box/storage/__pycache__/json_storage.cpython-312.pyc b/box/storage/__pycache__/json_storage.cpython-312.pyc new file mode 100644 index 0000000..ac0de14 Binary files /dev/null and b/box/storage/__pycache__/json_storage.cpython-312.pyc differ diff --git a/box/storage/__pycache__/storage.cpython-312.pyc b/box/storage/__pycache__/storage.cpython-312.pyc new file mode 100644 index 0000000..fdd1555 Binary files /dev/null and b/box/storage/__pycache__/storage.cpython-312.pyc differ diff --git a/box/storage/json_storage.py b/box/storage/json_storage.py new file mode 100644 index 0000000..28dfafa --- /dev/null +++ b/box/storage/json_storage.py @@ -0,0 +1,21 @@ +"""JSON file storage placeholder for Phase 1a.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from storage.storage import Storage + + +class JsonStorage(Storage): + def __init__(self, path: str) -> None: + self._path = Path(path) + + def load_config(self) -> dict: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def save_config(self, config: dict) -> None: + self._path.write_text(json.dumps(config, indent=2), encoding="utf-8") diff --git a/box/storage/storage.py b/box/storage/storage.py new file mode 100644 index 0000000..8acceba --- /dev/null +++ b/box/storage/storage.py @@ -0,0 +1,13 @@ +"""Storage interface for Box configuration and state.""" + +from __future__ import annotations + +from typing import Protocol + + +class Storage(Protocol): + def load_config(self) -> dict: + raise NotImplementedError + + def save_config(self, config: dict) -> None: + raise NotImplementedError diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..0acb9d6 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,45 @@ +# Klangkiste Box CLI + +This document lists all currently available CLI interactions (Phase 1a). + +## Commands + +### status +Show the current runtime status. + +Example: +``` +python box/main.py status +``` + +### 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 +``` + +### simulate-nfc-off +Simulate NFC tag removal. Stops playback and stores progress for that UID. + +Example: +``` +python box/main.py simulate-nfc-off CHIP_1 +``` + +### tick +Simulate playback time progression. Advances position and moves to next file when a file ends. + +Example: +``` +python box/main.py tick 90 +``` + +### run +Start an interactive session that keeps state in memory. Use `help` inside the session for available commands. + +Example: +``` +python box/main.py run +```