adacam/docs/hivemapper-odc-api-deep-dive-2026-03-23.md

38 KiB

Hivemapper odc-api Deep Technical Dive

Repository: https://github.com/Hivemapper/odc-api
Branch: bee
Audit Date: 2026-03-23
Purpose: This is the open-source firmware/API running on Hivemapper Bee dashcam devices


Table of Contents

  1. Architecture Overview
  2. Configuration & Constants
  3. API Endpoints Reference
  4. Security Vulnerabilities
  5. Services
  6. SQLite Schema
  7. Redis Usage
  8. Data Flow Pipeline
  9. Landmark Proxy Service (Cloud Upload)

1. Architecture Overview

Application Structure

The odc-api is a Node.js/Express application that runs on Hivemapper Bee dashcam devices. It serves as the central API for:

  • Device configuration and management
  • GPS/GNSS data handling
  • Frame kilometer (framekm) processing and upload
  • ML/AI landmark detection integration
  • Plugin management
  • LTE/WiFi connectivity

Entry Point: src/index.ts

// Main application initialization
const app: Application = express();
const server = new HttpServer(app);

// Port configuration
PORT = 5000;  // HTTP API
WS_PORT = 5001;  // WebSocket

// Static file serving
app.use('/public', express.static(PUBLIC_FOLDER));  // /mnt/data
app.use('/tmp', express.static(TMP_PUBLIC_FOLDER));  // /tmp/recording

// Service advertisement via Bonjour/mDNS
bonjour.publish({ name: 'DashcamService', type: 'dashcam', port: PORT });

Service Runner Pattern

The serviceRunner is a simple interval-based task scheduler defined in src/services/index.ts:

class ServiceRunner {
  services: IService[] = [];
  
  add(service: IService) {
    this.services.push(service);
  }
  
  run() {
    this.services.map((service: IService) => {
      if (service.interval || service.delay) {
        const interval = setInterval(() => {
          service.execute();
          if (service.delay) clearInterval(interval);
        }, service.interval || service.delay);
        
        if (!service.delay) service.execute();  // Run immediately if not delayed
      } else {
        service.execute();  // One-shot execution
      }
    });
  }
}

IService Interface:

interface IService {
  execute: () => Promise<void>;
  interval?: number;  // Recurring interval in ms
  delay?: number;     // One-time delay in ms
}

Registered Services (from src/index.ts)

Service Purpose
HeartBeatService Device health monitoring
HeartBeatTelemetryService Telemetry reporting
DeviceInfoService Device identification
IntegrityCheckService Data integrity validation
InitIMUCalibrationService IMU sensor calibration
InitCronService Scheduled task initialization
LoadPrivacyService Privacy zone loading
GetFrameKmsService Frame kilometer retrieval/upload
PluginService Plugin management
LandmarkProxyService Uploads landmarks to Hivemapper cloud
NetworkLogService Network activity logging
LTEFirmwareUpdateService LTE modem firmware updates
CleanupFramekmService Old framekm cleanup
PluginUpdateService Plugin auto-updates
WifiClientService WiFi client management

2. Configuration & Constants

Main Configuration: src/config/hdc.ts

// Network
export const PORT = 5000;
export const WS_PORT = 5001;

// File System Paths
export const PUBLIC_FOLDER = '/mnt/data';
export const TMP_PUBLIC_FOLDER = '/tmp/recording';
export const FRAMES_ROOT_FOLDER = '/tmp/recording/pic';
export const FRAMEKM_ROOT_FOLDER = '/mnt/data/framekm';
export const FRAMEKM_SHORT_ROOT_FOLDER = '/mnt/data/framekm_short';
export const FRAMEKM_CUSTOM_ROOT_FOLDER = '/mnt/data/framekm_custom';
export const UNPROCESSED_FRAMEKM_ROOT_FOLDER = '/mnt/data/unprocessed_framekm';
export const CACHED_OBSERVATIONS_FOLDER = '/mnt/data/cached_observations';
export const RAW_DATA_ROOT_FOLDER = '/mnt/data/raw';
export const DB_PATH = '/mnt/data/data-logger.v1.4.5.db';
export const GPS_ROOT_FOLDER = '/mnt/data/gps';
export const CONFIG_PATH = '/opt/dashcam/bin/config.json';
export const METADATA_ROOT_FOLDER = '/mnt/data/metadata';
export const LANDMARKS_METADATA_ROOT_FOLDER = '/mnt/data/landmarks';
export const ML_METADATA_ROOT_FOLDER = '/mnt/data/ml_metadata';
export const ML_ROOT_FOLDER = '/mnt/data/models';
export const PRIVACY_ZONES_CONFIG = '/mnt/data/ppz.json';

