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_noString✅Unique device serial number (e.g. "F1B698D6F930")
pkt_seq_noInt✅Incrementing 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