Skip to content

Multi-Sensor Support — Implementation Plan (v2.0)

Table of Contents

  1. Overview
  2. Current Architecture (v1.0)
  3. New Architecture (v2.0)
  4. Sensor Config Packet Format
  5. Version Detection Logic
  6. v2.0 Data Packet Format
  7. Device-to-Unit Mapping Changes
  8. Database Schema Changes
  9. Lambda Handler Changes
  10. Alert Generation for Multi-Sensor
  11. Backend API Changes
  12. Frontend Changes
  13. Backward Compatibility Guarantee
  14. Implementation Phases
  15. Open Questions

1. Overview

Goal

Enable a single IoT device to connect to multiple physical sensors (up to 4 sensor ports: S1–S4) and map each active sensor to an independent monitoring unit in the platform. Each unit retains its own threshold configuration and alert lifecycle.

What Changes

LayerChange
IoT DeviceSends sensor_enable array + sensor_configs in config packets, sends per-port sensor readings in data packets
Lambda (apps/lambda)Detects v2.0 by presence of sensor_enable, parses per-port readings, routes data and alerts to each mapped unit
MongoDBdevices collection gains sensor_units array; sensor_data gains sensor_index field
Backend APIAssign/query per-sensor unit mappings on a device
FrontendUI to map each active sensor port to a unit

What Does NOT Change (v1.0 compatibility)

Devices without sensor_enable in their payload continue to be processed by the existing v1.0 code path with no modification.


2. Current Architecture (v1.0)

Device–Unit Relationship

Device (1)  ──── unit_id ────►  Unit (1)

One device maps to exactly one unit. All readings (temperature, humidity, volt) feed a single unit's thresholds and alerts.

v1.0 Data Packet (existing)

json
{
  "id": "DEVICE_CODE_01",
  "hw_ver": "1.0", "os_ver": "2.0", "sw_ver": "3.0",
  "seq_no": 42,
  "time_stamp": [1735010000, 1735010060],
  "temperature": [24.5, 24.8],
  "humidity":    [61.2, 61.5],
  "volt":        [4.12, 4.11]
}

v1.0 Lambda Flow (existing)

formatInput → findDevice → findUnit → prepareSensorRecords
  → sensor_data.insertMany
  → generateErrorAlerts(recentSensorData, unit)
  → units.updateOne(live_alerts)
  → sendEmailsToNewAlerts / sendSmsAlerts

prepareSensorRecords iterates over event.time_stamp[] and zips temperature[i], humidity[i], volt[i] into one record per timestamp.


3. New Architecture (v2.0)

Device–Unit Relationship

Device (1)  ──── sensor_units[0].unit_id ────►  Unit A  (S1 sensor)
            ──── sensor_units[1].unit_id ────►  Unit B  (S2 sensor)
            ──── sensor_units[3].unit_id ────►  Unit D  (S4 sensor)
            (S3 disabled, no mapping required)

A single device with 4 sensor ports can independently monitor up to 4 different zones (units), each with its own thresholds and notification config.

v2.0 Lambda Flow (new)

formatInput → detectVersion(event)
  ├─ no sensor_enable  →  v1.0 path (unchanged)
  └─ sensor_enable present  →  v2.0 path
       findDevice
       → load sensor_units[] from device
       → parseMultiSensorRecords(event)      # per-port readings
       → sensor_data.insertMany(all records) # records carry sensor_index
       → for each active sensor port:
           load unit via sensor_units[i].unit_id
           build recentSensorData for that sensor
           generateErrorAlerts(recentSensorData, unit)
           units.updateOne(live_alerts)
           sendEmailsToNewAlerts / sendSmsAlerts
       → devices.updateOne(sensor_recent_data[])

4. Sensor Config Packet Format

MQTT Topic

uplink/<device_type>/config/<DeviceID>

Published from device when settings change (from device side or cloud side).

Config Packet Fields

