Upload
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user