Back to projects

// Evidence Dossier

LoRa Monitor 3DFAB — LoRa Helios

LoRaWAN sensors send data in different formats. LoRa Helios brings them into one dashboard to check node status, compare historical readings and export measurements without inspecting each device separately.

LoRaWANExpressMongoDBDocker
Project video preview.
Who it serves

Sensor network operators and users: see what arrived, when and from which node.

My responsibility

Full-stack development: TTN integration, API, storage, dashboard and deployment configuration.

Current status

Main system deployed in active production in late February 2026. Real field sensor telemetry; mobile GPS extension in experimental phase and non-operational in production.

What it enables

Reception, device histories, node comparison and CSV export in one workflow.

Context

LoRa Helios is the sensor monitoring platform developed for 3DFAB. It turns LoRaWAN transmissions received through The Things Network (TTN) into information operators and users can consult. Each reading is associated with a device and timestamp, supporting both latest readings and historical views.

The operator, users and I used the main system with physical sensors to check reception on September 19, 2026. The core system is deployed and operational in production. Mobile GPS tracking is a separate, non-operational experiment.

Problem

Receiving a message is not enough to analyze it. Sensors and decoders may use different field names, omit optional values and supply different timestamp formats. Repeated submissions and device-specific user permissions add further requirements.

The software needed a common model and a clear workflow: find a node, check the age of its readings, inspect its history and compare it with others without manually interpreting every payload.

My role

I designed and implemented the full-stack software: TTN webhook integration, validation and normalization, Express API, MongoDB models and indexes, auxiliary caching, authentication and device access. I also developed the React dashboard, history queries, node comparison, exports and user administration.

I prepared container configuration and architecture/deployment documentation and checked reception with the operator and users. The mobile device trial was a team effort; this account attributes the software and integration work to me.

Constraints

Design documentation assumes typical sensor transmissions every 10–30 minutes; this review did not measure that frequency in production. The dashboard polls the API every 30 seconds. Refreshing shows received data without increasing measurement frequency or creating continuous tracking.

Payloads depend on the sensor and decoder. Radio, TTN, the backend connection and the database are separate delivery dependencies. The model configures a 90-day TTL retention period. There is no durable ingestion queue or guarantee of recovering readings lost during an API restart. The portable GPS trial remains separate from core operation.

Solution

The operator locates devices using search and filters, checks the latest reading and opens a node for investigation. They select variables and a date range, explore charts and export CSV history. A view of 2–10 devices supports comparisons. Export selects dates and a variable independently of the chart’s quick reading-count limit.

Administrators manage users and device assignments, while clients query authorized nodes. Cards adapt to available fields. Coordinate and speed support exists in the software, but operation of the mobile GPS device remains experimental.

Architecture

The write path is LoRaWAN node → gateway → TTN → authenticated webhook → Express API → MongoDB. TTN supplies decoded payloads; the backend validates the envelope, normalizes fields and stores the reading. Ingestion works independently of an open dashboard.

React and Vite power the interface and Recharts displays histories. The dashboard summary polls the API every 30 seconds; history details reload when filters change or an update is requested. Redis caches device summaries; MongoDB is the primary store. Redis also receives internal Pub/Sub events, but no consumer turns them into browser push updates.

Telemetry ingestion, persistence and monitoring flow
Telemetry ingestion, persistence and monitoring flowFixed environmental nodes transmit telemetry via radio frequency (915 MHz). The gateway forwards it over an IP network to The Things Network (TTN), where a payload decoder outputs a JSON object. TTN triggers an authenticated HTTP webhook using timingSafeEqual to the Express server, which validates the payload schema with Joi, normalizes up to 11 environmental variables, persists the reading in MongoDB with a 90-day TTL, and invalidates the summary cache in Redis. The operator views the Vite React dashboard, which refreshes metrics via HTTP polling every 30 seconds against the Express API with no direct access to TTN or databases.RF 915 MHzIP / EthernetUplink JSONNormalizeView metrics30 s PollingStore measurementSummary cacheLoRaWAN Nodes915 MHz · FixedLoRa GatewayRF/IP ConcentratorTTN v3JSON DecoderHTTP WebhooktimingSafeEqualOperatorWeb browserReact DashboardVite · ChartsExpress ServerJoi Validation · 11 varsMongoDBRedisTelemetry ingestion, persistence and monitoring flowFixed environmental nodes transmit telemetry via radio frequency (915 MHz). The gateway forwards it over an IP network to The Things Network (TTN), where a payload decoder outputs a JSON object. TTN triggers an authenticated HTTP webhook using timingSafeEqual to the Express server, which validates the payload schema with Joi, normalizes up to 11 environmental variables, persists the reading in MongoDB with a 90-day TTL, and invalidates the summary cache in Redis. The operator views the Vite React dashboard, which refreshes metrics via HTTP polling every 30 seconds against the Express API with no direct access to TTN or databases.RF 915 MHzIP / EthernetUplink JSONNormalizePersistCache30 s PollingView metricsLoRaWAN Nodes915 MHz · FixedLoRa GatewayRF/IP ConcentratorTTN v3JSON DecoderHTTP WebhooktimingSafeEqualExpress ServerJoi Validation · 11 varsMongoDBRedisReact DashboardVite · ChartsOperatorWeb browser
Read the diagram and its limits