FieldTypeRequiredDescription
sl_noStringUnique device serial number (e.g. "F1B698D6F930")
pkt_seq_noIntIncrementing packet sequence ID
timeInt (epoch)Packet creation time (Unix timestamp)
data_measure_intervalIntOptionalSensor measurement interval (seconds)
disp_refresh_intervalIntOptionalDisplay refresh interval (seconds)
data_record_intervalIntOptionalSensing/record interval (seconds)
data_upload_intervalIntOptionalData upload interval (seconds)
rssi_intervalIntOptionalRSSI poll interval (seconds)
location_intervalIntOptionalLocation report interval (seconds)
developer_settingsJSONOptionalDeveloper custom config object
sensor_enableArray<Bool>OptionalPer-port enable flags. Presence of this field signals v2.0
sensor_config_formatIntMandatory (with sensor_configs)Structure version of sensor_configs (currently 1)
sensor_configsArrayMandatory (with sensor_enable)3-D array of sensor config (see below)
bat_volt_configsensor_config_formatOptionalBattery voltage sensor config using same format

sensor_config_format (Structure Version 1)

Each individual sensor parameter config is a 7-element array:

[sensor_parameter_int, sensor_type_int, calibration_array, high_thresh_set, high_thresh_clear, low_thresh_set, low_thresh_clear]
IndexFieldValues
0sensor_parameter_int0 = None, 1 = Temperature, 2 = Humidity
1sensor_type_int0 = None, 1 = RTD, 2 = HDC30X
2calibration_array[[start_calib, end_calib, calib_a, calib_b, calib_c], ...]
3high_thresh_setNumber — alert triggers above this value
4high_thresh_clearNumber — alert clears when value drops to this
5low_thresh_setNumber — alert triggers below this value
6low_thresh_clearNumber — alert clears when value rises to this

sensor_configs Structure

sensor_configs[port_index][parameter_index] = sensor_config_format
json
"sensor_configs": [
  [ [S1_PARAM1_config], [S1_PARAM2_config] ],  // S1: 2 parameters (e.g. temp + humidity)
  [ [S2_PARAM1_config] ],                       // S2: 1 parameter
  [ [S3_PARAM1_config] ],                       // S3: 1 parameter
  [ [S4_PARAM1_config] ]                        // S4: 1 parameter
]

Full Config Packet Example

json
{
  "sl_no": "F1B698D6F930",
  "pkt_seq_no": 10,
  "time": 1774373241,
  "sensor_enable": [true],
  "sensor_config_format": 1,
  "sensor_configs": [
    [
      [
        1,
        1,
        [ [-9999, 9999, 0, 1, 0] ],
        50,
        45,
        -20,
        -15
      ]
    ]
  ],
  "disp_refresh_interval": 300,
  "data_record_interval": 61,
  "data_upload_interval": 60,
  "data_measure_interval": 300,
  "rssi_interval": 300,
  "location_interval": 300,
  "developer_settings": {},
  "bat_volt_config": [
    0, 0,
    [ [0, 6, 0, 0.9045, 0.4851] ],
    null, null, null, null
  ]
}

In this example: sensor_enable: [true] — one sensor port active (S1), measuring Temperature (sensor_parameter_int: 1) with an RTD sensor (sensor_type_int: 1), thresholds: high alert at 50°C, clears at 45°C; low alert at -20°C, clears at -15°C.


5. Version Detection Logic

The presence of the sensor_enable array in the inbound data packet is the authoritative signal for v2.0 processing.

js
/**
 * Returns true if the event is a v2.0 multi-sensor packet.
 * v1.0 devices never include sensor_enable.
 */
function isMultiSensorPacket(event) {
  return Array.isArray(event.sensor_enable);
}

Version Routing in Handler

js
export const handler = Sentry.wrapHandler(async (input, context) => {
  const event = formatInput(input);

  // ... connect DB, find device, save raw_sensor_data ...

  if (isMultiSensorPacket(event)) {
    return await handleV2MultiSensor(event, deviceDetails, db, Sentry);
  } else {
    return await handleV1SingleSensor(event, deviceDetails, db, Sentry);
  }
});
  • handleV1SingleSensor = the current handler body, extracted verbatim — no logic changes.
  • handleV2MultiSensor = new function described in Section 9.