// External Commands
export const CMD = {
  RESTART_CAMERA: 'systemctl restart camera-bridge',
  START_CAMERA: 'systemctl start camera-bridge',
  STOP_CAMERA: 'systemctl stop camera-bridge',
  READ_DEVICE_INFO: '/opt/dashcam/bin/eeprom_access.py -r -f /tmp/dump.bin -o 0 -ba 0 -l 30',
  DEVICE_SSID: 'cat /sys/firmware/devicetree/base/serial-number',
  DELETE_FIRMWARE_FILES: 'rm -rf /data/lte-firmware/*.raucb && rm -rf /data/*.raucb',
};

API Utilities: src/util/api.ts

// Hivemapper API Base URL (via LTE proxy)
export const BASE_URL = 'https://hivemapper.com';
export const PROXY_URL = 'https://gateway.beemaps.com/';

// API call with auth cookie injection
async function callHmApi(url: string, options?: RequestInit): Promise<Response>;
async function callHmApiWithGateway(url: string, options?: RequestInit): Promise<Response>;

3. API Endpoints Reference

Route Mounting: src/routes/index.ts

router.use('/api/1', router);
router.use('/recordings', recordingsRouter);
router.use('/gps', gpsRouter);
router.use('/lora', loraRouter);
router.use('/upload', uploadRouter);
router.use('/ota', otaRouter);
router.use('/acl', aclRouter);
router.use('/config', configRouter);
router.use('/kpi', kpiRouter);
router.use('/framekm', framekmRouter);
router.use('/ml', mlRouter);
router.use('/metadata', metadataRouter);
router.use('/util', utilRouter);
router.use('/privacy', privacyRouter);
router.use('/led', ledRouter);
router.use('/logging', loggingRouter);
router.use('/db', dbRouter);
router.use('/instrumentation', instrumentationRouter);
router.use('/network', networkRouter);
router.use('/preview', previewRouter);
router.use('/firmware', firmwareRouter);
router.use('/plugin', pluginRouter);
router.use('/lane_counts', laneCountsRouter);
router.use('/embeddings', embeddingsRouter);
router.use('/aiEvents', aiEventsRouter);
router.use('/gnssConcise', gnssConciseRouter);
router.use('/zoo', modelZooRouter);
router.use('/zoo/2', modelZooV2Router);
router.use('/landmarks', landmarksRouter);
router.use('/position', positionRouter);
router.use('/time', timeRouter);
router.use('/beekeeper', beekeeperRouter);
router.use('/bursts', burstsRouter);
router.use('/file', fileRouter);
router.use('/lte', lteRouter);
router.use('/cache', cacheRouter);
router.use('/wifiClient', wifiClientRouter);
router.use('/gateway', gatewayRouter);

Core Endpoints (from src/routes/index.ts)

Method Path Auth Description Security Notes
GET /init None Initial device configuration -
GET /info None Device info (serial, firmware, etc.) Leaks device identifiers
GET /ping None Health check, returns device state -
GET /locktime None GNSS lock time info -
POST /cron None Schedule cron jobs ⚠️ Can schedule arbitrary tasks
GET /full_reset None DELETES ALL DATA 🚨 CRITICAL: No auth, mass deletion
GET /cleanup_cache None Deletes cache directories ⚠️ No auth, data deletion
GET /log None Read webserver log (last 2MB) Potential info disclosure
DELETE /log None Clear webserver log ⚠️ No auth
POST /cmd None EXECUTE ARBITRARY SHELL COMMANDS 🚨 CRITICAL: Unauthenticated RCE
POST /cmd/sync None Execute shell commands synchronously 🚨 CRITICAL: Unauthenticated RCE
GET /health None Health check -
GET /sensorquery None Query GNSS/IMU/magnetometer data -
GET /auth Cookie Authenticate with Hivemapper via cookie -
GET /plugins None List installed plugins -
POST /saveUserConfig None Save user configuration -
POST /lte-apn-config None Configure LTE APN settings ⚠️ Network config without auth

Landmarks Endpoints (src/routes/landmarks.ts)

Method Path Auth Description
GET /landmarks/ None List landmarks with optional time filtering
GET /landmarks/count None Count landmarks (internal)
GET /landmarks/last/:n None Get last N landmarks
GET /landmarks/id/:id None Get landmarks starting from ID
GET /landmarks/latest None Get most recent landmark
GET /landmarks/images/:id None Get image file paths for landmark
GET /landmarks/boundingBox/:id None Get bounding box for landmark
PUT /landmarks/upload None Upload landmark image to external URL
GET /landmarks/positionContext/:id None Get GPS context around landmark
DELETE /landmarks/deleteAll DEV_MODE Delete all landmarks (requires DEV_MODE=1)
GET /landmarks/:id/chips None Get observation chips for landmark
GET /landmarks/:id/chips/:chip_id None Get cropped chip image