Fixed environmental nodes transmit telemetry via radio frequency (915 MHz). The gateway forwards it over an IP network to The Things Network (TTN), where a payload decoder outputs a JSON object. TTN triggers an authenticated HTTP webhook using timingSafeEqual to the Express server, which validates the payload schema with Joi, normalizes up to 11 environmental variables, persists the reading in MongoDB with a 90-day TTL, and invalidates the summary cache in Redis. The operator views the Vite React dashboard, which refreshes metrics via HTTP polling every 30 seconds against the Express API with no direct access to TTN or databases.

The diagram reflects the fixed environmental telemetry architecture verified locally using network doubles and a synthetic test load of 1,797,705 records. Mobile GPS tracking is excluded from the operational flow due to signal instability and absence of field validation. The architecture lacks an intermediate message queue (e.g., MQTT broker/Kafka) for persistent store-and-forward ingestion during API downtime.

Technical implementation and evidence map. Unit and normalization tests executed locally; gateway and backend availability depend on deployment network infrastructure.

Data pipeline

1. TTN submits device identification and decoded_payload. The API checks webhook credentials, limits requests and validates the envelope with Joi.

2. Normalization resolves aliases for up to 11 available variables: particulate matter, temperature, humidity, battery, coordinates, speed and radio quality. RF metadata comes from the first reported gateway, with payload fallbacks. The original input is retained for diagnostics.

3. The reading is stored, the device is created or updated, and summary cache invalidation is attempted. Success is returned after processing; there is no queue acknowledging before persistence. Reconstructed local dates receive a UTC suffix, so the firmware must use a compatible time convention.

Concurrency and packet loss

A unique MongoDB index on deviceId and receivedAt prevents two readings with the same key from being stored, including competing inserts. The application treats a duplicate-key error as a successful repeat. An additional test sent 20 identical requests simultaneously, retaining one reading and one device with 19 duplicate responses. This protects that measurement identity; it does not recover radio packet loss.

Retries need a stable timestamp. If it is missing, normalization may assign receipt time and produce different keys. Saving a measurement and updating a device are separate operations, without a transaction covering the entire workflow. Fault injection reproduced a persisted reading with HTTP 500 and no device record.

When no Redis cache entry is available, the summary query reads MongoDB. This preserves a read path without making cache the main store. Ingestion has no durable queue or backend-owned retry mechanism; capacity and recovery must be evaluated per scenario.

Decisions

MongoDB versus PostgreSQL: optional document fields and device/date indexes fit the decoders and Mongoose model. PostgreSQL with JSONB was also a valid alternative. The implementation uses a conventional indexed collection with TTL, not a native time-series collection.

Polling versus WebSockets: a 30-second query interval suits infrequent transmissions and simplifies connection and proxy handling. The trade-off is that the screen does not receive every event instantly.

Auxiliary caching: Redis reduces repeated summary queries while history stays in MongoDB. Uncached reads can have a different cost.

Bearer versus HMAC: the integration uses TTN-supported credentials and timing-safe comparison. This authenticates the call rather than individually signing its content. Three ADRs document these decisions.

Security

JWT authentication and role/device assignment checks restrict clients to their nodes and user management to administrators.

The webhook has separate credentials, timing-safe comparison, Joi envelope validation and request limiting. Request records aid diagnostics, but the available tests do not establish complete traceability for every event. Envelope validation does not establish the physical validity of sensor measurements.

Key configuration, TLS, proxy behavior, allowed origins and service exposure are deployment concerns. The tests in this case study provide bounded functional evidence, not certification or a complete security audit.

Testing

On October 3, 2026, the original suite was rerun against reviewed local code with Node.js 22.23.2, Jest, Supertest and temporary MongoDB: 98/98 cases across 7 test files. Line coverage was 79.45%, and all four metrics exceeded the configured 70% threshold. The frontend built successfully; Vite warned that the JavaScript bundle exceeds 500 kB. These checks did not test TTN, physical sensors or production.

The 10 separate checks of concurrency, normalization, uncached reads and partial persistence failure belong to the isolated September 19 review; they were not rerun on October 3. The five public-example cases were run again. The original suite does not establish persistence of every audit event. Redis client behavior is mocked; this does not represent an actual Redis server outage. The table separates dates and scope.