6. v2.0 Data Packet Format

⚠️ Confirm with device firmware team before implementation. This section documents the expected format based on the config structure. The firmware must match this spec.

Design Principle

The data packet mirrors the sensor_configs shape. Each active sensor port sends an array of readings indexed by [port_index][param_index][timestamp_index].

v2.0 Data Packet

json
{
  "id": "F1B698D6F930",
  "sensor_enable": [true, true, false, false],
  "hw_ver": "2.0", "os_ver": "2.0", "sw_ver": "2.0",
  "seq_no": 55,
  "time_stamp": [1774373241, 1774373301],
  "volt":        [3.80, 3.79],
  "sensor_readings": [
    [ [25.5, 26.0] ],          // S1 — 1 param (temp), 2 timestamps
    [ [62.1, 63.0] ],          // S2 — 1 param (humidity), 2 timestamps
    [],                         // S3 — disabled
    []                          // S4 — disabled
  ]
}

sensor_readings Structure

sensor_readings[port_index][param_index][timestamp_index] = value (Number)
DimensionDescription
port_index0-based sensor port index (S1=0, S2=1, S3=2, S4=3)
param_index0-based parameter index matching sensor_configs[port][param]
timestamp_indexMatches index into time_stamp[] and volt[]

Determining Parameter Type

The parameter type (temperature or humidity) for a given reading is looked up from the device's stored sensor_configs:

js
const paramType = deviceDetails.settings.sensor_configs[portIndex][paramIndex][0];
// 1 = Temperature, 2 = Humidity

Note: If the device does not have sensor_configs stored (first packet), the Lambda must store them from the data packet or from a preceding config packet before it can interpret readings. See Section 9 for details.

Single-Sensor Port (simplified case)

When sensor_enable has only one true entry (common initial rollout):

json
{
  "id": "F1B698D6F930",
  "sensor_enable": [true],
  "time_stamp": [1774373241],
  "volt": [3.80],
  "sensor_readings": [
    [ [25.5] ]
  ]
}

7. Device-to-Unit Mapping Changes

Current Mapping (v1.0)

devices.unit_id  →  ObjectId  (single reference)

New Mapping (v2.0)

Add a sensor_units array to the device document where each entry links a sensor port index to a unit:

js
// devices collection — new field
sensor_units: [
  { sensor_index: 0, unit_id: ObjectId("...") },  // S1 → Unit A
  { sensor_index: 1, unit_id: ObjectId("...") },  // S2 → Unit B
  // S3, S4 omitted if not configured
]

Backward compatibility: The existing unit_id field is kept and still used exclusively by v1.0 devices. sensor_units is only populated by the admin/user for v2.0 devices.

Admin Assignment Flow

  1. Admin enables a device as multi-sensor (sets is_multi_sensor: true on the device doc).
  2. For each active sensor port, admin assigns a unit from the existing units list.
  3. Backend writes sensor_units[{ sensor_index, unit_id }] to the device doc.
  4. Lambda reads sensor_units at runtime for v2.0 processing.

Unit Side (no changes needed)

Units remain unaware of sensor ports. A unit does not store its sensor index — the mapping lives entirely on the device side. This allows reassigning a sensor port to a different unit without any unit model migration.


8. Database Schema Changes

8.1 devices Collection

Add the following fields to the device document:

New FieldTypeDescription
is_multi_sensorBooleantrue for v2.0 devices. Default: false.
sensor_unitsArray[{ sensor_index: Number, unit_id: ObjectId }]
sensor_recent_dataArray[{ sensor_index: Number, recent_sensor_data: Object }] — per-port cache

Example device document (v2.0):

