Przeglądaj źródła

feat: add simulator container + captures upload API

- CapturesModule: POST /v1/captures/upload (multipart, local storage),
  POST /v1/captures/register (metadata only), GET endpoints (JWT auth)
- Upload files saved to /uploads/captures/{projectId}/{date}/ with checksum
- Realtime event emitted on capture upload
- Simulator Python container: claim → auto-approve → heartbeat + image upload
  loop (uses internal key for self-approval, Pillow for placeholder JPEG)
- POST /v1/devices/pending/:code/approve-internal endpoint (X-Internal-Key auth)
- docker-compose: add timelapse_uploads volume, SIM_INTERNAL_KEY env,
  simulator service (2 devices, 30s capture interval)
- api-server: UPLOAD_DIR env var, mkdir /uploads in Dockerfile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
kingkong 2 miesięcy temu
rodzic
commit
047b59a380

+ 3 - 0
apps/api-server/Dockerfile

@@ -17,5 +17,8 @@ RUN npm install
 RUN cd packages/shared-types && npm run build
 RUN cd apps/api-server && npm run build
 
+# Create upload directory
+RUN mkdir -p /uploads/captures && chown -R node:node /uploads
+
 EXPOSE 3001
 CMD ["node", "apps/api-server/dist/apps/api-server/src/main.js"]

+ 166 - 0
apps/api-server/src/modules/captures/captures.controller.ts

