From 83bd89a25a12b562c205944115eee17bf88b041f Mon Sep 17 00:00:00 2001 From: mboehmlaender Date: Sun, 11 Jan 2026 21:24:50 +0000 Subject: [PATCH] Initial project structure for Klangkiste --- README.md | 17 ++ box/config/box_config.json | 35 +++ box/core/__init__.py | 0 box/core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 134 bytes .../box_controller.cpython-312.pyc | Bin 0 -> 9352 bytes box/core/__pycache__/state.cpython-312.pyc | Bin 0 -> 1540 bytes box/core/box_controller.py | 167 ++++++++++++++ box/core/state.py | 30 +++ box/hardware/__init__.py | 0 box/hardware/base_buttons.py | 10 + box/hardware/base_nfc.py | 10 + box/hardware/mock_buttons.py | 20 ++ box/hardware/mock_nfc.py | 18 ++ box/main.py | 206 ++++++++++++++++++ box/media/.keep | 0 box/media/book_1/01.mp3 | 0 box/media/book_1/02.mp3 | 0 box/media/book_1/03.mp3 | 0 box/media/book_2/01.mp3 | 0 box/media/book_2/02.mp3 | 0 box/media/book_2/03.mp3 | 0 box/media/book_3/01.mp3 | 0 box/media/book_3/02.mp3 | 0 box/media/book_3/03.mp3 | 0 box/media/book_4/01.mp3 | 0 box/media/book_4/02.mp3 | 0 box/media/book_4/03.mp3 | 0 box/media/book_5/01.mp3 | 0 box/media/book_5/02.mp3 | 0 box/media/book_5/03.mp3 | 0 box/player/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 136 bytes .../__pycache__/base_player.cpython-312.pyc | Bin 0 -> 1073 bytes .../__pycache__/mock_player.cpython-312.pyc | Bin 0 -> 1281 bytes box/player/base_player.py | 19 ++ box/player/mock_player.py | 23 ++ box/schemas/box_config.schema.json | 47 ++++ box/schemas/box_status.schema.json | 14 ++ box/storage/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 137 bytes .../__pycache__/json_storage.cpython-312.pyc | Bin 0 -> 1481 bytes .../__pycache__/storage.cpython-312.pyc | Bin 0 -> 807 bytes box/storage/json_storage.py | 21 ++ box/storage/storage.py | 13 ++ docs/cli.md | 45 ++++ 45 files changed, 695 insertions(+) create mode 100644 README.md create mode 100644 box/config/box_config.json create mode 100644 box/core/__init__.py create mode 100644 box/core/__pycache__/__init__.cpython-312.pyc create mode 100644 box/core/__pycache__/box_controller.cpython-312.pyc create mode 100644 box/core/__pycache__/state.cpython-312.pyc create mode 100644 box/core/box_controller.py create mode 100644 box/core/state.py create mode 100644 box/hardware/__init__.py create mode 100644 box/hardware/base_buttons.py create mode 100644 box/hardware/base_nfc.py create mode 100644 box/hardware/mock_buttons.py create mode 100644 box/hardware/mock_nfc.py create mode 100644 box/main.py create mode 100644 box/media/.keep create mode 100644 box/media/book_1/01.mp3 create mode 100644 box/media/book_1/02.mp3 create mode 100644 box/media/book_1/03.mp3 create mode 100644 box/media/book_2/01.mp3 create mode 100644 box/media/book_2/02.mp3 create mode 100644 box/media/book_2/03.mp3 create mode 100644 box/media/book_3/01.mp3 create mode 100644 box/media/book_3/02.mp3 create mode 100644 box/media/book_3/03.mp3 create mode 100644 box/media/book_4/01.mp3 create mode 100644 box/media/book_4/02.mp3 create mode 100644 box/media/book_4/03.mp3 create mode 100644 box/media/book_5/01.mp3 create mode 100644 box/media/book_5/02.mp3 create mode 100644 box/media/book_5/03.mp3 create mode 100644 box/player/__init__.py create mode 100644 box/player/__pycache__/__init__.cpython-312.pyc create mode 100644 box/player/__pycache__/base_player.cpython-312.pyc create mode 100644 box/player/__pycache__/mock_player.cpython-312.pyc create mode 100644 box/player/base_player.py create mode 100644 box/player/mock_player.py create mode 100644 box/schemas/box_config.schema.json create mode 100644 box/schemas/box_status.schema.json create mode 100644 box/storage/__init__.py create mode 100644 box/storage/__pycache__/__init__.cpython-312.pyc create mode 100644 box/storage/__pycache__/json_storage.cpython-312.pyc create mode 100644 box/storage/__pycache__/storage.cpython-312.pyc create mode 100644 box/storage/json_storage.py create mode 100644 box/storage/storage.py create mode 100644 docs/cli.md 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 0000000000000000000000000000000000000000..367e96a95b3c840b9c97c4c9efb0b8787f6ac37f GIT binary patch literal 134 zcmX@j%ge<81cyE)XM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrK(?)pI@S%os*cC zo}F1-lB%DSU!k9zUzDmJAD@|*SrQ+wS5Wzj!zMRBr8Fniu80+=ixG&6L5z>gjEsy$ H%s>_Ze)1l4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f81618426d0bd75ab65b42c67d7949a702e39ffa GIT binary patch literal 9352 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 O2*9}p^k1YhjsF2eNQ~J4 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ab3a646d5151074879c4845e89d0753c00913921 GIT binary patch literal 136 zcmX@j%ge<81cyE)XM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrLJF;pI@S%os*cC zo}F1-lB%DSU!h-+lUSKrq#qxjnU`4-AFo$X`HRCQH$SB`C)KWq6{wF9h>JmtkIamW Jj77{q768#PA6fta literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..463c4bbbf7f24f04e68b4842ee472851f826ae32 GIT binary patch literal 1073 zcmb7Dzi-qq6t)v5cfI};RiUCwTCoJ7-o}I&kPu=ybjMJo-Os%END29do7)vQLc7O6 zE(j+H;gowNt-Qpe$oRIpKj|AAFaW1k*6?&EjWSBce%}O@)ezS_?JK zauJQCin5j$(tbM5^3%ibm={yCn$@+`nJ#4Az_x#+q?WlXb63})=M3#00l6T8BoqXC zJmmD8B|h(Q4=e-ggAI5eXXx=(PDTkUk}|; z439xW>iFSgY8cYl=0e#aI#!@i*9Uk*zHaP)?(cWNNsdBq*Iz^{%v4xoPSq-y27@ED z4rqc&);40cwcHUsYw%HRB5VQte=Btd(v};sM$5?`lcB?QShV@aYgkMadv+odp2@PU z#Lbbr(FTpssq@cvmgZZyQR8CBQOp#MZTvyY-C|*$qHblzOiT8X-o6w0H-ap@a@Av+kztw70hUeq&c2Kt%`$5X|*hb*v zrzi}WVlAe)vO?aqS{5g{6qdT8B{;L#OQysLQ??dgOUAEd_Z_c1@Lt93Bexg&eGP6W z2KV5NpJ?~7@9ssh3)&A|KT|>M?f)b{kLQbi6ve5Z262?2T77}{4+{UbiEJpsrNhTh zkPew zB&lv3#_t=ZJB==akw)I{^)Z+Xq9B!WuzC%1e`;L7c*!!75A5v0-l_OhJKNkk6`z;J zXk+b3cSy$ROsZKyVaSdNKiP}ZXCvMmaj2l?S+IyKU+8KT_!4v{Az#;pB{B=bHmJ{l zAwvy6C4>fntnVO#8r(vyebpgU=oI}FC=6Mj0U;kTpzSlQF)8expCx**Jez|obUmSk z#3c3An+g6YaeiQvbZ{H}Z)6ln8u#XkBEu@>n-;ZsUDSWHwJ6c!KvAZRb<9%Cb+wx4 zbh0diwVArF&39+O+$&~WG@ywK=YUQ#h;q@4Bkc*G3FoCG1=vIhwkWrNjlvp4>5R2P z`xW_JEoEMnvKy-`BzZ~7x0xTBFF?ZuY6cJpSSNCy$Qluvb1^q?NFqXOl+cY6HnuE# zdtBl6?%3h>9U@y7NVbh1-EMjgz7(nAwn5R!uo^@G^bg5bCU4;oe*lwr0kl9S+dP8H o$}>~Kb9jhXX!#N<`&Wr`{+%_>+3q>>E}S~w{<86hAuCY)1-=dy0RR91 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..831049050ee854cdb9858963b4bf48dfaba0bad6 GIT binary patch literal 137 zcmX@j%ge<81cyE)XM*U*AOanHW&w&!XQ*V*Wb|9fP{ah}eFmxdrJ-MxpI@S%os*cC zo}F1-lB%DSU!h-Il3$dVo~j=opP83g5+AQuQ2C3)CO1E&G$+-rh!v=j5r~UHjE~HW KjEqIhKo$V`WFNx- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ac0de14aa2ce830693edd0798c94e888aaa076de GIT binary patch literal 1481 zcmb7Dy>A>v6rb5|-`O0<2?0xv7Gr_dfjgy(5JH3oVTmP@3#4mCo1Hm(FMGRYW)JL> zixe)B0tJb}j)as7iOBf_Qqt4dN|3`SR8fj_h)Wt*ytlg-oeT&u(!H;F``&MU_uema za}5IL$3Np_#USJfI+J5p1|6@0u}3)R5>C01(t*)6DB5Ofc1_S$Y7OkJUB>Lx8Ms}S z5`%n7xU);RE8GLKs@3(j&A=O7SY7)f^m|Dvd{yK!S{MFi8pUEGPq~nOFPHw6jYtXq zV$^Mxe40dwgOK#S5=-(iq!?mDu^9^lVSxeqab$ZL2d64_AIxbL65*O7T%bgrdQwtg*9h3H((w>mW;oj#ypwbS0*)*fR?mK2PQ&P)Mq zPolF>Q_X?6P9C3qbAS2!?hWhP#ux%5ehCd6{8P)v?1QmKc1R!e>C?w2pE?J%uZ)DO zk)j?BLi*0BL~#G5>dfCWIlX60NSx?35}4tMJuG_fU%E@RC$c!_Nwyx?5-Vs&Yyn$J zTS$?%(>&rz&qxt*R*0=4pb}?V;j4X#yyzz8usF|p$@=KrG<{PSE&S*bi0kB0YyQ^g z=IFTPAGQ34t))lvXK#G=xC-ts-&wi6a=g$!T4>*Q4;R`G7CwH^x_szemao9a@$}>% zH6_^_VEiAl|ASsy(pGok<~{46wkd(>v%_cT!frT8#zj`hSHYF9Va%#1YpL zGZ5D{9}YGZOf6EUorV01F6#}Ft_B2%?wIf9_l$o}|JQIM%#~X_$aRF(I;K(Hu7?y8= z2prstEE)*Lbc3-$&W9=5vy6Q;jMC~x;$FyeD9(epOUlmWv!shr6bM4igV-fwgWCSs zG3`(2^9Hp;TIFL_Fr!_@cpg{%dP9H<{Uk0-#iWZXo+ekT{clY+xYiQ)rfT{QS~xN# ykQ4923j9a%0>p556~6TklT!K{x%iYUJt58C$s13}+s~Z_4e599z96W}8h-(DltZQf literal 0 HcmV?d00001 diff --git a/box/storage/__pycache__/storage.cpython-312.pyc b/box/storage/__pycache__/storage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fdd155553385bd1769934989f8df910d294c6291 GIT binary patch literal 807 zcmaJCEMbZ(ui&MZc$~Oj_@zh$XC8aOlvpwqXz|&1eMztg%_2yy>P@WiW%)8n2yKV64eZk+}s9Jr{U; z56OyYmT*++DWUjMB)$rihfys2IBa&dS~9eHd5EKA-tHq=F?(6~z}I}d=1qTW?8|ai z=u)Ev^&G$e$`Q3LCN*_O=MP3;j2#^oX*E5}YpX|Z%<-sp&M)F{wrIMANtJZQPx@Oq z!z;;aIIry>yl3C{559H|TGEqkT@q*i5e(D`G$Zz)i=heKu9^l!5$sW}Tc=0*9}!oQ z^c2wRfa?h3*lR-2b`Dh|RG!&bzzr(iBIqMDVr(iM2Te!HDlN5?&90QCQS*ZMo|NzA zY2hl+qpq6>Yd3zQ2|~&ekb{~8ELrREU~m>9Y;s;6)tbMz+bI|#pg&iOa?_>>J!+1;}+;De8E|1b=j-oISy Bz)S!D literal 0 HcmV?d00001 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 +```