Multi-Sensor Support — Implementation Plan (v2.0)
Table of Contents
- Overview
- Current Architecture (v1.0)
- New Architecture (v2.0)
- Sensor Config Packet Format
- Version Detection Logic
- v2.0 Data Packet Format
- Device-to-Unit Mapping Changes
- Database Schema Changes
- Lambda Handler Changes
- Alert Generation for Multi-Sensor
- Backend API Changes
- Frontend Changes
- Backward Compatibility Guarantee
- Implementation Phases
- 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
| Layer | Change |
|---|---|
| IoT Device | Sends 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 |
| MongoDB | devices collection gains sensor_units array; sensor_data gains sensor_index field |
| Backend API | Assign/query per-sensor unit mappings on a device |
| Frontend | UI 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)
{
"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 / sendSmsAlertsprepareSensorRecords 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
| Field | Type | Required | Description |
|---|---|---|---|
sl_no | String | ✅ | Unique device serial number (e.g. "F1B698D6F930") |
pkt_seq_no | Int | ✅ | Incrementing packet sequence ID |
time | Int (epoch) | ✅ | Packet creation time (Unix timestamp) |
data_measure_interval | Int | Optional | Sensor measurement interval (seconds) |
disp_refresh_interval | Int | Optional | Display refresh interval (seconds) |
data_record_interval | Int | Optional | Sensing/record interval (seconds) |
data_upload_interval | Int | Optional | Data upload interval (seconds) |
rssi_interval | Int | Optional | RSSI poll interval (seconds) |
location_interval | Int | Optional | Location report interval (seconds) |
developer_settings | JSON | Optional | Developer custom config object |
sensor_enable | Array<Bool> | Optional | Per-port enable flags. Presence of this field signals v2.0 |
sensor_config_format | Int | Mandatory (with sensor_configs) | Structure version of sensor_configs (currently 1) |
sensor_configs | Array | Mandatory (with sensor_enable) | 3-D array of sensor config (see below) |
bat_volt_config | sensor_config_format | Optional | Battery 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]| Index | Field | Values |
|---|---|---|
| 0 | sensor_parameter_int | 0 = None, 1 = Temperature, 2 = Humidity |
| 1 | sensor_type_int | 0 = None, 1 = RTD, 2 = HDC30X |
| 2 | calibration_array | [[start_calib, end_calib, calib_a, calib_b, calib_c], ...] |
| 3 | high_thresh_set | Number — alert triggers above this value |
| 4 | high_thresh_clear | Number — alert clears when value drops to this |
| 5 | low_thresh_set | Number — alert triggers below this value |
| 6 | low_thresh_clear | Number — alert clears when value rises to this |
sensor_configs Structure
sensor_configs[port_index][parameter_index] = sensor_config_format"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
{
"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.
/**
* 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
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
{
"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)| Dimension | Description |
|---|---|
port_index | 0-based sensor port index (S1=0, S2=1, S3=2, S4=3) |
param_index | 0-based parameter index matching sensor_configs[port][param] |
timestamp_index | Matches 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:
const paramType = deviceDetails.settings.sensor_configs[portIndex][paramIndex][0];
// 1 = Temperature, 2 = HumidityNote: If the device does not have
sensor_configsstored (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):
{
"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:
// 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
- Admin enables a device as multi-sensor (sets
is_multi_sensor: trueon the device doc). - For each active sensor port, admin assigns a unit from the existing units list.
- Backend writes
sensor_units[{ sensor_index, unit_id }]to the device doc. - Lambda reads
sensor_unitsat 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 Field | Type | Description |
|---|---|---|
is_multi_sensor | Boolean | true for v2.0 devices. Default: false. |
sensor_units | Array | [{ sensor_index: Number, unit_id: ObjectId }] |
sensor_recent_data | Array | [{ sensor_index: Number, recent_sensor_data: Object }] — per-port cache |
Example device document (v2.0):
{
"_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 Field | Type | Description |
|---|---|---|
sensor_index | Number | Sensor port index (0–3). null or absent for v1.0 records. |
unit_id | ObjectId | The unit this record belongs to (denormalised for fast queries). |
Migration: Existing v1.0 records require no migration. The
sensor_indexandunit_idfields will simply be absent on old records.
Example v2.0 sensor data record:
{
"_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
// 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
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:
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:
/**
* 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
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)
| Code | Trigger |
|---|---|
HIGH_TEMPERATURE | temperature > unit.max_temperature |
LOW_TEMPERATURE | temperature < unit.min_temperature |
HIGH_HUMIDITY | humidity > unit.max_humidity |
LOW_HUMIDITY | humidity < unit.min_humidity |
LOW_BATTERY | volt < 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
// Request body
{
"sensor_units": [
{ "sensor_index": 0, "unit_id": "unitAObjectId" },
{ "sensor_index": 1, "unit_id": "unitBObjectId" }
]
}// Response
{
"message": "Sensor units updated",
"data": { "device": { ... } }
}11.2 Mark Device as Multi-Sensor
Endpoint: PATCH /api/v1/admin/devices/:deviceId
// 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=012. 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
| Scenario | Behaviour |
|---|---|
v1.0 packet (no sensor_enable) | isMultiSensorPacket returns false. handleV1SingleSensor runs unchanged. Zero risk of regression. |
v2.0 device with no sensor_units assigned | Records 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 model | Not changed. No migration required. |
Existing sensor_data records | Not 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 bugsThe 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
isMultiSensorPacketdetection. - [ ] Refactor handler into
handleV1SingleSensor+ routing block. - [ ] Implement
parseMultiSensorRecords. - [ ] Insert per-port sensor records with
sensor_indexandunit_id(unit_id nullable until assigned). - [ ] Update
devices.sensor_recent_data[]anddevices.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
handleV2MultiSensorwith per-port alert loop. - [ ] Test
generateErrorAlertscalled once per active port. - [ ] Verify
LOW_BATTERYis 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_sensorfield to device model. - [ ] Add
sensor_unitsarray to device model. - [ ] Implement
PUT /api/v1/admin/devices/:id/sensor-units. - [ ] Add
sensor_indexquery 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_indexfilter on Reports page. - [ ] Handle
is_multi_sensorin device listing/detail views.
Phase 5 — Production Rollout
- [ ] Deploy Lambda (v1.0 path always runs — zero risk).
- [ ] Admin assigns
sensor_unitson first v2.0 device. - [ ] Monitor Sentry for any v2.0 parsing errors.
- [ ] Validate
sensor_datarecords contain correctsensor_index.
Test Cases for parseMultiSensorRecords
// 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) === false15. Open Questions
| # | Question | Owner | Status |
|---|---|---|---|
| 1 | What is the exact v2.0 data packet format from firmware? Does it use sensor_readings[port][param][ts] as documented here? | Firmware team | ❓ Confirm |
| 2 | Does the device send sensor_configs in every data packet, or only in config packets? | Firmware team | ❓ Confirm |
| 3 | Should LOW_BATTERY alert fire once per device or per sensor port / unit? | Product | ❓ Decide |
| 4 | When 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 |
| 5 | Should the existing v1.0 unit_id on device be auto-migrated to sensor_units[{ sensor_index: 0, unit_id }] for upgraded devices? | Backend | ❓ Decide |
| 6 | MQTT config packets (uplink/.../config/...) — does the Lambda need to subscribe to and process these, or are they handled separately? | Backend | ❓ Confirm |