json
{
  "_id": "...",
  "code": "F1B698D6F930",
  "is_multi_sensor": true,
  "unit_id": null,
  "sensor_units": [
    { "sensor_index": 0, "unit_id": "ObjectId(unitA)" },
    { "sensor_index": 1, "unit_id": "ObjectId(unitB)" }
  ],
  "sensor_recent_data": [
    { "sensor_index": 0, "recent_sensor_data": { "temperature": 25.5, "date": "..." } },
    { "sensor_index": 1, "recent_sensor_data": { "humidity": 62.1, "date": "..." } }
  ],
  "settings": {
    "sensor_configs": [ ... ],
    "sensor_config_format": 1
  },
  "last_communicated_at": "..."
}

8.2 sensor_data Collection

Add sensor_index field to identify which port a reading came from:

New FieldTypeDescription
sensor_indexNumberSensor port index (0–3). null or absent for v1.0 records.
unit_idObjectIdThe unit this record belongs to (denormalised for fast queries).

Migration: Existing v1.0 records require no migration. The sensor_index and unit_id fields will simply be absent on old records.

Example v2.0 sensor data record:

json
{
  "_id": "...",
  "device_id": "ObjectId(device)",
  "unit_id":   "ObjectId(unitA)",
  "sensor_index": 0,
  "date": "2026-04-29T10:00:00Z",
  "temperature": 25.5,
  "volt": 3.80,
  "seq_no": 55
}

8.3 Indexes to Add

js
// sensor_data — fast lookup by unit and date for reports
{ unit_id: 1, date: -1 }

// devices — fast lookup of multi-sensor devices
{ is_multi_sensor: 1 }

9. Lambda Handler Changes

File: apps/lambda/src/index.mjs

9.1 New Helper: isMultiSensorPacket

js
function isMultiSensorPacket(event) {
  return Array.isArray(event.sensor_enable);
}

9.2 Refactor Handler Entry Point

Extract existing handler body into handleV1SingleSensor, then add v2.0 routing:

js
export const handler = Sentry.wrapHandler(async (input, context) => {
  let event;
  try {
    event = formatInput(input);
    context.callbackWaitsForEmptyEventLoop = false;

    const db = await connectToDatabase();
    const deviceDetails = await db.collection("devices").findOne({ code: event.id });

    // Always save raw packet (regardless of version)
    await db.collection("raw_sensor_data").insertOne({
      date: new Date(),
      ...event,
      device_id: deviceDetails?._id,
    });

    if (!deviceDetails) {
      console.log(`${event.id} Device not found`);
      return encryptData({ statusCode: 500, data: JSON.stringify({ success: false, message: `${event.id} Device not found` }) });
    }

    event.device_id = deviceDetails._id;
    event.device_code = deviceDetails.code;

    // VERSION ROUTING — critical branching point
    if (isMultiSensorPacket(event)) {
      return await handleV2MultiSensor(event, deviceDetails, db, Sentry);
    } else {
      return await handleV1SingleSensor(event, deviceDetails, db, Sentry);
    }
  } catch (err) {
    await Sentry?.captureException(err);
    console.error("Error:", err);
    return encryptData({ statusCode: 500, body: JSON.stringify({ success: false, message: err.message }) });
  }
});

9.3 New Function: parseMultiSensorRecords

Converts sensor_readings[port][param][ts] into flat sensor records per port:

js
/**
 * Parses v2.0 sensor_readings into per-port sensor records.
 *
 * @param {Object} event - Parsed event from formatInput
 * @param {Object} deviceDetails - Device document from MongoDB
 * @returns {{ portRecords: Map<number, Object[]>, networkRecords: Object[], settings: Object }}
 *   portRecords: Map<sensor_index, sensorRecord[]>
 */
