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.
120 lines
5.3 KiB
Python
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,
|
|
)
|