Legacy Diesel Generator Fleet Performance Uplift

This reference application is here to give you ideas and help you get started quickly. It's built on specific hardware that may not match yours, so focus on the sections that fit your use case. If you'd like to talk through your project and whether it's a good fit for Blues, reach out.
Building with an AI assistant? Point it at this accelerator's firmware in firmware/diesel_gen_monitor/ so it can learn how the device works and help you adapt it to your hardware.
This project is an asset performance optimization retrofit for operators of standby diesel generators at hospitals, data centers, and industrial sites. The device sits on the DIN rail inside the generator panel and reads the controller's existing Modbus port. Over cellular, it reports run hours, fault codes, fuel level, coolant temperature, oil pressure, and load, and it sends an alert right away on faults, coolant overtemp, low oil pressure, low fuel, or lost communication. It runs off the panel's battery-backed DC control bus and uses an external antenna routed outside the metal enclosure. That keeps the monitoring path alive when mains power and facility WiFi go dark, which is exactly the moment a standby generator either starts or doesn't. The hardware is a Finder OPTA with a Blues Wireless for OPTA cellular expansion (see §4 for the BOM). The wiring is strictly read-only, with no writes to start/stop or safety circuits.
1. Project Overview
The problem. Standby diesel generators at hospitals, data centers, and industrial sites are among the most important assets on the premises, and among the least watched. The controllers that run them (DeepSea (DSE), Woodward, Kohler, Caterpillar, Cummins, and a dozen others) have spoken Modbus RTU (Remote Terminal Unit, the most widely deployed industrial serial protocol) for decades. That interface already holds fuel level, load percentage, coolant temperature, oil pressure, run hours, and the active alarm register that reflects the controller's current fault state. When a failure-to-start event is asserted, it shows up there too. Hardly anyone reads any of it. The controller reports faults to an annunciator panel in the same room, which is exactly where nobody is once the building is dark and the emergency has already started.
A standby generator that fails to start during a utility outage is worse than no generator at all, because the false sense of protection can delay the emergency response. One that starts but is low on fuel, or trending toward coolant overtemp, will quit mid-outage with no warning. And run-hour data that never leaves the controller can't help anyone schedule preventive maintenance before a critical service interval slips past. Facilities that have generators but not generator monitoring run into all three of these regularly.
This project closes that gap for active alarm state, a firmware-observed alarm chronology, and operating data. A Blues Wireless for OPTA snapped onto a Finder OPTA industrial PLC (programmable logic controller) sits on the DIN rail inside the generator panel. Once a minute it polls seven holding registers over the controller's Modbus port, including the active alarm bitmask. It then sends alert events, an hourly alarm-history log, and hourly summaries over cellular to the Blues Notehub cloud service. You'll need to run new RS-485 wiring to the controller's communications terminals and match the controller's Modbus serial settings (baud rate, parity, slave address). Nothing writes to start/stop or safety circuits, because the firmware is strictly read-only over Modbus, and nothing depends on the facility network.
Why Notecard. It comes down to timing. A standby generator exists to supply power when mains fails, and that's exactly when the facility's WiFi router goes dark too. A monitor that depends on the building's LAN can't report a failure-to-start at the one moment it matters most. Worse, a LAN-only monitor can look like evidence that everything is fine, right up until the UPS batteries run dry. Cellular, with an antenna routed outside the metal generator enclosure, gives you a communication path that doesn't depend on the facility's power or network at all. It stays up because it draws from the generator panel's own battery-backed DC control bus, which runs off the same battery that starts the engine.
New to Blues?
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
Learn what we do in this 3-minute video that walks you through how we simplify wireless connectivity.
All of this works because a well-designed generator panel already has a battery-backed 12–24 VDC control bus whose whole job is to stay live when mains fails. The OPTA and Wireless for OPTA draw from that bus. So when the utility fails, the transfer switch opens, and the office lights go out, the monitor is already running on cellular and polling the controller once a minute for the data the facility manager needs.
Deployment scenario. One OPTA RS485 with Wireless for OPTA mounts on the DIN rail inside the generator's main control panel and runs on the panel's existing battery-backed DC control bus. RS-485 wiring goes to the controller's communications port, and the cellular antenna leaves through a cable gland. You do have to wire RS-485 and match the Modbus communication settings. Beyond that, nothing writes to safety circuits, and you don't need the OEM's cooperation or anything from IT.
2. System Architecture
Device-side responsibilities. The OPTA's Cortex-M7 host acts as the Modbus RTU client (master), and the generator controller is the server (slave). Once a minute, the host reads the same seven holding registers through the onboard RS-485 transceiver (engine RPM, fuel %, load %, oil pressure, coolant temperature, cumulative run hours, and the active alarm bitmask) and folds the results into rolling hourly statistics in RAM. Every poll feeds the three threshold-based alert rules and the alarm-word transition detector, so the host decides locally whether a cycle deserves an event or just goes into the next summary. Queued Notes travel from the host to the Notecard over I²C through the AUX connector on Wireless for OPTA. The host never has to deal with modem AT commands or raw sockets.
Notecard responsibilities. Every Note the host hands off goes into the
Notecard's on-device queue, and the Notecard handles the radio side from there.
It brings up cellular on the
hub.set outbound
cadence and flushes anything marked sync:true right away. The NOTE-WBNAW has
WiFi hardware, but production deployments leave it unconfigured on purpose,
because the facility's WiFi access point is offline during the very utility
failures this monitor is there to catch. Environment
variables
come down the same channel in the other direction. The facility manager can
retune thresholds and per-register addresses from Notehub without anyone
touching firmware, at least for controller families that expose the required
points as individually readable 16-bit registers with compatible units.
Vendor-specific scaling, signedness, and 32-bit fields need the production
extensions described in Limitations.
Notehub responsibilities. The data lands in Notehub.
Events arrive over the Internet through the Notecard's embedded global SIM,
Notehub stores every one, and project-level
routes send them
wherever the operator's downstream system needs them. Fleet-level environment
variables
let operators retune Modbus register addresses and alert thresholds without
reflashing firmware. The shipped firmware does individually addressed 16-bit
Modbus reads and doesn't scale register values. Vendor-specific scaling,
signed-field handling beyond coolant temperature (which the demo already reads
as int16_t), and 32-bit register handling are production extensions,
described in Limitations. Smart
Fleets are a natural
way to organize an installed base by controller family or site.
Routing to the cloud (high level). Notehub can route to HTTP, MQTT, AWS, Azure, GCP, Snowflake, and several other destinations. Route setup depends on your project, and this one doesn't ship a specific downstream endpoint. See the Notehub routing docs for details.
3. Technical Summary
You will have: A first gen_summary.qo Note in Notehub whose data_ok = 1
confirms Modbus connectivity, hourly summaries of generator status, and immediate alerts on
controller faults.
Minimum prerequisites: Arduino IDE or arduino-cli v1.3+, a generator
controller with an accessible Modbus port (DeepSea, Woodward, Kohler,
Caterpillar, Cummins, or equivalent), the baud rate and slave address from the
controller's commissioning menu, and a Notehub project.
Time required: 45 minutes for hardware assembly and wiring, 15 minutes for firmware and Notehub setup, and 5 minutes for first validation. The first event arrives in about an hour (one Modbus poll plus one report boundary).
Steps:
-
Assemble hardware: Mount the OPTA RS485 and Wireless for OPTA on the DIN rail, run the antenna through a cable gland, and power the pair from the generator panel's battery-backed 12–24 VDC bus (fused at 3 A, per Wiring and Assembly Step 2).
-
Wire Modbus: Connect OPTA
A/B/COMto controllerA/B/COM, with 120 Ω terminators at both ends (OPTA and controller). Use shielded, RS-485-rated twisted pair. -
Flash firmware: Copy the
PRODUCT_UIDfrom your Notehub project settings and paste it intodiesel_gen_monitor.inoat line 39. Compile and upload with the Arduino IDE, or run:arduino-cli core install "Arduino Mbed OS Opta Boards" arduino-cli lib install "Blues Wireless Notecard" "ArduinoModbus" "ArduinoRS485" sed -i '' 's|com.your-company.your-name:diesel_gen_monitor|YOUR_PRODUCT_UID|g' firmware/diesel_gen_monitor/diesel_gen_monitor.ino arduino-cli compile --fqbn arduino:mbed_opta:opta firmware/diesel_gen_monitor/ --uploadnote The Finder OPTA Codesys ships running the CODESYS runtime. The first time you upload Arduino firmware to it, you have to put it into bootloader mode yourself by double-tapping its RESET button, as described in the Wireless for OPTA Quickstart. That upload replaces the CODESYS runtime, and later uploads don't need the double tap.
-
Set Modbus register addresses: In Notehub, go to Fleet → Environment. Add
reg_engine_rpm,reg_fuel_pct,reg_load_pct,reg_oil_kpa,reg_coolant_c,reg_run_hours, andreg_alarm_word, using the correct addresses from your controller's Modbus map. Setmodbus_baud,modbus_slave_id,modbus_parity, andmodbus_stop_bitsto match the controller's configuration. The device picks these up on its next inbound sync, which is 120 minutes by default. To test right away, setinboundto 1 minute inhub.seton the device. -
Validate: Open Notehub and wait for the device to show up in the project. After the first Modbus poll (1 minute) and the first report boundary (60 minutes), you'll see a
gen_summary.qoNote withdata_ok = 1, fuel level, alarm word, and run status. If every Note showsdata_ok = 0, check the wiring, register addresses, baud rate, and slave address against the controller's commissioning menu (see "Modbus first-light" in Validation and Testing).
Sample Note (gen_summary.qo): Here's a sample Note from this device:
{
"file": "gen_summary.qo",
"body": {
"data_ok": 1,
"fuel_pct": 78.0,
"load_pct": -1.0,
"load_pct_peak": -1.0,
"oil_kpa_mean": -1.0,
"oil_kpa_peak": -1.0,
"coolant_c_mean": 24,
"coolant_c_peak": 24,
"run_hours": 1847,
"run_min": 0,
"stop_min": 60,
"engine_starts": 0,
"alarm_word": 0,
"alarm_word_stale": 0,
"samples_ok": 60,
"samples_failed": 0
}
}Firmware-observed alarm chronology. The firmware polls the controller's
active alarm bitmask once per sample_minutes (1 minute by default). It fires
a controller_alarm event the first time it sees an assertion, either on a
zero→non-zero transition or at the first valid poll after boot if those bits
are already set. Every distinct alarm-word transition goes into an 8-slot
on-device ring buffer (AlarmHistoryEntry): the initial assertion, any change
while the word stays non-zero (bits added or cleared while at least one fault
is still active), and the final clearance to zero. The buffer is flushed as
gen_alarm_log.qo at every report boundary, which gives operations teams a
fault-set chronology for each window. A transient fault that asserts and clears
entirely between two consecutive polls can still slip through. Reading the
controller's own internal timestamped alarm log (vendor-specific multi-register
reads that may cover events from before the monitor was installed) is a
production extension. See Limitations.
4. Hardware Requirements
| Part | Qty | Rationale |
|---|---|---|
| Finder OPTA Codesys (8A.04.9.024.832C) | 1 | Industrial PLC with an onboard RS-485 transceiver, DIN-rail mount, and 12–24 VDC supply input, included in the Wireless for OPTA Bundle. For this project it runs Arduino firmware that hosts the Modbus client and all the edge logic. The Arduino Opta RS485 and Arduino Opta WiFi also work, with no firmware changes. |
| Blues Wireless for OPTA (NA, SKU 992-00155-C) | 1 | Snaps onto the OPTA's right-hand expansion port and adds a Notecard Cell+WiFi (NOTE-WBNAW) over I²C. Cellular coverage is regional, so pick the SKU that matches where you're deploying (the EMEA variant is SKU 992-00156-C). |
| External cellular antenna, SMA, ~3m lead (e.g. SparkFun CEL-16432) | 1 required, 2 recommended | Route the primary cellular antenna through a cable gland to the outside of the metal generator enclosure, because rubber-duck antennas inside a steel cabinet can't reliably hold a cellular session. A diversity antenna on the second port improves LTE Cat-1 performance at sites with marginal signal. |
| Blues Mojo | 1 | Coulomb counter for commissioning. Splice it inline between the power supply and the Wireless for OPTA input to check Notecard energy per cellular session. It's bench-only and isn't deployed to the field. |
| 24 VDC DIN-rail supply, ≥10W (e.g. MeanWell HDR-15-24) | 1 | For bench testing only. In the field, power the OPTA and expansion from the generator panel's existing battery-backed DC control bus. The Wireless for OPTA Quickstart shows 12–24 VDC going to the OPTA's power terminals with that same rail jumped to the expansion's power input, which confirms the full 12–24 V range works end to end without a step-up converter. |
| 120 Ω termination resistor | 1–2 | RS-485 termination at each physical end of the bus. Place one at the OPTA and one at the controller. |
| Shielded twisted pair, 22 AWG, RS-485 rated, 1–3 m | 1 | A → A, B → B, shield → controller signal ground between the OPTA and the generator controller. Length depends on the panel layout. |
| DIN rail section, ~15 cm | 1 | Mount for the OPTA and expansion. The generator panel probably has DIN rail already. |
| Inline fuse holder with 3 A ATO/ATC blade fuse | 1 | Required field-installation safety item. Wire it in series with the positive supply lead from the battery-backed DC control bus, ahead of the OPTA's + terminal (see Wiring and Assembly Step 2). It protects the control bus if the monitoring hardware develops a sustained hard fault. 3 A covers the worst-case combined draw of the OPTA host (≤183 mA continuous at 12 V) plus the Notecard expansion's peak LTE burst current at the bus supply input. Validation and Testing explains how to verify the fuse size during commissioning. If the panel already takes DIN-rail accessories on that supply branch, a DIN-rail fused terminal block (such as Phoenix Contact or Weidmüller series) is a cleaner option. |
The Blues hardware ships with an active SIM that includes 500 MB of data and 10 years of service, with no activation fees and no monthly commitment.
5. Wiring and Assembly
Safety. Generator control panels contain hazardous voltages even when the generator is stopped. Installation must be done by qualified personnel following site lockout/tagout procedures, the generator and controller manufacturer's instructions, and applicable electrical codes. This reference design is read-only over Modbus. It never commands start/stop or changes any generator setpoint.
-
Mount. Snap the OPTA onto the DIN rail, then snap Blues Wireless for OPTA onto the OPTA's right-hand expansion port. Join the two with the supplied solderless AUX connector, which carries the I²C lines the Notecard uses. As the Wireless for OPTA Quickstart points out, USB-C doesn't power the expansion, so use the 12–24 VDC supply rail for any testing beyond the bench.
-
Power. In the field, install the 3 A inline fuse holder (see Hardware Requirements) in series with the positive supply lead from the battery-backed DC control bus before it reaches the OPTA's
+terminal. That fused branch protects the control bus if the monitoring hardware develops a sustained hard fault. Wire the OPTA's+and−terminals to the generator panel's battery-backed DC control bus (12 or 24 VDC, whichever the installation uses), with the fuse in the positive lead. The engine start battery keeps this bus live when mains power fails, so the monitor still has its independent communication path when the stakes are highest. Jump the same fused supply rail to the expansion's power input terminal so Wireless for OPTA shares the fused branch. The Wireless for OPTA Quickstart documents this exact wiring pattern for 12–24 VDC supplies. For bench testing, wire the 24 VDC DIN-rail supply's+VandGNDoutputs to the OPTA's+and−terminals the same way, then power the supply from mains. -
Antennas. Thread an SMA-female bulkhead lead through a cable gland for the primary cellular antenna and tighten it onto the first antenna port on Wireless for OPTA. If the enclosure layout allows, add a second lead for the diversity antenna. It makes a real difference to LTE Cat-1 performance in generator rooms deep inside buildings. The bundled rubber-duck antennas are for the bench only, since a steel enclosure kills their signal.
-
Modbus RS-485 bus. Wire the OPTA's RS-485 terminals to the generator controller's communication port:
- OPTA
A (+)→ ControllerA (+)/D+/TxD+(the name varies by vendor) - OPTA
B (−)→ ControllerB (−)/D−/TxD− - OPTA
COM(RS-485 GND) → the controller's documented RS-485 signal common or ground reference (check the controller's wiring diagram). Handle the cable shield/drain separately, following the controller vendor's grounding guidance, and don't assume it can stand in for signal common. - Put a 120 Ω resistor across
A/Bat each physical end of the cable run, one at the OPTA and one at the controller. With one OPTA and one controller, that's two terminators total. The controller's Modbus commissioning guide lists its terminal names. DeepSea 7000-series uses+Dand−D, and Woodward EasyGen labels themRS485+andRS485−.
- OPTA
-
Controller Modbus configuration. Configure the controller as a Modbus RTU server (slave), with the OPTA as the client (master). In the controller's Modbus setup menu, match the baud rate, parity, stop bits, and slave address to the firmware defaults (19200 / none / 1 / slave 1), or override the defaults with the
modbus_*environment variables on Notehub. -
Bench validation. During first-light testing, splice the Mojo inline between the 24 VDC supply and the Wireless for OPTA power input so it measures the per-session energy of the expansion + Notecard subsystem.
6. Notehub Setup
-
Create a project. Sign up at notehub.io and create a project. Then open the project dashboard and find the ProductUID in the top-right corner (it looks like
com.your-company.your-name:project). Copy it and paste it intofirmware/diesel_gen_monitor/diesel_gen_monitor.inoat line 39, replacingcom.your-company.your-name:diesel_gen_monitor. -
Claim the Notecard. Power up the panel. On its first cellular session, the Notecard provisions itself into your project.
-
Create a Fleet per controller family. Fleets group devices for shared configuration and routing. Register addresses and alarm bitmasks differ from one generator controller brand to the next, so one fleet per controller vendor works well: one for DeepSea 7000-series generators, another for Woodward EasyGen, and so on. Fleet-level environment variables hold the register addresses and thresholds for that controller family, and a device can override them with its own values if a site is set up unusually. Smart Fleets can move devices between fleets automatically based on a device-level tag or environment variable.
-
Set environment variables. Every variable below is optional, and the table shows the firmware defaults. Values you set in Notehub (Fleet → Environment Variables) override the compile-time defaults on the device's next inbound sync, with no reflashing.
Variable Default Purpose sample_minutes1Minutes between Modbus polls. report_minutes60Minutes between summary Notes ( gen_summary.qo).modbus_slave_id1Modbus server (slave) address of the generator controller. modbus_baud19200Bus baud rate. Must match the controller's configuration. modbus_paritynoneParity setting: none,even, orodd.modbus_stop_bits1Stop bits: 1or2.reg_engine_rpm768Holding-register address for engine speed (RPM). reg_fuel_pct769Holding-register address for fuel level (0–100%). reg_load_pct770Holding-register address for generator load (0–100%). reg_oil_kpa771Holding-register address for oil pressure (kPa). reg_coolant_c772Holding-register address for coolant temperature (°C, signed). reg_run_hours773Holding-register address for cumulative engine hours. reg_alarm_word774Holding-register address for the active alarm bitmask. fuel_low_pct25.0Fuel level (%) below which fuel_lowfires.coolant_alarm_c95.0Coolant temperature (°C) above which coolant_overtempfires.oil_low_kpa138.0Oil pressure (kPa, ≈20 psi) below which oil_low_pressurefires while running.alarm_mask_fts0Bitmask applied to alarm_wordto detect failure-to-start events.0disables it. Set it to the decimal value of your controller's FTS bitmask (e.g.1for bit 0,4for bit 2).0x-prefixed hex also works (e.g.0x0001).rpm_running100Engine RPM at or above which the engine counts as running, for separating stats and evaluating oil pressure. Controller register-map gotchas. The register-address defaults are only examples, based on a made-up contiguous map. Real controllers differ in 0-based vs 1-based addressing, per-register scaling (oil pressure may be in 0.1 bar, tenths of kPa, or raw psi depending on the controller and configuration), signedness (coolant temperature is often signed 16-bit), run hours stored as a 32-bit value across two consecutive registers with vendor-specific word order, and whether a register holds active alarms or a latched alarm history. See Limitations for the production path.
-
Configure routes. Add one route for
gen_event.qo(low-volume real-time alerts bound for on-call paging or a CMMS, computerized maintenance management system) and a second forgen_summary.qo(long-term storage and trend analysis). Sendgen_alarm_log.qoto the same real-time destination asgen_event.qo. The two share urgency, since fault chronology belongs next to alert notifications, butgen_alarm_log.qorides along with the periodic outbound sync instead of opening its own immediate cellular session. Keeping the three Notefiles separate at the source means each one can go to its own destination at its own urgency, with no filter logic in the route.
7. Firmware Design
The firmware is one Arduino sketch,
firmware/diesel_gen_monitor/diesel_gen_monitor.ino,
with the Modbus, Notecard, and statistics helpers split out into
diesel_gen_monitor_helpers.h
and
diesel_gen_monitor_helpers.cpp
to keep the orchestration in .ino readable.
Dependencies:
- Arduino Mbed OS Opta Boards core (install it from the Arduino IDE Boards Manager).
Blues Wireless Notecard(thenote-arduinolibrary). Install it from the Arduino Library Manager or witharduino-cli lib install "Blues Wireless Notecard". The note-arduino releases page lists the latest version.ArduinoModbusandArduinoRS485(official Arduino libraries, also from the Library Manager).
Modules
| Responsibility | Where |
|---|---|
Notecard hub.set, template definition | notecardConfigure, defineTemplates (.ino) |
| Environment-variable fetch and clamping | fetchEnvOverrides (_helpers.cpp) |
| Modbus serial re-init on env change | applyModbusSerialIfChanged (_helpers.cpp) |
| Hub cadence re-sync on env change | applyHubSetIfChanged (_helpers.cpp) |
| Seven-register Modbus poll with retry | pollGenerator, modbusReadOne (_helpers.cpp) |
| Rolling hourly statistics | RollingStats struct, accumulate (_helpers.cpp) |
Alarm word latch detection, history logging (logAlarmHistory/flushAlarmHistory), start-event counting | loop() (.ino) |
| Three threshold rules + edge trigger | evaluateRules (_helpers.cpp) |
| Immediate-sync alert emission | sendEvent (.ino) |
| Hourly templated summary | sendSummary (.ino) |
| Millis-based periodic scheduler (no sleep, since the host runs continuously on the battery-backed bus) | loop() (.ino) |
Sensor reading strategy
The firmware reads the seven holding registers one at a time with
modbusReadOne(). Pump VFDs often keep their registers contiguous, so you can
read them in a single burst. Generator controller Modbus maps vary so much from
vendor to vendor that individual reads with configurable addresses are the only
reliable approach. All seven reads at 19200 baud take under 500 milliseconds,
which is negligible at a one-minute polling cadence.
A sample only counts as valid if all seven reads succeed in a single attempt. A
partial success would look exactly like valid data with wrong values, and
that's worse than having no data. If any register read fails, the firmware
starts the set over, for up to three attempts in all, then declares the poll a
failure and emits a modbus_unreachable event.
Event payload design
The device writes two template-backed Notefiles. Templates store records as fixed-length binary instead of free-form JSON, which shrinks the on-wire payload 3–5×. A fleet of 50 generators sending hourly summaries on a prepaid SIM with a finite data budget can't do without that compression. Notehub decodes the template binary and shows it as JSON in your browser and in API responses.
gen_summary.qo (periodic, hourly by default). This example is a standby
window where the engine was stopped for the whole hour. It's the decoded JSON
you'll see in the Notehub dashboard or when you pull Notes through the API:
{
"file": "gen_summary.qo",
"body": {
"data_ok": 1,
"fuel_pct": 78.0,
"load_pct": -1.0,
"load_pct_peak": -1.0,
"oil_kpa_mean": -1.0,
"oil_kpa_peak": -1.0,
"coolant_c_mean": 24,
"coolant_c_peak": 24,
"run_hours": 1847,
"run_min": 0,
"stop_min": 60,
"engine_starts": 0,
"alarm_word": 0,
"alarm_word_stale": 0,
"samples_ok": 60,
"samples_failed": 0
}
}Schema Notes for downstream integrators:
data_ok: the primary validity flag.1means at least one Modbus poll succeeded this window, and all measurement fields are valid.0means a complete telemetry blackout (allsamples_failed, no controller contact). Whendata_ok = 0, treat every computed measurement field (fuel_pct,load_pct*,oil_kpa*,coolant_c*,run_min,stop_min) as undefined.alarm_wordandrun_hourswork differently. A zero there would look the same as "no alarms" or "zero hours," so the firmware carries forward the last-known values from before the blackout and setsalarm_word_stale = 1(see below).alarm_word_stale:0under normal operation (data_ok = 1), meaningalarm_wordcomes from this window's Modbus polls.1whendata_ok = 0, meaningalarm_wordis the last-known value from before the blackout (the firmware preserves it across the stats reset) andrun_hoursis the last successfully polled reading. Usealarm_word_staleto tell "no active alarms this window" apart from "controller was unreachable; alarm state unknown."-1.0/-1sentinel: shows up in running-only fields (load_pct,load_pct_peak,oil_kpa_mean,oil_kpa_peak) whenever the engine produced no running-state samples in the window, meaning the generator was stopped the whole hour. These fields only accumulate while the engine is spinning, so a0.0would look the same as a true zero reading. Whendata_ok = 0, the sentinel extends to every computed measurement field, includingfuel_pctandcoolant_c_*.samples_ok: the number of successful Modbus polls in the window (run_samples + stop_samples). Together withsamples_failed, it lets downstream analytics tell "generator stopped, telemetry healthy" apart from "controller unreachable or wiring fault."samples_failed: the number of polls in the window where all three Modbus retry attempts failed. A non-zero value here alongsiderun_min = 0is the main sign that monitoring coverage was degraded, rather than the generator just sitting idle.
gen_event.qo (immediate, sync:true). The two variants below, a per-poll
alert and a report-window alert, show how trigger_val / trigger_threshold
differ:
Per-poll alert (failure_to_start): the fresh sample already explains the
trigger, so trigger_val and trigger_threshold are -1.0:
{
"file": "gen_event.qo",
"body": {
"alert": "failure_to_start",
"engine_rpm": 0,
"fuel_pct": 82.0,
"load_pct": 0.0,
"oil_kpa": 0.0,
"coolant_c": 22,
"alarm_word": 1,
"run_hours": 1847,
"trigger_val": -1.0,
"trigger_threshold": -1.0
}
}Report-window alert (coolant_overtemp): trigger_val is the window peak
that crossed the threshold, and coolant_c is the last-known sample reading,
which may have dropped back down by report time:
{
"file": "gen_event.qo",
"body": {
"alert": "coolant_overtemp",
"engine_rpm": 1500,
"fuel_pct": 78.0,
"load_pct": 85.0,
"oil_kpa": 312.0,
"coolant_c": 88,
"alarm_word": 0,
"run_hours": 1849,
"trigger_val": 97.0,
"trigger_threshold": 95.0
}
}gen_alarm_log.qo (once per report_minutes when any alarm event occurred,
batched with the periodic outbound sync, not template-backed):
{
"file": "gen_alarm_log.qo",
"body": {
"count": 2,
"events": [
{ "alert": "controller_alarm", "alarm_word": 4, "elapsed_s": 180 },
{ "alert": "alarm_clear", "alarm_word": 0, "elapsed_s": 420 }
]
}
}Schema Notes for gen_event.qo downstream integrators:
trigger_val/trigger_threshold: present in everygen_event.qo. For report-window rules (fuel_low,coolant_overtemp,oil_low_pressure),trigger_valis the window aggregate or peak that crossed the configured limit (trigger_threshold). For per-poll alerts (controller_alarm,failure_to_start,modbus_unreachable), both fields are-1.0, because the sample fields already explain the trigger.- The sample fields (
engine_rpm,fuel_pct,load_pct,oil_kpa,coolant_c) carry the last-known polled reading at event time. For per-poll alerts that's the reading closest to the trigger, and for report-window alerts it's context on the machine's state. Withcoolant_overtemp,coolant_cmay be below the threshold at report time whiletrigger_val(the window peak) is above it, so always usetrigger_valto confirm the threshold crossing.
Schema Notes for gen_alarm_log.qo downstream integrators:
count: the number of entries in this flush (1–8). The ring buffer holds 8 entries, so if more than 8 alarm transitions happen in one report window, the oldest ones get overwritten.events[].alert: one of"controller_alarm","alarm_clear","failure_to_start", or"fts_clear". Assertions (controller_alarm,failure_to_start) include the assertedalarm_word, and clearances (alarm_clear,fts_clear) carryalarm_word: 0. One flush can hold severalcontroller_alarmentries with differentalarm_wordvalues if the active fault set changes while at least one bit stays set. That happens when a second fault asserts before the first clears, for example, or when one of several active faults clears before the others. Each entry records the exact bitmask at that poll. Read them inelapsed_sorder to get the full fault-set chronology for the window.events[].elapsed_s: seconds since device boot (millis()/1000) at event time. Use the Note timestamp from Notehub for wall-clock time.elapsed_sgives the relative timing between entries within one flush.- The firmware only emits
gen_alarm_log.qowhen at least one alarm event happened in the window, so a window with no alarm transitions produces no Note. Route it to the same real-time channel asgen_event.qofor a complete fault chronology.
Sync and power strategy
The OPTA and expansion draw continuously from the generator panel's
battery-backed DC control bus, so the main design concerns here are bus
efficiency and data budget, not host MCU sleep. The Notecard runs in
hub.set periodic mode
with outbound equal to report_minutes (60 minutes by default) and inbound
at twice that. Summary Notes collect in the on-device queue and go out together
in one cellular session per hour. Alert Notes set sync:true and go out as
soon as a session comes up, usually 15–60 seconds after the firmware detects
the trigger condition, no matter where the outbound timer stands.
Battery autonomy tradeoff. The always-on OPTA host is the biggest continuous load on a battery whose main jobs are starting the engine and keeping the control circuits alive during a mains failure. The OPTA RS485 draws 0.6–2.2 W at its supply voltage, which works out to roughly 50–183 mA at 12 V or 25–92 mA at 24 V. With no charge input, a 40 Ah start battery at the worst-case 12 V draw (~183 mA) runs the monitor for about 218 hours (~9 days). At the best-case draw, the same battery lasts roughly 800 hours, and a 100 Ah battery scales proportionally. In most installations the panel's trickle charger keeps the battery topped up and every generator run adds more charge, so the monitor's draw alone is unlikely to drain a healthy battery under normal site conditions. If your site expects multi-day utility outages with no generator exercise or panel charging, though, measure the actual host current (see Validation) and compare it to the site's rated battery capacity. Limitations has production sizing guidance.
Retry and error handling
- Each Modbus poll gets up to 3 attempts. If all three fail, the firmware skips
the sample and emits a
modbus_unreachableevent Note right away, so wiring problems show up at first light during commissioning. After that it rate-limitsmodbus_unreachableevents to once an hour, because a generator powered down for scheduled service shouldn't flood the event log. - Alarm-word monitoring uses per-alert latch flags instead of comparing raw
alarm words. A fault that stays asserted for hours fires a single
controller_alarmevent. The latch blocks repeats while the condition persists and rearms only when the word clears. Generator monitoring falls apart once alarm fatigue sets in and people start ignoring the alerts. fetchEnvOverrides()runs on every sample interval (every 1 minute by default) and again at every report boundary. It re-reads the Notecard's local environment-variable cache, but that cache only refreshes when Notehub delivers an inbound sync. The Notecard'sinboundcadence is set toreport_minutes * 2(120 minutes at the defaultreport_minutes = 60), so a change you make in Notehub can take up to one fullinboundinterval (120 minutes by default) to reach the device. Once an inbound sync updates the cache, the new values take effect within one sample period. The one exception issample_minutes. A new value goes intog_pending_sample_minutesand only becomes the active cadence when the current window closes with a confirmednote.add, sorun_min/stop_minalways reflect one consistent cadence per window. At the report boundary,applyHubSetIfChanged()re-issueshub.setifreport_minuteschanged, which keeps the Notecard's outbound cadence in step with the local summary cadence.
Key code snippet 1: latch-based alarm detection, retry-on-send-failure, and alarm history logging
The firmware checks the alarm register on every successful poll. Detection is
latch-based rather than transition-gated. The firmware evaluates the
condition against the per-alert latch flags (g_active_controller_alarm,
g_active_fts) instead of looking for a change in the stored alarm word. If
sendEvent() fails because of a transient I²C hiccup or a Notecard-side error,
the latch stays false, and the alert retries automatically on every later
sample while the fault is still asserted. Once the Note is queued, the latch
blocks re-firing, so a sustained fault still emits just one event per
asserted-alarm period. g_current_alarm_word updates on every sample no matter
what the latch state is, so the summary Note always carries the freshest alarm
word.
History logging records every distinct alarm_word value, not just the initial
assertion. A separate g_alarm_logged_controller / g_alarm_logged_fts flag
tracks whether the first entry for an assertion period has been written, and
the initial detection passes through that gate exactly once. While the alarm
word stays non-zero, any later poll that returns a different nonzero value
gets logged right away as another controller_alarm entry with the updated
bitmask. That covers bits added when a second fault asserts and bits cleared
while others persist. These mid-nonzero changes don't trigger a new
sendEvent(), because one alert per assertion period keeps a slowly evolving
fault set from causing alarm fatigue.
Keeping the history-logged state separate from the event-queued state matters
when sendEvent() fails. The latch stays false, and the next retry calls
sendEvent() again but does not re-log the initial entry. Without that
separation, send retries would pile up duplicate entries, which would corrupt
the chronology and push later events out of the 8-slot buffer. The matching
clearance (alarm_clear / fts_clear) is logged once when the alarm word goes
back to zero, or for fts_clear, when the masked FTS bits clear. It's gated on
the history flag instead of the event-queued latch, so a clearance entry always
pairs with its assertion, even if the Note never queued successfully.
Boot-seeding behavior. On the first valid poll after a boot or watchdog
reset, the firmware checks the alarm word right away. If those bits are already
asserted, it emits controller_alarm (and failure_to_start if
alarm_mask_fts is configured) without waiting for a zero-to-nonzero
transition that may never come during this boot session. Otherwise, a monitor
that rebooted mid-outage would stay quiet about a fault that was already there.
The boot-seeding block calls logAlarmHistory() and sets the matching
g_alarm_logged_* flag, so the main detection loop won't add a second history
entry for the same assertion period if sendEvent() fails at boot.
// Three-state detection: 0→nonzero (assertion), nonzero→different-nonzero
// (fault-set change while still alarming), and nonzero→0 (clearance).
// ...
if (s.alarm_word != 0 && !g_active_controller_alarm) {
// 0→nonzero: first detection of this assertion period, OR a retry
// of a previously-failed send while the assertion is still active.
// ...
if (!g_alarm_logged_controller || s.alarm_word != g_current_alarm_word) {
logAlarmHistory("controller_alarm", s.alarm_word);
g_alarm_logged_controller = true;
}
if (sendEvent("controller_alarm", &s)) {
g_active_controller_alarm = true;
}
} else if (s.alarm_word != 0 && g_active_controller_alarm &&
s.alarm_word != g_current_alarm_word) {
// nonzero→different-nonzero: fault bits changed while at least one
// remains asserted (e.g. a second fault added, or one of several
// cleared while others persist). Log the updated alarm_word to preserve
// the fault-set chronology. No sendEvent() — one alert per assertion
// period. g_alarm_logged_controller stays true so the final clearance
// entry is still written when the word eventually reaches 0.
logAlarmHistory("controller_alarm", s.alarm_word);
} else if (s.alarm_word == 0) {
// nonzero→0: all alarms cleared. Log the clearance once, gated on
// g_alarm_logged_controller so the clear entry is written even when
// sendEvent() never succeeded for this assertion period.
if (g_alarm_logged_controller) {
logAlarmHistory("alarm_clear", 0);
}
g_active_controller_alarm = false; // all alarms cleared; rearm
g_alarm_logged_controller = false; // rearm history flag for next assertion
}
// ...
if (g_alarm_mask_fts != 0) {
bool fts_active = ((s.alarm_word & g_alarm_mask_fts) != 0);
if (fts_active && !g_active_fts) {
// Log once on first detection; subsequent send retries do not re-log.
if (!g_alarm_logged_fts) {
logAlarmHistory("failure_to_start", s.alarm_word);
g_alarm_logged_fts = true;
}
if (sendEvent("failure_to_start", &s)) {
g_active_fts = true;
}
} else if (!fts_active) {
// Log the clearance once; gated on g_alarm_logged_fts so it fires
// even when sendEvent() never succeeded for this assertion period.
if (g_alarm_logged_fts) {
logAlarmHistory("fts_clear", 0);
}
g_active_fts = false; // rearm when FTS bits clear
g_alarm_logged_fts = false;
}
}Key code snippet 2: immediate-sync alert with trigger fields
sync:true tells the Notecard to skip the outbound interval and open a
cellular session immediately. Where the sample comes from, and what goes in the
trigger fields, depends on the event type:
controller_alarmandfailure_to_startpass the freshly polled sample directly, since it's the closest reading to the moment the alarm was detected.trigger_valandtrigger_thresholddefault to-1.0(not applicable).modbus_unreachablecarriesg_last_known_samplewhen a valid prior sample exists, or null/zero fields if no poll has succeeded yet.- Report-boundary threshold rules (
fuel_low,coolant_overtemp,oil_low_pressure) passg_last_known_sampleas machine-state context plustrigger_val(the window aggregate or peak that crossed the threshold) andtrigger_threshold(the configured limit). With both fields, every report-window alert explains itself. Acoolant_overtempevent showstrigger_val: 97.0andtrigger_threshold: 95.0even when the last-knowncoolant_csample has already dropped below 95°C.
sendEvent() makes up to three attempts. It uses requestAndResponse() so it
can check each response for an error, and it returns true only once the
Notecard accepts the Note. The callers set their latch flags on that true,
which is how a failed send gets retried on the next sample or report window.
bool sendEvent(const char *alert, const GenSample *s,
float trigger_val, float trigger_threshold) {
const uint8_t MAX_TRIES = 3;
for (uint8_t attempt = 0; attempt < MAX_TRIES; attempt++) {
J *req = notecard.newRequest("note.add");
if (!req) { delay(100); continue; }
JAddStringToObject(req, "file", "gen_event.qo");
JAddBoolToObject (req, "sync", true);
J *body = JAddObjectToObject(req, "body");
JAddStringToObject(body, "alert", alert);
JAddNumberToObject(body, "engine_rpm", s ? s->engine_rpm : 0);
JAddNumberToObject(body, "fuel_pct", s ? s->fuel_pct : 0.0f);
// ...
JAddNumberToObject(body, "alarm_word", s ? s->alarm_word : g_current_alarm_word);
JAddNumberToObject(body, "run_hours", s ? s->run_hours : stats.run_hours_last);
JAddNumberToObject(body, "trigger_val", trigger_val);
JAddNumberToObject(body, "trigger_threshold", trigger_threshold);
J *rsp = notecard.requestAndResponse(req);
bool ok = rsp && !notecard.responseError(rsp);
notecard.deleteResponse(rsp);
if (ok) return true;
// ...
delay(200);
}
// ...
return false;
}Key code snippet 3: oil-pressure rule (running-only, with trigger fields)
Oil pressure only means something when the engine is spinning. If the engine
produced no running-state samples in the window, the rule is skipped entirely,
which prevents a steady stream of false positives from a generator that sits in
standby for days at a time. trigger_val carries the window mean that crossed
the threshold, and trigger_threshold is the configured limit. The last-known
sample fields give context on the machine's current state.
if (stats.run_samples > 0) {
float oil_mean = stats.oil_sum / (float)stats.run_samples;
if (oil_mean < g_oil_low_kpa) {
if (!g_active_oil_low) {
if (sendEvent("oil_low_pressure", &g_last_known_sample,
oil_mean, g_oil_low_kpa)) {
g_active_oil_low = true;
}
}
} else {
g_active_oil_low = false;
}
}8. Data Flow
Collected. Every sample_minutes (1 minute by default): engine RPM, fuel
level %, generator load %, oil pressure kPa, coolant temperature °C, cumulative
run hours, and the active alarm bitmask. Each valid poll also updates the
running/stopped sample counts, and an RPM threshold crossing bumps the
engine-start counter.
Summarized. Every report_minutes (60 minutes by default): mean fuel
level, mean and peak load and oil pressure (running-state samples only), mean
and peak coolant temperature, cumulative run hours, minutes running and stopped
this window, engine start count, and the latest alarm word polled in the
window. That alarm word is non-zero whenever any alarm was active at the final
sample in the window, so a fault that lasts across a report boundary also
shows up as non-zero in the next summary.
Transmitted.
gen_summary.qo: once perreport_minutes, queued and batched with the Notecard's periodic outbound sync. It's template-encoded to keep the on-wire size small.gen_event.qo: sent immediately withsync:truewhenever a rule trips. You get one event per condition transition, not one per sample interval.gen_alarm_log.qo: once perreport_minutesif any alarm-word transition (assertion or clearance) happened in the window. It's full JSON instead of template-encoded, because the array length varies, and it's batched with the periodic outbound sync. Windows with no alarm activity don't produce one.
Routed. Notehub sends gen_event.qo and gen_alarm_log.qo to whatever
real-time channel the operator uses (CMMS tickets, on-call paging, a building
management system webhook, and so on). gen_event.qo arrives immediately
because of sync:true, while gen_alarm_log.qo rides the next periodic
outbound sync and fills in the per-window alarm chronology behind the event
stream. Point both at the same downstream destination so fault chronology and
event notifications end up in one place. gen_summary.qo routes separately to a long-term store for
fleet trend analysis.
Triggers. Six conditions generate alert events, and they fall into two timing groups.
Per-poll alerts are evaluated on every successful Modbus read, so their
latency is about one sample_minutes interval (1 minute by default):
controller_alarm: the active alarm word is seen non-zero for the first time, either on a zero→non-zero transition or at the first valid poll after boot if it's already asserted. One event fires per asserted-alarm period, and the flag rearms when the alarm word returns to zero. It catches any fault the controller's alarm map defines, including failure-to-start, under/over voltage, over-speed, high temperature, and low coolant level. Every assertion and clearance also goes into the local alarm-history ring buffer and gets flushed asgen_alarm_log.qo.failure_to_start: a subset ofcontroller_alarmthat watches the specific bit(s) the operator sets inalarm_mask_fts. It's disabled by default, so configure it for each controller family.modbus_unreachable: all three Modbus retry attempts failed. The first occurrence fires immediately, and later ones are rate-limited to once an hour.
Report-window alerts are evaluated once per report_minutes (60 minutes by
default) against the window's rolling aggregates, so by default they can lag
the triggering condition by up to one full report interval:
fuel_low: window-mean fuel level belowfuel_low_pct. It's edge-triggered and rearms when fuel rises back above the threshold (after a refill, for example).coolant_overtemp: window-peak coolant temperature abovecoolant_alarm_c. It uses the peak instead of the mean because an overheat can spike and resolve within a single report window.oil_low_pressure: window-mean oil pressure belowoil_low_kpawhile the engine was running. It's only evaluated whenrun_samples > 0.
9. Validation and Testing
Expected steady-state behavior. A correctly commissioned generator in
standby should produce one gen_summary.qo event per hour with run_min = 0,
engine_starts = 0, and alarm_word = 0. A weekly exercise test usually runs
about 30 minutes. The summary windows that straddle it will show non-zero
run_min, engine_starts = 1 in the window that caught the start, and
non-zero load_pct and oil_kpa_mean. The main validation step is checking
for that pattern during the first scheduled test after installation.
Modbus first-light. Before you connect to the real controller, run the
firmware against a software Modbus simulator (Modbus Mechanic, ModRSsim2, or
similar) through a USB-to-RS-485 adapter wired to the OPTA's RS-485 terminals.
Confirm the seven register reads hit the expected addresses. Keep in mind that
the firmware's serial output only shows Modbus retry failures and Notecard
debug traffic, and it doesn't print decoded register values. To see register
contents during bench bring-up, either add temporary Serial.print statements
inside pollGenerator() or check the data in the gen_summary.qo Notes that
show up in Notehub after the first report boundary.
Fault simulation. With a real controller connected, temporarily set
fuel_low_pct to 99 (the highest value the firmware accepts) in the fleet's
environment variables. The device pulls the new value on the next inbound sync,
and the report boundary after that trips fuel_low unless the tank reads 99%
or more. Check that the event shows up in Notehub as soon as a session comes
up, then restore the original value to rearm.
Power validation with Mojo. Splice the Mojo
inline between the 24 VDC supply and the Wireless for OPTA power input, and it
reports cumulative mAh at 1% accuracy over its Qwiic connector. In that spot it
measures the Wireless for OPTA expansion subsystem (the Notecard plus the
expansion's onboard regulators and I²C glue). It does not measure the OPTA
host, which draws power straight from the same control bus through its own
+/− supply terminals. The Notecard-subsystem figures below come from the
NOTE-WBNAW datasheet and the
Notecard low-power design
guide:
| Phase | Notecard subsystem current (Mojo measurement) |
|---|---|
| Notecard idle (radio off, between syncs) | ~8–18 µA @ 5V |
| Modem active (cellular session, LTE Cat-1) | ~250 mA average, ≤2 A peak bursts |
| WiFi active (hardware capability; leave unconfigured in production) | ~80 mA average |
The OPTA host runs all the time, because the firmware's millis-based scheduler
keeps the Cortex-M7 awake with no sleep state. The OPTA RS485
datasheet specifies 0.6–2.2 W at 24 VDC
(about 25–92 mA at 24 V, or roughly 50–183 mA at 12 V). That's the biggest
steady-state load on the control bus, and you have to measure it separately.
During bench bring-up, splice an ammeter or a second Mojo between the
control-bus supply and the OPTA's +/− terminals.
On the Mojo trace, idle current between syncs should sit in the µA range.
During hourly sync sessions the modem averages ~250 mA but can spike to ≤2 A in
brief RF bursts, which is normal LTE Cat-1 behavior and not a fault. Instead of
watching for a hard ceiling on instantaneous current, check energy per
session. A healthy hourly sync should add only a few mAh to the Mojo counter,
and the idle stretches between syncs should add almost nothing. Alert events
(sync:true) open extra modem sessions between the scheduled syncs, each with
the same average-then-burst current profile. If the device draws elevated
current continuously with no idle periods, the Notecard's periodic mode isn't
configured correctly. The Notecard low-power design
guide lists the
conditions needed to reach the lowest-power idle state.
In the field, the generator's start battery carries the control bus through a mains failure, but it won't last forever. The biggest continuous load is the OPTA host (0.6–2.2 W per the datasheet), and the Notecard expansion's 8–18 µA idle draw barely registers next to it. During commissioning, record the actual OPTA host current with the ammeter or second Mojo described above. Then compare it against the site's rated battery capacity to put a bound on worst-case autonomy, which matters most at 12 V sites with smaller batteries.
The 3 A fuse required in Hardware Requirements and installed per Wiring and Assembly Step 2 is sized for worst-case 12 V operation. The OPTA host draws up to ≤183 mA continuous, and the Notecard expansion can burst to ≤2 A at the bus input, for a combined worst-case total of ≤2.2 A. To check the sizing at your installation, add the measured OPTA supply current to the peak Notecard-expansion burst current (≤2 A) from the Mojo trace, and confirm the total stays below the installed fuse rating. At 24 V sites the bus currents are proportionally lower, which makes the 3 A rating conservative, but don't leave the fuse out.
10. Troubleshooting
Device appears in Notehub but all gen_summary.qo Notes show data_ok = 0
- Modbus polling failed on every retry. Check that OPTA
A/B/COMare wired to the matching terminals on the controller, and use a multimeter to verify continuity. - Baud rate mismatch. Compare
modbus_baud,modbus_parity, andmodbus_stop_bitsin Notehub (Fleet → Environment) against the controller's Modbus commissioning menu. They have to match exactly. - Slave address mismatch. Make sure
modbus_slave_idin Notehub matches the controller's configured address. You'll usually find it in the Modbus or network setup menu, and it defaults to 1. - Missing or incorrect termination resistors. Confirm there's a 120 Ω resistor at both physical ends of the RS-485 cable run, one at the OPTA and one at the controller.
- First-light validation. Before commissioning on the real controller, test with a USB-to-RS-485 adapter and a Modbus simulator (Modbus Mechanic, ModRSsim2) to confirm the firmware's Modbus reads work at all (see Validation and Testing).
Register addresses appear wrong or show −1.0
- Wrong register addresses. The defaults (768–774) come from a made-up map.
Look up your controller vendor and model in its Modbus datasheet, then
set
reg_engine_rpm,reg_fuel_pct, and the rest in Notehub (Fleet → Environment) to the right addresses for your controller. -1.0sentinels in running-only fields (load_pct,oil_kpa_mean) mean the engine was stopped for the whole report window, which is normal for a standby generator. A fuel level of-1.0ordata_ok = 0points to a communication failure, not a sensor problem.
Antenna connectivity issues
- Rubber-duck antennas don't work inside metal generator enclosures. Route the primary cellular antenna (SMA) through a cable gland to the outside, and add a second diversity antenna if the signal is marginal.
- Antenna not seated, or damaged. Make sure the SMA connector is fully tightened onto the antenna port.
Alert events not appearing in Notehub
- Environment variables not applied yet. Changes you make in Notehub take
effect on the device's next inbound sync, not right away. The default
inboundcadence is 120 minutes. To test quickly, temporarily set it to 1 minute withhub.seton the device or with a direct Notehub API call, and change it back when you're done. - Out-of-range or unrealistic rule thresholds. The firmware ignores a value
outside its allowed range and keeps the previous one (
fuel_low_pctonly accepts 1–99, for example). The report-window rules are also edge-triggered, so oncefuel_lowfires, it won't fire again until the fuel level rises back above the threshold. Check that the thresholds match what your site expects (e.g.,fuel_low_pct = 25.0for "alert when fuel falls below 25%").
11. Limitations and Next Steps
This reference design sticks to one path on purpose: taking a facility manager from a generator with a Modbus port to a generator with a live cellular uplink in well under a day, without writing anything back to the controller. A handful of details were kept simple so that path stays clean. Each one is listed below with the production extension that fills the gap.
Simplified for this reference design
These are the spots where the firmware stays generic on purpose. Once it's running against real controllers, a fleet deployment will want the extension noted for each one.
Register addresses, scaling, and signedness in the defaults describe a
made-up contiguous map. Each controller vendor publishes its own Modbus map, so
commissioning a real site means looking up the actual addresses, scaling
factors (oil pressure may be in 0.1 bar, tenths of kPa, or raw integer
psi), signedness, word counts, and addressing convention (0-based wire-level vs
1-based / Modicon "40001" notation). The demo already reads coolant temperature
as signed int16_t, but oil pressure and other fields may be signed on some
controllers too and need case-by-case handling. Run hours on the DeepSea
7000-series is a 32-bit value spanning two consecutive registers. The shipped
firmware reads seven individually addressed 16-bit registers with no scaling,
so production builds need vendor-specific handling.
Firmware-observed alarm history, not controller-internal log. The firmware
keeps its own alarm-history log. Every alarm assertion and clearance it detects
goes into an 8-slot ring buffer that's flushed as gen_alarm_log.qo at each
report boundary (see §3 and §7). Reading the controller's own internal
timestamped fault log is still a future enhancement. Most production
controllers keep a multi-register circular buffer of events, which can include
faults from before the monitor was installed, and getting at it takes a
vendor-specific read sequence (see Production Next Steps below).
Sampled alarm detection, not latched. The firmware polls the alarm word
once per sample_minutes (1 minute by default), so it never sees a transient
fault that asserts and clears entirely between two consecutive polls. If a
controller doesn't latch faults internally, the polling cadence has to be short
enough to catch the briefest fault pulse you expect. If it does latch them,
reading the alarm history log (above) is the more reliable option.
Failure-to-start detection requires operator configuration. The
alarm_mask_fts variable defaults to 0 (disabled) because bit positions
differ across controller families. A DeepSea 7000-series uses different alarm
bits than a Woodward EasyGen, for example. You'll need to look up your
controller's alarm register map and set the right mask. Until you do, the
generic controller_alarm alert catches failure-to-start events.
Single controller per OPTA. The firmware reads one Modbus slave ID, so a facility with several generators needs either one OPTA + Wireless for OPTA per generator or a firmware extension that round-robins across slave IDs on the same bus (with per-slave stat tracking).
Weekly test coverage can be straddled by an hourly summary. A 30-minute test that starts and finishes inside one 60-minute summary window shows up in that window's data, but the start event and any transient fault that clears during the test are only visible in the event Notefile. A production build could emit a summary at engine stop (the end of each run event) to get full run-level detail.
No Modbus writes. The firmware only reads. Sending remote start commands to the generator is out of scope, because that takes a safety analysis, E-stop wiring, and possibly functional-safety certification.
Continuous OPTA host draw on the start battery comes from having no sleep state. The OPTA's Cortex-M7 stays awake the whole time and draws 0.6–2.2 W from the battery-backed control bus. Panel trickle chargers handle that load in normal operation. A site that sees long utility outages without generator exercise or charger input, though, should measure the actual OPTA supply current during commissioning and make sure the battery capacity gives acceptable autonomy. At 12 V and the worst-case 2.2 W draw (~183 mA), a 40 Ah battery keeps the monitor alone running for roughly 9 days. At the best-case draw, the same battery stretches to ~33 days. Larger batteries and 24 V systems extend autonomy proportionally.
Production Next Steps
With a real fleet reporting in, these extensions are how the design grows into a commercial-grade monitoring product.
Vendor-specific register-map builds cover DeepSea 7000/8000, Woodward EasyGen 3000, Kohler RDC, Caterpillar EMCP 4, and Cummins PowerCommand, each with the right addresses, scaling, signedness, and 32-bit run-hour handling for that family.
Controller-internal alarm log readout, following each vendor's spec (DeepSea Event Log registers, Woodward EasyGen fault record, Kohler RDC history block, and so on), adds timestamped events to the firmware-observed history. That includes events from before the monitor was installed and from times the firmware was offline.
Per-controller baseline learning tracks run hours and load history for each device, so it can spot drift toward scheduled service intervals instead of waiting for a threshold breach.
Over-the-air firmware updates through Notecard Outboard Firmware Update would let a service company push a new register-map build to an entire fleet without a truck roll. ODFU on the OPTA needs AUX wiring that Wireless for OPTA doesn't break out today, so for now host firmware changes go through a local USB-C update.
Fuel consumption rate falls out of how fast the fuel level changes across run events. Combine it with load data and you get a fuel-per-kWh efficiency figure that builds up over the maintenance history.
Automatic weekly test verification would flag a test_not_detected event
when no engine start shows up in a configurable rolling window, so a skipped
test gets caught before an audit or inspection.
12. Summary
The facility manager used to find out about a failed-to-start generator from a
phone call after the UPS batteries gave up. Now fault transitions and threshold
trips show up in real time. Weekly exercise tests leave a clear signature in
the hourly summaries, with engine_starts, run_min, load_pct, and
oil_kpa_mean all filled in for the window that caught the test. And the
run-hour and load history needed to stay ahead of maintenance intervals streams
in continuously.
The controller inside that enclosure has been measuring fuel level, coolant
temperature, oil pressure, load, run hours, and active alarm state for years.
What it lacked was a way to report when mains is dark, the WiFi router is dead,
and the generator either starts or doesn't. An OPTA RS485 with Blues Wireless
for OPTA, running off the generator's own battery-backed control bus, gives it
that path: a cellular uplink that doesn't depend on facility power and doesn't
care whether the WiFi router is working. Automatic detection of a missed test
(flagging test_not_detected when no start shows up in a configurable rolling
window) is a planned next step, covered in
Limitations. The generator itself stays
exactly as it was, and its data finally leaves the generator room.
On This Page
- 1. Project Overview
- 2. System Architecture
- 3. Technical Summary
- 4. Hardware Requirements
- 5. Wiring and Assembly
- 6. Notehub Setup
- 7. Firmware Design
- Modules
- Sensor reading strategy
- Event payload design
- Sync and power strategy
- Retry and error handling
- Key code snippet 1: latch-based alarm detection, retry-on-send-failure, and alarm history logging
- Key code snippet 2: immediate-sync alert with trigger fields
- Key code snippet 3: oil-pressure rule (running-only, with trigger fields)
- 8. Data Flow
- 9. Validation and Testing
- 10. Troubleshooting
- 11. Limitations and Next Steps
- 12. Summary