move_detect/edge/config.py
유창욱 3f2823a764 refactor: rename cloud/ to server/ (cafe24 is web hosting, not cloud)
The PHP app runs on Cafe24 shared web hosting -- calling it 'cloud'
was misleading. Renames the directory, the edge client module, and
every doc/script/identifier that used the term.

- cloud/ -> server/ (incl. server/deploy/build_cafe24_package.sh paths)
- edge/cloudclient.py -> edge/serverclient.py, CloudClient -> ServerClient
- cloud/docs/cloud-deploy.md -> server/docs/server-deploy.md
- CLOUD_* env/config keys -> SERVER_*
- tests renamed to match

Tests: PHP 109 pass (4 skipped), pytest 167 pass. dist/ regenerated.
2026-07-20 11:30:52 +09:00

120 lines
5.3 KiB
Python

import math
from dataclasses import dataclass
from edge.detectors.fall import FallConfig
from edge.detectors.immobility import ImmobilityConfig
from edge.detectors.ghost import GhostConfig
from edge.detectors.zone import ZoneConfig
from edge.engine import EngineConfig
@dataclass
class Config:
device_id: str
camera_id: str
cafe24_url: str
secret: str
fall: FallConfig
immobility: ImmobilityConfig
ghost: GhostConfig
zone: ZoneConfig
engine: EngineConfig
def _f(env: dict, key: str, default: float) -> float:
return float(env[key]) if key in env else default
def frame_interval_from_env(env: dict) -> float:
"""Seconds per frame from TARGET_FPS, validated. TARGET_FPS=0 (ZeroDivisionError) or a
non-numeric value (ValueError) would otherwise crash at every startup into an infinite
systemd restart loop (Restart=always + StartLimitIntervalSec=0)."""
raw = env.get("TARGET_FPS", "12")
try:
fps = float(raw)
except (TypeError, ValueError):
raise ValueError(f"TARGET_FPS={raw!r} is not a number")
if fps <= 0:
raise ValueError(f"TARGET_FPS={raw!r} must be > 0")
return 1.0 / fps
def queue_db_path(env: dict) -> str:
"""Absolute sqlite path for the offline queue. A relative 'queue.db' resolves against the
unit's read-only WorkingDirectory under ProtectSystem=strict and cannot be created ->
crash loop. Default to the systemd StateDirectory, the one writable path."""
return env.get("QUEUE_DB") or "/var/lib/move-detect/queue.db"
# Ship-time placeholders that are non-empty (so they pass a blank check) yet are never a real
# provisioned value. A unit left at any of these uploads nothing while looking healthy.
_PLACEHOLDER_MARKERS = ("changeme", "your-domain.example", "example.com", "todo", "xxxx")
# Per-device secrets issued by the server are HMAC-derived (long hex); anything shorter is a
# stub left in the template, not a real key.
_MIN_SECRET_LEN = 16
def require_upload_config(cfg: Config) -> None:
"""Fail LOUD at startup when the daemon cannot possibly deliver events.
load_config defaults cafe24_url and secret to '' so unit tests can build a Config from a
minimal env — but a real daemon with either blank, left at a ship-time placeholder, or on
plaintext http runs perfectly green while every upload fails (a bad/empty URL makes requests
raise, which PostClient catches and returns False; http is refused by allow_redirects=False),
and the offline queue grows until the SD card is full. The operator sees a healthy unit and
an empty dashboard, or worker snapshots leave the Pi in plaintext. Refuse to start instead.
"""
problems: list[str] = []
url = cfg.cafe24_url.strip()
secret = cfg.secret.strip()
if not url:
problems.append("CAFE24_URL not set")
elif not url.lower().startswith("https://"):
problems.append(f"CAFE24_URL must be https:// (got {url!r}) — plaintext would leak "
"worker snapshots and the .htaccess redirect is not followed")
elif any(m in url.lower() for m in _PLACEHOLDER_MARKERS):
problems.append(f"CAFE24_URL is still a template placeholder ({url!r})")
if not secret:
problems.append("DEVICE_SECRET not set")
elif any(m in secret.lower() for m in _PLACEHOLDER_MARKERS):
problems.append("DEVICE_SECRET is still a template placeholder (e.g. CHANGEME)")
elif len(secret) < _MIN_SECRET_LEN:
problems.append(f"DEVICE_SECRET is too short (<{_MIN_SECRET_LEN} chars) — use the "
"per-device secret issued by the server, not a stub")
if problems:
raise ValueError(
"FATAL /etc/move-detect/edge.env: " + "; ".join(problems) + ". The daemon would run "
"but upload nothing. CAFE24_URL must include the app path, e.g. "
"https://<domain>/move_detect ; DEVICE_SECRET is the per-device secret from the server."
)
def load_config(env: dict) -> Config:
camera_id = env.get("CAMERA_ID", "cam-01")
# Per-second stillness threshold (units/second), shared by fall no-motion and immobility.
# track_displacement is now frame-rate-normalized, so this must be tuned per device — expose
# it via env instead of forcing a code edit + redeploy. Reject a non-finite or non-positive
# value at startup (a 0 or NaN would make everything read as "still" -> constant no_motion).
motion_thr = _f(env, "MOTION_THR", 0.25)
if not math.isfinite(motion_thr) or motion_thr <= 0:
raise ValueError(f"MOTION_THR must be a finite positive number, got {motion_thr!r}")
fall = FallConfig(
prone_confirm_s=_f(env, "FALL_PRONE_S", 3.0),
no_motion_s=_f(env, "FALL_NO_MOTION_S", 10.0),
motion_thr=motion_thr,
)
immobility = ImmobilityConfig(immobile_s=_f(env, "IMMOBILE_S", 120.0),
motion_thr=motion_thr)
ghost = GhostConfig(reappear_s=_f(env, "GHOST_REAPPEAR_S", 10.0))
zone = ZoneConfig(debounce_s=_f(env, "ZONE_DEBOUNCE_S", 0.75))
engine = EngineConfig(camera_id=camera_id, cooldown_s=_f(env, "COOLDOWN_S", 30.0))
return Config(
device_id=env.get("DEVICE_ID", "pi-unknown"),
camera_id=camera_id,
cafe24_url=env.get("CAFE24_URL", ""),
secret=env.get("DEVICE_SECRET", ""),
fall=fall, immobility=immobility, ghost=ghost, zone=zone, engine=engine,
)