function parseMultiSensorRecords(event, deviceDetails) {
  const portRecords = new Map(); // sensor_index -> records[]
  const networkRecords = [];
  const settings = {
    hw_ver: event.hw_ver,
    os_ver: event.os_ver,
    sw_ver: event.sw_ver,
    sensor_config_format: event.sensor_config_format ?? deviceDetails.settings?.sensor_config_format,
    sensor_configs: event.sensor_configs ?? deviceDetails.settings?.sensor_configs,
  };

  const sensorConfigs = settings.sensor_configs;
  const sensorEnable = event.sensor_enable;    // e.g. [true, true, false, false]
  const sensorReadings = event.sensor_readings; // 3D array

  if (!sensorReadings || !sensorConfigs) {
    console.warn("v2.0 packet missing sensor_readings or sensor_configs — skipping sensor parse");
    return { portRecords, networkRecords, settings };
  }

  sensorEnable.forEach((enabled, portIndex) => {
    if (!enabled) return;

    const portReadings = sensorReadings[portIndex]; // [[param1_values...], [param2_values...]]
    const portConfigs  = sensorConfigs[portIndex];  // [[param1_config], [param2_config], ...]

    if (!portReadings || !portConfigs) return;

    const records = [];

    event.time_stamp.forEach((ts, tsIndex) => {
      const record = {
        device_id:    event.device_id,
        sensor_index: portIndex,
        date:         new Date(ts * 1000),
        volt:         event.volt?.[tsIndex] ?? null,
        seq_no:       event.seq_no,
      };

      // Map each parameter reading to the correct named field
      portConfigs.forEach((paramConfig, paramIndex) => {
        const paramType = paramConfig[0]; // sensor_parameter_int
        const value = portReadings[paramIndex]?.[tsIndex] ?? null;

        if (paramType === 1) record.temperature = value; // Temperature
        if (paramType === 2) record.humidity    = value; // Humidity
      });

      records.push(record);
    });

    portRecords.set(portIndex, records);
  });

  // Network records (same as v1.0 — independent of sensor ports)
  if (event.operator?.length) {
    event.time_stamp.forEach((ts, i) => {
      networkRecords.push({
        device_id: event.device_id,
        date:      new Date(ts * 1000),
        operator: event.operator[i],
        mnc:      event.mnc[i],
        sinr:     event.sinr[i],
        mcc:      event.mcc[i],
        lac:      event.lac[i],
        rssi:     event.rssi[i],
        channel:  event.channel[i],
        rsrp:     event.rsrp[i],
        rsrq:     event.rsrq[i],
        ci:       event.ci[i],
        tech:     event.tech[i],
        band:     event.band[i],
      });
    });
  }

  return { portRecords, networkRecords, settings };
}

9.4 New Function: handleV2MultiSensor

