Skip to content

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

  1. Overview
  2. Device–Unit Relationship
  3. Handler Flow
  4. Encryption Protocol
  5. Event Payload Format
  6. Response Format
  7. Alert Logic
  8. Source Structure
  9. Environment Variables
  10. Dependencies
  11. Test Suite
  12. 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:

json
{
  "_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.

ParameterValue
AlgorithmAES-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

jsonc
// 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 above

Multiple 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:

jsonc
// 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:

jsonc
{
  "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_codeCondition
HIGH_TEMPERATUREtemperature > unit.max_temperature
LOW_TEMPERATUREtemperature < unit.min_temperature
HIGH_HUMIDITYhumidity > unit.max_humidity
LOW_HUMIDITYhumidity < unit.min_humidity
LOW_BATTERYvolt < 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 DB

Environment Variables

VariableRequiredDescription
MONGODB_URIMongoDB connection string
DB_NAMEMongoDB database name
SENTRY_DSN✅ (prod)Sentry DSN for error tracking
SENTRY_ENVIRONMENToptionalproduction / staging
AWS_REGIONAWS region for SES / Pinpoint
FROM_EMAILSES verified sender address
ENTITY_NAMEoptionalDisplay name in email templates

MONGODB_URI and DB_NAME are 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/.

PackagePurpose
mongodbMongoDB driver
@aws-sdk/client-sesSend emails via AWS SES
@aws-sdk/client-pinpoint-sms-voice-v2Send SMS via Pinpoint
dotenvLoad .env in local development
@sentry/aws-serverlessLambda Layer at runtime — not installed locally

Test Suite

Running Tests

bash
# 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-modules is 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

FileTestsCoverage
unit/input-helper.test.js23formatInput, encryptData, AES round-trips, malformed inputs
unit/email-service.test.js18Skip empty alerts, SES params, deduplication, 5 alert templates
unit/sms-service.test.js28Skip flags, DLT filtering, phone validation (10 cases), Pinpoint failure
e2e/handler.test.js45Full handler: all device/unit/alert/notification scenarios

ESM Mocking Notes

The source uses native ESM ("type": "module"). Key patterns:

  • Use jest.unstable_mockModule() (not jest.mock()) + dynamic import() after registering mocks
  • mongodb is redirected via moduleNameMapper in jest.config.js because the source has its own src/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 mockDb and the proxy delegates automatically

Deployment

This Lambda is deployed to AWS independently of the monorepo.

  1. npm ci inside src/ to install production deps
  2. Zip src/ directory (excluding Layer packages)
  3. 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.

Intecog Logistech IoT Monitoring Platform