@@ -0,0 +1,166 @@
+import {
+  Controller,
+  Post,
+  Get,
+  Param,
+  Body,
+  Query,
+  UseGuards,
+  UseInterceptors,
+  UploadedFile,
+  ParseFilePipe,
+  MaxFileSizeValidator,
+  FileTypeValidator,
+  Req,
+} from '@nestjs/common'
+import { FileInterceptor } from '@nestjs/platform-express'
+import { diskStorage } from 'multer'
+import { extname } from 'path'
+import { Request } from 'express'
+import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'
+import { ApiKeyGuard } from '../../common/guards/api-key.guard'
+import { CapturesService } from './captures.service'
+
+// Multer storage config — save with original extension
+const storage = diskStorage({
+  destination: '/tmp/timelapse-uploads',
+  filename: (_req, file, cb) => {
+    const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`
+    cb(null, `${unique}${extname(file.originalname)}`)
+  },
+})
+
+@Controller('v1/captures')
+export class CapturesController {
+  constructor(private readonly service: CapturesService) {}
+
+  /**
+   * POST /v1/captures/upload
+   * Device uploads a capture image. Auth via X-API-Key header.
+   * Multipart: file + fields (deviceId, capturedAt, resolution?, exposureMs?, iso?, aperture?, gpsLat?, gpsLng?)
+   */
+  @Post('upload')
+  @UseGuards(ApiKeyGuard)
+  @UseInterceptors(
+    FileInterceptor('file', {
+      storage,
+      limits: { fileSize: 100 * 1024 * 1024 }, // 100MB max
+    }),
+  )
+  async uploadCapture(
+    @Req() req: Request,
+    @UploadedFile(
+      new ParseFilePipe({
+        validators: [
+          new MaxFileSizeValidator({ maxSize: 100 * 1024 * 1024 }),
+          new FileTypeValidator({ fileType: /(jpeg|jpg|png|tiff|webp)$/i }),
+        ],
+      }),
+    )
+    file: Express.Multer.File,
+    @Body('deviceId') deviceId: string,
+    @Body('capturedAt') capturedAt: string,
+    @Body('resolution') resolution?: string,
+    @Body('exposureMs') exposureMs?: string,
+    @Body('iso') iso?: string,
+    @Body('aperture') aperture?: string,
+    @Body('gpsLat') gpsLat?: string,
+    @Body('gpsLng') gpsLng?: string,
+  ) {
+    if (!deviceId || !capturedAt) {
+      return { error: 'deviceId and capturedAt are required' }
+    }
+
+    return this.service.handleUpload({
+      file,
+      apiKey: (req as any).apiKey,
+      deviceId,
+      capturedAt,
+      resolution,
+      exposureMs: exposureMs ? parseInt(exposureMs, 10) : undefined,
+      iso: iso ? parseInt(iso, 10) : undefined,
+      aperture,
+      gpsLat,
+      gpsLng,
+    })
+  }
+
+  /**
+   * POST /v1/captures/register
+   * Register capture metadata without file (capture was stored elsewhere / already uploaded).
+   * Auth via X-API-Key header.
+   */
+  @Post('register')
+  @UseGuards(ApiKeyGuard)
+  async registerCapture(
+    @Req() req: Request,
+    @Body('deviceId') deviceId: string,
+    @Body('capturedAt') capturedAt: string,
+    @Body('fileKey') fileKey: string,
+    @Body('checksum') checksum?: string,
+    @Body('resolution') resolution?: string,
+    @Body('fileSizeBytes') fileSizeBytes?: string,
+    @Body('exposureMs') exposureMs?: string,
+    @Body('iso') iso?: string,
+    @Body('aperture') aperture?: string,
+    @Body('gpsLat') gpsLat?: string,
+    @Body('gpsLng') gpsLng?: string,
+  ) {
+    if (!deviceId || !capturedAt || !fileKey) {
+      return { error: 'deviceId, capturedAt, fileKey are required' }
+    }
+
+    return this.service.registerCapture({
+      apiKey: (req as any).apiKey,
+      deviceId,
+      capturedAt,
+      fileKey,
+      checksum,
+      resolution,
+      fileSizeBytes: fileSizeBytes ? parseInt(fileSizeBytes, 10) : undefined,
+      exposureMs: exposureMs ? parseInt(exposureMs, 10) : undefined,
+      iso: iso ? parseInt(iso, 10) : undefined,
+      aperture,
+      gpsLat,
+      gpsLng,
+    })
+  }
+
+  // ── JWT-protected routes ──────────────────────────────────────
+
+  /**
+   * GET /v1/captures/project/:projectId
+   * List captures for a project.
+   */
+  @Get('project/:projectId')
+  @UseGuards(JwtAuthGuard)
+  async getByProject(
+    @Param('projectId') projectId: string,
+    @Query('limit') limit?: string,
+  ) {
+    return this.service.getCapturesByProject(projectId, limit ? parseInt(limit, 10) : 100)
+  }
+
+  /**
+   * GET /v1/captures/device/:deviceId
+   * List captures for a device.
+   */
+  @Get('device/:deviceId')
+  @UseGuards(JwtAuthGuard)
+  async getByDevice(
+    @Param('deviceId') deviceId: string,
+    @Query('limit') limit?: string,
+  ) {
+    return this.service.getCapturesByDevice(deviceId, limit ? parseInt(limit, 10) : 100)
+  }
+
+  /**
+   * GET /v1/captures/:id
+   * Get single capture detail.
+   */
+  @Get(':id')
+  @UseGuards(JwtAuthGuard)
+  async getById(@Param('id') id: string) {
+    return this.service.getCaptureById(id)
+  }
+}

+ 9 - 3
apps/api-server/src/modules/captures/captures.module.ts

@@ -1,8 +1,14 @@
 import { Module } from '@nestjs/common'
+import { CapturesController } from './captures.controller'
+import { CapturesService } from './captures.service'
+import { CapturesRepository } from './captures.repository'
+import { DevicesModule } from '../devices/devices.module'
+import { RealtimeModule } from '../../realtime/realtime.module'
 
 @Module({
-  controllers: [],
-  providers: [],
-  exports: [],
+  imports: [DevicesModule, RealtimeModule],
+  controllers: [CapturesController],
+  providers: [CapturesService, CapturesRepository],
+  exports: [CapturesService],
 })
 export class CapturesModule {}

+ 87 - 0
apps/api-server/src/modules/captures/captures.repository.ts

@@ -0,0 +1,87 @@
+import { Injectable } from '@nestjs/common'
+import { eq, desc, and, gte } from 'drizzle-orm'
+import { db } from '../../db/database.module'
+import { captures } from '../../db/schema'
+import { nanoid } from 'nanoid'
+
+@Injectable()
+export class CapturesRepository {
+  async createCapture(data: {
+    projectId: string
+    deviceId: string
+    capturedAt: Date
+    fileKey: string
+    thumbnailKey?: string
+    checksum?: string
+    resolution?: string
+    fileSizeBytes?: number
+    exposureMs?: number
+    iso?: number
+    aperture?: string
+    gpsLat?: string
+    gpsLng?: string
+    metadata?: Record<string, unknown>
+  }) {
+    const id = nanoid()
+    await db.insert(captures).values({
+      id,
+      projectId: data.projectId,
+      deviceId: data.deviceId,
+      capturedAt: data.capturedAt,
+      fileKey: data.fileKey,
+      thumbnailKey: data.thumbnailKey ?? null,
+      checksum: data.checksum ?? null,
+      resolution: data.resolution ?? null,
+      fileSizeBytes: data.fileSizeBytes ?? null,
+      exposureMs: data.exposureMs ?? null,
+      iso: data.iso ?? null,
+      aperture: data.aperture ?? null,
+      gpsLat: data.gpsLat ?? null,
+      gpsLng: data.gpsLng ?? null,
+      status: 'uploaded' as any,
+      metadata: data.metadata ?? {},
+    })
+    return this.findById(id)
+  }
+
+  async findById(id: string) {
+    const result = await db.select().from(captures).where(eq(captures.id, id)).limit(1)
+    return result[0] ?? null
+  }
+
+  async findByProjectId(projectId: string, limit = 100) {
+    return db
+      .select()
+      .from(captures)
+      .where(eq(captures.projectId, projectId))
+      .orderBy(desc(captures.capturedAt))
+      .limit(limit)
+  }
+
+  async findByDeviceId(deviceId: string, limit = 100) {
+    return db
+      .select()
+      .from(captures)
+      .where(eq(captures.deviceId, deviceId))
+      .orderBy(desc(captures.capturedAt))
+      .limit(limit)
+  }
+
+  async countByProjectToday(projectId: string): Promise<number> {
+    const start = new Date()
+    start.setHours(0, 0, 0, 0)
+    const result = await db
+      .select({ count: captures.id })
+      .from(captures)
+      .where(and(eq(captures.projectId, projectId), gte(captures.capturedAt, start)))
+    return result.length
+  }
+
+  async updateStatus(id: string, status: string) {
+    await db.update(captures).set({ status: status as any }).where(eq(captures.id, id))
+  }
+
+  async markUploaded(id: string, uploadedAt: Date) {
+    await db.update(captures).set({ uploadedAt, status: 'uploaded' as any }).where(eq(captures.id, id))
+  }
+}

+ 150 - 0
apps/api-server/src/modules/captures/captures.service.ts

@@ -0,0 +1,150 @@
+import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from '@nestjs/common'
+import { CapturesRepository } from './captures.repository'
+import { DevicesRepository } from '../devices/devices.repository'
+import { RealtimeGateway } from '../../realtime/gateways/realtime.gateway'
+import * as fs from 'fs'
+import * as path from 'path'
+import * as crypto from 'crypto'
+
+export interface UploadedFile {
+  filename: string
+  originalname: string
+  mimetype: string
+  size: number
+  path: string
+}
+
+@Injectable()
+export class CapturesService {
+  private readonly uploadDir: string
+
+  constructor(
+    private readonly repo: CapturesRepository,
+    private readonly deviceRepo: DevicesRepository,
+    private readonly realtime: RealtimeGateway,
+  ) {
+    this.uploadDir = process.env['UPLOAD_DIR'] || '/uploads/captures'
+    fs.mkdirSync(this.uploadDir, { recursive: true })
+  }
+
+  private async validateDeviceApiKey(deviceId: string, apiKey: string) {
+    const device = await this.deviceRepo.findDeviceById(deviceId)
+    if (!device) throw new NotFoundException(`Device ${deviceId} not found`)
+    if (!device.apiKeyHash) throw new NotFoundException('Device not provisioned')
+    const validKey = await this.deviceRepo.verifyApiKey(device.apiKeyHash, apiKey)
+    if (!validKey) throw new UnauthorizedException('Invalid API key')
+    if (!device.projectId || !device.orgId) throw new BadRequestException('Device not assigned to a project')
+    return device
+  }
+
+  async handleUpload(params: {
+    file: UploadedFile
+    apiKey: string
+    deviceId: string
+    capturedAt: string
+    resolution?: string
+    exposureMs?: number
+    iso?: number
+    aperture?: string
+    gpsLat?: string
+    gpsLng?: string
+  }) {
+    const device = await this.validateDeviceApiKey(params.deviceId, params.apiKey)
+
+    // Compute checksum
+    const fileBuffer = fs.readFileSync(params.file.path)
+    const checksum = crypto.createHash('sha256').update(fileBuffer).digest('hex')
+
+    // Storage path: /uploads/captures/{projectId}/{YYYY-MM-DD}/{id}.{ext}
+    const dateStr = new Date(params.capturedAt).toISOString().slice(0, 10)
+    const ext = path.extname(params.file.originalname) || '.jpg'
+    const id = crypto.randomUUID().slice(0, 12)
+    const storagePath = path.join(this.uploadDir, device.projectId, dateStr)
+    fs.mkdirSync(storagePath, { recursive: true })
+
+    const fileKey = `${device.projectId}/${dateStr}/${id}${ext}`
+    const destPath = path.join(this.uploadDir, fileKey)
+    fs.renameSync(params.file.path, destPath)
+
+    const capture = await this.repo.createCapture({
+      projectId: device.projectId,
+      deviceId: params.deviceId,
+      capturedAt: new Date(params.capturedAt),
+      fileKey,
+      checksum,
+      resolution: params.resolution ?? null,
+      fileSizeBytes: params.file.size,
+      exposureMs: params.exposureMs ?? null,
+      iso: params.iso ?? null,
+      aperture: params.aperture ?? null,
+      gpsLat: params.gpsLat ?? null,
+      gpsLng: params.gpsLng ?? null,
+      metadata: { originalFilename: params.file.originalname, uploadedAt: new Date().toISOString() },
+    })
+
+    this.realtime.emitCaptureUploaded(params.deviceId, device.projectId, capture.id)
+
+    return {
+      captureId: capture.id,
+      fileKey,
+      checksum,
+      sizeBytes: params.file.size,
+      capturedAt: params.capturedAt,
+    }
+  }
+
+  async registerCapture(params: {
+    apiKey: string
+    deviceId: string
+    capturedAt: string
+    fileKey: string
+    checksum?: string
+    resolution?: string
+    fileSizeBytes?: number
+    exposureMs?: number
+    iso?: number
+    aperture?: string
+    gpsLat?: string
+    gpsLng?: string
+  }) {
+    const device = await this.validateDeviceApiKey(params.deviceId, params.apiKey)
+
+    const capture = await this.repo.createCapture({
+      projectId: device.projectId,
+      deviceId: params.deviceId,
+      capturedAt: new Date(params.capturedAt),
+      fileKey: params.fileKey,
+      checksum: params.checksum ?? null,
+      resolution: params.resolution ?? null,
+      fileSizeBytes: params.fileSizeBytes ?? null,
+      exposureMs: params.exposureMs ?? null,
+      iso: params.iso ?? null,
+      aperture: params.aperture ?? null,
+      gpsLat: params.gpsLat ?? null,
+      gpsLng: params.gpsLng ?? null,
+    })
+
+    this.realtime.emitCaptureUploaded(params.deviceId, device.projectId, capture.id)
+
+    return { captureId: capture.id, fileKey: params.fileKey }
+  }
+
+  async getCapturesByProject(projectId: string, limit = 100) {
+    return this.repo.findByProjectId(projectId, limit)
+  }
+
+  async getCapturesByDevice(deviceId: string, limit = 100) {
+    return this.repo.findByDeviceId(deviceId, limit)
+  }
+
+  async getCaptureById(id: string) {
+    const capture = await this.repo.findById(id)
+    if (!capture) throw new NotFoundException(`Capture ${id} not found`)
+    return capture
+  }
+
+  async getCaptureStats(projectId: string) {
+    const today = await this.repo.countByProjectToday(projectId)
+    return { totalCaptures: today, todayCaptures: today }
+  }
+}

+ 21 - 0
apps/api-server/src/modules/devices/devices.controller.ts

@@ -44,6 +44,27 @@ export class DevicesController {
     return this.devicesService.approveDevice(claimCode, user.userId, body.projectId)
   }
 
+  /**
+   * POST /v1/devices/pending/:code/approve-internal
+   * Internal endpoint for simulator to auto-approve devices.
+   * Uses SIM_INTERNAL_KEY env var for authentication (no org context needed).
+   */
+  @Post('pending/:code/approve-internal')
+  approveDeviceInternal(
+    @Param('code') claimCode: string,
+    @Body() body: { projectId?: string },
+    @Req() req: any,
+  ) {
+    const internalKey = process.env['SIM_INTERNAL_KEY']
+    const providedKey = req.headers['x-internal-key']
+    if (!internalKey || providedKey !== internalKey) {
+      req.status(403).json({ error: 'Forbidden' })
+      return
+    }
+    // If no projectId provided, the service will try to auto-detect or fail
+    return this.devicesService.approveDeviceInternal(claimCode, body.projectId ?? null)
+  }
+
   @Post('pending/:code/reject')
   @UseGuards(JwtAuthGuard, RolesGuard)
   @Roles('org_admin')

+ 1 - 1
apps/api-server/src/modules/devices/devices.module.ts

@@ -8,6 +8,6 @@ import { RealtimeModule } from '../../realtime/realtime.module'
   imports: [RealtimeModule],
   controllers: [DevicesController],
   providers: [DevicesService, DevicesRepository],
-  exports: [DevicesService],
+  exports: [DevicesService, DevicesRepository],
 })
 export class DevicesModule {}

+ 19 - 0
apps/api-server/src/modules/devices/devices.service.ts

@@ -4,6 +4,9 @@ import { DeviceStatus } from '@shared/types'
 import { HeartbeatDto } from './dto/heartbeat.dto'
 import { RealtimeGateway } from '../../realtime/gateways/realtime.gateway'
 import { nanoid } from 'nanoid'
+import { db } from '../../db/database.module'
+import { projects } from '../../db/schema'
+import { eq } from 'drizzle-orm'
 
 function generateClaimCode(): string {
   const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
@@ -99,6 +102,22 @@ export class DevicesService {
     return { status: 'rejected' as const }
   }
 
+  /**
+   * Auto-approve for simulator/internal use.
+   * If projectId is null, picks the first available project.
+   */
+  async approveDeviceInternal(claimCode: string, projectId: string | null) {
+    // Auto-detect project if not provided
+    if (!projectId) {
+      const allProjects = await db.select().from(projects).limit(1)
+      if (allProjects.length === 0) {
+        throw new NotFoundException('No projects found — create a project first')
+      }
+      projectId = allProjects[0].id
+    }
+    return this.approveDevice(claimCode, 'simulator', projectId)
+  }
+
   // ── Heartbeat ──────────────────────────────────────────────
 
   async createHeartbeat(dto: HeartbeatDto, apiKey: string) {

+ 7 - 0
apps/simulator/.dockerignore

@@ -0,0 +1,7 @@
+__pycache__
+*.pyc
+*.pyo
+.git
+*.md
+node_modules
+*.log

+ 19 - 0
apps/simulator/Dockerfile

@@ -0,0 +1,19 @@
+FROM python:3.11-slim
+
+RUN apt-get update && apt-get install -y --no-install-recommends \
+    libjpeg-dev \
+    zlib1g-dev \
+    fonts-dejavu-core \
+    && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+
+COPY requirements.txt ./
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY simulator/ ./simulator/
+
+# Create config directory
+RUN mkdir -p /etc/timelapse
+
+ENTRYPOINT ["python3", "-u", "simulator/main.py"]

+ 2 - 0
apps/simulator/requirements.txt

@@ -0,0 +1,2 @@
+requests>=2.31.0
+Pillow>=10.0.0

+ 416 - 0
apps/simulator/simulator/main.py

@@ -0,0 +1,416 @@
+#!/usr/bin/env python3
+"""
+simulator/main.py
+
+Device simulator cho POC — chạy từ đầu: claim → approve → heartbeat + capture upload.
+
+Config: /etc/timelapse/simulator.env
+  SIM_SERVER_URL=http://localhost:3001
+  SIM_DEVICE_COUNT=2
+  SIM_CAPTURE_INTERVAL=60        # seconds between captures
+  SIM_PROJECT_ID=               # optional: project ID to assign (auto-detect if empty)
+  SIM_ORG_ID=                   # optional: org ID (auto-detect if empty)
+
+Luồng:
+1. Đọc /etc/machine-id hoặc sinh UUID cố định
+2. Claim device → lấy claim code
+3. Auto-approve: gọi internal API bằng seed admin token
+   (Đỡ phải manual approve trên dashboard)
+4. Poll /v1/devices/claim/:code/status để lấy bootstrap API key
+5. Heartbeat loop (cứ 30s)
+6. Capture loop (cứ SIM_CAPTURE_INTERVAL):
+   - Tạo ảnh placeholder nhẹ bằng Pillow
+   - Upload lên /v1/captures/upload
+   - Gửi heartbeat cập nhật capturesToday
+"""
+
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import os
+import random
+import string
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+
+from PIL import Image, ImageDraw, ImageFont
+
+import requests
+
+# ── Config ──────────────────────────────────────────────────────────
+
+SERVER_URL = os.environ.get('SIM_SERVER_URL', 'http://localhost:3001').rstrip('/')
+DEVICE_COUNT = int(os.environ.get('SIM_DEVICE_COUNT', '2'))
+CAPTURE_INTERVAL = int(os.environ.get('SIM_CAPTURE_INTERVAL', '60'))
+PROJECT_ID = os.environ.get('SIM_PROJECT_ID', '') or None
+ORG_ID = os.environ.get('SIM_ORG_ID', '') or None
+
+# Internal secret để auto-approve device (không cần org/user context)
+SIM_INTERNAL_KEY = os.environ.get('SIM_INTERNAL_KEY', 'sim-internal-dev-key-123')
+
+CONFIG_DIR = Path(os.environ.get('SIM_CONFIG_DIR', '/etc/timelapse'))
+CONFIG_FILE = CONFIG_DIR / 'simulator.env'
+DEVICE_DIR = Path(os.environ.get('SIM_DEVICE_DIR', '/etc/timelapse/devices'))
+
+# ── Logging ─────────────────────────────────────────────────────────
+
+def log(msg: str) -> None:
+    print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True)
+
+def log_err(msg: str) -> None:
+    print(f"[{datetime.now().strftime('%H:%M:%S')}] ERROR: {msg}", flush=True, file=sys.stderr)
+
+# ── Utilities ───────────────────────────────────────────────────────
+
+def get_machine_id() -> str:
+    """Read /etc/machine-id for stable device UUID."""
+    try:
+        with open('/etc/machine-id', 'r') as f:
+            return f.read().strip()
+    except FileNotFoundError:
+        pass
+    # Fallback: generate once and save
+    fallback = 'sim-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=12))
+    DEVICE_DIR.mkdir(parents=True, exist_ok=True)
+    id_file = DEVICE_DIR / '.machine_id'
+    if id_file.exists():
+        return id_file.read_text().strip()
+    id_file.write_text(fallback)
+    return fallback
+
+def random_uuid(prefix: str = 'sim') -> str:
+    return f"{prefix}-" + ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
+
+def generate_claim_code() -> str:
+    chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
+    return ''.join(random.choices(chars, k=6))
+
+# ── Placeholder image generator ─────────────────────────────────────
+
+def generate_placeholder_image(device_id: str, seq: int, width=640, height=480) -> bytes:
+    """Generate a small JPEG placeholder with device info overlay."""
+    img = Image.new('RGB', (width, height), color=(
+        random.randint(80, 120),  # R
+        random.randint(120, 160), # G
+        random.randint(160, 200), # B
+    ))
+    draw = ImageDraw.Draw(img)
+
+    # Watermark text
+    now = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')
+    lines = [
+        f"Device: {device_id[:16]}",
+        f"Seq: {seq:04d}",
+        f"Time: {now}",
+        f"Simulator v1.0",
+    ]
+    try:
+        # Try to use a basic font, fallback to default
+        font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', 18)
+    except Exception:
+        font = ImageFont.load_default()
+
+    y = 20
+    for line in lines:
+        draw.text((10, y), line, fill=(255, 255, 255), font=font)
+        y += 26
+
+    # Add a small colored rectangle in corner (visual variety)
+    for _ in range(3):
+        rx = random.randint(0, width - 60)
+        ry = random.randint(0, height - 40)
+        rw = random.randint(30, 60)
+        rh = random.randint(20, 40)
+        color = (random.randint(0, 200), random.randint(0, 200), random.randint(0, 200))
+        draw.rectangle([rx, ry, rx + rw, ry + rh], fill=color)
+
+    buf = io.BytesIO()
+    # Quality 60 = small file, still recognizable
+    img.save(buf, format='JPEG', quality=60, optimize=True)
+    return buf.getvalue()
+
+# ── Device lifecycle ────────────────────────────────────────────────
+
+class SimulatedDevice:
+    def __init__(self, index: int, server_url: str):
+        self.index = index
+        self.server_url = server_url
+        self.device_id: str | None = None
+        self.api_key: str | None = None
+        self.claim_code: str | None = None
+        self.capture_seq: int = 0
+
+        # Unique per device instance
+        machine_id = get_machine_id()
+        base = hashlib.sha256(f"{machine_id}-{index}".encode()).hexdigest()[:16]
+        self.device_uuid = f"sim-{base}"
+        self.device_name = f"Sim-Cam-{index+1:02d}"
+        self.serial_no = f"SIM-{index+1:03d}"
+
+    # ── Step 1: Claim ───────────────────────────────────────────────
+
+    def claim(self) -> bool:
+        payload = {
+            'deviceUuid': self.device_uuid,
+            'deviceName': self.device_name,
+            'serialNo': self.serial_no,
+        }
+        try:
+            r = requests.post(f"{self.server_url}/v1/devices/claim", json=payload, timeout=10)
+            if not r.ok:
+                log_err(f"[{self.serial_no}] claim failed: {r.status_code} {r.text[:120]}")
+                return False
+            data = r.json()
+            self.claim_code = data.get('claimCode')
+            if not self.claim_code:
+                log_err(f"[{self.serial_no}] no claimCode in response: {data}")
+                return False
+            log(f"[{self.serial_no}] claimed | code={self.claim_code}")
+            return True
+        except Exception as e:
+            log_err(f"[{self.serial_no}] claim error: {e}")
+            return False
+
+    # ── Step 2: Auto-approve ───────────────────────────────────────
+
+    def _auto_approve(self) -> bool:
+        """Approve device using internal key (no org context needed)."""
+        try:
+            r = requests.post(
+                f"{self.server_url}/v1/devices/pending/{self.claim_code}/approve-internal",
+                json={'projectId': PROJECT_ID},
+                headers={'X-Internal-Key': SIM_INTERNAL_KEY, 'Content-Type': 'application/json'},
+                timeout=10,
+            )
+            if r.ok:
+                log(f"[{self.serial_no}] auto-approved (internal)")
+                return True
+            else:
+                log_err(f"[{self.serial_no}] approve failed: {r.status_code} {r.text[:120]}")
+                return False
+        except Exception as e:
+            log_err(f"[{self.serial_no}] approve error: {e}")
+            return False
+
+    # ── Step 3: Poll for bootstrap API key ────────────────────────
+
+    def poll_bootstrap_key(self, max_wait: int = 60) -> bool:
+        if not self.claim_code:
+            log_err(f"[{self.serial_no}] no claim code to poll")
+            return False
+
+        log(f"[{self.serial_no}] polling claim status (max {max_wait}s)...")
+        deadline = time.time() + max_wait
+
+        while time.time() < deadline:
+            try:
+                r = requests.get(
+                    f"{self.server_url}/v1/devices/claim/{self.claim_code}/status",
+                    timeout=10,
+                )
+                if not r.ok:
+                    log(f"[{self.serial_no}] poll: {r.status_code}")
+                    time.sleep(5)
+                    continue
+
+                data = r.json()
+                status = data.get('status')
+
+                if status == 'approved':
+                    self.api_key = data.get('apiKey')
+                    self.device_id = data.get('deviceId')
+                    if self.api_key:
+                        log(f"[{self.serial_no}] APPROVED | deviceId={self.device_id} | apiKey={self.api_key[:8]}...")
+                        return True
+                    else:
+                        log_err(f"[{self.serial_no}] approved but no apiKey in response")
+                        return False
+                elif status in ('rejected', 'expired'):
+                    log_err(f"[{self.serial_no}] claim {status}")
+                    return False
+
+                time.sleep(5)
+            except Exception as e:
+                log_err(f"[{self.serial_no}] poll error: {e}")
+                time.sleep(5)
+
+        log_err(f"[{self.serial_no}] bootstrap key poll timed out")
+        return False
+
+    # ── Step 4: Heartbeat ──────────────────────────────────────────
+
+    def heartbeat(self) -> bool:
+        if not self.device_id or not self.api_key:
+            return False
+
+        payload = {
+            'deviceId': self.device_id,
+            'status': random.choice(['online', 'online', 'online', 'degraded']),
+            'tempC': round(random.uniform(38, 62), 1),
+            'batteryPct': random.choice([None, random.randint(30, 100)]),
+            'storageFreeGb': random.randint(8, 120),
+            'capturesToday': self.capture_seq,
+            'lastCaptureAt': datetime.utcnow().isoformat(),
+            'firmwareVersion': 'sim-1.0.0',
+            'networkStatus': random.choice(['online', 'online', 'degraded']),
+        }
+        try:
+            r = requests.post(
+                f"{self.server_url}/v1/devices/{self.device_id}/heartbeat",
+                json=payload,
+                headers={'X-API-Key': self.api_key, 'Content-Type': 'application/json'},
+                timeout=10,
+            )
+            if r.ok:
+                data = r.json()
+                pending = data.get('pendingCommands', 0)
+                log(f"[{self.serial_no}] hb ok | seq={self.capture_seq} | pending_cmds={pending}")
+                return True
+            else:
+                log_err(f"[{self.serial_no}] hb fail: {r.status_code} {r.text[:80]}")
+                return False
+        except Exception as e:
+            log_err(f"[{self.serial_no}] hb err: {e}")
+            return False
+
+    # ── Step 5: Capture + Upload ──────────────────────────────────
+
+    def capture_and_upload(self) -> bool:
+        if not self.device_id or not self.api_key:
+            return False
+
+        self.capture_seq += 1
+        captured_at = datetime.utcnow().isoformat()
+
+        # Generate image
+        img_bytes = generate_placeholder_image(self.device_id or self.serial_no, self.capture_seq)
+
+        try:
+            files = {'file': (f'cap_{self.capture_seq:04d}.jpg', img_bytes, 'image/jpeg')}
+            data = {
+                'deviceId': self.device_id,
+                'capturedAt': captured_at,
+                'resolution': '640x480',
+                'exposureMs': str(random.randint(10, 100)),
+                'iso': str(random.choice([100, 200, 400, 800])),
+                'aperture': random.choice(['f/2.8', 'f/4', 'f/5.6', 'f/8']),
+            }
+            r = requests.post(
+                f"{self.server_url}/v1/captures/upload",
+                files=files,
+                data=data,
+                headers={'X-API-Key': self.api_key},
+                timeout=30,
+            )
+            if r.ok:
+                resp = r.json()
+                file_key = resp.get('fileKey', '?')
+                size_kb = resp.get('sizeBytes', 0) // 1024
+                log(f"[{self.serial_no}] uploaded #{self.capture_seq} | {size_kb}KB | key={file_key[:40]}")
+                return True
+            else:
+                log_err(f"[{self.serial_no}] upload fail: {r.status_code} {r.text[:120]}")
+                return False
+        except Exception as e:
+            log_err(f"[{self.serial_no}] upload err: {e}")
+            return False
+
+    # ── Full provisioning + loop ───────────────────────────────────
+
+    def run(self) -> bool:
+        log(f"[{self.serial_no}] Starting simulator...")
+
+        # 1. Claim
+        if not self.claim():
+            return False
+
+        # 2. Auto-approve (if admin token available)
+        if SIM_INTERNAL_KEY:
+            self._auto_approve()
+
+        # 3. Poll bootstrap key
+        if not self.poll_bootstrap_key(max_wait=60):
+            log_err(f"[{self.serial_no}] Failed to get bootstrap key — will retry claim")
+            # Re-claim (get fresh code)
+            if not self.claim():
+                return False
+            if SIM_INTERNAL_KEY:
+                self._auto_approve()
+            if not self.poll_bootstrap_key(max_wait=30):
+                log_err(f"[{self.serial_no}] Giving up on provisioning")
+                return False
+
+        # 4. Loops
+        heartbeat_count = 0
+        last_capture = 0
+
+        while True:
+            now = time.time()
+
+            # Heartbeat every 30s
+            if now - heartbeat_count * 30 >= 30 or heartbeat_count == 0:
+                self.heartbeat()
+                heartbeat_count += 1
+
+            # Capture every CAPTURE_INTERVAL seconds
+            if now - last_capture >= CAPTURE_INTERVAL:
+                self.capture_and_upload()
+                last_capture = now
+
+            time.sleep(5)  # check every 5s
+
+
+# ── Auto-detect org/project for auto-approve ─────────────────────────
+
+def detect_org_project() -> tuple[str | None, str | None]:
+    """Try to auto-detect first org/project from the API (no auth needed for public endpoints)."""
+    # Without auth, we can't list orgs — just return None
+    # The approve endpoint will fail, simulator will wait for manual approval
+    return None, None
+
+
+# ── Main ─────────────────────────────────────────────────────────────
+
+def main() -> None:
+    log(f"Simulator starting | server={SERVER_URL} | devices={DEVICE_COUNT} | interval={CAPTURE_INTERVAL}s")
+
+    devices: list[SimulatedDevice] = []
+    for i in range(DEVICE_COUNT):
+        d = SimulatedDevice(i, SERVER_URL)
+        devices.append(d)
+
+    # Run all devices concurrently (threads)
+    import threading
+
+    def run_device(d: SimulatedDevice) -> None:
+        while True:
+            try:
+                d.run()
+            except Exception as e:
+                log_err(f"[{d.serial_no}] unexpected error: {e}")
+            log(f"[{d.serial_no}] restarting in 10s...")
+            time.sleep(10)
+
+    threads = [threading.Thread(target=run_device, args=(d,), daemon=True) for d in devices]
+
+    for t in threads:
+        t.start()
+        time.sleep(1)  # stagger to avoid thundering herd
+
+    log(f"All {DEVICE_COUNT} device simulators running")
+
+    # Keep main thread alive
+    try:
+        while True:
+            time.sleep(60)
+    except KeyboardInterrupt:
+        log("Shutting down...")
+        sys.exit(0)
+
+
+if __name__ == '__main__':
+    main()

+ 3 - 0
bun.lock

@@ -5,6 +5,7 @@
     "": {
       "name": "construction-timelapse",
       "devDependencies": {
+        "@types/multer": "^2.1.0",
         "@types/node": "^20.11.0",
         "eslint": "^8.56.0",
         "prettier": "^3.2.0",
@@ -513,6 +514,8 @@
 
     "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
 
+    "@types/multer": ["@types/multer@2.1.0", "", { "dependencies": { "@types/express": "*" } }, "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA=="],
+
     "@types/node": ["@types/node@20.19.37", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw=="],
 
     "@types/oauth": ["@types/oauth@0.9.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA=="],

+ 18 - 0
docker-compose.yml

@@ -46,6 +46,8 @@ services:
       GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
       GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
       GOOGLE_CALLBACK_URL: ${GOOGLE_CALLBACK_URL:-http://localhost:3001/v1/auth/google/callback}
+      UPLOAD_DIR: /uploads/captures
+      SIM_INTERNAL_KEY: ${SIM_INTERNAL_KEY:-sim-internal-dev-key-123}
     ports:
       - '3001:3001'
     depends_on:
@@ -55,6 +57,7 @@ services:
         condition: service_healthy
     volumes:
       - ./apps/api-server/src:/app/src:ro
+      - timelapse_uploads:/uploads/captures
 
   web-dashboard:
     build:
@@ -86,6 +89,21 @@ services:
       redis:
         condition: service_healthy
 
+  simulator:
+    build:
+      context: .
+      dockerfile: apps/simulator/Dockerfile
+    restart: unless-stopped
+    environment:
+      SIM_SERVER_URL: http://api-server:3001
+      SIM_DEVICE_COUNT: '2'
+      SIM_CAPTURE_INTERVAL: '30'
+      SIM_INTERNAL_KEY: ${SIM_INTERNAL_KEY:-sim-internal-dev-key-123}
+    depends_on:
+      api-server:
+        condition: service_started
+
 volumes:
   postgres_data:
   redis_data:
+  timelapse_uploads:

+ 1 - 0
package.json

@@ -25,6 +25,7 @@
     "db:seed": "npm run seed --workspace=apps/api-server"
   },
   "devDependencies": {
+    "@types/multer": "^2.1.0",
     "@types/node": "^20.11.0",
     "eslint": "^8.56.0",
     "prettier": "^3.2.0",