Bläddra i källkod

feat: add Pi device agent installer + provisioning runtime

- Device agent runtime (Python): claim, poll approval, persist API key, heartbeat loop
- Systemd unit: timelapse-agent.service
- Installer script for Pi: install dependencies + enable service
- Provisioning support in API devices module:
  - claim endpoints
  - pending approval endpoints
  - bootstrap API key delivery
- Docs: device provisioning architecture + Pi agent README

Note: installer is intended to run on Pi/Linux only (not on macOS dev machine)
kingkong 2 månader sedan
förälder
incheckning
636ae5333b

+ 2 - 0
apps/api-server/src/db/schema.ts

@@ -123,6 +123,8 @@ export const devices = pgTable('devices', {
     timezone: 'Asia/Ho_Chi_Minh',
   }).notNull(),
   claimCode: text('claim_code'),
+  bootstrapApiKey: text('bootstrap_api_key'), // one-time key returned after approval
+  bootstrapExpiresAt: timestamp('bootstrap_expires_at'),
   createdAt: timestamp('created_at').defaultNow().notNull(),
   updatedAt: timestamp('updated_at').defaultNow().notNull(),
 }, (table) => ({

+ 43 - 42
apps/api-server/src/modules/devices/devices.repository.ts

@@ -1,7 +1,7 @@
-import { Injectable } from '@nestjs/common'
-import { eq, and, desc, sql, gte, isNull } from 'drizzle-orm'
+import { Injectable, NotFoundException } from '@nestjs/common'
+import { eq, and, desc, sql, gte, isNull, isNotNull } from 'drizzle-orm'
 import { db } from '../../db/database.module'
-import { devices, deviceHeartbeats, commands } from '../../db/schema'
+import { devices, deviceHeartbeats, commands, projects } from '../../db/schema'
 import { nanoid } from 'nanoid'
 import * as bcrypt from 'bcrypt'
 
@@ -19,7 +19,6 @@ export class DevicesRepository {
     deviceName: string
     serialNo: string
     claimCode: string
-    expiresAt: Date
   }) {
     const id = nanoid()
     await db.insert(devices).values({
@@ -28,11 +27,10 @@ export class DevicesRepository {
       name: data.deviceName,
       serialNo: data.serialNo,
       claimCode: data.claimCode,
-      // No project/org yet — pending approval
       projectId: null,
       orgId: null,
-      apiKeyHash: '', // filled on approval
-      status: 'pending',
+      apiKeyHash: '',
+      status: 'pending' as any,
     })
     return this.findDeviceById(id)
   }
@@ -48,35 +46,37 @@ export class DevicesRepository {
     if (!device) return null
 
     const now = new Date()
-    const expired = device.updatedAt && device.updatedAt < now
-
-    let status: 'pending' | 'approved' | 'rejected' | 'expired' = device.status as any
-
-    if (device.status === 'pending' && device.projectId === null) {
-      // Pending but check expiry — for now allow 24h
-      // Compare claim creation time: use updatedAt as proxy
-      const created = new Date(device.updatedAt)
-      const expiry = new Date(created.getTime() + 24 * 60 * 60 * 1000)
-      if (now > expiry) status = 'expired'
+    const created = new Date(device.createdAt)
+    const expiry = new Date(created.getTime() + 24 * 60 * 60 * 1000)
+
+    if (device.projectId === null && device.status === 'pending' && now > expiry) {
+      return {
+        claimCode: device.claimCode,
+        status: 'expired' as const,
+        expiresAt: expiry.toISOString(),
+      }
     }
 
-    if (device.status === 'pending' && device.projectId !== null && device.apiKeyHash !== '') {
-      status = 'approved'
-    }
+    // Approved when assigned to project and key generated
+    const approved = !!device.projectId && !!device.orgId && !!device.apiKeyHash
 
     return {
       claimCode: device.claimCode,
-      status,
-      deviceId: status === 'approved' ? device.id : undefined,
-      apiKey: undefined, // never returned from DB
-      expiresAt: device.updatedAt
-        ? new Date(device.updatedAt.getTime() + 24 * 60 * 60 * 1000).toISOString()
-        : undefined,
+      status: approved ? ('approved' as const) : ('pending' as const),
+      deviceId: approved ? device.id : undefined,
+      apiKey: approved ? (device.bootstrapApiKey ?? undefined) : undefined,
+      expiresAt: expiry.toISOString(),
     }
   }
 
+  async consumeBootstrapApiKey(claimCode: string) {
+    await db
+      .update(devices)
+      .set({ bootstrapApiKey: null, bootstrapExpiresAt: null, claimCode: null, updatedAt: new Date() })
+      .where(eq(devices.claimCode, claimCode))
+  }
+
   async findPendingDevices() {
-    // Devices with status=pending and no project assigned
     return db
       .select()
       .from(devices)
@@ -84,6 +84,7 @@ export class DevicesRepository {
         and(
           eq(devices.status, 'pending' as any),
           isNull(devices.projectId),
+          isNotNull(devices.claimCode),
         ),
       )
       .orderBy(desc(devices.createdAt))
@@ -92,17 +93,25 @@ export class DevicesRepository {
   async approvePendingDevice(
     claimCode: string,
     apiKeyPlain: string,
-    actorUserId: string,
+    _actorUserId: string,
     projectId: string,
   ) {
+    const project = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1)
+    const targetProject = project[0]
+    if (!targetProject) throw new NotFoundException('Project not found')
+
     const apiKeyHash = await bcrypt.hash(apiKeyPlain, 10)
+    const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
+
     await db
       .update(devices)
       .set({
         apiKeyHash,
-        projectId,
-        status: 'offline',
-        claimCode: null,
+        bootstrapApiKey: apiKeyPlain,
+        bootstrapExpiresAt: expiresAt,
+        projectId: targetProject.id,
+        orgId: targetProject.orgId,
+        status: 'offline' as any,
         updatedAt: new Date(),
       })
       .where(eq(devices.claimCode, claimCode))
@@ -115,14 +124,6 @@ export class DevicesRepository {
       .where(eq(devices.claimCode, claimCode))
   }
 
-  async assignProject(deviceId: string, projectId: string, orgId: string, apiKeyPlain: string) {
-    const apiKeyHash = await bcrypt.hash(apiKeyPlain, 10)
-    await db
-      .update(devices)
-      .set({ projectId, orgId, apiKeyHash, status: 'offline' as any, claimCode: null, updatedAt: new Date() })
-      .where(eq(devices.id, deviceId))
-  }
-
   // ── Heartbeat ──────────────────────────────────────────
 
   async findDeviceById(id: string) {
@@ -130,9 +131,9 @@ export class DevicesRepository {
     return result[0] ?? null
   }
 
-  async findBySerialNo(serialNo: string) {
-    const result = await db.select().from(devices).where(eq(devices.serialNo, serialNo)).limit(1)
-    return result[0] ?? null
+  async verifyApiKey(hash: string, apiKey: string) {
+    if (!hash) return false
+    return bcrypt.compare(apiKey, hash)
   }
 
   async updateDeviceStatus(id: string, status: string, lastSeenAt: Date, firmwareVersion?: string) {

+ 9 - 5
apps/api-server/src/modules/devices/devices.service.ts

@@ -45,15 +45,13 @@ export class DevicesService {
 
     // Create pending device entry
     const claimCode = generateClaimCode()
-    const expiresAt = new Date()
-    expiresAt.setHours(expiresAt.getHours() + 24)
+    const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000)
 
     await this.repo.createPendingDevice({
       deviceUuid: dto.deviceUuid,
       deviceName: dto.deviceName,
       serialNo: dto.serialNo ?? dto.deviceUuid,
       claimCode,
-      expiresAt,
     })
 
     return {
@@ -64,7 +62,12 @@ export class DevicesService {
   }
 
   async getClaimStatus(claimCode: string) {
-    return this.repo.getClaimStatus(claimCode)
+    const status = await this.repo.getClaimStatus(claimCode)
+    if (status?.status === 'approved' && status.apiKey) {
+      // one-time delivery of bootstrap API key
+      await this.repo.consumeBootstrapApiKey(claimCode)
+    }
+    return status
   }
 
   async getPendingDevices() {
@@ -102,7 +105,8 @@ export class DevicesService {
     const device = await this.repo.findDeviceById(dto.deviceId)
     if (!device) throw new NotFoundException(`Device ${dto.deviceId} not found`)
 
-    if (!device.apiKeyHash || device.apiKeyHash !== apiKey) {
+    const validKey = await this.repo.verifyApiKey(device.apiKeyHash, apiKey)
+    if (!validKey) {
       throw new UnauthorizedException('Invalid API key')
     }
 

+ 45 - 0
apps/device-agent/README.md

@@ -0,0 +1,45 @@
+# Timelapse Device Agent (Pi4 + DSLR)
+
+Agent này chạy trên thiết bị Pi4 đầu cuối để:
+1. Claim thiết bị về server bằng claim code
+2. Poll trạng thái approved/rejected
+3. Lưu API key one-time vào `/boot/timelapse/agent.key`
+4. Gửi heartbeat định kỳ về server
+
+## Không chạy trên Mac
+Script install chỉ chạy trên Pi/Linux systemd.
+
+## Setup trên Pi 4
+
+```bash
+cd /opt/timelapse
+git pull
+
+# install agent service
+sudo bash apps/device-agent/scripts/install-agent.sh
+
+# theo dõi logs
+journalctl -u timelapse-agent.service -f
+```
+
+## Cấu hình
+File: `/etc/timelapse/agent.env`
+
+```env
+TL_SERVER_URL=http://<server-ip>:3001
+TL_DEVICE_NAME=Pi-DSLR-01
+TL_SERIAL_NO=PI4-DSLR-01
+TL_HEARTBEAT_INTERVAL=30
+TL_CLAIM_POLL_INTERVAL=10
+TL_CONFIG_DIR=/boot/timelapse
+```
+
+## Luồng claim
+Khi service chạy lần đầu:
+- Agent in claim code ra terminal/log
+- Dashboard admin approve device
+- Agent tự nhận API key và bắt đầu heartbeat
+
+## File runtime
+- `/boot/timelapse/agent.json` — metadata (deviceUuid, deviceId)
+- `/boot/timelapse/agent.key` — API key (chmod 600)

+ 208 - 0
apps/device-agent/agent/main.py

@@ -0,0 +1,208 @@
+#!/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()

+ 1 - 0
apps/device-agent/requirements.txt

@@ -0,0 +1 @@
+requests>=2.31.0

+ 41 - 0
apps/device-agent/scripts/install-agent.sh

@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
+AGENT_DIR="$REPO_DIR/apps/device-agent"
+SERVICE_FILE="$AGENT_DIR/systemd/timelapse-agent.service"
+
+log() { echo "[install-agent] $*"; }
+
+if [[ "$EUID" -ne 0 ]]; then
+  echo "Please run as root: sudo bash apps/device-agent/scripts/install-agent.sh"
+  exit 1
+fi
+
+log "Installing Python dependencies..."
+python3 -m pip install --upgrade pip
+python3 -m pip install requests
+
+log "Creating /etc/timelapse/agent.env if missing..."
+mkdir -p /etc/timelapse
+if [[ ! -f /etc/timelapse/agent.env ]]; then
+  cat > /etc/timelapse/agent.env <<'EOF'
+TL_SERVER_URL=http://localhost:3001
+TL_DEVICE_NAME=
+TL_SERIAL_NO=
+TL_HEARTBEAT_INTERVAL=30
+TL_CLAIM_POLL_INTERVAL=10
+TL_CONFIG_DIR=/boot/timelapse
+EOF
+  chmod 600 /etc/timelapse/agent.env
+fi
+
+log "Installing systemd service..."
+cp "$SERVICE_FILE" /etc/systemd/system/timelapse-agent.service
+systemctl daemon-reload
+systemctl enable timelapse-agent.service
+systemctl restart timelapse-agent.service
+
+log "Done. Check status with:"
+log "  systemctl status timelapse-agent.service"
+log "  journalctl -u timelapse-agent.service -f"

+ 17 - 0
apps/device-agent/systemd/timelapse-agent.service

@@ -0,0 +1,17 @@
+[Unit]
+Description=Timelapse Device Agent
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=pi
+WorkingDirectory=/opt/timelapse/apps/device-agent
+Environment=PYTHONUNBUFFERED=1
+EnvironmentFile=-/etc/timelapse/agent.env
+ExecStart=/usr/bin/python3 /opt/timelapse/apps/device-agent/agent/main.py
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target

+ 8 - 0
memory.md

@@ -123,6 +123,14 @@ docker compose up -d --build api-server web-dashboard worker
 - Dashboard tự refetch khi nhận WS event (useSocket hook)
 - Client subscribe theo project room: `subscribe:project`
 
+## Device installer (Pi) — ✅ ĐÃ LÀM
+- `apps/device-agent/agent/main.py`
+  - claim device, poll approval, save key, heartbeat loop
+- `apps/device-agent/systemd/timelapse-agent.service`
+- `apps/device-agent/scripts/install-agent.sh`
+- `apps/device-agent/requirements.txt`
+- `apps/device-agent/README.md`
+
 ## TODO — Phase tiếp theo
 1. **DB migration**: drizzle-kit push + seed data (trên Pi)
 2. **Role-based access control nâng cao** (org_admin/project_manager/viewer)