js
async function handleV2MultiSensor(event, deviceDetails, db, Sentry) {
  const user = await db.collection("users").findOne({ _id: deviceDetails.user_id });
  const currentDate = new Date();

  const { portRecords, networkRecords, settings } = parseMultiSensorRecords(event, deviceDetails);

  // Build flat list of all sensor records across all ports for bulk insert
  const allSensorRecords = [];
  for (const records of portRecords.values()) {
    allSensorRecords.push(...records);
  }

  if (allSensorRecords.length) {
    await db.collection("sensor_data").insertMany(allSensorRecords);
  }
  if (networkRecords.length) {
    await db.collection("network_data").insertMany(networkRecords);
  }

  // Load sensor_units mapping from device
  const sensorUnitsMap = new Map(
    (deviceDetails.sensor_units || []).map(su => [su.sensor_index, su.unit_id])
  );

  // Build sensor_recent_data[] for device update
  const sensorRecentData = [];

  // Per-port alert processing
  const notificationPromises = [];

  for (const [portIndex, records] of portRecords.entries()) {
    if (!records.length) continue;

    const recentRecord = records[records.length - 1];
    sensorRecentData.push({ sensor_index: portIndex, recent_sensor_data: recentRecord });

    const unitId = sensorUnitsMap.get(portIndex);
    if (!unitId) {
      console.log(`Device ${event.device_code} sensor port ${portIndex} has no unit assigned — skipping alerts`);
      continue;
    }

    const unit = await db.collection("units").findOne({ _id: unitId });
    if (!unit) {
      console.log(`Unit ${unitId} not found for sensor port ${portIndex}`);
      continue;
    }

    let liveAlerts = generateErrorAlerts(recentRecord, unit, user);
    const unitUpdateData = {
      live_alerts: liveAlerts,
      recent_sensor_data: recentRecord,
      last_communicated_at: currentDate,
    };

    await db.collection("units").updateOne({ _id: unit._id }, { $set: unitUpdateData });

    if (user) {
      // Apply humidity preference filter
      if (!user?.preferences?.enable_humidity) {
        liveAlerts = liveAlerts.filter(a => a.error_code !== "HIGH_HUMIDITY" && a.error_code !== "LOW_HUMIDITY");
      }

      const newAlerts = uniqueResultOne(liveAlerts, unit.live_alerts || []);

      const emailPromise = unit.email_alert !== false
        ? sendEmailsToNewAlerts(newAlerts, unit, user, db, Sentry)
        : Promise.resolve();
      const smsEnabled = unit.sms_alert !== false && user?.features?.SMS_ENABLE !== false;
      const smsPromise = smsEnabled
        ? sendSmsAlerts(newAlerts, unit, user, db, Sentry)
        : Promise.resolve();

      notificationPromises.push(
        Promise.allSettled([emailPromise, smsPromise]).then(([emailResult, smsResult]) => {
          if (emailResult.status === "rejected") {
            console.error(`Email failed for port ${portIndex}:`, emailResult.reason);
            return captureNotificationError(Sentry, emailResult.reason, {
              channel: "email", scope: "notification_workflow_v2",
              unit_id: unit._id?.toString(), unit_name: unit.name,
              sensor_index: portIndex, alert_count: newAlerts.length,
            });
          }
          if (smsResult.status === "rejected") {
            console.error(`SMS failed for port ${portIndex}:`, smsResult.reason);
            return captureNotificationError(Sentry, smsResult.reason, {
              channel: "sms", scope: "notification_workflow_v2",
              unit_id: unit._id?.toString(), unit_name: unit.name,
              sensor_index: portIndex, alert_count: newAlerts.length,
            });
          }
        })
      );
    }
  }

  // Device-level update
  const deviceUpdateData = {
    last_communicated_at: currentDate,
    settings: { ...deviceDetails.settings, ...settings },
  };
  if (sensorRecentData.length) {
    deviceUpdateData.sensor_recent_data = sensorRecentData;
    // Also store the overall most recent reading for display convenience
    deviceUpdateData.recent_sensor_data = sensorRecentData[sensorRecentData.length - 1]?.recent_sensor_data;
  }
  if (networkRecords.length) {
    deviceUpdateData.recent_network_data = networkRecords[networkRecords.length - 1];
  }

  await db.collection("devices").updateOne({ _id: event.device_id }, { $set: deviceUpdateData });

  // Await all notification error captures (not critical path)
  await Promise.allSettled(notificationPromises);

  return encryptData({ statusCode: 200, body: JSON.stringify({ success: true, message: "Multi-sensor data saved successfully" }) });
}

10. Alert Generation for Multi-Sensor

Per-Port Threshold Resolution

Each active sensor port maps to a separate unit with its own threshold configuration. The existing generateErrorAlerts(recentSensorData, unit, user) function is reused without modification.

The key is calling it once per active port with the correct unit:

port 0  →  unit A  →  generateErrorAlerts(port0RecentRecord, unitA, user)
port 1  →  unit B  →  generateErrorAlerts(port1RecentRecord, unitB, user)

Alert Error Codes (unchanged)

CodeTrigger
HIGH_TEMPERATUREtemperature > unit.max_temperature
LOW_TEMPERATUREtemperature < unit.min_temperature
HIGH_HUMIDITYhumidity > unit.max_humidity
LOW_HUMIDITYhumidity < unit.min_humidity
LOW_BATTERYvolt < 3.95

LOW_BATTERY is a device-level alert (not unit-specific). On multi-sensor devices, emit it once on whichever unit the first active port is mapped to, or create a dedicated device-level alert mechanism (deferred to a later iteration).