Plugin Endpoints (src/routes/plugin.ts)

Method Path Auth Description
GET /plugin/list None List available plugins
POST /plugin/enable/:name None Enable a plugin
POST /plugin/disable/:name None Disable a plugin
GET /plugin/status/:name None Get plugin status

Network Endpoints (src/routes/network.ts)

Method Path Auth Description
GET /network/status None Network connectivity status
GET /network/wifi/scan None Scan for WiFi networks
POST /network/wifi/connect None Connect to WiFi network

Gateway/Proxy Endpoints (src/routes/gateway.ts)

Method Path Auth Description
GET /gateway/pending None Get pending proxy requests
POST /gateway/proxyResponse None Submit proxy response
POST /gateway/forward None Forward HTTP request through proxy

File Endpoints (src/routes/file.ts)

Method Path Auth Description Security Notes
GET /file/* None Read arbitrary file ⚠️ Path traversal risk
POST /file/* None Write arbitrary file 🚨 Arbitrary file write
DELETE /file/* None Delete arbitrary file 🚨 Arbitrary file delete

Privacy Endpoints (src/routes/privacy.ts)

Method Path Auth Description
GET /privacy/zones None Get privacy zones
POST /privacy/zones None Set privacy zones
DELETE /privacy/zones None Clear privacy zones

OTA/Firmware Endpoints (src/routes/ota.ts, src/routes/firmware.ts)

Method Path Auth Description Security Notes
POST /ota/upload None Upload firmware file ⚠️ No auth on firmware upload
POST /firmware/update None Install firmware 🚨 Unauthenticated firmware install

4. Security Vulnerabilities

🚨 CRITICAL: Unauthenticated Remote Code Execution

Endpoint: POST /cmd and POST /cmd/sync

router.post('/cmd', async (req, res) => {
  try {
    exec(req.body.cmd, { encoding: 'utf-8' }, (error, stdout, stderr) => {
      if (error) {
        res.json({ error: stdout || stderr });
      } else {
        res.json({ output: stdout });
      }
    });
  } catch (error: unknown) {
    res.json({ error });
  }
});

Impact: Any network-adjacent attacker can execute arbitrary shell commands on the device with no authentication.

Exploit:

curl -X POST http://dashcam:5000/api/1/cmd \
  -H "Content-Type: application/json" \
  -d '{"cmd": "cat /etc/shadow"}'

🚨 CRITICAL: Unauthenticated Mass Data Deletion

Endpoint: GET /full_reset

router.get('/full_reset', async (req: Request, res: Response) => {
  const cmds = [
    'rm -f /data/recording/framekm/*',
    'rm -f /data/recording/framekm_short/*',
    'rm -f /data/recording/framekm_custom/*',
    'rm -f /data/recording/metadata/*',
    'rm -f /data/recording/landmarks/*',
    'rm -f /data/recording/csv/*',
    'rm -f /data/recording/data-logger*',
    'rm -f /data/recording/odc-api*',
    'rm -rf /data/redis_handler/',
    'rm -rf /data/recording/redis_handler/',
    'rm -f /data/recording/*.log*',
  ];
  // ... executes all commands
});

Impact: Any network-adjacent attacker can wipe all recorded mapping data.


🚨 CRITICAL: Arbitrary File Operations

Endpoint: /file/* (GET/POST/DELETE)

The file endpoint allows reading, writing, and deleting arbitrary files on the device filesystem with no authentication.


⚠️ HIGH: Unauthenticated Firmware Installation

Endpoint: POST /firmware/update (in src/config/hdc.ts)

export const updateFirmware = async (req: Request, res: Response) => {
  try {
    const output = execSync('rauc install /tmp/' + req.query.filename, {
      encoding: 'utf-8',
    });
    res.json({ output });
  } catch (error: any) {
    res.json({ error: error.stdout || error.stderr });
  }
};

Impact: Attacker can install malicious firmware with no authentication. Combined with file upload, this allows complete device takeover.


⚠️ HIGH: Network Configuration Without Auth

Endpoints: /network/wifi/connect, /lte-apn-config

Attackers can reconfigure network settings to redirect traffic or establish persistence.


⚠️ MEDIUM: Information Disclosure

  • /info - Leaks device serial number, firmware version
  • /log - Exposes application logs which may contain sensitive data
  • /sensorquery - Exposes precise GPS coordinates

Summary: No Authentication Model

The entire API has no authentication whatsoever. All endpoints are accessible to anyone on the same network (typically the device's AP or connected WiFi).


5. Services

LandmarkProxyService (src/services/landmarkProxyService.ts)

Purpose: Uploads detected landmarks (signs, roadwork, etc.) to Hivemapper cloud.

Interval: Every 5 minutes

Cloud Endpoint: PUT /plugins/auditMultiplePlugins

export const LandmarkProxyService: IService = {
  execute: async () => {
    const pluginsToPush = await getAllEnabledNonBeekeeperPlugins();
    if (pluginsToPush.length === 0) return;

    const landmarks = await fetchLandmarksFromId(state.last_scanned_id + 1, 1000);
    const filtered = filterLandmarks(landmarks);

    const payload = {
      pluginNames: pluginsToPush,
      dataType: 'landmark',
      unit: await getAnonymousID(),
      firmware: dashcamInfo.api_version || 'unknown',
      session: getSessionId(),
      payloads: filtered,  // Landmark data with GPS coords
    };

    await callHmApiWithGateway('/plugins/auditMultiplePlugins', {
      method: 'PUT',
      body: JSON.stringify(payload),
      headers: { 'Content-Type': 'application/json' },
    });
  },
  interval: 5 * 60 * 1000,  // 5 minutes
};

Data Uploaded:

  • Device anonymous ID
  • Firmware version
  • Session ID
  • All detected landmarks with:
    • GPS coordinates (lat/lon/alt)
    • Classification (speed-sign, stop-sign, roadwork, etc.)
    • Confidence scores
    • Timestamps
    • Camera position at detection time

GetFrameKmsService

Handles frame kilometer packaging and upload to Hivemapper for HONEY rewards.

HeartBeatService

Periodic health checks and telemetry reporting.

PluginService

Manages third-party plugins (external ML models, custom processing).


6. SQLite Schema

Database Location

/mnt/data/data-logger.v1.4.5.db

Key Tables

landmarks Table

@Entity('landmarks')
export class Landmarks {
  @PrimaryGeneratedColumn({ type: 'integer', name: 'id' })
  id: number | null;

  @Column('integer', { name: 'ts' })
  ts: number;  // Timestamp

  @Column('integer', { name: 'map_feature_id', nullable: true })
  mapFeatureId: number | null;

  @Column('integer', { name: 'framekm_id', nullable: true })
  framekmId: number | null;

  @Column('text', { name: 'image_name' })
  imageName: string;

  @Column('integer', { name: 'image_id', nullable: true })
  imageId: number | null;

  @Column('integer', { name: 'class_id', nullable: true })
  classId: number | null;

  @Column('text', { name: 'class_label', nullable: true })
  classLabel: string | null;

  @Column('integer', { name: 'speed_label', nullable: true })
  speedLabel: number | null;

  @Column('real', { name: 'speed_label_conf', nullable: true })
  speedLabelConf: number | null;

  @Column('real', { name: 'distance', nullable: true })
  distance: number | null;

  @Column('integer', { name: 'x_center', nullable: true })
  xCenter: number | null;

  @Column('integer', { name: 'y_center', nullable: true })
  yCenter: number | null;

  @Column('real', { name: 'lat', nullable: true })
  lat: number | null;

  @Column('real', { name: 'lon', nullable: true })
  lon: number | null;

  @Column('real', { name: 'alt', nullable: true })
  alt: number | null;

  @Column('real', { name: 'azimuth', nullable: true })
  azimuth: number | null;

  @Column('real', { name: 'width', nullable: true })
  width: number | null;

  @Column('real', { name: 'height', nullable: true })
  height: number | null;

  @Column('real', { name: 'pitch', nullable: true })
  pitch: number | null;

  @Column('real', { name: 'roll', nullable: true })
  roll: number | null;

  @Column('real', { name: 'yaw', nullable: true })
  yaw: number | null;

  @Column('real', { name: 'confidence', nullable: true })
  confidence: number | null;

  @Column('integer', { name: 'x1', nullable: true })
  x1: number | null;

  @Column('integer', { name: 'y1', nullable: true })
  y1: number | null;

  @Column('integer', { name: 'x2', nullable: true })
  x2: number | null;

  @Column('integer', { name: 'y2', nullable: true })
  y2: number | null;

  @Column('real', { name: 'cam_lat', nullable: true })
  camLat: number | null;

  @Column('real', { name: 'cam_lon', nullable: true })
  camLon: number | null;

  @Column('real', { name: 'cam_heading', nullable: true })
  camHeading: number | null;

  @Column('integer', { name: 'track_id', nullable: true })
  trackId: number | null;

  @Column('text', { name: 'attributes', nullable: true })
  attributes: string | null;  // JSON string

  @Column('text', { name: 'model_id', nullable: true })
  modelId: string | null;

  @Column('text', { name: 'model_hash', nullable: true })
  modelHash: string | null;
}

map_features Table

@Entity('map_features')
export class MapFeatures {
  @PrimaryGeneratedColumn({ type: 'integer', name: 'id' })
  id: number | null;

  @Column('real', { name: 'lat', nullable: true })
  lat: number | null;

  @Column('real', { name: 'lon', nullable: true })
  lon: number | null;

  @Column('real', { name: 'alt', nullable: true })
  alt: number | null;

  @Column('real', { name: 'azimuth', nullable: true })
  azimuth: number | null;

  @Column('integer', { name: 'class_id', nullable: true })
  classId: number | null;

  @Column('text', { name: 'class_label', nullable: true })
  classLabel: string | null;

  @Column('real', { name: 'width', nullable: true })
  width: number | null;

  @Column('real', { name: 'height', nullable: true })
  height: number | null;

  @Column('integer', { name: 'observation_count', nullable: true })
  observationCount: number | null;

  @Column('real', { name: 'cam_lat', nullable: true })
  camLat: number | null;

  @Column('real', { name: 'cam_lon', nullable: true })
  camLon: number | null;

  @Column('real', { name: 'pos_confidence', nullable: true })
  posConfidence: number | null;

  @Column('real', { name: 'depth_confidence', nullable: true })
  depthConfidence: number | null;

  @Column('real', { name: 'overall_confidence', nullable: true })
  overallConfidence: number | null;

  @Column('text', { name: 'attributes', nullable: true })
  attributes: string | null;
}

config Table

Key-value store for device configuration.

CREATE TABLE config (
  key TEXT PRIMARY KEY,
  value TEXT
);

plugins Table

CREATE TABLE plugins (
  plugin TEXT PRIMARY KEY,
  state TEXT  -- 'enabled' or 'disabled'
);

landmark_proxy_state Table

Tracks cloud upload progress.

@Entity('landmark_proxy_state')
export class LandmarkProxyState {
  @PrimaryGeneratedColumn({ type: 'integer', name: 'id' })
  id: number;

  @Column('integer', { name: 'last_scanned_id', default: 0 })
  last_scanned_id: number;
}

7. Redis Usage

Connection

// src/util/redis.ts
const client = createClient();  // Defaults to localhost:6379

Key Namespaces

// LTE Status Keys
export enum RedisLTEKeys {
  OPERATOR = 'LTE_INIT_OPERATOR',
  APN = 'LTE_INIT_APN',
  PROTOCOL = 'LTE_INIT_PROTOCOL',
  PIN_STATUS = 'LTE_INIT_PIN_STATUS',
  ICCID = 'LTE_INIT_ICCID',
  IMEISV = 'LTE_INIT_IMEISV',
  IMEI = 'LTE_INIT_IMEI',
  LTE_HEALTHY = 'LTE_INIT_LTE_HEALTHY',
}

// External ML Model Integration
export enum RedisExternalModelKeys {
  REDIS_KEY_READY = 'EXTERNAL_MODEL_CLASSIFIER_READY',
  REDIS_KEY_INPUT_QUEUE = 'EXTERNAL_MODEL_INPUT_QUEUE',
  REDIS_KEY_OUTPUT_QUEUE = 'EXTERNAL_MODEL_OUTPUT_QUEUE',
}

// Map AI Service Status
export enum RedisMapAIKeys {
  READY = 'MAP_AI_READY',
}

// Frame Counters
export enum RedisFrameCountKeys {
  RGB = 'FRAME_COUNT_RGB',
  LEFT = 'FRAME_COUNT_LEFT',
  RIGHT = 'FRAME_COUNT_RIGHT',
}

IPC Pattern

Redis is used for inter-process communication between:

  • odc-api (this service)
  • depthai_gate (camera frame acquisition)
  • map-ai (landmark detection ML)
  • External classifier models

The ML pipeline uses Redis queues:

  1. Frames pushed to EXTERNAL_MODEL_INPUT_QUEUE
  2. Classification results read from EXTERNAL_MODEL_OUTPUT_QUEUE

8. Data Flow Pipeline

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   Camera HW     │────▶│  depthai_gate   │────▶│   map-ai        │
│  (RGB + Stereo) │     │ (Frame capture) │     │ (ML detection)  │
└─────────────────┘     └─────────────────┘     └─────────────────┘
                                                        │
                                                        │ Redis
                                                        ▼
┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│  SQLite DB      │◀────│    odc-api      │◀────│ Detection       │
│  (landmarks,    │     │ (This service)  │     │ Results         │
│   map_features) │     │                 │     │                 │
└─────────────────┘     └─────────────────┘     └─────────────────┘
        │                       │
        │                       │ LTE / WiFi
        │                       ▼
        │               ┌─────────────────┐
        │               │  Hivemapper     │
        │               │  Cloud API      │
        │               │                 │
        │               │ - framekm upload│
        │               │ - landmark data │
        │               │ - telemetry     │
        │               └─────────────────┘
        │
        ▼
┌─────────────────┐
│  Local Storage  │
│  /mnt/data/     │
│  - framekm/     │
│  - landmarks/   │
│  - metadata/    │
└─────────────────┘

File System Layout

/mnt/data/
├── data-logger.v1.4.5.db    # Main SQLite database
├── framekm/                  # Processed frame kilometers
├── framekm_short/            # Short framekilometers
├── framekm_custom/           # Custom resolution framekilometers
├── unprocessed_framekm/      # Pending processing
├── cached_observations/      # Landmark detection image chips
├── metadata/                 # Frame metadata
├── landmarks/                # Landmark metadata
├── gps/                      # GPS/GNSS logs
├── imu/                      # IMU sensor data
├── models/                   # ML models
├── ppz.json                  # Privacy zones config
├── wifi.cfg                  # WiFi configuration
├── camera.conf               # Camera configuration
└── config/                   # eMMC configuration

/tmp/recording/
├── pic/                      # Live camera frames
└── ...

/opt/dashcam/bin/
├── config.json               # Device configuration
├── camera_config.json        # Camera settings
├── ml/                       # ML scripts and models
├── eeprom_access.py          # EEPROM reader
├── imucalibrator             # IMU calibration tool
├── acl                       # Access control tool
├── cleanup_framekm.sh        # Cleanup script
└── bridge.sh                 # Camera bridge script

9. Landmark Proxy Service (Cloud Upload)

What Gets Uploaded

The LandmarkProxyService uploads detected map features (landmarks) to Hivemapper's cloud infrastructure.

Endpoint: PUT https://hivemapper.com/api/plugins/auditMultiplePlugins

Payload Structure:

{
  pluginNames: string[],        // Active non-beekeeper plugins
  dataType: 'landmark',
  unit: string,                 // Anonymous device ID
  firmware: string,             // Firmware version
  session: string,              // Session UUID
  payloads: [                   // Array of landmarks
    {
      id: number,
      class_label: string,      // 'speed-sign', 'stop-sign', etc.
      class_label_confidence: number,
      overall_confidence: number,
      ts: number,               // Unix timestamp (ms)
      lat: number,              // Landmark latitude
      lon: number,              // Landmark longitude
      alt: number,              // Altitude
      width: number,            // Estimated physical width
      height: number,           // Estimated physical height
      pos_confidence: number,
      azimuth: number,
      attributes: {             // Class-specific attributes
        speed_label?: number,
        speed_label_conf?: number,
        turn_rule?: string,
        // etc.
      }
    }
  ]
}

Filtered Landmark Classes

Only these classes are uploaded to cloud:

Uploaded as-is:

  • speed-sign (EU)
  • turn-restriction (EU)
  • turn-rules-eu (EU)
  • regulatory-speed-sign (US)
  • turn-rules-sign (US)
  • turn-restriction-sign (US)
  • roadwork-cone
  • roadwork-post
  • roadwork-panel
  • roadwork-barrel
  • roadwork-barricade
  • stop-sign

Transformed to generic-sign:

  • blue-polygon-sign (EU)
  • blue-circle-sign (EU)
  • general-yellow-sign (EU)
  • warning-sign (EU)
  • highway-sign (US)
  • one-way-sign (US)
  • vehicle-flow-sign (US)
  • advisory-speed-sign (US)
  • yield-sign
  • do-not-enter-sign
  • other-prohibitory-sign

Confidence Threshold

export const OVERALL_CONFIDENCE_THRESHOLD = 0.6;

Only landmarks with overall_confidence >= 0.6 are uploaded.

Upload Interval

Every 5 minutes, up to 1000 landmarks per batch.


Appendix: Key Files Reference

File Purpose
src/index.ts Application entry point
src/routes/index.ts Route mounting and core endpoints
src/routes/landmarks.ts Landmark API endpoints
src/routes/plugin.ts Plugin management endpoints
src/routes/gateway.ts HTTP proxy endpoints
src/services/index.ts ServiceRunner definition
src/services/landmarkProxyService.ts Cloud upload service
src/services/getFrameKmsService.ts Frame kilometer upload
src/services/pluginService.ts Plugin lifecycle
src/sqlite/index.ts Database initialization
src/sqlite/landmarks.ts Landmark queries
src/sqlite/config.ts Config storage
src/sqlite/plugins.ts Plugin state storage
src/entities/entities/Landmarks.ts Landmark entity/schema
src/entities/entities/MapFeatures.ts Map feature entity/schema
src/config/hdc.ts Device constants
src/util/redis.ts Redis client
src/util/api.ts Hivemapper API calls

10. Improvement Analysis (ADAMaps Integration)

10a. odc-api Bloat Audit

We run a liberated Bee cut off from Hivemapper's cloud. The following services are dead weight:

Service Kill? Reason
LandmarkProxyService Kill Uploads to hivemapper.com — blocked at network level but consumes CPU every 5min
HeartBeatTelemetryService Kill Telemetry to Hivemapper cloud
GetFrameKmsService Kill FrameKm upload to Hivemapper for HONEY rewards — we don't want this
LTEFirmwareUpdateService Kill LTE modem firmware — we don't use LTE
LTEInstrumentationUploadService Kill LTE telemetry
LogLteUsageService Kill LTE tracking
LteGnssAuthService Kill Hivemapper GNSS auth via LTE
BkGnssAuthService Kill Beekeeper GNSS auth
NetworkLogService Kill Network logging to Hivemapper
PluginService ⚠️ Careful Manages plugins — kill if no plugins enabled
PluginUpdateService Kill Auto-updates from Hivemapper
PluginStatsUploadService Kill Stats upload to cloud
CheckForLogRequestsService Kill Remote log requests from Hivemapper
CleanupGatewayService Kill Gateway proxy cleanup
HeartBeatService ⚠️ Keep Local health state used by other services
InitialiseConfigService Keep Config loading
DeviceInfoService Keep Device ID needed for our forwarder
InitIMUCalibrationService Keep IMU calibration
LoadPrivacyService Keep Privacy zones — relevant if we add fuzzing
GetUserConfigService Keep User config
UsbStateCheckService Keep USB state management
LandmarkProxyService REPLACE Run our own adacam-forwarder instead
WifiClientService Keep WiFi connectivity

Estimated savings from killing cloud services: ~30-40% CPU reduction, eliminates all outbound Hivemapper traffic.

10b. Database Architecture: Two Tables We Should Know

There are two separate tables that our forwarder should understand:

landmarks — individual detection observations (one row per detection event)

  • Has: id, ts, image_name, class_label, confidence, lat, lon, x1/y1/x2/y2, cam_lat, cam_lon, cam_heading, attributes (JSON)
  • This is what our forwarder currently reads from

map_features — Hivemapper's fused/deduped sign positions (one row per physical sign)

  • Has: lat, lon, class_label, observation_count, overall_confidence, pos_confidence
  • This is their clustered output — equivalent to our signs table in ADAMaps
  • We could read from here instead for already-deduplicated signs

Recommendation: Keep reading from landmarks for raw ingestion. Use map_features as a secondary source to bootstrap ADAMaps signs table with pre-clustered positions.

10c. Columns We're Missing in Our Forwarder

Comparing what's in the landmarks schema vs what our forwarder currently captures:

Column Captured? Notes
id cursor key
ts timestamp
class_label sign type
confidence detection confidence
lat sign GPS lat
lon sign GPS lon
image_name for image upload
x1/y1/x2/y2 bbox
cam_lat MISSING camera position at detection — useful for triangulation
cam_lon MISSING camera position at detection
cam_heading MISSING vehicle heading — key for sign facing direction
attributes MISSING JSON with speed_label, turn_rule etc — critical for sign text
azimuth MISSING estimated sign azimuth
width/height MISSING estimated physical size
overall_confidence MISSING fused confidence (different from confidence)
map_feature_id MISSING FK to map_features table
model_id MISSING which ML model detected it

Immediate action: Update adacam-forwarder.py to capture cam_lat, cam_lon, cam_heading, and attributes. The attributes JSON is especially important — it contains speed_label (the actual speed number from a speed sign), speed_label_conf, and turn_rule for turn restriction signs. This is the sign text we've been trying to capture manually.

10d. The attributes JSON — Key Contents

For speed signs:

{"speed_label": 25, "speed_label_conf": 0.999}

For turn restrictions:

{"turn_rule": "noLeftTurn"}

For roadwork:

{"roadwork_type": "construction"}

This means Hivemapper's model already reads speed values from signs — we just haven't been forwarding this data. Our whole Phase 2 "read the sign text" agent task might be partially automated by just consuming attributes.

10e. Image Strategy: Chip Endpoint vs Full Frame

The /landmarks/:id/chips/:chip_id endpoint uses Jimp to crop the full frame to just the bounding box.

Current approach: We upload full frames, our API stores them, we serve crops via Pillow.

Better approach: Let odc-api do the crop on-device, upload only the chip. Smaller payload, same result. Could hit GET /api/1/landmarks/:id/chips/:chip_id directly from the forwarder instead of uploading the full cached_observations file.

Tradeoff: REST call per detection vs direct SQLite + file read. At 500 detections per cycle, the REST overhead is acceptable. The bandwidth savings (~95% smaller images) are significant over cellular.

10f. Cursor Strategy

Our current forwarder uses last_detection_id and queries WHERE id > :last_id. This matches exactly what odc-api's fetchLandmarksFromId(id, limit) does internally.

Should we use the REST endpoint instead of direct SQLite?

  • Pro: Cleaner, survives odc-api schema changes, already filters landmarks by class
  • Pro: Gets the filterLandmarks() transform for free (normalizes generic-sign classes)
  • Con: Adds HTTP overhead, potential for missed detections if odc-api crashes
  • Con: filterLandmarks() strips some classes we might want (warning-sign, yield-sign)

Recommendation: Stick with direct SQLite. Add cam_lat, cam_lon, cam_heading, attributes to the SELECT. Don't rely on their class filtering — we want all classes.

10g. Architectural Recommendation: Replace odc-api?

odc-api is 35+ route files, TypeScript, Node.js, TypeORM. It's doing a lot we don't need.

Option A: Keep odc-api, kill dead services

  • Edit /opt/odc-api/odc-api-bee.js (compiled bundle) — painful
  • OR compile a stripped version from source
  • Saves ~14% CPU (just killing Node.js bloat)

Option B: Replace odc-api with adacam-api

  • Write a lean Python or Go service that does only what we need:
    • Serve /api/1/info, /api/1/ping (Hivemapper app compatibility)
    • Proxy to Redis for ML status
    • Serve /landmarks endpoints
    • Run our forwarder logic inline
  • Eliminates Node.js entirely (~30MB RAM savings)
  • Full control over what runs

Option C: Run both (current approach)

  • Keep odc-api for compatibility and ML pipeline
  • Our forwarder reads SQLite directly, separate process
  • Cleanest for now, most stable

Recommendation: Option C short-term. Option B long-term if we want to run on multiple Bees without the dead weight. The ML pipeline (depthai_gate → map-ai → Redis → SQLite) is independent of odc-api's cloud upload logic, so odc-api is only needed for the SQLite writes and local API serving.

10h. Security: What Else to Block on Our Bee

We've already blocked /api/1/cmd at network level. Additional endpoints to firewall:

Endpoint Risk Action
POST /cmd/sync RCE Block
GET /full_reset Mass data deletion Block
GET /cleanup_cache Data deletion Block
POST /firmware/update Firmware install Block
POST /ota/upload Firmware upload Block
GET /file/* Arbitrary file read Block
POST /file/* Arbitrary file write Block
DELETE /file/* Arbitrary file delete Block
POST /network/wifi/connect Network reconfig Block
POST /lte-apn-config Network reconfig Block
GET /auth Cookie auth to Hivemapper Block (stops cookie passing)

Our current iptables hardening blocks inbound from non-AP interfaces. These need to also be blocked on the AP interface itself, since anyone connected to the Bee AP has full access to port 5000.

10i. LandmarkProxyService — Does It Still Call Home?

Yes. Even with beekeeper disabled, LandmarkProxyService checks getAllEnabledNonBeekeeperPlugins(). If any plugin is enabled, it uploads. If all plugins are disabled, it skips the upload.

Our hardening (blocking hivemapper.com in /etc/hosts) prevents the actual upload. But the service still runs every 5 minutes, fetches landmarks from SQLite, formats the payload, and makes a TCP connection attempt that fails silently.

Fix: Disable all plugins via POST /api/1/plugin/disable/:name for each active plugin. Confirm with GET /api/1/plugins — all should show enabled: false. This stops LandmarkProxyService from doing anything at all.


Document generated: 2026-03-23 | Last updated: 2026-03-23 (improvement analysis added)