Initial project structure for Klangkiste
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")
|
||||
@@ -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
|
||||
+45
@@ -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 <uid>
|
||||
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 <uid>
|
||||
Simulate NFC tag removal. Stops playback and stores progress for that UID.
|
||||
|
||||
Example:
|
||||
```
|
||||
python box/main.py simulate-nfc-off CHIP_1
|
||||
```
|
||||
|
||||
### tick <seconds>
|
||||
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
|
||||
```
|
||||
Reference in New Issue
Block a user