Upload
This commit is contained in:
Binary file not shown.
Binary file not shown.
+30
-2
@@ -4,12 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from core.box_controller import BoxController
|
||||
from core.lifecycle import NetworkLifecycle
|
||||
from core.pairing import PairingManager, PairingState
|
||||
from spotify.base import SpotifyBackend
|
||||
from wifi.base import WifiBackend
|
||||
|
||||
@@ -24,6 +25,7 @@ def create_app(
|
||||
lifecycle: NetworkLifecycle,
|
||||
wifi_backend: WifiBackend,
|
||||
spotify_backend: SpotifyBackend,
|
||||
pairing_manager: PairingManager,
|
||||
persist_state: Callable[[], None],
|
||||
) -> FastAPI:
|
||||
app = FastAPI()
|
||||
@@ -40,6 +42,7 @@ def create_app(
|
||||
state = controller.state
|
||||
return {
|
||||
"box_id": state.box_id,
|
||||
"pairing_state": pairing_manager.state.value,
|
||||
"wifi_state": snapshot.wifi_state.value,
|
||||
"ip_address": snapshot.ip_address,
|
||||
"connected_ssid": snapshot.connected_ssid,
|
||||
@@ -59,10 +62,13 @@ def create_app(
|
||||
}
|
||||
|
||||
@app.post("/command")
|
||||
def command(request: CommandRequest) -> dict[str, Any]:
|
||||
def command(request: CommandRequest, http_request: Request) -> dict[str, Any]:
|
||||
cmd = request.command
|
||||
payload = request.payload or {}
|
||||
|
||||
if _requires_pairing(cmd) and not _is_authorized(pairing_manager, http_request):
|
||||
raise HTTPException(status_code=403, detail="pairing required")
|
||||
|
||||
if cmd == "nfc_on":
|
||||
uid = payload.get("uid")
|
||||
if not isinstance(uid, str):
|
||||
@@ -125,3 +131,25 @@ def create_app(
|
||||
return {"ok": True}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _requires_pairing(command: str) -> bool:
|
||||
allowed_without_pairing = {
|
||||
"wifi_add_profile",
|
||||
"wifi_reset",
|
||||
"spotify_set_tokens",
|
||||
"spotify_clear",
|
||||
}
|
||||
return command not in allowed_without_pairing
|
||||
|
||||
|
||||
def _is_authorized(pairing: PairingManager, http_request: Request) -> bool:
|
||||
if not pairing.is_paired():
|
||||
return False
|
||||
token = pairing.get_api_token()
|
||||
if not token:
|
||||
return False
|
||||
header = http_request.headers.get("X-API-Token")
|
||||
if not header:
|
||||
return False
|
||||
return header == token
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
"""Server announce and pairing polling client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from core.identity import compute_fingerprint
|
||||
from core.pairing import PairingManager, PairingState
|
||||
from storage.secret_store import SecretStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnnounceConfig:
|
||||
server_url: str
|
||||
firmware_version: str
|
||||
capabilities: dict[str, Any]
|
||||
announce_interval_seconds: int = 30
|
||||
polling_interval_seconds: int = 10
|
||||
|
||||
|
||||
def build_payload(
|
||||
box_id: str,
|
||||
secret_seed: str,
|
||||
config: AnnounceConfig,
|
||||
) -> dict[str, Any]:
|
||||
fingerprint = compute_fingerprint(box_id, secret_seed)
|
||||
return {
|
||||
"box_id": box_id,
|
||||
"fingerprint": fingerprint,
|
||||
"firmware_version": config.firmware_version,
|
||||
"capabilities": config.capabilities,
|
||||
}
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
async def announce_loop(
|
||||
box_id: str,
|
||||
secrets: SecretStore,
|
||||
pairing: PairingManager,
|
||||
config: AnnounceConfig,
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
secret_seed = secrets.ensure_secret_seed()
|
||||
payload = build_payload(box_id, secret_seed, config)
|
||||
announce_url = f"{config.server_url}/api/boxes/announce"
|
||||
|
||||
while not stop_event.is_set():
|
||||
if not pairing.is_paired():
|
||||
await asyncio.to_thread(_post_json, announce_url, payload)
|
||||
await asyncio.sleep(config.announce_interval_seconds)
|
||||
|
||||
|
||||
async def pairing_poll_loop(
|
||||
box_id: str,
|
||||
pairing: PairingManager,
|
||||
config: AnnounceConfig,
|
||||
stop_event: asyncio.Event,
|
||||
) -> None:
|
||||
poll_url = f"{config.server_url}/api/boxes/poll-pairing"
|
||||
payload = {"box_id": box_id}
|
||||
while not stop_event.is_set():
|
||||
if pairing.is_paired():
|
||||
await asyncio.sleep(config.polling_interval_seconds)
|
||||
continue
|
||||
pairing.set_state(PairingState.PAIRING_PENDING)
|
||||
response = await asyncio.to_thread(_post_json, poll_url, payload)
|
||||
if response and response.get("paired") is True:
|
||||
token = response.get("api_token")
|
||||
if isinstance(token, str) and token:
|
||||
pairing.save_api_token(token)
|
||||
await asyncio.sleep(config.polling_interval_seconds)
|
||||
+17
-2
@@ -2,14 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import string
|
||||
|
||||
PREFIX = "klangkiste-"
|
||||
ALPHABET = string.ascii_lowercase + string.digits
|
||||
LETTERS = string.ascii_lowercase
|
||||
DIGITS = string.digits
|
||||
SUFFIX_LENGTH = 10
|
||||
STATIC_APP_SALT = "klangkiste-static-app-salt-v1"
|
||||
|
||||
|
||||
def generate_box_id() -> str:
|
||||
suffix = "".join(secrets.choice(ALPHABET) for _ in range(SUFFIX_LENGTH))
|
||||
start_with_letter = secrets.choice([True, False])
|
||||
suffix_chars = []
|
||||
for i in range(SUFFIX_LENGTH):
|
||||
if (i % 2 == 0) == start_with_letter:
|
||||
suffix_chars.append(secrets.choice(LETTERS))
|
||||
else:
|
||||
suffix_chars.append(secrets.choice(DIGITS))
|
||||
suffix = "".join(suffix_chars)
|
||||
return f"{PREFIX}{suffix}"
|
||||
|
||||
|
||||
def compute_fingerprint(box_id: str, secret_seed: str) -> str:
|
||||
payload = f"{box_id}{secret_seed}{STATIC_APP_SALT}".encode("utf-8")
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pairing state management for the Box."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from storage.state_store import StateStore
|
||||
from storage.secret_store import SecretStore
|
||||
|
||||
|
||||
class PairingState(str, Enum):
|
||||
UNPAIRED = "UNPAIRED"
|
||||
PAIRING_PENDING = "PAIRING_PENDING"
|
||||
PAIRED = "PAIRED"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PairingSnapshot:
|
||||
state: PairingState
|
||||
|
||||
|
||||
class PairingManager:
|
||||
def __init__(self, state_store: StateStore, secret_store: SecretStore) -> None:
|
||||
self._state_store = state_store
|
||||
self._secret_store = secret_store
|
||||
self._state = PairingState.UNPAIRED
|
||||
self._load()
|
||||
|
||||
@property
|
||||
def state(self) -> PairingState:
|
||||
return self._state
|
||||
|
||||
def set_state(self, state: PairingState) -> None:
|
||||
self._state = state
|
||||
self._save()
|
||||
|
||||
def snapshot(self) -> PairingSnapshot:
|
||||
return PairingSnapshot(state=self._state)
|
||||
|
||||
def is_paired(self) -> bool:
|
||||
return self._state == PairingState.PAIRED
|
||||
|
||||
def get_api_token(self) -> str | None:
|
||||
return self._secret_store.get_api_token()
|
||||
|
||||
def save_api_token(self, token: str) -> None:
|
||||
self._secret_store.save_api_token(token)
|
||||
self.set_state(PairingState.PAIRED)
|
||||
|
||||
def _load(self) -> None:
|
||||
data = self._state_store.load()
|
||||
pairing = data.get("pairing") if isinstance(data.get("pairing"), dict) else {}
|
||||
state_raw = pairing.get("state")
|
||||
if isinstance(state_raw, str) and state_raw in PairingState.__members__:
|
||||
self._state = PairingState[state_raw]
|
||||
|
||||
def _save(self) -> None:
|
||||
data = self._state_store.load()
|
||||
data["pairing"] = {"state": self._state.name}
|
||||
self._state_store.save(data)
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"box_id": "klangkiste-x1u8i4x5e4",
|
||||
"settings": {},
|
||||
"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"
|
||||
},
|
||||
"UID_6": {
|
||||
"type": "folder",
|
||||
"path": "media/book_6"
|
||||
}
|
||||
},
|
||||
"server_url": "http://127.0.0.1:7000",
|
||||
"firmware_version": "0.1.0",
|
||||
"capabilities": {
|
||||
"nfc": true,
|
||||
"audio": true,
|
||||
"spotify": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"wifi": {
|
||||
"connected_ssid": null
|
||||
},
|
||||
"resume": {
|
||||
"UID_1": {
|
||||
"file_index": 1,
|
||||
"position": 79
|
||||
}
|
||||
},
|
||||
"pairing": {
|
||||
"state": "PAIRING_PENDING"
|
||||
}
|
||||
}
|
||||
+52
-10
@@ -13,9 +13,11 @@ from typing import Any
|
||||
import uvicorn
|
||||
|
||||
from api.http import create_app
|
||||
from core.announce import AnnounceConfig, announce_loop, pairing_poll_loop
|
||||
from core.box_controller import BoxController
|
||||
from core.identity import generate_box_id
|
||||
from core.lifecycle import NetworkLifecycle
|
||||
from core.pairing import PairingManager
|
||||
from core.state import BoxState, RuntimeState
|
||||
from player.mock_player import MockPlayer
|
||||
from setup.webui import create_setup_app
|
||||
@@ -35,7 +37,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config/box_config.json",
|
||||
help=\"Path to local box config JSON (legacy seed)\",
|
||||
help="Path to local box config JSON (legacy seed)",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
@@ -137,6 +139,12 @@ def main() -> int:
|
||||
box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {}))
|
||||
if isinstance(box_meta.get("tags"), dict):
|
||||
box_meta["tags"] = _normalize_tag_paths(box_meta.get("tags", {}))
|
||||
if "server_url" not in box_meta:
|
||||
box_meta["server_url"] = os.getenv("KLANGKISTE_SERVER_URL", "http://127.0.0.1:7000")
|
||||
if "firmware_version" not in box_meta:
|
||||
box_meta["firmware_version"] = "0.1.0"
|
||||
if "capabilities" not in box_meta:
|
||||
box_meta["capabilities"] = {"nfc": True, "audio": True, "spotify": True}
|
||||
box_store.save(box_meta)
|
||||
|
||||
state_store = StateStore(base_dir)
|
||||
@@ -144,9 +152,11 @@ def main() -> int:
|
||||
|
||||
state = _state_from_data(box_meta, state_data)
|
||||
secrets = SecretStore(base_dir, state.box_id)
|
||||
secrets.ensure_secret_seed()
|
||||
wifi_backend = MockWifiBackend(state_store, secrets)
|
||||
spotify_backend = MockSpotifyBackend(secrets)
|
||||
lifecycle = NetworkLifecycle(wifi_backend)
|
||||
pairing_manager = PairingManager(state_store, secrets)
|
||||
controller = BoxController(
|
||||
state=state,
|
||||
player=MockPlayer(),
|
||||
@@ -177,15 +187,25 @@ def main() -> int:
|
||||
return 0
|
||||
|
||||
if args.command == "run":
|
||||
asyncio.run(
|
||||
_run_with_api(
|
||||
controller,
|
||||
state_store,
|
||||
lifecycle,
|
||||
wifi_backend,
|
||||
spotify_backend,
|
||||
try:
|
||||
asyncio.run(
|
||||
_run_with_api(
|
||||
controller,
|
||||
state_store,
|
||||
lifecycle,
|
||||
wifi_backend,
|
||||
spotify_backend,
|
||||
pairing_manager,
|
||||
secrets,
|
||||
AnnounceConfig(
|
||||
server_url=str(box_meta.get("server_url")),
|
||||
firmware_version=str(box_meta.get("firmware_version")),
|
||||
capabilities=dict(box_meta.get("capabilities", {})),
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
return 0
|
||||
@@ -213,12 +233,16 @@ async def _run_with_api(
|
||||
lifecycle: NetworkLifecycle,
|
||||
wifi_backend: MockWifiBackend,
|
||||
spotify_backend: MockSpotifyBackend,
|
||||
pairing_manager: PairingManager,
|
||||
secrets: SecretStore,
|
||||
announce_config: AnnounceConfig,
|
||||
) -> None:
|
||||
app = create_app(
|
||||
controller,
|
||||
lifecycle,
|
||||
wifi_backend,
|
||||
spotify_backend,
|
||||
pairing_manager,
|
||||
lambda: _persist_resume(controller, storage),
|
||||
)
|
||||
api_server = uvicorn.Server(
|
||||
@@ -234,7 +258,7 @@ async def _run_with_api(
|
||||
setup_server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
setup_app,
|
||||
host="127.0.0.1",
|
||||
host="0.0.0.0",
|
||||
port=9000,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
@@ -244,6 +268,18 @@ async def _run_with_api(
|
||||
tick_task = asyncio.create_task(
|
||||
_auto_tick_loop(controller, storage, stop_event)
|
||||
)
|
||||
announce_task = asyncio.create_task(
|
||||
announce_loop(
|
||||
controller.state.box_id,
|
||||
secrets=secrets,
|
||||
pairing=pairing_manager,
|
||||
config=announce_config,
|
||||
stop_event=stop_event,
|
||||
)
|
||||
)
|
||||
poll_task = asyncio.create_task(
|
||||
pairing_poll_loop(controller.state.box_id, pairing_manager, announce_config, stop_event)
|
||||
)
|
||||
api_task = asyncio.create_task(api_server.serve())
|
||||
setup_task = asyncio.create_task(setup_server.serve())
|
||||
|
||||
@@ -261,6 +297,12 @@ async def _run_with_api(
|
||||
tick_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await tick_task
|
||||
announce_task.cancel()
|
||||
poll_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await announce_task
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await poll_task
|
||||
setup_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await setup_task
|
||||
|
||||
@@ -2,3 +2,4 @@ fastapi==0.115.6
|
||||
uvicorn==0.30.6
|
||||
pydantic==2.9.2
|
||||
cryptography==43.0.3
|
||||
python-multipart==0.0.9
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -36,6 +36,35 @@ class SecretStore:
|
||||
token = fernet.encrypt(payload)
|
||||
self._path.write_bytes(token)
|
||||
|
||||
def ensure_secret_seed(self) -> str:
|
||||
data = self.load_secrets()
|
||||
seed = data.get("secret_seed")
|
||||
if isinstance(seed, str) and seed:
|
||||
return seed
|
||||
seed_value = self._generate_seed()
|
||||
data["secret_seed"] = seed_value
|
||||
self.save_secrets(data)
|
||||
return seed_value
|
||||
|
||||
def get_secret_seed(self) -> str | None:
|
||||
data = self.load_secrets()
|
||||
seed = data.get("secret_seed")
|
||||
if isinstance(seed, str) and seed:
|
||||
return seed
|
||||
return None
|
||||
|
||||
def save_api_token(self, token: str) -> None:
|
||||
data = self.load_secrets()
|
||||
data["api_token"] = token
|
||||
self.save_secrets(data)
|
||||
|
||||
def get_api_token(self) -> str | None:
|
||||
data = self.load_secrets()
|
||||
token = data.get("api_token")
|
||||
if isinstance(token, str) and token:
|
||||
return token
|
||||
return None
|
||||
|
||||
def clear_wifi_secrets(self) -> None:
|
||||
data = self.load_secrets()
|
||||
if "wifi_profiles" in data:
|
||||
@@ -55,3 +84,9 @@ class SecretStore:
|
||||
dklen=32,
|
||||
)
|
||||
return base64.urlsafe_b64encode(raw)
|
||||
|
||||
@staticmethod
|
||||
def _generate_seed() -> str:
|
||||
import secrets
|
||||
|
||||
return secrets.token_hex(32)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user