#!/usr/bin/env python3 """ Timelapse Device Agent (Pi4 + DSLR) - Provision device claim - Poll approval - Persist API key - Send heartbeat loop """ import os import time import json import socket import platform import uuid from pathlib import Path from datetime import datetime import requests # ── Config ────────────────────────────────────────────────── SERVER_URL = os.environ.get('TL_SERVER_URL', 'http://localhost:3001') DEVICE_NAME = os.environ.get('TL_DEVICE_NAME', socket.gethostname()) SERIAL_NO = os.environ.get('TL_SERIAL_NO', f'PI4-{socket.gethostname()}') CONFIG_DIR = Path(os.environ.get('TL_CONFIG_DIR', '/boot/timelapse')) CONFIG_FILE = CONFIG_DIR / 'agent.json' API_KEY_FILE = CONFIG_DIR / 'agent.key' HEARTBEAT_INTERVAL = int(os.environ.get('TL_HEARTBEAT_INTERVAL', '30')) POLL_INTERVAL = int(os.environ.get('TL_CLAIM_POLL_INTERVAL', '10')) def now_str() -> str: return datetime.now().strftime('%H:%M:%S') def log(msg: str): print(f"[{now_str()}] {msg}", flush=True) def get_device_uuid() -> str: # Stable UUID per machine machine_id = Path('/etc/machine-id') if machine_id.exists(): return machine_id.read_text().strip() return str(uuid.getnode()) def ensure_config_dir(): CONFIG_DIR.mkdir(parents=True, exist_ok=True) def load_config() -> dict: if not CONFIG_FILE.exists(): return {} try: return json.loads(CONFIG_FILE.read_text()) except Exception: return {} def save_config(cfg: dict): ensure_config_dir() CONFIG_FILE.write_text(json.dumps(cfg, indent=2)) def save_api_key(key: str): ensure_config_dir() API_KEY_FILE.write_text(key) os.chmod(API_KEY_FILE, 0o600) def load_api_key() -> str | None: if not API_KEY_FILE.exists(): return None return API_KEY_FILE.read_text().strip() def claim_device(device_uuid: str) -> dict: url = f"{SERVER_URL}/v1/devices/claim" payload = { 'deviceUuid': device_uuid, 'deviceName': DEVICE_NAME, 'serialNo': SERIAL_NO, } r = requests.post(url, json=payload, timeout=10) r.raise_for_status() return r.json() def poll_claim_status(claim_code: str) -> dict: url = f"{SERVER_URL}/v1/devices/claim/{claim_code}/status" r = requests.get(url, timeout=10) r.raise_for_status() return r.json() def gather_metrics() -> dict: temp_c = None battery = None try: p = Path('/sys/class/thermal/thermal_zone0/temp') if p.exists(): temp_c = round(int(p.read_text().strip()) / 1000, 1) except Exception: pass try: statvfs = os.statvfs('/') free_gb = int((statvfs.f_bavail * statvfs.f_frsize) / (1024**3)) except Exception: free_gb = 50 return { 'tempC': temp_c, 'batteryPct': battery, 'storageFreeGb': free_gb, 'capturesToday': 0, 'lastCaptureAt': datetime.utcnow().isoformat(), 'networkStatus': 'online', } def send_heartbeat(device_id: str, api_key: str): url = f"{SERVER_URL}/v1/devices/{device_id}/heartbeat" headers = { 'Content-Type': 'application/json', 'X-API-Key': api_key, } payload = { 'deviceId': device_id, 'status': 'online', 'firmwareVersion': 'agent-0.1.0', **gather_metrics(), } r = requests.post(url, json=payload, headers=headers, timeout=10) if r.ok: data = r.json() log(f"heartbeat ok | pendingCommands={data.get('pendingCommands', 0)}") else: log(f"heartbeat failed {r.status_code}: {r.text[:120]}") def provision_if_needed() -> tuple[str, str]: cfg = load_config() api_key = load_api_key() if cfg.get('deviceId') and api_key: return cfg['deviceId'], api_key device_uuid = cfg.get('deviceUuid') or get_device_uuid() cfg['deviceUuid'] = device_uuid save_config(cfg) claim = claim_device(device_uuid) claim_code = claim.get('claimCode', '') status = claim.get('status', 'pending') if status == 'approved' and claim.get('deviceId') and claim.get('apiKey'): cfg['deviceId'] = claim['deviceId'] save_config(cfg) save_api_key(claim['apiKey']) return cfg['deviceId'], claim['apiKey'] if not claim_code: raise RuntimeError('Claim failed: empty claim code') log('=================================================') log('DEVICE WAITING FOR APPROVAL') log(f' Claim code: {claim_code}') log(f' Device UUID: {device_uuid}') log(f' Device name: {DEVICE_NAME}') log(' Approve in dashboard before continuing...') log('=================================================') while True: s = poll_claim_status(claim_code) st = s.get('status') if st == 'approved' and s.get('apiKey') and s.get('deviceId'): cfg['deviceId'] = s['deviceId'] save_config(cfg) save_api_key(s['apiKey']) log(f"Approved! deviceId={s['deviceId']}") return s['deviceId'], s['apiKey'] if st in ('rejected', 'expired'): raise RuntimeError(f'Claim {st}. Please rerun setup.') time.sleep(POLL_INTERVAL) def main(): log('Timelapse Device Agent starting...') device_id, api_key = provision_if_needed() log(f'Provisioned as deviceId={device_id}') while True: try: send_heartbeat(device_id, api_key) except Exception as e: log(f'heartbeat error: {e}') time.sleep(HEARTBEAT_INTERVAL) if __name__ == '__main__': main()