Initial project structure for Klangkiste

This commit is contained in:
2026-01-11 21:24:50 +00:00
commit 83bd89a25a
45 changed files with 695 additions and 0 deletions
+35
View File
@@ -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
}
}
}
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+167
View File
@@ -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)
+30
View File
@@ -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)
View File
+10
View File
@@ -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
+10
View File
@@ -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
+20
View File
@@ -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()
+18
View File
@@ -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
+206
View File
@@ -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 <UID>")
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 <UID>")
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 <seconds>")
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 <UID> simulate NFC tag detection")
print(" nfc-off <UID> simulate NFC tag removal")
print(" tick <seconds> 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())
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -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
+23
View File
@@ -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
+47
View File
@@ -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
}
+14
View File
@@ -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
}
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21
View File
@@ -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")
+13
View File
@@ -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