sensor_config Thresholds vs. Unit Thresholds

The device's sensor_configs carry their own threshold values (high_thresh_set, high_thresh_clear, low_thresh_set, low_thresh_clear). For v2.0, the unit thresholds remain the source of truth for alert generation on the platform side. The device-side thresholds are stored in devices.settings.sensor_configs for reference and could be used to pre-populate unit thresholds when a sensor is first assigned.


11. Backend API Changes

11.1 Assign Sensor Port to Unit

Endpoint: PUT /api/v1/admin/devices/:deviceId/sensor-units

json
// Request body
{
  "sensor_units": [
    { "sensor_index": 0, "unit_id": "unitAObjectId" },
    { "sensor_index": 1, "unit_id": "unitBObjectId" }
  ]
}
json
// Response
{
  "message": "Sensor units updated",
  "data": { "device": { ... } }
}

11.2 Mark Device as Multi-Sensor

Endpoint: PATCH /api/v1/admin/devices/:deviceId

json
// Request body
{ "is_multi_sensor": true }

11.3 Get Device Sensor Config

Endpoint: GET /api/v1/device/:deviceId/sensor-config

Returns deviceDetails.settings.sensor_configs for rendering in the UI.

11.4 Sensor Data Query Change

The existing /api/v1/report/ and sensor data APIs use device_id to query sensor_data. For multi-sensor devices, add support for filtering by sensor_index:

GET /api/v1/report/sensor-data?device_id=...&sensor_index=0

12. Frontend Changes

12.1 Device Edit Page — Sensor Unit Assignment

When device.is_multi_sensor === true, show a new "Sensor Port Mapping" section:

Sensor Port  |  Status   |  Assigned Unit
─────────────┼───────────┼────────────────
S1 (Port 0)  │  Enabled  │  [Unit A ▼]
S2 (Port 1)  │  Enabled  │  [Unit B ▼]
S3 (Port 2)  │  Disabled │  —
S4 (Port 3)  │  Disabled │  —
  • Enabled/disabled state comes from device.settings.sensor_enable[].
  • Unit dropdown lists all units belonging to the user (GET /api/v1/unit).
  • On save, calls PUT /api/v1/admin/devices/:id/sensor-units.

12.2 Unit Dashboard

No changes required — units continue to display their own live_alerts and recent_sensor_data. The data will now arrive from one sensor port of a multi-sensor device, but the unit is unaware of this.

12.3 Reports Page

Add sensor_index filter when the selected device is is_multi_sensor:

Device: [F1B698D6F930 ▼]  Sensor: [S1 ▼]  Date range: [...]

13. Backward Compatibility Guarantee

ScenarioBehaviour
v1.0 packet (no sensor_enable)isMultiSensorPacket returns false. handleV1SingleSensor runs unchanged. Zero risk of regression.
v2.0 device with no sensor_units assignedRecords saved with sensor_index. No unit found → no alerts fired. last_communicated_at still updated on device.
v2.0 device with partial sensor_units (some ports unmapped)Only mapped ports generate alerts. Unmapped ports save data but skip alert/notification logic.
Existing unit modelNot changed. No migration required.
Existing sensor_data recordsNot changed. Old records simply lack sensor_index.

v1.0 Code Isolation Strategy

handleV1SingleSensor(event, deviceDetails, db, Sentry)
  │  Exact copy of current handler body (lines ~60–170)
  │  No modifications allowed during v2.0 implementation
  └─ Only touch this function to fix existing v1.0 bugs

The current prepareSensorRecords, generateErrorAlerts, and uniqueResultOne functions remain shared utilities used by both paths.


14. Implementation Phases

Phase 1 — Foundation (Lambda only, no UI)