Results and scope of the checks
CheckResultScope
Original suite98/98 cases · 7 filesRerun on October 3, 2026 with Jest, Supertest and temporary MongoDB.
Original coverage79.45% linesStatements 78.88% · branches 73.76% · functions 87.27%. Does not cover the entire frontend or every failure mode.
Additional checks10/10 casesSeptember 19, 2026 review: real modules, synthetic data and fault injection in an isolated copy; not rerun on October 3.
Concurrent duplicates20 requests → 1 readingPromise.all with an initialized unique index: 20 HTTP 200, 19 duplicate responses and 1 device.
Distinct readings20 requests → 20 readingsDifferent timestamps, simultaneous requests. Validates this scenario, not maximum capacity.
Uncached readMongoDB responseReal route and helper; Redis client operations mocked to fail.
Partial failureLimitation reproducedDevice update failure: HTTP 500 with a persisted reading and no device record.

Deployment

Docker Compose separates the Express backend, compiled frontend served by Nginx and Redis, with restart policies and health checks. Documentation describes Coolify deployment and MongoDB Atlas as external storage.

I prepared that configuration and deployment documentation. The core system is deployed in production. The technical review ran locally without exposing the production application server; configured health checks are not presented as a measured availability history.

Outcome

The result is a shared workflow for reviewing reception and exploring measurements: latest readings, device/date histories, multi-node comparison and CSV export. The core system operates in production with verified field sensor reception by the operator and end users.

The functional benefit is a common representation of data and permissions in one interface. No percentage time saving or measured uptime is claimed. Mobile GPS remains a separate experimental extension.

Evidence

This case combines reviewed local code, three documented architecture decisions, local test verification and active field deployment. An isolated September 19, 2026 review added 10 checks; on October 3, the 98 original tests were rerun, the frontend was built and the public sample was verified.

Tests used synthetic data and temporary MongoDB. They did not access operational databases or the application server. The core deployment is active in production, while production uptime, latency and radio delivery were not formally measured.

The video shows the interface and the final section presents the five-case normalization example. Real sensor data and infrastructure access details are not published.

Field observations

Field use with physical sensors was verified with the operator and users, with the core system deployed in production. The portable ESP32 route was an additional team trial, without published range, battery-life or packet-loss measurements.

Load tests

Load and capacity: simultaneous checks use 20 requests and synthetic data against a local server. They evaluate integrity in those scenarios, not the LoRa link, production latency, sustained load or maximum capacity. Earlier 10,000-event and latency figures remain outside the substantiated results.

Software quality and UX

The interface emphasizes finding readings and understanding their age. The activity indicator means the latest reading is less than 30 minutes old, not that sensor connectivity was directly tested. Search, filters and detail views shorten navigation to a specific node.

Charts support date ranges and different scales; comparison displays multiple device series on a common time axis without physically correcting their clocks. CSV export supports analysis outside the dashboard.

Resolving aliases with ?? preserves valid zeros. Distinguishing zero from a missing value prevents the UI from hiding a real reading. The following example isolates and tests that decision.

Code sample and engineering test

A self-contained public adaptation of the normalization rule: three environmental variables, equivalent aliases, canonical-name precedence and null for missing data. It excludes authentication, persistence and private data.

The test runs five inputs and checks that the original payload is unchanged. It demonstrates this normalization contract; backend concurrency is checked separately in the testing section.

JavaScript / Node.js
// Isolated adaptation: decoded sensor fields only.
function normalizeSensorPayload(rawPayload) {
const data = rawPayload.uplink_message?.decoded_payload ?? {};
return {
temperature: data.temperature ?? data.temp ?? data.t ?? data.temperatura ?? null,
humidity: data.humidity ?? data.hum ?? data.h ?? data.humedad ?? null,
battery: data.battery ?? data.batt ?? data.v_bat ?? null,
};
}

Isolated sample test

JavaScript / Node.js
import assert from 'node:assert/strict';
// Run together with the sample using Node.js.
const cases = [
[{ temperature: 0, humidity: 0, battery: 0 },
{ temperature: 0, humidity: 0, battery: 0 }],
[{ temp: 21, hum: 45, batt: 3.7 },
{ temperature: 21, humidity: 45, battery: 3.7 }],
[{ temperatura: 18, humedad: 60, v_bat: 3.5 },
{ temperature: 18, humidity: 60, battery: 3.5 }],
[{ temperature: 0, temp: 99, humidity: null, hum: 40 },
{ temperature: 0, humidity: 40, battery: null }],
[{}, { temperature: null, humidity: null, battery: null }],
];
for (const [input, expected] of cases) {
const raw = { uplink_message: { decoded_payload: input } };
const before = JSON.stringify(raw);
assert.deepEqual(normalizeSensorPayload(raw), expected);
assert.equal(JSON.stringify(raw), before);
}
// 5 cases: zeros, aliases, precedence, missing fields, no input mutation.

5 cases executed: zeros, abbreviated aliases, Spanish aliases, precedence and missing fields; all original inputs preserved.