main.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. #!/usr/bin/env python3
  2. """
  3. Timelapse Device Agent (Pi4 + DSLR)
  4. - Provision device claim
  5. - Poll approval
  6. - Persist API key
  7. - Send heartbeat loop
  8. """
  9. import os
  10. import time
  11. import json
  12. import socket
  13. import platform
  14. import uuid
  15. from pathlib import Path
  16. from datetime import datetime
  17. import requests
  18. # ── Config ──────────────────────────────────────────────────
  19. SERVER_URL = os.environ.get('TL_SERVER_URL', 'http://localhost:3001')
  20. DEVICE_NAME = os.environ.get('TL_DEVICE_NAME', socket.gethostname())
  21. SERIAL_NO = os.environ.get('TL_SERIAL_NO', f'PI4-{socket.gethostname()}')
  22. CONFIG_DIR = Path(os.environ.get('TL_CONFIG_DIR', '/boot/timelapse'))
  23. CONFIG_FILE = CONFIG_DIR / 'agent.json'
  24. API_KEY_FILE = CONFIG_DIR / 'agent.key'
  25. HEARTBEAT_INTERVAL = int(os.environ.get('TL_HEARTBEAT_INTERVAL', '30'))
  26. POLL_INTERVAL = int(os.environ.get('TL_CLAIM_POLL_INTERVAL', '10'))
  27. def now_str() -> str:
  28. return datetime.now().strftime('%H:%M:%S')
  29. def log(msg: str):
  30. print(f"[{now_str()}] {msg}", flush=True)
  31. def get_device_uuid() -> str:
  32. # Stable UUID per machine
  33. machine_id = Path('/etc/machine-id')
  34. if machine_id.exists():
  35. return machine_id.read_text().strip()
  36. return str(uuid.getnode())
  37. def ensure_config_dir():
  38. CONFIG_DIR.mkdir(parents=True, exist_ok=True)
  39. def load_config() -> dict:
  40. if not CONFIG_FILE.exists():
  41. return {}
  42. try:
  43. return json.loads(CONFIG_FILE.read_text())
  44. except Exception:
  45. return {}
  46. def save_config(cfg: dict):
  47. ensure_config_dir()
  48. CONFIG_FILE.write_text(json.dumps(cfg, indent=2))
  49. def save_api_key(key: str):
  50. ensure_config_dir()
  51. API_KEY_FILE.write_text(key)
  52. os.chmod(API_KEY_FILE, 0o600)
  53. def load_api_key() -> str | None:
  54. if not API_KEY_FILE.exists():
  55. return None
  56. return API_KEY_FILE.read_text().strip()
  57. def claim_device(device_uuid: str) -> dict:
  58. url = f"{SERVER_URL}/v1/devices/claim"
  59. payload = {
  60. 'deviceUuid': device_uuid,
  61. 'deviceName': DEVICE_NAME,
  62. 'serialNo': SERIAL_NO,
  63. }
  64. r = requests.post(url, json=payload, timeout=10)
  65. r.raise_for_status()
  66. return r.json()
  67. def poll_claim_status(claim_code: str) -> dict:
  68. url = f"{SERVER_URL}/v1/devices/claim/{claim_code}/status"
  69. r = requests.get(url, timeout=10)
  70. r.raise_for_status()
  71. return r.json()
  72. def gather_metrics() -> dict:
  73. temp_c = None
  74. battery = None
  75. try:
  76. p = Path('/sys/class/thermal/thermal_zone0/temp')
  77. if p.exists():
  78. temp_c = round(int(p.read_text().strip()) / 1000, 1)
  79. except Exception:
  80. pass
  81. try:
  82. statvfs = os.statvfs('/')
  83. free_gb = int((statvfs.f_bavail * statvfs.f_frsize) / (1024**3))
  84. except Exception:
  85. free_gb = 50
  86. return {
  87. 'tempC': temp_c,
  88. 'batteryPct': battery,
  89. 'storageFreeGb': free_gb,
  90. 'capturesToday': 0,
  91. 'lastCaptureAt': datetime.utcnow().isoformat(),
  92. 'networkStatus': 'online',
  93. }
  94. def send_heartbeat(device_id: str, api_key: str):
  95. url = f"{SERVER_URL}/v1/devices/{device_id}/heartbeat"
  96. headers = {
  97. 'Content-Type': 'application/json',
  98. 'X-API-Key': api_key,
  99. }
  100. payload = {
  101. 'deviceId': device_id,
  102. 'status': 'online',
  103. 'firmwareVersion': 'agent-0.1.0',
  104. **gather_metrics(),
  105. }
  106. r = requests.post(url, json=payload, headers=headers, timeout=10)
  107. if r.ok:
  108. data = r.json()
  109. log(f"heartbeat ok | pendingCommands={data.get('pendingCommands', 0)}")
  110. else:
  111. log(f"heartbeat failed {r.status_code}: {r.text[:120]}")
  112. def provision_if_needed() -> tuple[str, str]:
  113. cfg = load_config()
  114. api_key = load_api_key()
  115. if cfg.get('deviceId') and api_key:
  116. return cfg['deviceId'], api_key
  117. device_uuid = cfg.get('deviceUuid') or get_device_uuid()
  118. cfg['deviceUuid'] = device_uuid
  119. save_config(cfg)
  120. claim = claim_device(device_uuid)
  121. claim_code = claim.get('claimCode', '')
  122. status = claim.get('status', 'pending')
  123. if status == 'approved' and claim.get('deviceId') and claim.get('apiKey'):
  124. cfg['deviceId'] = claim['deviceId']
  125. save_config(cfg)
  126. save_api_key(claim['apiKey'])
  127. return cfg['deviceId'], claim['apiKey']
  128. if not claim_code:
  129. raise RuntimeError('Claim failed: empty claim code')
  130. log('=================================================')
  131. log('DEVICE WAITING FOR APPROVAL')
  132. log(f' Claim code: {claim_code}')
  133. log(f' Device UUID: {device_uuid}')
  134. log(f' Device name: {DEVICE_NAME}')
  135. log(' Approve in dashboard before continuing...')
  136. log('=================================================')
  137. while True:
  138. s = poll_claim_status(claim_code)
  139. st = s.get('status')
  140. if st == 'approved' and s.get('apiKey') and s.get('deviceId'):
  141. cfg['deviceId'] = s['deviceId']
  142. save_config(cfg)
  143. save_api_key(s['apiKey'])
  144. log(f"Approved! deviceId={s['deviceId']}")
  145. return s['deviceId'], s['apiKey']
  146. if st in ('rejected', 'expired'):
  147. raise RuntimeError(f'Claim {st}. Please rerun setup.')
  148. time.sleep(POLL_INTERVAL)
  149. def main():
  150. log('Timelapse Device Agent starting...')
  151. device_id, api_key = provision_if_needed()
  152. log(f'Provisioned as deviceId={device_id}')
  153. while True:
  154. try:
  155. send_heartbeat(device_id, api_key)
  156. except Exception as e:
  157. log(f'heartbeat error: {e}')
  158. time.sleep(HEARTBEAT_INTERVAL)
  159. if __name__ == '__main__':
  160. main()