Goal: Process v2.0 packets end-to-end and persist data correctly. No alerts yet.

  • [ ] Add isMultiSensorPacket detection.
  • [ ] Refactor handler into handleV1SingleSensor + routing block.
  • [ ] Implement parseMultiSensorRecords.
  • [ ] Insert per-port sensor records with sensor_index and unit_id (unit_id nullable until assigned).
  • [ ] Update devices.sensor_recent_data[] and devices.settings.sensor_configs.
  • [ ] Write unit tests for parseMultiSensorRecords (see test cases below).
  • [ ] Deploy, test with a real v2.0 device packet.

Phase 2 — Alert Wiring

Goal: Fire per-port alerts and notifications.

  • [ ] Implement handleV2MultiSensor with per-port alert loop.
  • [ ] Test generateErrorAlerts called once per active port.
  • [ ] Verify LOW_BATTERY is emitted once (first active port).
  • [ ] Write integration test: 4-port device, 2 active, 2 units, both alert independently.

Phase 3 — Admin Backend APIs

Goal: Allow admin to configure multi-sensor devices.

  • [ ] Add is_multi_sensor field to device model.
  • [ ] Add sensor_units array to device model.
  • [ ] Implement PUT /api/v1/admin/devices/:id/sensor-units.
  • [ ] Add sensor_index query support to report endpoint.

Phase 4 — Frontend UI

Goal: Users can manage sensor-to-unit mappings.

  • [ ] Sensor Port Mapping section on Device Edit page.
  • [ ] sensor_index filter on Reports page.
  • [ ] Handle is_multi_sensor in device listing/detail views.

Phase 5 — Production Rollout

  • [ ] Deploy Lambda (v1.0 path always runs — zero risk).
  • [ ] Admin assigns sensor_units on first v2.0 device.
  • [ ] Monitor Sentry for any v2.0 parsing errors.
  • [ ] Validate sensor_data records contain correct sensor_index.

Test Cases for parseMultiSensorRecords

js
// Test 1: Single enabled port (most common v2.0 case)
const event = {
  id: "DEV001", device_id: "...",
  sensor_enable: [true],
  time_stamp: [1774373241],
  volt: [3.80],
  sensor_readings: [[[25.5]]]
};
const deviceDetails = {
  settings: {
    sensor_config_format: 1,
    sensor_configs: [[[1, 1, [[-9999,9999,0,1,0]], 50, 45, -20, -15]]]
  }
};
// Expected: portRecords.get(0) = [{ temperature: 25.5, volt: 3.80, sensor_index: 0, date: Date(1774373241000) }]

// Test 2: Two enabled ports (temp + humidity)
// sensor_enable: [true, true, false, false]
// port 0: temp sensor, port 1: humidity sensor
// Expected: portRecords.get(0)[0].temperature = 25.5
//           portRecords.get(1)[0].humidity    = 62.1

// Test 3: Port disabled — no records created
// sensor_enable: [false]
// Expected: portRecords.size === 0

// Test 4: Missing sensor_readings — graceful no-op
// sensor_readings: undefined
// Expected: portRecords.size === 0, no throw

// Test 5: v1.0 packet — isMultiSensorPacket returns false
// event without sensor_enable
// Expected: isMultiSensorPacket(event) === false

15. Open Questions

#QuestionOwnerStatus
1What is the exact v2.0 data packet format from firmware? Does it use sensor_readings[port][param][ts] as documented here?Firmware team❓ Confirm
2Does the device send sensor_configs in every data packet, or only in config packets?Firmware team❓ Confirm
3Should LOW_BATTERY alert fire once per device or per sensor port / unit?Product❓ Decide
4When a sensor port has 2 parameters (e.g. S1 has temperature + humidity from a single HDC30X), do both values go to the same unit, or can they be split across two units?Product❓ Decide
5Should the existing v1.0 unit_id on device be auto-migrated to sensor_units[{ sensor_index: 0, unit_id }] for upgraded devices?Backend❓ Decide
6MQTT config packets (uplink/.../config/...) — does the Lambda need to subscribe to and process these, or are they handled separately?Backend❓ Confirm

Intecog Logistech IoT Monitoring Platform