| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208 |
- #!/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()
|