Lambda: IoT Sensor Ingestion — v1 (Current Architecture)
AWS Lambda function that receives sensor telemetry from IoT devices, persists readings to MongoDB, evaluates threshold alerts, and dispatches email/SMS notifications when conditions breach configured limits.
This is the current production architecture (v1.0). Each device connects to exactly one sensor and maps to exactly one monitoring unit. For the multi-sensor upgrade plan, see Multi-Sensor v2.
Table of Contents
- Overview
- Device–Unit Relationship
- Handler Flow
- Encryption Protocol
- Event Payload Format
- Response Format
- Alert Logic
- Source Structure
- Environment Variables
- Dependencies
- Test Suite
- Deployment
Overview
IoT devices (cold rooms, monitoring units) periodically push sensor data to this Lambda via AWS IoT Core or a direct invocation. Each payload carries temperature, humidity, and battery voltage readings. The Lambda:
- Decrypts the payload (AES-256-CBC) if it arrives encrypted
- Persists raw and processed sensor records to MongoDB
- Evaluates per-unit threshold rules to compute
live_alerts - Sends email (AWS SES) and SMS (AWS Pinpoint) notifications for new alerts only
- Returns an AES-encrypted response to the device
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 alert state.
The unit_id foreign key is stored directly on the devices collection document:
{
"_id": "...",
"code": "DEVICE_CODE_01",
"unit_id": "ObjectId(unitA)"
}Handler Flow
Device payload
│
▼
formatInput(input)
├─ plain JSON → pass through (id field present)
└─ { data: "<base64>" } → AES-256-CBC decrypt
│
▼
raw_sensor_data.insertOne(...) ← always written, even on device-not-found
│
▼
devices.findOne({ code: event.id })
└─ not found → return 500 "Device not found"
│
▼
units.findOne({ _id: device.unit_id })
users.findOne({ _id: device.user_id })
│
▼
prepareSensorRecords(event)
├─ sensorRecords → sensor_data.insertMany(...)
│ filters out |temperature| > 800 (hardware fault guard)
└─ networkRecords → network_data.insertMany(...)
present when event includes operator/roaming arrays
│
▼
recentSensorData = sensorRecords[last]
├─ none (all filtered) → return 200 "without any sensor data"
├─ older than cached → return 200 "past records"
└─ newest → devices.updateOne({ recent_sensor_data, last_communicated_at })
│
▼
unit not found → return 400 "Unit not found"
│
▼
generateErrorAlerts(recentSensorData, unit, user)
checks: temperature > max_temperature → HIGH_TEMPERATURE
temperature < min_temperature → LOW_TEMPERATURE
humidity > max_humidity → HIGH_HUMIDITY
humidity < min_humidity → LOW_HUMIDITY
volt < 3.95 V → LOW_BATTERY
│
▼
units.updateOne({ live_alerts, recent_sensor_data, last_communicated_at })
│
▼
newAlerts = liveAlerts − already-existing alerts (uniqueResultOne)
├─ user.preferences.enable_humidity = false → drop humidity alerts
├─ unit.email_alert = false → skip email
├─ unit.sms_alert = false or user.features.SMS_ENABLE = false → skip SMS
└─ Promise.allSettled([
sendEmailsToNewAlerts(newAlerts, unit, user, db, Sentry),
sendSmsAlerts(newAlerts, unit, user, db, Sentry),
])
rejected → captureNotificationError(Sentry, ...) — does NOT fail the response
│
▼
return encryptData({ statusCode: 200, body: { success: true } })Encryption Protocol
All communication between devices and the Lambda uses AES-256-CBC with a fixed key/IV pair baked into the firmware and the Lambda source.
| Parameter | Value |
|---|---|
| Algorithm | AES-256-CBC |
| Key (hex) | a99df3c9e3e7ef1142d543eb9f69b60f591b7d3f167cde3178f2e7dfdbd4d090 |
| IV (hex) | 9b2f5dc7207ac604b1c6d2e9f5fd8891 |
Encrypted payload layout (device → Lambda):
[ 4 random bytes ][ JSON bytes ][ PKCS#7 padding ]The 4 random prefix bytes are stripped before JSON parsing.
Encrypted response layout (Lambda → device):
{ "data": "<base64(4-random-bytes + JSON-bytes + PKCS#7-padding)>" }Plain JSON events (with id field present) skip decryption entirely and are processed as-is. This supports direct invocation during development.
Event Payload Format
// Plain JSON (development / direct invocation)
{
"id": "DEVICE_CODE_01", // device code — matches devices.code
"hw_ver": "1.0",
"os_ver": "2.0",
"sw_ver": "3.0",
"seq_no": 42,
"time_stamp": [1735010000], // Unix timestamp array (one per reading)
"temperature": [24.5], // °C array
"humidity": [61.2], // %RH array
"volt": [4.12], // Battery voltage array
// Optional network info:
"operator": ["Airtel"],
"roaming": [0]
}
// Encrypted (production device)
{ "data": "<base64-ciphertext>" } // decrypts to the plain format aboveMultiple readings can be batched: time_stamp, temperature, humidity, and volt arrays must all be the same length.
Response Format
The Lambda always returns an AES-encrypted response:
// Success
{ "data": "<base64>" }
// decrypts to: { "statusCode": 200, "body": "{\"success\":true,\"message\":\"Data saved successfully\"}" }
// Device not found
// decrypts to: { "statusCode": 500, "body": "{\"success\":false,\"message\":\"DEVICE_CODE Device not found\"}" }
// Unit not found
// decrypts to: { "statusCode": 400, "body": "{\"success\":false,\"message\":\"DEVICE_CODE Unit not found\"}" }
// Unhandled error
// decrypts to: { "statusCode": 500, "body": "{\"success\":false,\"message\":\"<error message>\"}" }Alert Logic
Alerts are stored in units.live_alerts as an array of objects:
{
"error_code": "HIGH_TEMPERATURE", // see table below
"start_date": "2024-12-24T03:13:20.000Z", // first occurrence timestamp
"count": 3, // times this alert has fired
"value": 25.4 // reading that triggered it
}error_code | Condition |
|---|---|
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 V |
Repeat-alert behaviour: if an alert already exists in unit.live_alerts, the count is incremented and start_date is preserved. Email/SMS are only sent for new alert codes (not already in previous live_alerts). This prevents notification spam on every reading.
Source Structure
src/
├── index.mjs Main Lambda handler (export: handler)
├── inputHelper.js formatInput() / encryptData() / AES utils
└── services/
├── notifications/
│ ├── emailService.mjs sendEmailsToNewAlerts() — AWS SES
│ └── smsService.mjs sendSmsAlerts() — AWS Pinpoint SMS V2
├── monitoring/
│ └── sentry.mjs captureNotificationError()
└── database/
└── alertLogger.mjs Log failed notifications to DBEnvironment Variables
| Variable | Required | Description |
|---|---|---|
MONGODB_URI | ✅ | MongoDB connection string |
DB_NAME | ✅ | MongoDB database name |
SENTRY_DSN | ✅ (prod) | Sentry DSN for error tracking |
SENTRY_ENVIRONMENT | optional | production / staging |
AWS_REGION | ✅ | AWS region for SES / Pinpoint |
FROM_EMAIL | ✅ | SES verified sender address |
ENTITY_NAME | optional | Display name in email templates |
MONGODB_URIandDB_NAMEare captured at module-evaluation time (not per-invocation), so they must be set before the Lambda container starts.
Dependencies
Production dependencies are installed in src/node_modules/ (the Lambda package manages its own npm install, separate from the monorepo). Dev dependencies (Jest, Babel) live in apps/lambda/node_modules/.
| Package | Purpose |
|---|---|
mongodb | MongoDB driver |
@aws-sdk/client-ses | Send emails via AWS SES |
@aws-sdk/client-pinpoint-sms-voice-v2 | Send SMS via Pinpoint |
dotenv | Load .env in local development |
@sentry/aws-serverless | Lambda Layer at runtime — not installed locally |
Test Suite
Running Tests
# From apps/lambda/
node --experimental-vm-modules node_modules/.bin/jest
# With coverage report
node --experimental-vm-modules node_modules/.bin/jest --coverage
# Single test file
node --experimental-vm-modules node_modules/.bin/jest --testPathPattern="handler"
# Filter by test name
node --experimental-vm-modules node_modules/.bin/jest --testNamePattern="should generate HIGH_TEMPERATURE"
# From monorepo root
pnpm --filter @my-app/lambda-saveSdSensorData test
--experimental-vm-modulesis required for Jest ESM support.
Current results: 114 tests, 4 suites — all passing.
Test Infrastructure
__tests__/
├── e2e/
│ └── handler.test.js End-to-end: full handler invocation with mocked DB
├── unit/
│ ├── input-helper.test.js Unit: formatInput() / encryptData() / AES round-trips
│ ├── email-service.test.js Unit: sendEmailsToNewAlerts() with mocked SES
│ └── sms-service.test.js Unit: sendSmsAlerts() with mocked Pinpoint
├── helpers/
│ └── lambda-wrapper.js LambdaTestHarness + event builders + crypto helpers
├── fixtures/
│ └── events.js Pre-built Lambda event fixtures (plain + encrypted)
└── mocks/
├── db.mock.js MongoDB document factories + makeMockDb()
├── sentry-layer.mock.js Stub for @sentry/aws-serverless (Lambda Layer)
└── mongodb-esm.mock.mjs Static ESM MongoClient stub (used via moduleNameMapper)Test Files
| File | Tests | Coverage |
|---|---|---|
unit/input-helper.test.js | 23 | formatInput, encryptData, AES round-trips, malformed inputs |
unit/email-service.test.js | 18 | Skip empty alerts, SES params, deduplication, 5 alert templates |
unit/sms-service.test.js | 28 | Skip flags, DLT filtering, phone validation (10 cases), Pinpoint failure |
e2e/handler.test.js | 45 | Full handler: all device/unit/alert/notification scenarios |
ESM Mocking Notes
The source uses native ESM ("type": "module"). Key patterns:
- Use
jest.unstable_mockModule()(notjest.mock()) + dynamicimport()after registering mocks mongodbis redirected viamoduleNameMapperinjest.config.jsbecause the source has its ownsrc/node_modules/mongodb@sentry/aws-serverless(Lambda Layer) is mapped to a pass-through stub- A proxy DB pattern handles the module-level connection cache: each test resets
mockDband the proxy delegates automatically
Deployment
This Lambda is deployed to AWS independently of the monorepo.
npm ciinsidesrc/to install production deps- Zip
src/directory (excluding Layer packages) - Upload to AWS Lambda console or via AWS CLI
Environment variables are set in the Lambda function's Configuration → Environment variables panel. The @sentry/aws-serverless package is attached as a Lambda Layer.
