EV Charger Session & Utilization Monitor

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/ev_charger_session_monitor/ so it can learn how the device works and help you adapt it to your hardware.
This project is a cellular energy monitoring retrofit for Level 2 EV chargers. It's for fleet managers, facilities teams, and charging-network operators who need to know how their installed chargers are actually being used. The design wires a DIN-rail energy meter in series with each charger's AC feed and reports three streams over cellular to the Blues Notehub cloud service: per-session metered kWh and peak power, hourly utilization and availability, and mains-offline alerts. A hardware-metered instrument measures the energy. Charger availability comes from the meter's V_rms register, because when mains voltage is absent, the circuit is definitely offline. You don't modify the charger hardware, enroll in OCPP, or involve the site's IT team. The hardware is a Notecarrier CX with a Notecard Cell+WiFi and an EASTRON SDM120-Modbus energy meter (§4 has the BOM), and §10 covers design boundaries and production expansion paths.
1. Project Overview
The problem. Fleet managers, facilities teams, and third-party charging network operators are installing Level 2 EVSE (electric vehicle supply equipment) at workplace parking lots, fleet depots, and retail locations, and almost none of them know how those chargers actually get used. Nobody can tell you whether the 48-amp circuit in Bay 3 runs at 90% utilization while Bay 4 sits idle all day, or whether sessions bunch up in the morning rush so that employees arriving at 9 a.m. find every port taken. Without instrumentation, questions like these get answered by anecdote or not at all.
This project fills that gap with a series-wired DIN-rail energy meter (EASTRON SDM120-Modbus) on the EVSE circuit feed. The device polls it over Modbus RTU and reports session-level energy delivery and port utilization directly, no matter who built the charger, what network it's on, or whether the site team has any standing with the charging network operator. OCPP-based integrations give you richer per-driver and per-RFID records where a charger exposes that interface, but they depend on cooperation from the network operator and vary across Blink, ChargePoint, EV Connect, and in-house fleet stacks. The hardware-meter approach needs none of that. It reads the physics of the circuit, which works on any Level 2 EVSE ever installed.
What this design delivers. You get a metered session and utilization monitor built around a DIN-rail single-phase energy meter (EASTRON SDM120-Modbus or compatible) on the EVSE feed. The device polls the meter every 30 seconds over Modbus RTU. Per-session energy is the difference in the meter's cumulative import-kWh register between session open and close, so the hardware computes it instead of the firmware estimating it from current alone. Peak power (W) is the highest active-power reading seen during the session.
Charger availability comes from the meter's V_rms register. In each reporting
window, the firmware counts the minutes the supply voltage stayed above the
configurable voltage_present_v threshold (available_min) and divides that by
the window's full wall-clock duration (elapsed_min) to get
availability_pct. Wakes where the meter can't be reached count against
availability in the denominator, whether mains is absent, the wiring is faulted,
or every Modbus retry fails. That way the metric correctly drops below 100 %
whenever the charger was offline for any reason. A companion
sample_coverage_pct field reports the fraction of window time that had valid
meter readings, which gives you a data-quality signal separate from
availability. A separate mains_absent alert fires when no mains-present
reading has shown up for longer than the configurable alert_offline_min
threshold. That's a definitive sign of a tripped breaker, a power loss, or a
sustained meter fault. Sites that also need per-driver or per-RFID records
should add an OCPP back-end integration alongside this meter-based monitor. See
§10 for boundaries and expansion paths.
Why Notecard. The network is the biggest headache in instrumenting EV chargers. Chargers in a corporate parking structure almost always sit on a circuit run by a third-party operator (a fleet management company, a charging network, or a facilities contractor) whose people have zero standing with the site's IT department. IT treats the parking lot as a hostile network zone. Nobody hands out WiFi credentials or provisions a VLAN, and no IT ticket is getting approved by Friday. Even for in-house fleet teams, "just connect it to the corporate network" means months of security review for every new device class.
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.
Cellular avoids all of that. A Notecard Cell+WiFi (MBGLW) with its included prepaid SIM goes in the same electrical panel as the charger circuit, connects to an LTE Cat-1 bis tower from the parking lot, and starts reporting session data within minutes. Site IT never gets involved, and there's no network form to fill out or AP to pair with. WiFi stays available as an opportunistic fallback for the rare site with a legitimate parking-structure AP, but you never need it. The same firmware image and hardware SKU deploy the same way at a workplace, a fleet depot, or a multi-tenant retail center.
Deployment scenario. A DIN-rail assembly mounts inside the electrical panel that feeds the EVSE circuit. The SDM120-Modbus energy meter is wired in series on the EVSE circuit (the live conductor runs through the meter's current input), and a SparkFun BOB-10124 (SP3485) RS-485 transceiver breakout bridges the meter's RS-485 port to the host's 3.3 V UART. The Notecarrier CX and Notecard MBGLW run from a 5 V / 2.4 A DIN-rail supply (e.g. MeanWell HDR-15-5) fed from whatever mains voltage the panel has. That's usually 120 VAC line-to-neutral where a neutral is accessible at the subpanel, or 208/240 VAC line-to-line where only two line conductors are available. The HDR-15-5 accepts 85–264 VAC, so it handles both. The whole assembly takes four to six DIN-rail modules of panel space, and you don't modify the charger hardware, enroll in OCPP, or coordinate with IT.
Panel placement note. Whether you can mount the DIN-rail assembly inside the main EVSE panel depends on the enclosure's listing and the local electrical code, since not every listed enclosure allows third-party low-voltage auxiliary equipment in the mains compartment. If the panel interior isn't an option, mount the assembly in a separately listed auxiliary enclosure bolted next to the main panel, and run the RS-485 signal cable and AC supply conductors between the two enclosures through a listed conduit entry or knockout. When you can't confirm that the main panel is suitable, the auxiliary enclosure is the safe default.
2. System Architecture
Device-side responsibilities. Everything in the panel runs on a 30-second
loop. The Notecarrier CX's onboard STM32L433 host wakes when the Notecard raises
its ATTN pin at the end of a
card.attn sleep. It
polls the SDM120 over Modbus RTU for V_rms, active power, and import kWh, then
runs a two-state session machine that decides whether the charger is actively
delivering energy. Per-session energy is the difference in the meter's
cumulative import-kWh register between session open and close, so the firmware
never has to estimate from current alone or assume a power factor. Availability
gets tracked on every wake as well. window_elapsed_sec advances
unconditionally (wall-clock time), while window_available_sec only advances
when the meter is valid and V_rms ≥ voltage_present_v. The resulting
availability_pct counts any wake where the circuit was offline or unreadable
against the charger, instead of hiding it. When a session closes, the host
queues a charger_session.qo Note right away with sync:true, and window
statistics queue into charger_summary.qo on the report cadence. Between wakes
the host sleeps in STM32 STOP2 (a few µA) with its RAM intact, so session and
window state just stays in memory. The host is usually awake for less than two
seconds per wake.
Notecard responsibilities. The Notecard handles the network. It queues
Notes locally, opens the cellular (or WiFi)
session on the configured
hub.set outbound
cadence (60 minutes by default), and flushes any sync:true session or alert
Note the moment it arrives. It also distributes environment
variables
from Notehub, so an operator can retune thresholds and reporting intervals
across a fleet of panels without re-flashing firmware.
Notehub responsibilities. The Notecard's embedded global SIM carries Notes
from the panel over supported carriers worldwide and delivers them to
Notehub. Three Notefiles arrive separately so each can
take its own path downstream. charger_session.qo (per completed session,
real-time) goes to a utilization dashboard or fleet management system,
charger_summary.qo (periodic) goes to a long-term analytics store, and
charger_alert.qo (mains-absent / charger-offline alert) goes to an on-call
endpoint. Smart Fleets
are the natural way to organize devices. With one fleet per property,
environment-variable overrides (a site's known line voltage, say) stay scoped to
the right chargers.
Routing to the cloud (high level). Notehub can route to HTTP, MQTT, AWS, Azure, GCP, Snowflake, and other destinations. Route setup depends on your project and is outside the scope of this reference, so see the Notehub routing docs for configuration details.
3. Technical Summary
If you want to see data before reading the rest, here's the fastest path from parts to a first event in Notehub:
- Create a Notehub project at notehub.io and copy its
ProductUID (it looks like
com.your-company:ev-charger-monitor). - Wire the bench rig: Notecarrier CX + Notecard MBGLW + SDM120-Modbus
energy meter + SparkFun BOB-10124 RS-485 transceiver (D0/D1/D2 pins), plus
the
ATTN→D5jumper for host sleep. §5 has the full wiring details. - Set your ProductUID: the sketch deliberately has no
PRODUCT_UIDplaceholder to edit, and it refuses to compile (#error) until one is defined. Either add#define PRODUCT_UID "com.your-company:ev-charger-monitor"near the top offirmware/ev_charger_session_monitor/ev_charger_session_monitor.ino(above the#ifndef PRODUCT_UIDguard), or pass it as a build flag witharduino-cli(see §7.1). - Flash: compile and upload with the board options
STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none(§7.1 has thearduino-clicommands and IDE build settings). Theusb=noneoption turns off the USB CDC stack, which has to be off for the host to sleep. - Watch Notehub: open your project's Events tab. You'll see
_session.qowithin seconds, which proves the radio works, then acharger_summary.qowithin the hour and acharger_session.qoafter you run a test load (see §9).
The rest of this page covers component selection, wiring, Notehub configuration, and validation in detail.
Here is a sample Note this device emits:
{
"sessions": 1,
"total_kwh": 5.9,
"avg_session_kwh": 5.9,
"peak_w": 10320.0,
"charging_min": 38,
"idle_min": 22,
"utilization_pct": 63.3,
"available_min": 60,
"availability_pct": 100.0,
"sample_coverage_pct": 100.0
}4. Hardware Requirements
| Part | Qty | Rationale |
|---|---|---|
| Notecarrier CX | 1 | Integrated carrier with an onboard STM32L433 host, so you don't need a separate MCU. To keep idle draw as low as possible, the host sleeps in STOP2 between samples and the Notecard's ATTN pin wakes it. |
| Notecard Cell+WiFi (MBGLW) | 1 | LTE Cat-1 bis with global coverage and a WiFi fallback. Cellular is the primary path, and §1 explains why site WiFi is rarely an option in EV charger deployments. The included SIM comes with 500 MB of data and 10 years of service. For technical details, see the MBGLW datasheet. |
| Blues Mojo (bench validation only) | 1 | Coulomb counter for measuring per-wake energy and checking the sleep/transmit power profile during bring-up. It isn't deployed to the field. See §9. |
| SparkFun CEL-16432 LTE Hinged External Antenna, 698–2700 MHz, SMA Male | 1 | Required for reliable signal from inside a metal enclosure. The SMA connector screws onto the SMA female pigtail at the panel wall, which puts the radiating element outside the metal cabinet. A rubber-duck antenna left inside a steel panel will lose a lot of signal. |
| SparkFun WRL-18568 SMA to U.FL Cable, 150 mm | 1 | Connects the Notecard's U.FL cellular port to the SMA female bulkhead connector in the panel wall. If the Notecarrier mounts farther from the wall, a longer SMA-to-U.FL pigtail from Mouser or DigiKey can substitute. |
| EASTRON SDM120-Modbus 45 A direct-connect single-phase DIN-rail energy meter | 1 | Wired in series on the EVSE circuit (the live conductor runs through the current input terminals). Measures V_rms, active power (W), and cumulative import kWh with Class 1 accuracy, and its RS-485 Modbus RTU interface connects to the host UART. It's rated 45 A direct-connect, which covers 32 A and 40 A Level 2 EVSE. For 48 A or 60 A EVSE circuits, use the SDM120CT variant with a suitable 5 A secondary split-core CT, or the SDM230-Modbus (100 A direct). The SDM120 needs both a live conductor and a neutral terminal for voltage sensing, so confirm both are accessible at the panel before you specify this meter. The Modbus protocol document (register map, baud-rate defaults, function codes) is a PDF download after free registration on the Eastron Europe website. |
| SparkFun RS-485 Transceiver Breakout – SP3485 (BOB-10124) | 1 | Converts the host's 3.3 V UART to the RS-485 differential pair the SDM120 needs. The SP3485 chip runs from a single 3.3 V supply, so you avoid the logic-level compatibility problems that come with 5 V-only transceivers. Wire RO to D0 (Serial1 RX), DI to D1 (TX), and the RTS pad to D2. The board routes the RTS pad to both DE (driver enable) and RE (receiver enable) internally, so you don't need a separate jumper. Then wire VCC to +3V3 and GND to GND. |
| AC/DC DIN-rail supply, 85–264VAC input, 5 V / 2.4 A output (e.g. MeanWell HDR-15-5) | 1 | Makes 5 V DC from whatever mains voltage the panel has (120 VAC L-N or 208/240 VAC L-L). The 2.4 A output rating leaves enough transient headroom for Notecard cellular transmit bursts (≤2 A peak), and the DIN-rail mount fits cleanly in the panel enclosure next to the EVSE breaker. |
| DIN-rail enclosure, ≥6-module width | 1 | Houses the Notecarrier CX, DIN-rail supply, and RS-485 transceiver module inside the electrical panel. The SDM120 itself is a separate DIN-rail device wired in series on the EVSE circuit. |
All 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
Electrical safety. The 120/240VAC feed inside an electrical panel is hazardous. This installation puts the SDM120 energy meter in series on the EVSE feed, which means interrupting and re-terminating the live mains conductors. Panel work must be performed by a qualified electrician following the applicable electrical codes and lockout/tagout procedures.
Panel placement. Whether you can mount third-party low-voltage electronics inside a mains EVSE panel depends on the enclosure's UL/CSA listing and the local electrical code. If the panel listing or jurisdiction doesn't allow auxiliary equipment in the mains compartment, install the Notecarrier CX, power supply, and RS-485 transceiver module in a separately listed auxiliary enclosure mounted next to the main panel. Run the RS-485 signal cable and AC supply conductors between the two enclosures through a listed conduit entry or knockout. Any time you can't confirm the main panel's interior is suitable, go with the auxiliary enclosure. It's the safe default.
SDM120 meter installation. The SDM120-Modbus is a series element, so the EVSE circuit's live conductor has to pass through the meter's current input terminal. Break the live conductor feeding the EVSE and route it through the meter's current input terminals, with line-in on the input terminal and line-out on the output terminal. Follow the terminal labels printed on the SDM120 faceplate exactly. Designations vary by variant and firmware revision, so don't rely on a generic reference for this wiring. Connect the neutral conductor to the meter's neutral terminal, because the SDM120 needs a neutral connection for its internal voltage measurement. Do not route the neutral through the current input. The SDM120 powers itself from the same line and neutral conductors that feed the EVSE, and the datasheet shows the exact supply terminal connections for your variant. Wiring the meter interrupts the EVSE circuit, so follow lockout/tagout procedures and have a qualified electrician do the work.
Circuit ampacity. The SDM120-Modbus is rated 45 A continuous, which suits 32 A EVSE circuits (40 A breaker) and 40 A EVSE circuits (50 A breaker). For 48 A EVSE (60 A breaker) or higher, substitute the SDM120CT variant with a suitably rated split-core CT, or the SDM230-Modbus (100 A rated). §10 has more substitution guidance.
RS-485 wiring. Use twisted-pair cable (120 Ω characteristic impedance recommended) to connect the SDM120's RS-485 terminals (A+ and B−) to the matching A and B terminals on the SparkFun BOB-10124 breakout, through either the 3.5 mm screw terminal or the 0.1" header. Then wire the breakout to the Notecarrier CX header:
- BOB-10124 RO (receiver output) → D0 (Serial1 RX) on the Notecarrier CX dual 16-pin header
- BOB-10124 DI (driver input) → D1 (Serial1 TX)
- BOB-10124 RTS (direction control) → D2 (the board routes this pad to both DE and RE internally)
- BOB-10124 VCC → +3V3
- BOB-10124 GND → GND
The firmware drives D2 HIGH before it transmits a Modbus request frame and LOW right after, which switches the transceiver from receive to transmit and back. A 120 Ω termination resistor across the A+ and B− terminals at the far end of the RS-485 bus cuts down on reflections, but at the short cable lengths you'll usually have in a panel enclosure (< 1 m), it's optional.
Notecard and antenna. Seat the Notecard Cell+WiFi (MBGLW) in the Notecarrier CX's M.2 slot. Connect the u.FL-to-SMA pigtail to the Notecard's cellular u.FL port, thread the SMA end through a cable gland in the panel enclosure wall, and attach the external antenna outside the panel. A rubber-duck antenna left inside a metal panel will lose a lot of signal. The Notecard talks to the STM32 host over I²C on the internal Notecarrier bus, so that path needs no extra wiring.
ATTN-to-D5 jumper (host sleep). Fit a short jumper between the ATTN and
D5 pins on the Notecarrier CX. Both sit on the same 16-pin header (ATTN at
position 3, D5 at position 14). The Notecard pulls ATTN low while the host
sleeps and raises it when the next poll is due, and the rising edge on D5
wakes the host from STOP2. Leave the Notecarrier CX EN pin unconnected. On
the CX it enables the shared 3.3 V VIO rail (host, Qwiic, 3V3_OUT, and
Notecard I/O), so driving it from ATTN would brown out the whole board instead
of putting the host to sleep. See Putting a Host to Sleep Between Sensor
Readings
for the full pattern.
Power. Wire mains power to the MeanWell HDR-15-5 input terminals. Use 120
VAC line-to-neutral where a neutral conductor is accessible at the panel, or
208/240 VAC line-to-line where only two line conductors are available. The
HDR-15-5 accepts 85–264 VAC and handles either configuration, but check the
local electrical code for the permitted supply tap. Connect the HDR-15-5's 5V
and GND output terminals to the Notecarrier CX's +VBAT and GND pads. For
bench bring-up, put the Mojo in series between the HDR-15-5's 5V output and
the +VBAT pad, and connect the Mojo's Qwiic port to the Notecarrier CX's Qwiic
connector. §9 describes what to look for on the
trace.
Pin summary:
- D0 (Serial1 RX) → BOB-10124 RO
- D1 (Serial1 TX) → BOB-10124 DI
- D2 → BOB-10124 RTS (routes to DE + RE internally)
- +3V3 → BOB-10124 VCC
- GND → BOB-10124 GND
- ATTN → D5 (jumper; Notecard wakes the host from STOP2)
- SDA / SCL → Notecard (internal to the Notecarrier; onboard pull-ups present)
- +VBAT → 5V from HDR-15-5 (via Mojo on bench)
- Cellular u.FL → pigtail to external SMA antenna outside the panel
6. Notehub Setup
-
Create a project. Sign up at notehub.io and create a project. Copy the ProductUID, which looks like
com.your-company.your-name:ev-charger-monitor, and define it asPRODUCT_UID. Either add#define PRODUCT_UID "com.your-company:ev-charger-monitor"above the#ifndef PRODUCT_UIDguard infirmware/ev_charger_session_monitor/ev_charger_session_monitor.ino, or pass it as a build flag:arduino-cli compile ... --build-property 'compiler.cpp.extra_flags=-DPRODUCT_UID="com.your-company:ev-charger-monitor"'. The sketch stops with a compile-time#errorifPRODUCT_UIDis undefined, so nobody can flash a misconfigured binary by accident. For bench work before you have a Notehub project,-DALLOW_EMPTY_PRODUCT_UIDsuppresses the error. Never ship a build with that flag. -
Claim the Notecard. Power up the assembled unit. The Notecard associates itself with your Notehub project on its first cellular connection, and the device shows up in Devices within a minute or two.
-
Create a Fleet per property. Fleets group devices for shared configuration and routing. One fleet per property or tenant is the natural unit here, since every charger at a given site shares the same line voltage, session-detection thresholds, and routing destinations. Smart Fleets can auto-assign new devices to the right fleet at commissioning time. All devices in a single Notehub project share the same ProductUID, though, so fleet rules need to key on a device attribute that varies by site. That could be an installer-applied tag (e.g.,
site:depot-north), a serial/UID range reserved for each property during provisioning, or location metadata filled in during the first sync. -
Set environment variables. In Notehub, open the Fleet → Environment tab (or Device → Environment for a per-device override). Add or update any of the variables below, and the device fetches them on its next inbound sync without a reflash. Every variable is optional. If you don't set one, the firmware default shown below applies.
Variable Default Purpose sample_interval_sec30Seconds between host wakes and Modbus meter polls. report_interval_min60Minutes between charger_summary.qoNotes. Changing this also re-applieshub.setso the Notecard's outbound cadence stays in sync.session_threshold_w500Active power (W) above which a charging session counts as open. Level 2 EVSE minimum pilot current (6 A × 208 V) is ~1250 W, so 500 W sits safely below any real session but above charger standby draw. session_end_count3Consecutive below-threshold wakes before a session is closed. At the default 30-second interval, that's a 90-second grace period that rides out brief power dips during EV charge-phase transitions. voltage_present_v85V_rms floor for mains-present classification. Readings below this threshold count against charger availability and start the alert_offline_mintimer. 85 V is below the sag floor for both 120 V and 208/240 V feeds.alert_offline_min240Minutes without a mains-present reading before a charger_alert.qoNote fires. Set to0to disable offline alerting entirely.modbus_slave_id1Modbus slave address of the SDM120. The SDM120 factory default is 1. Change it if you have multiple meters on the same bus. modbus_baud9600Modbus RTU baud rate. It must match the SDM120's configured baud. The factory default is 2400 on older units and 9600 on most current stock, so check your meter's display or documentation. -
Configure routes. At minimum, add one route for
charger_session.qo(real-time delivery to a fleet management system or dashboard webhook) and a second forcharger_summary.qo(long-term analytics or historian). If the mains-absent alert matters at your sites, add a third route forcharger_alert.qoto an on-call or ticketing endpoint. The three Notefiles are kept separate so each one can go out at its own urgency, with no filter logic in the route.
What you should see in Notehub
Three event types matter:
_session.qo: automatic Notecard housekeeping on each cellular session. It confirms the radio is reaching Notehub.charger_summary.qo: one perreport_interval_min(hourly by default). Here's a sample body for a 60-minute window with one session:{ "sessions": 1, "total_kwh": 5.9, "avg_session_kwh": 5.9, "peak_w": 10320.0, "charging_min": 38, "idle_min": 22, "utilization_pct": 63.3, "available_min": 60, "availability_pct": 100.0, "sample_coverage_pct": 100.0 }total_kwhis the SDM120's import-kWh register delta: the most recent valid closing reading minus the register value when the window opened. That's actual metered energy, not an estimate. If the meter poll fails on the exact wake when the summary fires, the firmware uses the most recent valid reading as the closing value, sototal_kwhstays accurate and the next window's kWh baseline isn't corrupted. By construction,charging_min+idle_minalways equalstotal_min.total_mincounts only wakes where the meter returned valid data. Wakes where every Modbus retry failed stay out of the utilization accumulators, so transient bus faults can't inflate idle time or distortutilization_pct. The formula isutilization_pct=charging_min / total_min × 100.available_minis the number of minutes V_rms was ≥voltage_present_v, andavailability_pct=available_min / elapsed_min × 100. Hereelapsed_minis the full wall-clock window duration, including wakes where the meter was unreachable, which count as unavailable.sample_coverage_pct=total_min / elapsed_min × 100shows the fraction of wall-clock window time that had valid meter readings. A value of 100.0 means every poll succeeded, and anything lower flags Modbus or mains downtime during the window. Take a fully energized window where every poll succeeded and the charger was in use for 38 of 60 minutes:available_min= 60,availability_pct= 100.0,sample_coverage_pct= 100.0, andutilization_pct= 63.3.charger_session.qo: one per completed charging session, sent immediately (sync:true). Sample body:{ "session_kwh": 10.2, "duration_min": 63.5, "peak_w": 10320.0, "start_epoch": 1746283200, "timing_valid": true }session_kwhis the SDM120's import-kWh delta (close reading minus open reading), so it's the actual metered energy for this session.peak_wis the highest active-power reading seen during the session. Both fields are always valid, whatevertiming_validsays. If a session opened before the Notecard finished its first cellular time sync,timing_validisfalseandduration_minandstart_epochare emitted as0. Suppress or flag records withtiming_valid: falsein downstream analytics, though you can still trust theirsession_kwhandpeak_w.charger_alert.qo: sent once, then suppressed until mains returns, whenalert_offline_minminutes pass without a mains-present V_rms reading. The timer counts from the last confirmed mains-present epoch. If mains has never been confirmed, it counts from the device's first valid epoch after commissioning, so the alert fires even for a circuit that's been dead since day one. Sample body:{ "alert": "mains_absent", "offline_min": 248 }
7. Firmware Design
The firmware lives in four files inside firmware/ev_charger_session_monitor/:
ev_charger_session_monitor.inohassetup(),loop(), and the top-level app flow (one-time init, per-wake Modbus meter poll, sleep scheduling).ev_charger_session_monitor_helpers.hholds shared constants, theStatestruct, extern declarations, and function prototypes.ev_charger_session_monitor_helpers.cpphas all the helper implementations (Modbus polling, session state machine, Note emission, env-var handling, host sleep).cx_sleep.hhandles host sleep for the Notecarrier CX: thecard.attnrequest, STOP2 entry, andATTN-pin wake.
7.1 Installing and flashing
Dependencies:
- Arduino core for STM32
(
stm32duino/Arduino_Core_STM32). Add the board manager URLhttps://github.com/stm32duino/BoardManagerFiles/raw/main/package_stmicroelectronics_index.jsonunder File → Preferences → Additional Boards Manager URLs. The Notecarrier CX's host uses the Cygnet board definition (see the build settings below). Blues Wireless Notecardlibrary (note-arduino). Install it from the Arduino Library Manager (arduino-cli lib install "Blues Wireless Notecard") or search for "Blues Wireless Notecard" in the IDE Library Manager. Check the note-arduino releases page for any newer stable version.ModbusMasterlibrary by Doc Walker, available in the Arduino Library Manager. Install it witharduino-cli lib install "ModbusMaster"or search for "ModbusMaster" in the IDE, and use version ≥ 2.0.1. It provides the Modbus RTU master implementation the firmware uses to talk to the SDM120 over Serial1.STM32duino Low Powerand its dependencySTM32duino RTC(stm32duino/STM32LowPower). Install them witharduino-cli lib install "STM32duino Low Power" "STM32duino RTC"or from the IDE Library Manager. They provide the STOP2 entry andATTN-pin wake used incx_sleep.h.
Build settings: The Notecarrier CX's host is the same STM32L433 as the Blues
Cygnet, so select Blues boards → Cygnet (pnum=CYGNET) and set USB
support (if available) to None (usb=none). If the USB CDC stack is
enabled and no USB host is attached (or the Notecarrier CX USB-C switch is in
NC), the STM32's USB wakeup interrupt fires continuously and the host never
stays in STOP2. Debug output goes to the LPUART on the Notecarrier CX debug jack
instead, which an ST-LINK V3 exposes as a virtual COM port (see Serial Logging
With STLINK). Serial1 on D0/D1
stays dedicated to the RS-485 link.
Flashing with the Arduino IDE: Open ev_charger_session_monitor.ino, apply
the build settings above, and hit Upload. With the Notecarrier CX's USB-C
switch in HST, you can reach the host's DFU bootloader over the USB-C port
(hold BOOT, tap RST). You can also upload over SWD with an ST-LINK on the
debug jack.
Flashing with arduino-cli: from the firmware directory, run:
# Typical modern stm32duino core
cd firmware/ev_charger_session_monitor
arduino-cli lib install "Blues Wireless Notecard" "ModbusMaster" "STM32duino Low Power" "STM32duino RTC"
# usb=none disables the USB CDC stack, which must be off for the host to sleep.
# PRODUCT_UID must be defined or the build fails with #error (see §6 step 1).
arduino-cli compile -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none \
--build-property 'compiler.cpp.extra_flags=-DPRODUCT_UID="com.your-company:ev-charger-monitor"' .
arduino-cli upload -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none -p /dev/cu.usbmodem* .sketch.yaml in that directory sets the same board options, so
arduino-cli compile without -b picks them up too. Replace
/dev/cu.usbmodem* with the port the Notecarrier enumerates on your machine,
usually COMx on Windows, /dev/ttyACM* on Linux, or /dev/cu.usbmodem* on
macOS.
After flashing, open a serial monitor at 115200 baud on the ST-LINK virtual
COM port. On power-up you'll see [app] power-up — will configure Notecard.
Once the meter is connected, each wake prints
[app] V=240.1 V P=0 W kWh=12.345 and then goes quiet for 30 seconds while
the host sleeps in STOP2. If the meter is missing, unpowered, or miswired,
you'll see [app] WARN: Modbus voltage read error 0xE2 (response timeout) on
every wake. A 0x02 error means the slave responded but rejected the register
address, which usually points to a slave-ID or register-map mismatch. The
library header documents the other ModbusMaster error codes.
7.2 Modules
| Responsibility | Function |
|---|---|
Notecard configuration (hub.set, template registration, accelerometer disable) | initNotecard, defineTemplates |
| RS-485 / Modbus initialization (once at power-up, and on baud/ID change) | initModbus |
| Time-filtered environment variable fetch | fetchEnvOverrides |
| SDM120 Modbus RTU poll (V_rms, active power, import kWh) | pollMeter |
| Epoch time from Notecard | getEpoch |
| Session state machine (IDLE ↔ CHARGING) with availability tracking | runSessionStateMachine |
| Per-session Note emission | emitSessionNote |
| Hourly summary Note emission and window reset | emitSummaryNote |
| Mains-absent alert emission | emitOfflineAlert |
Host sleep (STOP2 until the Notecard raises ATTN) | sleepHost → cxSleepUntilAttn (cx_sleep.h) |
7.3 Sensor reading strategy
pollMeter sends the SDM120 three sequential Modbus RTU read transactions
(Function Code 0x04, input registers), one for each parameter it needs: V_rms
(register 0x0000), active power in Watts (register 0x000C), and cumulative
import energy in kWh (register 0x0048). Each parameter is a big-endian IEEE-754
float stored across two consecutive 16-bit registers. A 20 ms pause between
transactions gives the SDM120 enough time to respond at 9600 baud, since a full
9-byte response frame takes under 10 ms. If any read returns a non-zero Modbus
error code, pollMeter retries the whole three-register sequence up to three
times, with a 50 ms pause between attempts. That absorbs the single-frame bus
collisions and turnaround-timing faults that show up most often at power-on or
after a brief conductor disturbance.
If all three attempts fail, which points to an unpowered meter, faulty wiring,
or a misconfigured slave ID, pollMeter returns valid = false.
runSessionStateMachine then suppresses every session-state transition and all
window accumulation (charging_sec, idle_sec) for that wake. A session in
progress is neither extended nor closed (its below_threshold_count doesn't
increment), and the failed sample adds no idle time. window_elapsed_sec still
advances unconditionally, though, so the failed poll counts against
availability_pct as an unavailable interval and lowers sample_coverage_pct.
The mains-absent offline timer keeps advancing on its own (via
last_mains_epoch in loop()), so a sustained meter fault still triggers the
mains_absent alert after alert_offline_min minutes.
The SDM120 does all the V and I measurement and math internally, and the host just reads the finished result registers. The Notecarrier side needs no ADC sampling, no voltage or power-factor assumptions, and no analog front-end components.
7.4 Event payload design
charger_summary.qo uses a
template
that encodes each record as ~40 bytes on the wire, compared with ~200 bytes as
free-form JSON. At one record per hour per charger across a 50-unit fleet, that
adds up over the life of a 500 MB SIM. The template is registered once at cold
boot and survives Notecard reboots.
charger_session.qo and charger_alert.qo are left untemplated on purpose.
Session Notes are infrequent (one per EV charge, usually 1–8 per day), and the
alert body has a different shape from the session body. Leaving both
untemplated avoids the schema conflict you'd get from mixing two different body
shapes under one template.
Template definition snippet:
bool defineTemplates() {
J *req = notecard.newRequest("note.template");
JAddStringToObject(req, "file", FILE_SUMMARY);
JAddNumberToObject(req, "port", 50);
J *body = JAddObjectToObject(req, "body");
JAddNumberToObject(body, "sessions", 12); // completed sessions in window
JAddNumberToObject(body, "total_kwh", 14.1); // metered energy delivered
JAddNumberToObject(body, "avg_session_kwh", 14.1); // per-session average
JAddNumberToObject(body, "peak_w", 14.1); // peak active power in window
JAddNumberToObject(body, "charging_min", 12); // minutes delivering energy
JAddNumberToObject(body, "idle_min", 12); // minutes not delivering energy
JAddNumberToObject(body, "utilization_pct", 14.1); // charging_min / total_min × 100
JAddNumberToObject(body, "available_min", 12); // minutes with V_rms ≥ floor
JAddNumberToObject(body, "availability_pct", 14.1); // available_min / elapsed_min × 100
JAddNumberToObject(body, "sample_coverage_pct", 14.1); // total_min / elapsed_min × 100
bool ok = notecard.sendRequest(req);
if (!ok) debugSerial.println("[app] WARN: note.template failed — will retry on next wake");
else debugSerial.println("[app] Notefile template registered for " FILE_SUMMARY);
return ok;
}Template notation explained: 12 means a 2-byte signed integer (range
-32,768 to 32,767), and 14.1 means a 4-byte IEEE-754 float (the .1 marks
the field as a float and doesn't set its precision). The template shrinks each
full summary record from roughly 200 bytes of free-form JSON to about 40 bytes
on the wire, which matters over a 500 MB SIM's lifetime at one record per hour
per device.
7.5 Low-power strategy
The device is grid-tied, so the absolute energy budget isn't a big concern. A disciplined sleep pattern still matters, though. It keeps the enclosure cooler, reduces component stress, and gives you firmware that ports directly to a battery- or solar-backed variant without a rewrite.
After each 30-second wake, sleepHost() calls cxSleepUntilAttn() (in
cx_sleep.h). That function sends a card.attn sleep request for
sample_interval_sec seconds, waits for the Notecard to pull ATTN low, and
puts the STM32 host into STOP2, where it draws a few microamps and keeps its
RAM. When the interval is up, the Notecard raises ATTN, the rising edge on
D5 wakes the host, and execution picks up on the next line of loop().
Because the host resumes in place instead of cold-booting, the State struct
(session baseline, window accumulators, pending Note, cached config) just lives
in RAM. Nothing is serialized to the Notecard, and setup() runs only once per
power-up. If the sleep request fails or ATTN never goes low (a missing jumper,
for example), sleepHost() waits out the interval awake and tries again on the
next cycle, so the 30-second cadence holds either way.
Between cellular sessions the Notecard idles at 8–18 µA. The Modbus poll adds roughly 100–200 ms to each wake (three transactions at 9600 baud with 20 ms pauses), but the host MCU is still only active for about one to two seconds out of every 30. The radio runs about once an hour, plus any session-end sync events.
hub.set runs in periodic mode, with outbound matching
report_interval_min (60 min by default) and inbound at 120 min. Session-end
Notes set sync:true, so the Notecard opens a cellular session right away
instead of waiting for the outbound timer. With the default
session_end_count=3 and a 30-second sample interval, the state machine waits
for 3 consecutive below-threshold wakes (a 90-second grace period) before it
formally closes the session and queues the Note. The cellular sync then adds
roughly 15–60 seconds. With default settings, expect a completed session to show
up in Notehub roughly 2–3 minutes after the car unplugs. Lowering
session_end_count (to 1, say) shortens the grace period, but it raises the
risk of falsely closing a session during EV charge-phase transitions.
7.6 Retry and error handling
pollMeterretries the complete three-register Modbus sequence up to three times per wake before it declares a poll failure. When every retry fails,runSessionStateMachinesuppresses all session-state transitions and all window accumulation (charging_sec,idle_sec) for that wake. A session already in progress stays open (itsbelow_threshold_countdoesn't increment), and the failed sample adds no idle time.window_elapsed_secstill advances, as it does on every wake, so the failed poll counts againstavailability_pctin the denominator and also lowerssample_coverage_pct. The mains-absent offline timer keeps advancing regardless, so a sustained meter fault still triggers themains_absentalert afteralert_offline_minminutes.fetchEnvOverridespasses the storedenv_last_modifiedtimestamp toenv.get'stimefilter. If nothing has changed since the last fetch, the Notecard answers with an{env-not-modified}error, and the function returns right away without parsing anything or touching the config, which keeps I²C overhead minimal. Environment variable values always arrive as strings, so the firmware parses each one withstrtod()before range-checking it. A change tomodbus_slave_idormodbus_baudre-runsinitModbus()on the spot.getEpochreturns 0 until the Notecard has synced and has the time. The summary Note only fires whennow > 0andwindow_start_epoch > 0, so it can't go out before a cellular session is established. The mains-absent alert is guarded byoffline_ref > 0, whereoffline_refis the last mains-present epoch, orwindow_start_epochif mains has never been confirmed. That keeps the alert quiet until the device has a valid clock and the configured absence duration has really elapsed.emitSessionNotesetstiming_valid = falsewhen thestart_epochparameter is 0, which happens when a session opened before the Notecard finished its first cellular time sync.duration_minandstart_epochwould be wildly wrong in that case, so both go out as 0, andtiming_valid: falsein the Note body tells downstream consumers to suppress them.session_kwhandpeak_waren't affected and are always emitted normally.- A change to
report_interval_minre-applieshub.setthroughapplyHubCadence(). Thehub_cadence_dirtyflag is set before the attempt and only cleared on confirmed success, so one transient I²C failure can't leave the Notecard stuck on the old outbound cadence. Like thenotecard_configuredretry pattern,loop()retries on every later wake until the cadence update is confirmed. - If
emitSessionNote()fails at session end, the completed payload goes into a dedicated pending-note struct (pending_session_*) andsession_activeis cleared right away.runSessionStateMachine()retries the pending Note at the top of each later wake, but it always falls through to normal state-machine processing whether or not the retry worked. That way a back-to-back charging session during a transient Notecard fault still gets detected and opened, instead of being silently blocked by a retry loop. Retry-path window attribution: if the hourly summary fires and resets the window accumulators between the session-end failure and a later successful retry, the session's count and completed-session kWh land in the newer window, not the one where the session actually ended. §10 covers this edge case in more detail. alert_offline_minaccepts0or any positive integer. Set it to0to disable mains-absent alerting, or to a positive integer to adjust the threshold. The firmware only applies a variable that's set and non-empty, so a missing env var leaves the current setting alone. That also means deleting a variable in Notehub keeps its last value until the next power cycle, when the firmware defaults come back.
7.7 Key code snippet: session-end Note with immediate sync
When the session state machine closes a session, it computes session_kwh from
the meter's import-kWh register delta, stores the payload in a pending-note
struct (pending_session_*), and clears session_active immediately before
calling emitSessionNote. Every payload value goes in as an explicit parameter,
so the function behaves the same at first close and during pending-note retries,
after session state has been cleared and maybe overwritten by a new session. A
timing_valid guard covers sessions that opened before the Notecard had a valid
epoch. If start_epoch is 0, duration_min and start_epoch would be wildly
wrong, so they're emitted as 0 and timing_valid is set to false.
session_kwh and peak_w are valid either way.
bool emitSessionNote(float kwh, float peak_w,
uint32_t start_epoch, uint32_t end_epoch) {
bool timing_valid = (start_epoch > 0 && end_epoch >= start_epoch);
uint32_t dur_sec = timing_valid ? (end_epoch - start_epoch) : 0;
J *req = notecard.newRequest("note.add");
JAddStringToObject(req, "file", FILE_SESSION);
JAddBoolToObject(req, "sync", true);
J *body = JAddObjectToObject(req, "body");
JAddNumberToObject(body, "session_kwh", kwh);
JAddNumberToObject(body, "duration_min", (float)dur_sec / 60.0f);
JAddNumberToObject(body, "peak_w", peak_w);
JAddNumberToObject(body, "start_epoch", timing_valid ? (double)start_epoch : 0.0);
JAddBoolToObject(body, "timing_valid", timing_valid);
// ...
}7.8 Key code snippet: sleeping until the Notecard raises ATTN
cxSleepUntilAttn() asks the Notecard to hold ATTN low for exactly
sample_interval_sec seconds, confirms the pin has gone low, and puts the STM32
into STOP2. Since the host stays alive, the card.attn call is a request with a
checked result, not a fire-and-forget command. STOP2 retains RAM, so the
session-start kWh baseline, peak power, and hourly window state are all still
there when loop() runs again. setup() arms the ATTN wake once with
cxSleepBegin(), and every loop() pass ends with a call to sleepHost():
void sleepHost() {
uint32_t sleep_sec = state.sample_interval_sec;
if (!cxSleepUntilAttn(notecard, sleep_sec, NULL, &debugSerial)) {
// The Notecard didn't take the sleep request, or ATTN never went low
// (check the ATTN -> D5 jumper). Keep the sample cadence and retry.
debugSerial.println("[app] WARN: ATTN sleep failed — waiting out the interval awake");
delay(sleep_sec * 1000UL);
}
}7.9 Key code snippet: Modbus meter poll
pollMeter reads three SDM120 input-register pairs in sequence. Each pair is a
big-endian IEEE-754 float, and regsToFloat puts the two 16-bit halves back
together with a union. The excerpt shows the active-power read (register
0x000C) inside the three-attempt retry loop. The voltage and import-kWh reads
on either side of it follow the same pattern.
static float regsToFloat(uint16_t hi, uint16_t lo) {
union { float f; uint32_t u; } v;
v.u = ((uint32_t)hi << 16) | (uint32_t)lo;
return v.f;
}
// ...
bool pollMeter(MeterReading *out) {
// ...
const int kMaxAttempts = 3;
for (int attempt = 1; attempt <= kMaxAttempts; attempt++) {
if (attempt > 1) delay(50); // let the bus settle before retry
uint8_t rc;
// ...
// ── Active power ─────────────────────────────────────────────────────
rc = node.readInputRegisters(SDM_REG_POWER, 2);
if (rc != ModbusMaster::ku8MBSuccess) {
debugSerial.print("[app] WARN: Modbus power read error 0x");
debugSerial.print(rc, HEX);
if (attempt < kMaxAttempts) { debugSerial.println(" — retrying"); continue; }
debugSerial.println();
return false;
}
float pw = regsToFloat(node.getResponseBuffer(0), node.getResponseBuffer(1));
// ...
out->power_w = (pw > 0.0f) ? pw : 0.0f; // clamp export to zero
// ...
}
// ...
}8. Data Flow
Collected. Every sample_interval_sec (30 s by default): V_rms, active
power (W), and cumulative import kWh from the SDM120 through three Modbus RTU
reads. The SDM120 does all the measurement and math itself, and the firmware
reads the finished result registers.
Session tracking. The state machine opens a session when
active_power_w ≥ session_threshold_w and closes it after session_end_count
consecutive below-threshold wakes. At close time, the firmware computes session
energy from the meter's cumulative import-kWh register:
session_kwh = import_kwh_at_close − import_kwh_at_openThat's actual metered energy, with no voltage or power-factor approximation. Window total kWh works the same way at summary time:
window_total_kwh = import_kwh_now − import_kwh_at_window_openAvailability tracking. On every wake, window_elapsed_sec grows by
sample_interval_sec no matter what (wall-clock time). If meter.valid and
V_rms ≥ voltage_present_v are both true, window_available_sec grows by
sample_interval_sec too. When the meter can't be reached (it's unpowered, the
wiring is faulted, or every Modbus retry failed), meter.valid is false.
window_elapsed_sec still grows but window_available_sec doesn't, so those
wakes count as unavailable in availability_pct =
available_min / elapsed_min × 100. sample_coverage_pct =
total_min / elapsed_min × 100 is the data-quality signal. A value of 100.0
means every poll in the window succeeded, and a lower value shows how much of
the window lacked valid meter readings.
Transmitted.
charger_session.qo: one Note per completed session, sent immediately (sync:true). It carries metered session kWh (import-kWh delta), duration in minutes, peak active power (peak_w), the UTC epoch of the session start, and atiming_validflag.session_kwhandpeak_ware always valid. Whentiming_validisfalse(the session opened before the first time sync),duration_minandstart_epochgo out as 0, and downstream consumers must treat them as invalid. This is the record that tells you how much energy Charger 3 delivered on Tuesday at 8 a.m.charger_summary.qo: one templated Note perreport_interval_min(hourly by default), queued on the Notecard and sent on the outbound sync. It contains the session count, metered total kWh (import-kWh register delta using the last valid closing reading), per-session average kWh, peak active power (peak_w), active minutes (charging_min), inactive minutes (idle_min), utilization percentage, and three availability fields. Those areavailable_min(minutes with V_rms ≥voltage_present_v),availability_pct=available_min / elapsed_min × 100(a wall-clock denominator, so invalid-meter wakes count as unavailable), andsample_coverage_pct=total_min / elapsed_min × 100(the data-quality signal).charging_mincounts minutes with active power above the session threshold plus any grace-period wakes, andidle_minis the rest. Both use only valid-poll wakes as the denominator, so Modbus faults don't affectutilization_pct.available_minreflects the minutes when the charger circuit was confirmed energized. This is the record that answers both "do we need more chargers?" and "was this charger actually available to be used?"charger_alert.qo: sent once ifalert_offline_minpasses without a mains-present V_rms reading, then suppressed until mains returns. It's a definitive signal. The SDM120 needs mains power to respond on Modbus, so consecutive failed reads at9600baud combined with a below-threshold V_rms reliably mean a tripped breaker, a power loss, or a sustained meter fault, not a healthy charger sitting idle.
Routed. Notehub sends the three Notefiles to separate places.
charger_session.qo goes to whatever real-time channel the operator uses (fleet
dashboard, CMMS work order, alerting service). charger_summary.qo goes to a
long-term analytics store for trend analysis ("was March utilization higher than
January?"), and charger_alert.qo goes to an on-call or ticketing endpoint.
Trigger logic summary:
- Session opens:
active_power_w ≥ session_threshold_wfor one sample - Session closes:
session_end_countconsecutive below-threshold samples - Hourly summary: wall-clock window of
report_interval_minelapsed - Mains-absent alert:
alert_offline_min> 0 andalert_offline_minminutes elapsed since the last confirmed mains-present reading (or device commissioning if mains has never been confirmed); fires once; resets when mains returns; disabled whenalert_offline_min = 0
9. Validation and Testing
Expected steady-state cadence. In normal use, a charger generates one
charger_summary.qo per hour (24/day), one charger_session.qo per session
(usually 1–8/day), and zero charger_alert.qo events.
In a healthy window, sample_coverage_pct should be 100.0, meaning every Modbus
poll succeeded. A summary that keeps showing utilization_pct above 80% means
the charger is effectively saturated. If availability_pct is below 95%, the
charger circuit has been losing power often and needs a maintenance visit.
Bench simulation. Before you install in a live panel, test session detection
on the bench by connecting the SDM120 to a live AC circuit with a resistive load
(and observe every electrical safety precaution). A resistive load of 500 W or
more will exceed the default session_threshold_w = 500 W. With the SDM120
powered and the RS-485 connection wired correctly, the serial monitor should
print [app] V=XXX.X V P=XXXX W kWh=X.XXX every 30 seconds. Plug the load in
and watch for [app] session STARTED. Leave it running for a few minutes, then
unplug it. You should count three wakes showing power near 0 W, which is the
90-second grace period (session_end_count = 3, 30 seconds each). The
charger_session.qo Note queues with sync:true once the session formally
closes. Give it roughly 2–3 minutes from unplugging to show up in Notehub (90
seconds of grace period + 15–60 seconds of cellular sync).
If you don't have a live SDM120 for initial bring-up, you can check the Modbus
stack by connecting the RS-485 bus to a Modbus RTU simulator (e.g., a PC running
Modbus Poll or a second Arduino running a Modbus slave sketch). The firmware
prints [app] WARN: meter poll failed this wake for each failed read, but it
still runs the Notecard initialization, env-var fetch, and sleep cycle
correctly.
Using Mojo to validate power behavior. For bench bring-up, put the
Mojo inline between the HDR-15-5 5V output and
the Notecarrier CX +VBAT pad. Here are the published Notecard current figures
from the low-power firmware design
guide:
| Phase | Expected current |
|---|---|
| Notecard idle (host in STOP2, radio off) | ~10–20 µA @ 5V (Notecard 8–18 µA + host ~1–2 µA) |
| Host active (Modbus poll + I²C, ~1–2 seconds per wake) | Measure with Mojo (see the note below) |
| Notecard cellular session (LTE Cat-1 bis) | ~200–400 mA average, ≤2A peak bursts |
The host MCU's active-phase current depends on clock configuration and peripheral state, so measure it from the Mojo trace instead of relying on an earlier estimate. A healthy trace has a near-zero baseline with brief 1–2-second elevated blips at each 30-second wake, one longer 15–30-second burst at ~200–400 mA every hour (the outbound sync), and some shorter bursts at session ends. Total daily energy depends on session count, session frequency, and cellular signal quality. Characterize it on the bench with a known load pattern, and use that measured figure as your deployment baseline.
Two failure patterns jump out on the Mojo:
- Host never sleeping: a continuous elevated baseline instead of 30-second
gaps. The host isn't entering STOP2. Check the
ATTN→D5jumper on the Notecarrier CX header (the serial log shows[app] WARN: ATTN sleep failedwhenATTNnever reachesD5), and confirm the firmware was built with USB support set to None (usb=none). With USB CDC enabled, the host leaves STOP2 within a millisecond. - Radio never going idle: long continuous transmit blocks instead of clean
bursts. That means the Notecard isn't reaching its
periodicmode idle, so watch the serial output to confirm thehub.setcall succeeded at power-up.
Mojo is a bench-validation tool, and deployed units running from a panel supply don't need it.
10. Limitations and Next Steps
A meter-based monitor and an OCPP-integrated monitor answer different questions. This reference design deliberately answers the simpler one, which is how the circuit is being used, whatever the charger brand or network. That choice comes with trade-offs (heuristic session boundaries, no per-driver records, single-phase coverage), and they're listed below along with the natural production extensions.
Design Boundaries
These are the spots where the meter-based approach gives up authoritative session detail to stay simple and brand-agnostic. A production deployment may want to add a meter, a phase, or an OCPP back-end at any of them.
Energy-meter integration is SDM120 Modbus RTU, not OCPP. This project
integrates a hardware energy meter on the charger's AC feed. It doesn't
implement OCPP or any direct charger-network integration, so it can't provide
per-driver, per-RFID, or charger-network session records. Session boundaries
come from a power-threshold heuristic
(active_power_w ≥ session_threshold_w), not authoritative OCPP
StartTransaction/StopTransaction events. Sites that also need per-driver
records or OCPP-correlated data should add an OCPP back-end integration
alongside this meter-based monitor, as described in the production next steps
at the end of this section.
SDM120 ampacity and neutral requirement. The SDM120-Modbus direct-connect variant is rated 45 A continuous, which covers 32 A EVSE (40 A breaker) and 40 A EVSE (50 A breaker). For 48 A or 60 A EVSE circuits, substitute the SDM120CT variant (it uses an external split-core CT, so its rating depends on the CT and can cover any primary current) or the SDM230-Modbus (100 A direct-connect). The SDM120 also needs a neutral connection for its internal voltage measurement. In a subpanel with only two line conductors (e.g., a 240 V line-to-line feed with no neutral), it can't measure voltage and won't work correctly. In that case, use a single-phase meter that accepts a two-wire (L-L) supply voltage input, or run a neutral conductor from the main panel.
Availability is derived from V_rms, not OCPP status. available_min and
availability_pct come from the SDM120's V_rms register. When V_rms ≥
voltage_present_v, the charger circuit counts as energized. Wakes where the
meter poll fails outright (because mains is absent, the wiring is faulted, or
every Modbus retry fails) also count against availability. They add to the
wall-clock elapsed_min denominator but not to available_min, so
availability_pct correctly drops below 100 % whenever the circuit was offline
for any reason. sample_coverage_pct separates sustained meter faults from
genuine mains absence. If availability_pct and sample_coverage_pct are both
low, the charger was probably offline. If availability_pct is low but
sample_coverage_pct is near 100, the V_rms readings were valid and point to a
prolonged low-voltage or brownout condition.
This design does not detect an EVSE that's powered but internally faulted,
such as a GFI trip inside the EVSE, a J1772 pilot fault, or an EVSE error state.
Those faults leave the circuit energized, so they look like idle time
(charging_min = 0, available_min = elapsed_min). Sites that need per-charger
EVSE fault detection should add OCPP status-notification integration on top of
this meter monitor. Within those limits, availability_pct is still a useful
signal. A sustained availability_pct < 100 in a summary window confirms a real
mains interruption during that period, and availability_pct = 100 with
utilization_pct = 0 means the charger was powered but unused.
Metered accuracy is Class 1, not revenue-grade. The SDM120 is IEC 62053-21 Class 1, meaning ±1% accuracy under reference conditions. That's enough for utilization monitoring and tenant cost allocation, but it falls short of the revenue-grade Class 0.5 or 0.2 instruments that utility billing requires. For billing applications, substitute a revenue-grade instrument.
Monitoring is single-phase only. This design monitors one single-phase EVSE circuit. Three-phase 208 V or 480 V commercial charging installations (typically DCFC, DC fast chargers) need a three-phase energy meter, and this reference design doesn't cover them.
Session detection is power-threshold only. The firmware can't tell "EV
actively charging at full rate" from "EV plugged in but in top-off mode at
reduced power." Both produce above-threshold active power and show up as one
continuous session. When the BMS pauses charging, power usually drops below the
threshold, so a pause longer than the session_end_count grace period (90
seconds by default) closes the session, and the resumed charge opens a new one.
An OCPP-integrated data source would give you the authoritative session state.
Sessions spanning summary windows, retry-path attribution, and single-slot
data loss. Window total_kwh uses the meter-register delta, so it captures
all the energy in the window period regardless of session boundaries.
window_sessions only counts sessions that close in the window, though, so a
session still running when the summary fires gets counted in the window where it
ends. On the retry path, if emitSessionNote() fails at session close and the
pending retry only succeeds after the summary window has already reset,
window_sessions and window_completed_session_kwh go to the newer window.
Single-slot data loss: the pending-session store holds exactly one
completed-session payload. If a second session closes before the first Note has
been queued (a Notecard fault that lasts longer than one complete charging
session), the older session record is overwritten and permanently lost, and
the firmware prints a [app] WARN: overwriting unconfirmed pending session Note
line to serial. Production deployments that need guaranteed per-session
delivery should replace the single-slot pending store with a small ring buffer.
The single slot is the pending_session_* fields of the State struct in
ev_charger_session_monitor_helpers.h.
No Notecard outboard firmware update wired. The firmware doesn't configure Notecard Outboard DFU for over-the-air host firmware updates, so updating firmware takes a physical USB connection.
Production Next Steps
With the basic meter monitor running, the next steps push toward billing-grade accuracy and multi-port, OCPP-correlated coverage. They're listed roughly from most immediately useful to most dependent on outside integrations.
Upgrade to a revenue-grade meter. Swap in a single-phase Class 0.5 or 0.2 DIN-rail energy meter for utility-billing accuracy.
Add a second meter for two-port chargers. For a two-port charger (two EVSE circuits in the same panel), put a second SDM120 on the shared RS-485 bus with a different Modbus slave ID. Then extend the firmware to poll both meters and emit per-port session and summary Notes.
Implement OCPP session correlation. Connect to the charger's OCPP back-end
through its WebSocket/HTTP interface or a network proxy to capture
StartTransaction / StopTransaction events. Cross-referencing those records
with the energy-meter session data gives you per-driver, per-RFID, per-session
energy records.
Wire Notecard Outboard DFU. Connecting it to the host's boot/reset pins enables over-the-air host firmware updates across the deployed fleet.
Replace the single-slot pending store with a ring buffer. A small ring
buffer (4–8 slots) in the State struct guarantees per-session delivery even
through extended Notecard faults. It replaces the pending_session_* fields in
ev_charger_session_monitor_helpers.h.
11. Summary
The fleet manager who used to wonder whether Bay 3 was actually being used now has the answer in Notehub, with per-session metered kWh, peak demand, session duration, port utilization, and hourly charger availability. The SDM120 measures real energy delivery and mains voltage. The Notecard dials home over LTE Cat-1 bis without IT involvement, an AP to pair with, or any per-site network setup, and session records and availability summaries start landing within minutes of commissioning.
The data answers two questions at once (how are the chargers being used and how reliably are they available), and the answers point to different problems. If a site runs at 85% utilization with 100% availability, it needs a scheduling policy rather than more hardware. A site at 72% availability needs a maintenance visit. When two chargers are maxed out while six others sit idle, that's a fleet routing problem. None of those signals was visible before, and the same SKU works on any Level 2 EVSE ever installed.
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
- 7.1 Installing and flashing
- 7.2 Modules
- 7.3 Sensor reading strategy
- 7.4 Event payload design
- 7.5 Low-power strategy
- 7.6 Retry and error handling
- 7.7 Key code snippet: session-end Note with immediate sync
- 7.8 Key code snippet: sleeping until the Notecard raises ATTN
- 7.9 Key code snippet: Modbus meter poll
- 8. Data Flow
- 9. Validation and Testing
- 10. Limitations and Next Steps
- 11. Summary