Use Your AI Agent to Talk to Your Products with Notehub IQ

Blues Developers
What’s New
Resources
API Reference
Notecard and Notehub API documentation
Blog
Technical articles for developers
Connected Product Guidebook
In-depth guides for connected product development
Developer Certification
Get certified on wireless connectivity with Blues
Newsletter
The monthly Blues developer newsletter
Terminal
Connect to a Notecard in your browser
Webinars
Listing of Blues technical webinars
Blues.comNotehub
Shop
Docs
Help
Support DocsNotehub StatusSecurity AdvisoriesVisit our Forum
Sign In
Docs Home
What’s New
Resources
API Reference
Notecard and Notehub API documentation
Blog
Technical articles for developers
Connected Product Guidebook
In-depth guides for connected product development
Developer Certification
Get certified on wireless connectivity with Blues
Newsletter
The monthly Blues developer newsletter
Terminal
Connect to a Notecard in your browser
Webinars
Listing of Blues technical webinars
Blues.comNotehub
Shop
Docs
Example Apps
Samples
Building a Simple Asset Tracker
Building Edge AI Applications
Continuous Asset Tracking with External GPS and Immediate Location Sync
Controlling Device State and Receiving Acknowledgment
Managing a Notecard's WiFi Network Remotely
Putting a Host to Sleep Between Sensor Readings
Routing Data from Notehub to a Custom Cloud Endpoint
Using LEDs and NeoPixels to Monitor Notecard Status
Accelerators
Asset Location Tracking
Heavy Equipment Hours-of-Use & Utilization Tracker
Returnable Container / Tote Pool Tracker
Untethered Trailer and Chassis Fleet Tracker
Asset Performance Optimization
Legacy Diesel Generator Fleet Performance Uplift
Solar Array String-Level Performance Dashboard
Battery Management Systems
Aerial Lift / Rental Equipment Battery Health Monitor
Off-Grid Solar Battery Site Controller
Remote Cabinet Backup Battery Sentinel
Downtime Prevention
Municipal Wastewater Lift Station Monitor
Rooftop HVAC Predictive Maintenance
VFD Pump Predictive Maintenance
Energy Monitoring Solutions
Commercial Tenant Sub-Metering Bridge
EV Charger Session & Utilization Monitor
Utility Distribution Transformer Load Monitor
Energy Savings
Commercial Plug-Load & After-Hours Waste Dashboard
Demand-Response Solar + Battery Dispatcher
Multi-Site Walk-In Cooler Energy & Setpoint Monitor
Industrial Equipment Monitoring
CNC Machine Spindle Load and Cycle Time Tracker
Injection Molding Shot-to-Shot Process Monitor
Trailer Manufacturer Connected Trailer Platform
Loss Prevention
Construction Equipment Anti-Theft Tracker with Immobilizer
Reefer Trailer Cold-Chain & Door-Event Monitor
Remote Patient Monitoring
Cellular Medication Adherence Pillbox
Post-Discharge Vitals Relay Hub
Remotely Monitored Assets
Off-Grid Livestock Water Tank Monitor
Remote Apiary Hive Health Monitor
Safety Assurance
Construction Site Environmental & Noise Exposure Monitor
Lone Worker Panic and Fall Detection Beacon
Regulatory-Grade Pharmacy and Lab Cold Storage Audit Monitor
Supply Chain Tracking
Pallet-Attached Cold Chain Logger
Rail Car Condition & Interchange Tracker
Truck Roll Reduction
Commercial Grease Interceptor Level Monitor
Propane / LPG Tank Fill Telemetry
homechevron_rightExample Appschevron_rightAcceleratorschevron_rightAerial Lift / Rental Equipment Battery Health Monitor

Aerial Lift / Rental Equipment Battery Health Monitor

Aerial Lift / Rental Equipment Battery Health Monitor banner
note

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/lift_battery_monitor/ so it can learn how the device works and help you adapt it to your hardware.

This project is a battery management systems reference design for the electric battery pack on a rental scissor lift, boom lift, or telehandler. It monitors the pack continuously and reports state of charge, cycle Ah throughput, rolling state of health, and thermal status to the rental company's fleet management platform. Wherever the lift happens to be sitting, the data goes out over cellular, with satellite as a fallback.

1. Project Overview

The problem. Electric scissor lifts, boom lifts, and telehandlers are replacing more and more diesel units on job sites. They're quieter, cleaner, and cheaper to run, but they bring a failure mode diesel never had. A machine that looks fine in the yard shows up at the job site with a dead or nearly dead pack, and the rental company loses a day of revenue before anyone knows there's a problem.

The rental model makes this worse. A machine might sit on a customer's site for three weeks, come back to the yard for a quick visual inspection, and go back out the next morning, and nobody watches the pack in between. Say a cell group has developed a high-impedance connection. It looks fine on the charger, with full voltage at the terminals, then drops out under load a few minutes into the shift. The customer's crew loses half a day waiting for a swap, and the rental company pays for an emergency service call. The battery might have had years of useful life left if someone had caught the fault early, but it gets written off instead.

This project is the watcher. A small monitoring device clipped to the pack reads pack bus voltage through a precision I²C power monitor (INA228). In field builds it also measures pack current, either through an external precision shunt wired to the INA228's differential inputs (the default, ENABLE_ACS758 0) or through an ACS758 Hall-effect sensor on the traction conductor (the alternative, ENABLE_ACS758 1). A probe mounted to the battery housing records pack temperature. If the machine's BMS (battery management system) exposes individual cell-group voltages over CAN (Controller Area Network) bus, the device pulls those in too (see the CAN Note below). From those four data streams it keeps a running SoC (state of charge) estimate, adds up a cycle Ah throughput total with a sampled Ah estimator, and maintains a rolling SoH (state of health) estimate that updates whenever a heuristic charge-cycle threshold is crossed. Three of those numbers, plus temperature, show up on the rental company's fleet dashboard within seconds of a threshold trip. The rest of the time they arrive in a single hourly telemetry Note.

Why Notecard. The lift is sitting on a construction site that probably doesn't have public WiFi. Even if it does, the rental company's fleet app has to work the same way at every job site, whether that's a residential project in rural Montana or a high-rise downtown. With cellular, nobody has to fill out a network form, pair with an AP, or file an IT ticket. Construction sites also end up in coverage gaps, though, like below grade inside a building, in mountain foothills, or out in rural areas where the crew has gone to break ground. That's where the Skylo satellite fallback comes in. When cellular isn't reachable, the alert Notes that normally travel over LTE-M go out through Notecard for Skylo's NTN (Non-Terrestrial Network) radio instead. The firmware turns that fallback on once with card.transport and never has to know which link carried an alert. The satellite link is a backup, not the primary path, and it's what lets the system work the same way at every job. Notecard for Skylo puts cellular, Skylo satellite, and WiFi on a single M.2 SoM, so there's no companion board to wire up and no second UART to manage.

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.

Deployment scenario. A sealed IP67 enclosure is strapped to the battery pack housing or bolted to the machine frame near the pack. It runs off the machine's 12V auxiliary circuit, which is derived internally from the main pack and available as a utility supply on most electric lifts. Two sensing leads run from the enclosure to the INA228 bus-voltage monitor, whose VIN+ pad connects to the pack's positive terminal for high-side bus-voltage measurement. A single NTC probe wire runs to the pack housing. In the default field build, an external precision shunt sits inline on the main traction conductor to sense pack current. The alternative build (ENABLE_ACS758 1) puts an ACS758 Hall-effect sensor there instead. When the machine's BMS is CAN-accessible, an OBD-style cable runs to the machine's service port. You don't modify the BMS or the charger, and the customer doesn't have to get involved.

2. System Architecture

System architecture: INA228 power monitor + NTC pack temperature (plus optional ACS758 hall and CAN BMS) → Notecarrier CX with STM32 host and NOTE-NBGLWX → cellular or Skylo NTN satellite → Notehub → rental ERP / analytics / on-call

Device-side responsibilities. The host's job is to turn four raw signals into pack health numbers the rental company can act on. Every sample_interval_s seconds (300 seconds, or 5 minutes, by default), the STM32 host on the Notecarrier CX wakes up and reads pack voltage from the INA228 over I²C. It reads pack current from whichever source the build uses: INA228 readCurrent() through an external precision shunt in the default field build (ENABLE_ACS758 0), the ACS758 Hall-effect sensor on A1 in the alternative build (ENABLE_ACS758 1), or the onboard INA228 shunt in bench builds (BENCH_ONLY 1). Pack temperature comes from the NTC thermistor. When the machine's BMS is CAN-accessible, the host also polls cell-group voltages over the SPI CAN interface. (CAN BMS integration requires vendor-specific CAN ID and frame parser configuration before the feature will work with any real BMS. The shipped firmware is a placeholder for this path. See §7.3 and §10.) From those four streams, the firmware updates SoC with a voltage-based OCV lookup. It adds absolute current above a 0.5 A noise floor to a running cycle Ah throughput total (one sampled Ah estimate per wake), and it updates a rolling SoH estimate whenever a heuristic pseudo-cycle completes (SoC dips below 30%, then recovers above 90%). Threshold checks run after every sample, and any trip fires an immediate Note. Between wakes the Notecard idles and the host sleeps in STM32 STOP2 (a few µA) until the Notecard's ATTN pin wakes it through card.attn. The full assembly still draws quiescent current from the buck regulator, INA228, thermistor divider, and any optional CAN hardware, which puts it well above the Notecard's own idle figure. §9 shows how to measure the whole device with Mojo.

Notecard responsibilities. Notecard for Skylo decides when those numbers actually leave the machine. It holds Notes in its onboard queue, runs the cellular or satellite session on the configured hub.set outbound cadence, and sends any sync:true alert the moment a threshold trips. Environment variables from the Blues Notehub cloud service come down through the Notecard too, so fleet-level threshold changes reach devices without a reflash. Notecard for Skylo ships set to wifi-cell, so the firmware sends card.transport with method cell-ntn when it configures the Notecard. From then on, whenever terrestrial cellular is unavailable, the built-in Skylo NTN radio takes over on its own. The firmware doesn't manage that handoff, and an alert from a rural job site reaches Notehub the same way one from an urban yard does.

Notehub responsibilities. Once Notes leave the lift, the Notecard's embedded global SIM carries them over supported carriers worldwide to Notehub. Notehub stores every event, and project-level routes send each one onward. Hourly telemetry summaries and immediate alerts use separate Notefiles, so the rental company can send each kind where it belongs. Alerts go to an on-call or rental ERP system for immediate triage, and summaries go to a long-term analytics store for cycle-life trending. Smart Fleets let a single firmware image serve machines with different pack chemistries and rated capacities. Fleet-level environment variables hold the pack specs, and per-device overrides cover the machines that don't match.

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 this project doesn't ship a specific downstream endpoint or dashboard, so see the Notehub routing docs for details.

Satellite data budget. Notecard for Skylo includes 10 KB of satellite data. Hourly summaries stay off the satellite link. The battery_status.qo template has no format or port, which makes it a non-NTN Notefile, so the Notecard only sends summaries over cellular and holds any queued ones until cellular comes back. Critical alerts (low SoC, thermal excursion, SoH degradation) use a compact template with a port, so they travel over whatever link is available, satellite included. Fleet operators still get warned about dangerous pack conditions, even on job sites with no cellular coverage.

3. Technical Summary

  1. Flash firmware. Clone the repo and edit firmware/lift_battery_monitor/lift_battery_monitor.ino to set your PRODUCT_UID (from your notehub.io project). Then compile and upload:

    arduino-cli lib install "Blues Wireless Notecard" "STM32duino Low Power" "STM32duino RTC" "Adafruit INA228"
    arduino-cli compile -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none firmware/lift_battery_monitor/
    arduino-cli upload  -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none \
      -p /dev/cu.usbmodem* firmware/lift_battery_monitor/

    (Replace /dev/cu.usbmodem* with your platform's serial port, which is COM* on Windows.) The usb=none board option turns off the USB CDC stack, and it has to be off for the host to sleep (see §7.1).

  2. Wire on the bench. Connect the INA228 over I²C (Qwiic), connect a 48V bench supply to INA228 VIN+ and an electronic load to VIN−, and fit the ATTN→D6 jumper on the Notecarrier CX header (§5). Open a serial monitor at 115200 baud on the ST-LINK virtual COM port (the Notecarrier CX debug jack) and watch for [meas] lines reporting pack voltage and current.

  3. Check Notehub. Your board should show up in the Devices tab within one minute. The Events tab fills in with _session.qo (confirming the radio works), battery_status.qo (hourly summary), and battery_alert.qo (on a threshold trip). Within report_interval_m minutes (60 by default), you should see the first summary with voltage, current, temperature, SoC, SoH, cycle Ah, and CAN health.

At that point the device is sampling every 5 minutes, reporting hourly, and sending immediate alerts on pack fault conditions.

Here's a sample Note this device emits:

{
  "pack_v":         56.3,
  "cur_a":          12.4,
  "temp_c":         28.5,
  "soc_pct":        74,
  "soh_pct":        88,
  "throughput_ah":  42.1,
  "can_ok":         true
}

4. Hardware Requirements

PartQtyRationale
Notecarrier CX1Integrated carrier with an onboard STM32L433 host, so you don't need a separate MCU. Exposes I²C (Qwiic), SPI, and six ADC pins on the dual 16-pin header. The host sleeps in STOP2 between wakes, and the Notecard's ATTN pin wakes it up.
Notecard for Skylo (NOTE-NBGLWX) (datasheet)1Puts LTE-M, NB-IoT, Skylo satellite NTN, and WiFi on one M.2 SoM. Cellular is the primary path, and Skylo takes over automatically when coverage drops. Includes 500 MB of cellular data, 10 KB of satellite data, and 10 years of service.
Antenna kit (included with NOTE-NBGLWX)1 setEvery Notecard for Skylo order ships with two antennas: (1) a Skylo-certified LTE/S-Band/L-Band antenna for the MAIN u.FL port and (2) a passive GPS/GNSS antenna for the GPS u.FL port. Mount both outdoors with an unobstructed view of the sky, and route the leads through dedicated cable glands in the IP67 enclosure. Don't substitute either antenna. The NOTE-NBGLWX is certified on Skylo's network only with the supplied antennas, and any replacement needs a new EIRP delta test and may get the device blocked by Skylo. See the NOTE-NBGLWX datasheet's Antenna Requirements and the antenna placement guide.
Blues Mojo1Coulomb counter on the 5V power rail that gives you ground-truth energy numbers during bring-up. It's for the bench only and isn't deployed to the field unit (see §9).
Adafruit INA228 I²C 85V, 20-bit Power Monitor (product 5832)1Bus-voltage sensing in all builds, plus current sensing through an external shunt in the default field build (ENABLE_ACS758 0). Reads pack bus voltage (up to 85 V common-mode) over I²C in every configuration. In the default field build it also measures pack current through an external precision shunt wired to VIN+/VIN– (see below). The ACS758 alternative field build (ENABLE_ACS758 1) keeps the INA228 for bus-voltage sensing only. In bench builds (BENCH_ONLY 1) it measures current through the onboard 15 mΩ shunt (≤10 A). The STEMMA QT connector makes the I²C wiring tool-free.
External precision current shunt, rated for full pack discharge current at 50–75 mV full-scale (default field build, ENABLE_ACS758 0, BENCH_ONLY 0)1Primary field current-sensing component. Pick a manganin or nichrome alloy shunt rated for the pack's peak discharge current (usually 50–200 A) at a full-scale voltage drop of 50–75 mV. For example, 0.5 mΩ works for 100 A at 50 mV, and 0.375 mΩ for 200 A at 75 mV. Wire the shunt inline on the main traction conductor, with INA228 VIN+ on the shunt high side and VIN– on the low side. Once you've picked a shunt, update DEFAULT_SHUNT_MOHM and DEFAULT_SHUNT_MAX_A in lift_battery_monitor.ino to match before you build. Mouser and Digi-Key stock precision shunts in this range (search "current sense resistor manganin 200A").
Allegro ACS758LCB-200B-PFF-T Hall-effect current sensor (alternative field build, set ENABLE_ACS758 1)0–1Optional alternative to the external shunt for field current sensing. Bidirectional ±200 A inline Hall-effect sensor. Use this path if you want galvanic isolation from the traction bus. It needs the same conductor work as the shunt, because it isn't a clamp-on or non-invasive part. To install it, you break the main traction conductor and re-terminate it through the sensor's primary conductor terminals (IP+ → IP–), and that conductor work must be done by qualified personnel following machine-specific lockout/tagout procedures. Requires a 5 V supply. Power it from the Pololu 5 V rail, not Notecarrier CX +3V3, because the ACS758 VCC minimum is 4.5 V. At VCC = 5 V the output is ratiometric, with 2.5 V at 0 A and ±10 mV/A sensitivity. At +200 A the output reaches 4.5 V, which is over the host's 3.3 V ADC rail, so you need a 10 kΩ / 20 kΩ voltage divider on VOUT (see §5). Set ENABLE_ACS758 to 1 in lift_battery_monitor_config.h before building. Digi-Key and Mouser stock it under the full part number ACS758LCB-200B-PFF-T.
10 kΩ resistor, 1% or 5%, 1/4 W (ACS758 alternative build only, ENABLE_ACS758 1)0–1High-side leg of the ACS758 VOUT voltage divider. With the 20 kΩ low-side resistor, it scales the 0–4.5 V ACS758 output down to the host's 3.3 V ADC rail (×2/3 ratio). See the §5 ACS758 wiring. You don't need it for the default external-shunt path.
20 kΩ resistor, 1% or 5%, 1/4 W (ACS758 alternative build only, ENABLE_ACS758 1)0–1Low-side leg of the ACS758 VOUT voltage divider (from the A1/divider mid-point to GND). You don't need it for the default external-shunt path.
100 nF ceramic capacitor, 10 V or higher (ACS758 alternative build only, ENABLE_ACS758 1)0–1Local bypass capacitor on the ACS758 VCC supply pin, placed as close to the sensor pins as you can. It keeps high-frequency noise on the 5 V rail from coupling into the ratiometric VOUT output and adding current-measurement error. You don't need it for the default external-shunt path.
10 kΩ NTC thermistor, β=3950, waterproof probe1Measures pack housing temperature. A waterproof probe holds up to the enclosure penetration and battery-bay humidity.
10 kΩ 1% resistor (thermistor divider)1Pull-up for the thermistor voltage divider. At 1% tolerance, temperature error stays below ±1°C across the range that matters here.
Waveshare MCP2515 CAN Bus Module (MCP2515 controller + TJA1050 transceiver, ~23 × 33 mm, 5V logic) (optional)0–1Reads cell-group voltages from the machine's BMS CAN bus. This module is small enough for a sealed enclosure, so don't swap in an Arduino-shield-form CAN board. It needs the level shifter described in §5. The machine-side CAN connector and cable are machine-specific. The OBD/service-port connector type, pinout, and cable gauge depend on the machine's manufacturer and model, and they aren't part of this BOM. Check the machine's service manual for the CAN bus access port details before you order a mating connector or cable.
Adafruit TXB0104 Quad Bi-Directional Level Shifter (product 1875) (optional, required with CAN module)0–1Translates the 3.3V host SPI signals (MOSI, MISO, SCK, CS) to the 5V logic level the Waveshare MCP2515 module needs. The TXB0104 has a push-pull output stage rated for SPI. Don't substitute a MOSFET-based open-drain level converter like the SparkFun BOB-12009. Those are designed for I²C and are unreliable on push-pull SPI lines.
Pololu 5V, 2.5A Step-Down Voltage Regulator D24V22F51Powers the Notecarrier CX (VBAT+ accepts 2.5–5.5V) and the optional CAN module from the machine's 12V auxiliary circuit. It takes up to 36V in, and 2.5A of continuous output leaves plenty of headroom.
IP67 waterproof enclosure, ~6×4×2″1Keeps battery-bay splash, dust, and the occasional hosing during machine wash-down off the electronics.

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

Wiring: INA228 on I²C, NTC thermistor on ADC, optional ACS758 on A1 or CAN BMS on SPI; ATTN → D6 jumper so the Notecard can wake the host from STOP2; MAIN antenna for cellular + Skylo NTN; 12 V pack aux → buck regulator → Mojo (bench) → +VBAT

All host I/O lands on the Notecarrier CX dual 16-pin header. A short jumper between ATTN and D6 on that header lets the Notecard wake the sleeping host. Notecard for Skylo seats in the carrier's M.2 slot, and during bench validation the Mojo sits inline between the 5V supply output and the Notecarrier's VBAT+ pad.

warning

⚠ Safety. Battery packs on electric aerial lifts carry potentially lethal voltages (24–80V DC) and very high fault currents. All connections to the high-voltage pack circuit must be made by qualified personnel following applicable electrical codes and the machine manufacturer's service procedures. Use appropriately rated wire, fusing, and insulation for every connection to the pack. The INA228 and its wiring must be rated for the pack's full voltage and expected fault current. This project has no connection to the machine's control system, and it can't command motion or charging.

Pack-bus-voltage sensing over I²C (INA228), all builds:

I²C connections (same for all builds):

  • Notecarrier CX SDA → INA228 STEMMA QT SDA (or breakout SDA pin)
  • Notecarrier CX SCL → INA228 STEMMA QT SCL (or breakout SCL pin)
  • Notecarrier CX +3V3 → INA228 VCC
  • Notecarrier CX GND → INA228 GND

All INA228 logic signals run at 3.3 V referenced to pack-negative ground, which is also the host's GND rail.

Pack-current sensing, primary field wiring (external precision shunt, ENABLE_ACS758 0, BENCH_ONLY 0):

The external shunt is wired inline on the main traction conductor. Current flows through it just as it does through the INA228 bench shunt or the ACS758, but a properly rated external shunt can carry the full traction current safely. Wire the INA228 VIN+ and VIN– differentially across the shunt.

warning

⚠ High-current conductor work. Breaking and re-terminating the traction conductor for an inline shunt must be done by qualified personnel with the pack fully isolated, meaning the main contactor is open and the pack-negative bus bar is disconnected or insulated. Follow the machine manufacturer's lockout/tagout procedures before handling the traction conductor.

High-voltage connections for the external shunt (primary field path):

  • Battery pack positive terminal → external shunt IN+ (high side) → INA228 VIN+
  • External shunt IN– (low side) → main load/charger positive bus and INA228 VIN–
  • Battery pack negative terminal → system GND → INA228 GND

readCurrent() returns calibrated milliamps because the firmware calls setShunt() at startup with DEFAULT_SHUNT_MOHM * 0.001f and DEFAULT_SHUNT_MAX_A, and readPackVI() divides the result by 1000 to get amps. Before building, update those two constants in lift_battery_monitor.ino to match the installed shunt. Insulate every wire for the pack's full voltage rating.

Pack-current sensing, alternative field wiring (ACS758LCB-200B-PFF-T, ENABLE_ACS758 1):

The ACS758LCB-200B-PFF-T is an inline Hall-effect current sensor, not a clamp-on or non-invasive part. You have to break and re-terminate the pack's main traction conductor through the sensor's primary conductor terminals (IP+ → IP–). Use this path if you need galvanic isolation from the traction bus, or if the external shunt path is impractical.

warning

⚠ High-current conductor work. Breaking and re-terminating the traction conductor must be done by qualified personnel, with the pack fully isolated: main contactor open and pack-negative bus bar disconnected or insulated. Follow the machine manufacturer's lockout/tagout procedures before handling the traction conductor.

  • Battery pack positive conductor → ACS758 IP+ terminal
  • ACS758 IP– terminal → main load/charger positive bus
  • 5 V supply rail (Pololu D24V22F5 output) → ACS758 VCC (place a 100 nF ceramic capacitor between VCC and GND close to the sensor pins). Don't use Notecarrier CX +3V3. The ACS758 VCC minimum is 4.5 V, so it won't work correctly from 3.3 V.
  • Notecarrier CX GND → ACS758 GND
  • ACS758 VOUT → 10 kΩ resistor → Notecarrier CX A1, plus a 20 kΩ resistor from the A1 node to GND

At VCC = 5 V the sensor is ratiometric, with VOUT = 2.5 V at 0 A and ±10 mV/A sensitivity. At a full-scale +200 A discharge, VOUT = 4.5 V, which is more than the host's 3.3 V ADC rail can take. The 10 kΩ / 20 kΩ voltage divider scales VOUT by 2/3, so VADC_max = 3.0 V. To convert back to current:

curA = (VADC × 1.5 − 2.5) / 0.010

One ADC count (3.3 V / 4095) works out to about 0.121 A. Set ENABLE_ACS758 to 1 in lift_battery_monitor_config.h before building, and readPackVI() does this conversion for you. The 2.5 V zero point and 10 mV/A sensitivity come from cfg.acs758_zero_v and cfg.acs758_mv_per_a, which default to those values and can be recalibrated from Notehub (see §6):

#if ENABLE_ACS758
    // Alternative field path: ACS758LCB-200B-PFF-T on A1.
    // VCC = 5 V; VIOUT = cfg.acs758_zero_v + (I × cfg.acs758_mv_per_a mV/A)
    // 10 kΩ / 20 kΩ divider scales VIOUT by 2/3 → VADC = VIOUT × (2/3)
    // Undo divider: VIOUT = VADC × 1.5
    // ...
    float vadc_v = analogRead(A1) * (3.3f / 4095.0f);
    curA = (vadc_v * 1.5f - cfg.acs758_zero_v) / (cfg.acs758_mv_per_a * 0.001f);

This path doesn't use the INA228 readCurrent() call. Wire both INA228 VIN+ and VIN– to the pack positive terminal so no traction current flows through the onboard shunt:

  • Battery pack positive terminal → INA228 VIN+ and VIN− (connect both pads to the same pack-positive node)
  • Battery pack negative terminal → system GND → INA228 GND
note

Why both VIN+ and VIN− go to pack positive in the ACS758 path. The Adafruit INA228 breakout has a 15 mΩ shunt hard-wired between the VIN+ and VIN− pads on the PCB. If VIN− went to the load/charger positive bus, the full pack discharge current would flow through that onboard shunt, even if the firmware never calls readCurrent(). At 50–200 A traction currents the shunt would be destroyed and could become a hazard. Shorting both pads to pack positive puts zero volts across the shunt. readBusVoltage() still returns the full pack voltage at VIN+ relative to GND, and this wiring doesn't affect it. Insulate both the VIN+ and VIN− wires for the pack's full voltage rating.

warning

Pack-bus-voltage and current sensing, bench/prototype wiring (INA228 onboard shunt, BENCH_ONLY 1, ≤10 A only):

⚠ Bench/prototype only. The INA228's onboard 15 mΩ shunt is rated to about 10 A continuous. Don't put the onboard shunt inline on a traction-pack conductor at real lift discharge currents (50–200 A). This path is only for bench testing with a bench supply and a current-limited load. For any field installation, use the primary external shunt wiring above.

In bench builds where pack current stays below 10 A, the INA228 can sense bus voltage and current at the same time from a single inline placement. The I²C connections are the same as above. Add the high-voltage connections below, and note that VIN− goes to the bench load here, not back to pack positive as it does in the ACS758 path:

  • Battery pack positive terminal → INA228 VIN+ (shunt high side)
  • INA228 VIN– (shunt low side) → bench load positive terminal
  • Battery pack negative terminal → system GND → INA228 GND

readCurrent() works in this configuration because the 15 mΩ shunt bridges VIN+ and VIN–. When BENCH_ONLY 1 is set, the firmware calls setShunt() with the onboard shunt values (0.015 Ω, 10 A), so bench testing doesn't need any register change.

Note on the Notecarrier CX errata. The v1.3 board silkscreen has the MOSI and MISO labels swapped on the dual 16-pin header. When you wire the CAN module's SPI, check each pin's function in the Notecarrier CX datasheet instead of trusting the header labels.

Thermistor (A0):

  • Notecarrier CX +3V3 → 10 kΩ 1% series resistor → Notecarrier CX A0
  • Notecarrier CX A0 (same junction) → NTC probe top lead → Notecarrier CX GND
  • Mount the NTC probe against the pack housing with thermal compound, and secure it with a cable tie or adhesive pad.

CAN module (optional, SPI, via level shifter):

The Waveshare MCP2515 CAN Bus Module runs at 5V logic, so every SPI line has to pass through the Adafruit TXB0104 level shifter first. The TXB0104 has a push-pull output stage rated for SPI. Don't swap in a MOSFET-based open-drain converter. Power the CAN module from the 5V supply rail (the same output as the Pololu D24V22F5), not from the Notecarrier's +3V3 header pin.

  • 5V supply (Pololu D24V22F5 output, or Mojo LOAD during bench validation; see Power chain below) → CAN module VCC and TXB0104 VCCB
  • Notecarrier CX +3V3 → TXB0104 VCCA, and connect TXB0104 OE to VCCA to enable the translator
  • Notecarrier CX GND → TXB0104 GND and CAN module GND
  • Notecarrier CX header MOSI → TXB0104 A1 ↔ TXB0104 B1 → CAN module SI
  • Notecarrier CX header MISO → TXB0104 A2 ↔ TXB0104 B2 ← CAN module SO
  • Notecarrier CX header SCK → TXB0104 A3 ↔ TXB0104 B3 → CAN module SCK
  • Notecarrier CX header D5 → TXB0104 A4 ↔ TXB0104 B4 → CAN module CS (which is why the ATTN wake jumper below uses D6)
  • CAN module CANH / CANL → machine OBD/CAN service port CAN_H / CAN_L
  • Don't add a terminator at the device end when you connect to an existing machine service-port stub. The vehicle/machine CAN backbone is already terminated at both ends, and a third resistor loads the bus and can disrupt BMS communications. Only add a 120 Ω resistor if you've confirmed with a DMM that the segment is unterminated and the monitor sits at a true electrical end of a dedicated CAN run.

ATTN-to-D6 jumper (host sleep):

Fit a short jumper between the ATTN and D6 pins on the Notecarrier CX header. Both are on the same 16-pin header. This design uses D6 instead of the usual D5 because D5 is the CAN module's chip select, and the firmware sets CX_ATTN_PIN to D6 to match. The Notecard pulls ATTN low while the host sleeps and raises it when the next sample is due, and the rising edge on D6 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. Putting a Host to Sleep Between Sensor Readings covers the full pattern.

Ground reference (verify before connecting):

⚠ Commissioning check. This design bonds the machine's 12 V auxiliary negative, the pack negative, and the monitor's logic GND to a common rail. That works on machines where the 12 V auxiliary supply comes straight from the main traction pack, which covers most electric aerial lifts. Some machines, though, use an isolated 12 V DC/DC converter that isn't referenced to pack negative. Before making any connections: measure the DC voltage between the 12 V auxiliary negative terminal and the pack negative terminal with a multimeter. A reading near 0 V confirms a common reference, and the wiring below is safe. Any other reading means the auxiliary supply is isolated. In that case, power the monitoring circuit from an isolated 5 V DC/DC converter module (for example, a Murata or Vicor isolated SIP brick), and note that connecting INA228 GND to the monitor's logic GND would bridge the isolation barrier. A qualified electrical engineer must evaluate and approve isolated sensing before you go any further.

Power chain:

⚠ Commissioning check: always-on aux rail. The monitor has to stay powered whenever the main traction pack is energized, including when the machine is keyed off, the charger is disconnected, and the machine is parked in storage. Those are exactly the stretches when dead-on-arrival conditions develop (parasitic discharge, thermal excursions during storage, charger faults at night). Before wiring the 12 V aux feed, verify with a multimeter that it stays live in all of these states: key off, charger unplugged, master disconnect open. If the rail drops in any of them, don't use it to power the monitor. Instead, feed the Pololu DC/DC converter's input directly from the main traction-pack positive terminal through an appropriately rated fuse (≤3 A slow-blow, with wire gauge rated for the full pack voltage). The Pololu D24V22F5 accepts up to 36 V. For packs above 36 V, substitute a DC/DC converter rated for the full pack voltage (up to 85 V, to match the INA228's common-mode limit). See §10 for the related deployment limitation.

Field deployment (Mojo not installed):

  • Machine 12V aux (confirmed always-on; see the commissioning check above) → DC/DC converter input → 5V rail → Notecarrier CX VBAT+ and CAN module VCC / TXB0104 VCCB (if installed)
  • Machine chassis GND → DC/DC converter GND → Notecarrier CX GND

Bench validation (Mojo installed):

  • Machine 12V aux → DC/DC converter input → 5V rail → Mojo BAT → Mojo LOAD → Notecarrier CX VBAT+ and CAN module VCC / TXB0104 VCCB (if installed)
  • Route all 5V loads from the Mojo LOAD pin so the trace captures the whole 5V subsystem's current, including the CAN module and level shifter, not just the Notecard/Notecarrier branch.
  • Machine chassis GND → DC/DC converter GND → Notecarrier CX GND

Antenna (Notecard for Skylo):

  • Use the two antennas that come in the NOTE-NBGLWX kit (see the §4 BOM). Connect the Skylo-certified LTE/S-Band/L-Band antenna to the MAIN u.FL port and the passive GPS/GNSS antenna to the GPS u.FL port.
  • Route both antenna leads through dedicated cable glands to the outside of the IP67 enclosure. Both antennas need to be outdoors with a clear view of the sky, and Skylo satellite acquisition requires that nothing metallic sits between the MAIN antenna and the sky.
  • Don't substitute either antenna. The §4 BOM row explains the certification implications, and the antenna placement guide covers routing and clearance.

6. Notehub Setup

  1. Create a project. Sign up at notehub.io and create a project. Copy the ProductUID (it looks like com.your-company.your-name:lift-battery) and paste it into the #define PRODUCT_UID line in the firmware sketch.

  2. Claim the Notecard. Power up the assembled unit. The Notecard associates itself with your project on its first cellular session, so there's no manual claim step. The device shows up in the Devices tab within a few minutes.

  3. Create a Fleet per machine category. Fleets group devices for shared configuration. For a rental company, one fleet per pack chemistry and voltage makes sense (for example, "Fleet-Lithium-48V" and "Fleet-LeadAcid-48V"). Smart Fleet rules can't match on chemistry automatically, because the firmware doesn't emit a pack_chemistry field in telemetry. Assign new devices to the right fleet by hand when you first claim them, or use per-device environment overrides for machines that don't match the fleet spec.

  4. Set environment variables. In Notehub, go to Fleets → your fleet → Environment (or Devices → your device → Environment for a per-machine override). Every variable is optional, and the table shows the firmware defaults. The device picks up changes on its next inbound sync, so you don't need a reflash or a truck roll.

    VariableDefaultPurpose
    soc_alert_pct20.0SoC (%) below which the soc_low alert fires.
    temp_high_c45.0Pack temperature (°C) above which temp_high fires.
    temp_low_c5.0Pack temperature (°C) below which temp_low fires (charging damage risk).
    soh_alert_pct70.0Rolling SoH (%) below which the soh_low alert fires.
    rated_cap_ah100.0Nameplate capacity of the pack in Ah. SoH estimation uses it as the denominator, so set it correctly for your machine model.
    chemistrylithiumBattery chemistry for the OCV→SoC lookup table. Accepts lithium (LiFePO4-biased) or lead_acid.
    cell_delta_mv200.0Largest allowed cell-group voltage imbalance in mV (CAN only). Above this threshold, cell_imbalance fires.
    sample_interval_s300Seconds between samples. The host sleeps in STOP2 between wakes, and card.attn wakes it.
    report_interval_m60Minutes between summary Notes. On the next wake after you change it in Notehub, the firmware re-applies hub.set so the Notecard's outbound cadence matches the new value, with no reboot needed.
    acs758_zero_v2.5(ACS758 builds, ENABLE_ACS758 1 only.) ACS758 output voltage at zero current (V). Nominal is 2.5 V at VCC = 5 V. At commissioning, measure it with the machine powered and the main contactor open (no traction current), then set this to the value you see to calibrate out Hall-sensor zero-offset error. A biased zero point shifts every Ah accumulation sample and corrupts throughput and SoH over time.
    acs758_mv_per_a10.0(ACS758 builds, ENABLE_ACS758 1 only.) ACS758 sensitivity (mV/A). It's 10.0 mV/A for the ACS758LCB-200B variant in the BOM, so check the datasheet if you substitute a different variant.
  5. Configure routes. Add one route for battery_alert.qo (to an on-call endpoint or rental ERP webhook for real-time triage) and a second for battery_status.qo (to a long-term analytics store for cycle-life trending). Since the two Notefiles are separate from the start, each can go to a different system at its own urgency, and the routes don't need any filtering.

What you should see in Notehub

The Events tab should start filling in within a minute of first power-on, with three event types:

  • _session.qo (appears first): automatic Notecard housekeeping on each cellular or satellite session. This confirms the radio is reaching Notehub. There's no custom body, since the Notecard logs it on its own.

  • battery_status.qo (appears after report_interval_m minutes, 60 by default): the hourly rolling summary with all the data from the sample window. Sample body:

    {
      "pack_v":         56.3,
      "cur_a":          12.4,
      "temp_c":         28.5,
      "soc_pct":        74,
      "soh_pct":        88,
      "throughput_ah":  42.1,
      "can_ok":         true
    }

    Field Notes:

    • cur_a is positive during discharge and negative during charge (if the pack is being actively charged).
    • The firmware fills all seven fields on every summary. Templated Notefiles drop any field that's exactly 0 or false on the way to Notehub (omitempty), so a missing field means zero or false.
    • temp_c is averaged over valid (non-NaN) reads only. If the thermistor produces no valid reads in the window, temp_c will be -9999.0. If you see that next to a reasonable pack_v, check the thermistor wiring.
    • can_ok is always false when ENABLE_CAN_BMS is 0 (the default), so the field doesn't show up in Notehub at all. That's expected, not a fault. It only reflects live CAN bus health when ENABLE_CAN_BMS is 1 and a CAN module is wired.
  • battery_alert.qo (appears only on a threshold trip): sent right away (within 15–60 seconds) with sync:true for every alert type except can_error. The firmware fills all five fields: alert (string), pack_v (V), soc_pct (%), temp_c (°C), and extra_v (alert-specific value). As with the summary, a field that's exactly 0 is left out in Notehub. §8 has the per-alert payload shapes. can_error is the one exception. It queues for normal outbound delivery (not sync:true) and is suppressed for an hour after each emission, so it doesn't flood the Notefile if the BMS is powered off.

7. Firmware Design

The firmware is five files in firmware/lift_battery_monitor/:

  • lift_battery_monitor_config.h holds the build-configuration toggles (ENABLE_CAN_BMS, ENABLE_ACS758, BENCH_ONLY). This is the one file to edit when you change build options. Both the .ino and the .cpp include it, so the two translation units always agree.
  • lift_battery_monitor.ino has the global declarations, setup(), and loop().
  • lift_battery_monitor_helpers.cpp contains every function implementation: Notecard config, sensor reads, SoC/SoH estimation, and alert logic.
  • lift_battery_monitor_helpers.h declares the shared types (AppState, Config), extern declarations, and function prototypes.
  • cx_sleep.h handles host sleep: the card.attn request, STOP2 entry, and ATTN-pin wake (cxSleepBegin, cxSleepUntilAttn).

7.1 Installing and flashing

Dependencies:

  • Arduino core for STM32: stm32duino/Arduino_Core_STM32. Install it from the Arduino IDE Boards Manager (search "STM32 MCU based boards"), or add the index URL https://github.com/stm32duino/BoardManagerFiles/raw/main/package_stmicroelectronics_index.json under File → Preferences → Additional Boards Manager URLs. The Notecarrier CX's host uses the Cygnet board definition (see the build settings below).
  • Blues Wireless Notecard: install it from the Arduino Library Manager or with arduino-cli lib install "Blues Wireless Notecard". The latest version is on the note-arduino releases page.
  • STM32duino Low Power (and its dependency STM32duino RTC): stm32duino/STM32LowPower. Install it from the Library Manager. It provides the STOP2 entry and ATTN-pin wake that cx_sleep.h uses.
  • Adafruit INA228: install it from the Library Manager or with arduino-cli lib install "Adafruit INA228". It pulls in Adafruit BusIO as a dependency. The firmware calls readBusVoltage() and readCurrent(), the modern API defined on the Adafruit_INA2xx base class. Those names were checked against the current library source (Adafruit_INA2xx.h).
  • mcp2515 by autowp (only if ENABLE_CAN_BMS is set to 1 in lift_battery_monitor_config.h): install it from the Library Manager or with arduino-cli lib install "mcp2515".

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 (the Notecarrier CX USB-C switch in NC, or no cable), 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).

Flashing with the Arduino IDE: Open lift_battery_monitor.ino, apply the build settings above, and click Upload. With the Notecarrier CX's DIP switch in HST, you can reach the host's DFU bootloader over the USB-C port (hold BOOT, tap RST). Or upload over SWD with an ST-LINK on the debug jack.

Flashing with arduino-cli:

First, confirm your STM32 core version and board name:

arduino-cli board listall | grep -i cygnet

The board options are STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none. If your installed core differs, substitute whatever listall reports for the board, but keep usb=none. It disables USB CDC, which has to be off for the host to sleep. Then compile and upload:

# Compile
arduino-cli compile -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none firmware/lift_battery_monitor/

# Upload (replace /dev/cu.usbmodem* with your serial port)
arduino-cli upload  -b STMicroelectronics:stm32:Blues:pnum=CYGNET,usb=none \
  -p /dev/cu.usbmodem* firmware/lift_battery_monitor/

Platform-specific serial ports:

  • macOS: /dev/cu.usbmodem* (the wildcard matches the ST-LINK's virtual COM port)
  • Linux: /dev/ttyACM* or /dev/ttyUSB* (check ls /dev/tty* after plugging in)
  • Windows: COM3 (or higher; check Device Manager → Ports)

Open a serial monitor at 115200 baud on the ST-LINK virtual COM port to watch the [meas] lines during bring-up. After each sample cycle the host sleeps for sample_interval_s seconds, and the serial output goes quiet until the next wake. That's normal. It just means the host is in STOP2, waiting for the Notecard to raise ATTN.

7.2 Modules

ResponsibilityWhere
Notecard configuration (hub.set, templates)notecardConfigure, defineTemplates
Env-variable fetch and clampfetchEnvOverrides
Pack voltage (INA228) + current (external shunt via INA228, or ACS758 on A1)readPackVI
NTC thermistor readreadPackTempC
OCV→SoC lookup + linear interpolationvoltageToSoC
Sampled Ah throughput accumulator and SoH updateupdateThroughput, updateSoH
Alert evaluation and emissioncheckAlerts, sendAlert
Hourly summary emissionsendSummary
Optional CAN BMS cell-group readpollCanBms (#if ENABLE_CAN_BMS)
Host sleep (STOP2 until the Notecard raises ATTN)cx_sleep.h: cxSleepBegin, cxSleepUntilAttn
Application state (held in RAM across sleep cycles)AppState

7.3 Sensor reading strategy

INA228. The Adafruit INA228 (default I²C address 0x40) gives a 20-bit measurement of bus voltage (up to 85 V) and, when configured for it, differential current through an external shunt wired to VIN+/VIN–. The firmware always calls readBusVoltage() to read pack bus voltage. What happens with current depends on the build. In the default field build (ENABLE_ACS758 0, BENCH_ONLY 0) the firmware also calls readCurrent() for current through the external field shunt. The ACS758 alternative build (ENABLE_ACS758 1) never calls readCurrent() and takes pack current from the ACS758 Hall-effect sensor on A1 instead. In bench builds (BENCH_ONLY 1), readCurrent() returns current through the onboard 15 mΩ shunt. A single I²C transaction for bus voltage takes under 5 ms. At startup the firmware calls setShunt() with DEFAULT_SHUNT_MOHM * 0.001f and DEFAULT_SHUNT_MAX_A (field/external shunt path) or 0.015 Ω / 10 A (bench path). That writes the SHUNT_CAL register, which calibrates readCurrent() for the installed shunt. The Adafruit library returns that current in milliamps, so readPackVI() divides it by 1000 before the ±200 A range check, the Ah accumulators, and the summary see it.

Thermistor. The firmware averages sixteen 12-bit ADC samples on A0, converts the result to resistance with the divider ratio, and then converts that to temperature with the β (simplified Steinhart-Hart) equation: T = 1 / (1/T₀ + (1/β) × ln(R/R₀)).

CAN BMS (conditional). When ENABLE_CAN_BMS is 1, the firmware initializes the MCP2515 at 250 kbps (configurable), and each wake polls for pending CAN frames up to a short timeout. Frames matching BMS_CELL_GROUP_ID get parsed as 2-byte big-endian cell-group voltages in millivolts. A classic CAN frame carries at most 8 data bytes (DLC ≤ 8), so no more than four 16-bit values fit in one frame, and the placeholder parser pulls out however many complete pairs the frame's DLC allows. The exact CAN ID, the frame layout, and whether the BMS uses multi-frame messages all vary by BMS. Since the firmware only ships a placeholder, you'll need to update BMS_CELL_GROUP_ID and parseCellGroupFrame() to match the actual BMS protocol. Common rental-equipment BMS vendors expose cell voltages on SAE J1939 parameter groups at 250 kbps, but check the machine's service documentation.

7.4 Event payload design

There are two template-backed Notefiles. battery_alert.qo uses compact format (format: "compact") with port 51, which is what lets its Notes go over satellite. It has no delete flag, because critical alerts have to get through on any available transport. battery_status.qo is templated too, but it has no format or port, so it's a non-NTN Notefile and hourly summaries only go out over cellular. Summaries queued when the Notecard falls back to satellite wait for the next cellular session, and the Notecard won't accept new ones while it's on satellite (see §7.6).

battery_status.qo hourly summary:

{
  "pack_v":         56.3,
  "cur_a":          12.4,
  "temp_c":         28.5,
  "soc_pct":        74,
  "soh_pct":        88,
  "throughput_ah":  42.1,
  "can_ok":         true
}

battery_alert.qo immediate alert (example, soc_low):

{
  "alert":    "soc_low",
  "pack_v":   51.9,
  "soc_pct":  18,
  "temp_c":   27.3,
  "extra_v":  0.0
}

The firmware puts all five fields in every alert Note. extra_v is 0.0 for alert types that have no auxiliary value (soc_low, can_error), and since templated Notefiles drop zero values on the way to Notehub (omitempty), those alerts arrive without extra_v. For the other types it carries alert-specific context (see §8).

7.5 Low-power strategy

The sidecar runs on machine power, but keeping the host truly asleep still matters. It keeps heat down in the battery bay and makes sure the monitor doesn't noticeably drain the pack it's watching. After each sample cycle, loop() calls cxSleepUntilAttn() (in cx_sleep.h), which sends a card.attn sleep request for sample_interval_s seconds, waits for the Notecard to pull ATTN low, and puts the STM32 into STOP2. There it draws a few microamps and keeps its RAM intact. When the interval is up, the Notecard raises ATTN, the rising edge on D6 wakes the host, and execution picks up on the next line of loop(). Because the host resumes in place instead of cold-booting, the AppState struct just lives in RAM. SoC, the SoH accumulators, alert cooldowns, and the running summary window all survive the sleep cycle without being serialized anywhere, and setup() runs only once per power-up. If the sleep request fails or ATTN never goes low (missing jumper), loop() waits out the interval awake and tries again on the next cycle, so the sample cadence holds either way.

The Notecard itself idles at ~8–18 µA between cellular sessions, which is the SoM's own draw from the datasheet. The assembled device also draws quiescent current from the buck regulator, the INA228 breakout (always-on I²C), the thermistor divider, and any optional CAN hardware, so the whole-device idle figure at the 5V rail is a good deal higher. During bench validation, use Mojo to measure the real assembled current instead of leaning on the Notecard datasheet figure alone (see §9). Sampling runs every 5 minutes and transmission every 60 minutes. If you change report_interval_m in Notehub, the firmware re-applies hub.set on the next wake, so the Notecard's outbound cadence stays in sync without a reboot. Every alert except can_error skips the transmit timer entirely with sync: true. can_error fires on the first failed CAN poll (once the Notecard clock is set) and is then suppressed for one hour after each emission. It queues for normal outbound delivery instead of triggering an immediate cellular session.

7.6 Retry and error handling

  • If readPackVI() returns values outside the plausible range (bus voltage < 10 V or > 85 V, the INA228's common-mode hardware limit, or current outside ±200 A), or if the INA228 isn't found (its init is retried on each wake until it succeeds), the firmware aborts the whole sample cycle. It goes straight to sleep without advancing the summary accumulators, updating SoC/SoH/throughput, or evaluating any alert thresholds. readPackTempC() runs before readPackVI(), so temperature has already been read, but the firmware throws that value away when the cycle aborts, and the thermistor accumulator doesn't advance for that wake. Alert cooldown epochs survive the abort in AppState. If every wake in a report_interval_m window aborts, summ_count will be zero. When the report timer fires on an empty window, sendSummary() advances last_summ_epoch without emitting a Note and starts a fresh full report interval from there. As a result, the first good sample after a long sensor fault starts a new window instead of triggering an under-populated summary right away. When a summary does go out, the firmware fills all seven fields. temp_c averages only valid (non-NaN) thermistor reads, and it will be -9999.0 if the thermistor produced no valid reads across the window's successful samples.
  • Alert cooldowns (ALERT_COOLDOWN_SEC, 30 minutes by default) keep a value that's drifting slowly across a threshold from paging the on-call every sample cycle.
  • While the Notecard is on satellite, it refuses note.add to the non-compact battery_status.qo. sendSummary() treats that like any failed add. It keeps the window open and tries again on the next wake, and after MAX_SUMM_RETRIES (3) failed attempts it discards that window and starts a new one. A long satellite stretch leaves gaps in the hourly summaries, but SoC, SoH, and cycle Ah stay in RAM and arrive in the next summary sent over cellular.
  • CAN read failures (when CAN is enabled) set can_ok: false in the next summary. A can_error alert fires right away on the first failed poll (once the Notecard clock is set), and later failures emit at most once per hour. A CAN failure never stops the non-CAN sensors from reporting normally.

7.7 Key code snippet 1: satellite-aware template definition

Notes can only go over the Skylo NTN satellite link if their template uses format: "compact" and a port. The port is a unique integer (1–100) that lets the Notecard refer to the Notefile over the air by number instead of by its full name string. battery_alert.qo gets both. The hourly summary template leaves both out, and that's what keeps summaries off the metered satellite link, following the non-NTN pattern in Satellite Best Practices.

Template type hints: Both template bodies use numeric "hints" to tell the Notecard each field's type for on-wire compression:

  • 14.1 = 32-bit IEEE 754 float (4 bytes), used for voltages, currents, temperatures, and Ah
  • 21 = 8-bit unsigned integer (1 byte), used for percentages (SoC, SoH, 0–100)
  • true / false = boolean (1 bit, packed), used for status flags like can_ok

Those type codes tell the Notecard how to pack pack_v: 56.3 and soc_pct: 74 into as few bytes as possible. The alert placeholder is a non-numeric string, which declares a variable-length string field that fits every alert name.

// Hourly status summary — cellular-preferred, satellite-suppressed.  With no
// format or port, queued summaries wait for the next cellular session, and
// the Notecard refuses note.add to this Notefile while it is on NTN.
J *req = notecard.newRequest("note.template");
JAddStringToObject(req, "file", "battery_status.qo");
J *body = JAddObjectToObject(req, "body");
JAddNumberToObject(body, "pack_v",  14.1);  // 4-byte float (TFLOAT32)
JAddNumberToObject(body, "cur_a",   14.1);
JAddNumberToObject(body, "temp_c",  14.1);
JAddNumberToObject(body, "soc_pct", 21);    // 1-byte unsigned int (TUINT8)
JAddNumberToObject(body, "soh_pct", 21);
JAddNumberToObject(body, "throughput_ah", 14.1);
JAddBoolToObject(body,   "can_ok",  true);
// ...

// Immediate alert — no delete flag: critical alerts must reach the fleet
// via cellular or Skylo satellite, whichever link is available
req = notecard.newRequest("note.template");
JAddStringToObject(req, "file", "battery_alert.qo");
JAddNumberToObject(req, "port", ALERT_PORT);
JAddStringToObject(req, "format", "compact");
body = JAddObjectToObject(req, "body");
// ...
JAddStringToObject(body, "alert",   "________________");  // 16-char placeholder
JAddNumberToObject(body, "pack_v",  14.1);
JAddNumberToObject(body, "soc_pct", 21);
JAddNumberToObject(body, "temp_c",  14.1);
JAddNumberToObject(body, "extra_v", 14.1);

7.8 Key code snippet 2: immediate-sync alert

sync: true brings up the Notecard's radio right away instead of waiting for the next hourly outbound window. If cellular isn't available, Notecard for Skylo opens a Skylo satellite session instead. checkAlerts() calls sendAlert() with the alert name and the alert-specific extra_v, for example sendAlert("soc_low", packV, socPct, tempC, 0.0f).

void sendAlert(const char *alert, float packV, float socPct, float tempC,
               float extraV) {
    J *req = notecard.newRequest("note.add");
    JAddStringToObject(req, "file", "battery_alert.qo");
    // port is bound in note.template (ALERT_PORT); it is not a valid note.add field.
    JAddBoolToObject(req, "sync", true);
    J *body = JAddObjectToObject(req, "body");
    JAddStringToObject(body, "alert",   alert);
    JAddNumberToObject(body, "pack_v",  packV);
    JAddNumberToObject(body, "soc_pct", (int)socPct);
    // -9999.0 is the machine-readable missing-data sentinel for an invalid or
    // absent thermistor reading — unambiguously distinct from a real 0°C pack.
    JAddNumberToObject(body, "temp_c",  isnan(tempC) ? -9999.0f : tempC);
    JAddNumberToObject(body, "extra_v", extraV);
    if (!notecard.sendRequest(req)) {
        dbgSerial.print("[warn] alert note failed: "); dbgSerial.println(alert);
    } else {
        dbgSerial.print("[alert] "); dbgSerial.println(alert);
    }
}

7.9 Key code snippet 3: sleeping until the Notecard raises ATTN

cxSleepUntilAttn() asks the Notecard to hold ATTN low for exactly cfg.sample_interval_s seconds, confirms the pin has gone low, and puts the STM32 into STOP2. Since the host stays alive, the firmware checks the result of the card.attn call instead of firing it and forgetting it. When the Notecard raises ATTN, loop() moves on to the next sample with the accumulator totals, alert cooldowns, and SoH history all still in RAM. The sketch defines CX_ATTN_PIN as D6 before it includes cx_sleep.h, and setup() arms the wake once with cxSleepBegin().

// ATTN wake pin.  D5 is the MCP2515 chip select when ENABLE_CAN_BMS is set,
// so the ATTN jumper lands on D6 instead (must be defined before cx_sleep.h).
#define CX_ATTN_PIN D6
#include "cx_sleep.h"
// ...
    // Arm the ATTN wake: the Notecard raises ATTN (D6) to end each sleep.
    cxSleepBegin();
// ...
void loop(void) {
    runCycle();

    if (!cxSleepUntilAttn(notecard, cfg.sample_interval_s, NULL, &dbgSerial)) {
        // The Notecard didn't take the sleep request, or ATTN never went low
        // (check the ATTN -> D6 jumper).  Keep the sample cadence and try again.
        dbgSerial.println("[sleep] ATTN sleep failed — waiting out the interval awake");
        delay(cfg.sample_interval_s * 1000UL);
    }
}

7.10 Key code snippet 4: SoC interpolation from OCV lookup table

Linear interpolation between table entries smooths out the SoC curve, and the chemistry environment variable picks the table at runtime.

float voltageToSoC(float voltage, bool isLithium) {
    const float (*tbl)[2] = isLithium ? SOC_TABLE_LITHIUM : SOC_TABLE_LEAD_ACID;
    const int   n = isLithium ? SOC_LITHIUM_ROWS : SOC_LEAD_ACID_ROWS;
    if (voltage >= tbl[0][0]) return tbl[0][1];
    if (voltage <= tbl[n-1][0]) return tbl[n-1][1];
    for (int i = 0; i < n - 1; i++) {
        if (voltage >= tbl[i+1][0]) {
            float slope = (tbl[i][1] - tbl[i+1][1]) / (tbl[i][0] - tbl[i+1][0]);
            return tbl[i+1][1] + slope * (voltage - tbl[i+1][0]);
        }
    }
    return 0.0f;
}

8. Data Flow

Data flow: 5-min sample of pack V/I/temp → SoC and Ah-throughput accumulation → battery_alert.qo (sync:true on threshold trip) and battery_status.qo (hourly templated, suppressed on NTN to conserve satellite budget) → Notehub

Every sample_interval_s seconds the firmware wakes, reads up to four data sources, and decides what to send. Alerts and summaries go out on two separate transmit paths, each with its own timer, and neither one depends on the other.

Collected (per wake):

  • Pack bus voltage (V): INA228 over I²C (readBusVoltage()), all builds
  • Pack current (A): INA228 readCurrent() through the external precision shunt in the default field build (ENABLE_ACS758 0, BENCH_ONLY 0), the ACS758LCB-200B-PFF-T Hall-effect sensor on A1 in the alternative field build (ENABLE_ACS758 1), or the INA228 onboard 15 mΩ shunt through readCurrent() in the bench build (BENCH_ONLY 1, ≤ 10 A only)
  • Pack housing temperature (°C): NTC thermistor on A0
  • Cell-group voltages in mV (up to 4 per frame from the placeholder parser): optional CAN BMS

Computed (per wake, from collected + retained state):

  • SoC (%): voltage-based OCV lookup, linearly interpolated, with no temperature compensation (see §10)
  • Cycle Ah throughput: a sampled bidirectional Ah accumulator (|current| × Δt once per wake, 0.5 A noise floor). It counts both charge and discharge current and resets when the pseudo-cycle SoH threshold triggers. Note: this isn't depth of discharge, because it doesn't track the net discharged deficit below full. §10 covers the production Coulomb-counting extension.
  • SoH (%): EWMA of the discharge-only Ah accumulated per pseudo-cycle, divided by rated_cap_ah, and updated when SoC crosses the 30% → 90% heuristic threshold

Transmitted:

  • battery_status.qo: one record per report_interval_m (24 records/day by default), queued and sent on the Notecard's hourly outbound sync. It holds window-averaged pack_v, cur_a, and temp_c, plus the current SoC, SoH, cycle Ah throughput accumulator (throughput_ah), and CAN health flag (can_ok is false by design when ENABLE_CAN_BMS=0, not a fault). It's templated but not compact, so it only travels over cellular, and summaries queued while the Notecard is on satellite wait for cellular to come back. Example body:

    { "pack_v": 56.3, "cur_a": 12.4, "temp_c": 28.5, "soc_pct": 74, "soh_pct": 88, "throughput_ah": 42.1, "can_ok": true }
  • battery_alert.qo: sent only on a threshold trip. Every alert type except can_error carries sync: true, so it skips the outbound timer and goes out right away over cellular or Skylo satellite, whichever is available. can_error queues for normal outbound delivery (see below). A 30-minute cooldown per alert keeps the on-call from getting paged over and over.

Alert triggers:

Every alert Note uses the same fixed compact schema, and the firmware always emits all five fields: alert (string), pack_v (V), soc_pct (%), temp_c (°C, or -9999.0 if there was no valid thermistor read when the alert fired), and extra_v (float, 0.0 when no auxiliary value applies). Notehub leaves out any field that's exactly 0 (templated Notefiles use omitempty), so downstream consumers should treat a missing pack_v or extra_v as 0.0.

Alert TypeTriggerextra_v valueNotes
soc_lowSoC drops below soc_alert_pct0.0Example: {"alert":"soc_low","pack_v":51.9,"soc_pct":18,"temp_c":27.3,"extra_v":0.0}
temp_highTemp exceeds temp_high_cTemperature at trip (°C)Same as temp_c. Lithium loses capacity above ~45°C, and runaway becomes a risk above 60°C.
temp_lowTemp falls below temp_low_cTemperature at trip (°C)Lithium must not be charged below 0°C.
soh_lowRolling SoH drops below soh_alert_pctCurrent SoH (%)Time to schedule a pack inspection or replacement.
cell_imbalance (CAN)Max–min cell voltage exceeds cell_delta_mvVoltage delta (mV)pack_v = 0.0, temp_c = -9999.0 (CAN poll doesn't re-sample V/I/T).
can_error (CAN)No CAN frame received during poll window0.0Fires on the first failure, then is suppressed for 1 hour after each emission. pack_v = 0.0, temp_c = -9999.0. Queues for normal outbound (not sync:true).

Routing: Notehub sends battery_alert.qo to a real-time notification channel and battery_status.qo to a long-term analytics store for cycle trending and fleet-level SoH dashboards. This project doesn't ship a specific downstream endpoint, so see the Notehub routing docs for setup details.

9. Validation and Testing

Expected cadence. A healthy machine in steady state should send one battery_status.qo per hour and no battery_alert.qo events. SoH starts at 100% and moves toward the true measured value after the first full discharge/charge cycle, so give it a few days of normal operation before the SoH number settles. Threshold trips in the first week usually mean the default thresholds don't match that particular pack. Tune them with environment variables instead of reflashing.

Bench bring-up. Before you connect to a live machine:

  1. Power the Notecarrier CX + Notecard from a bench supply at 5V through the Mojo.
  2. Connect the bench supply positive to INA228 VIN+ (the shunt high side). Wire INA228 VIN– to a bench electronic load set to 1–2 A constant current, and return the electronic load to the supply GND, which is also INA228 GND. Set the supply to 48 V. An electronic load is much safer and easier to control than a power resistor here. At 48 V and 5 A you're dissipating 240 W, and a resistive load rated for that gets dangerously hot. If a resistor is all you have, use at least a 50 W chassis-mount unit and keep the test short.
  3. Check the serial output at 115200 baud for [meas] lines with reasonable pack_v and cur_a values. Then confirm that the first battery_status.qo shows up in Notehub within the first report_interval_m minutes.
  4. Set soc_alert_pct to a high value (for example, 95) in the Fleet environment to force an immediate alert. battery_alert.qo should appear in Notehub with sync: true latency, usually 15–60 seconds after the cellular session comes up.

Validating power behavior with Mojo. Blues Mojo has an LTC2959 coulomb counter that's accurate to 1% over its measurement range. For a whole-device measurement, use the bench wiring in §5 and splice Mojo in so it catches every 5V load. That means the Notecarrier CX VBAT+ pad and, if the CAN subsystem is installed, the CAN module VCC and TXB0104 VCCB supply lines. With every load downstream of Mojo, the deep-sleep current in the trace includes the INA228, thermistor divider, buck regulator, and optional CAN hardware along with the Notecard itself. So the assembled idle figure will be well above Notecard's published 8–18 µA. Use Mojo to find the real number for your build instead of relying on the datasheet. The per-state current figures below take the Notecard rows from the NOTE-NBGLWX datasheet. The host-awake and CAN rows are estimates from component specs, so verify them with Mojo:

Firmware stateCurrent @ 5V
Deep sleep (host in STOP2, Notecard idle between sessions)~8–18 µA for the Notecard alone (NOTE-NBGLWX datasheet), plus ~1–2 µA for the STM32L433 in STOP2. The assembled device draws noticeably more, because the INA228, thermistor divider, and buck regulator each add quiescent current. Measure the assembled idle with Mojo to get the real figure for your build.
Host awake, sampling (INA228 I²C + ADC read, ~2–3 seconds)Initial estimate ~15–25 mA at the 5V rail, worked out from the STM32L433 active-run, INA228, thermistor divider, and buck-regulator quiescent specs, not measured on the bench. Add ~5–10 mA if ENABLE_ACS758 1 (ACS758 VCC load at 5 V). Measure with Mojo before deployment, since the real assembled current depends on PCB layout, regulator selection, and peripheral choices.
CAN polling (when ENABLE_CAN_BMS=1, CAN module powered from Mojo LOAD output)Initial estimate ~10–15 mA additional above the host-awake baseline, worked out from the MCP2515, TJA1050, and TXB0104 datasheet quiescent figures, not measured on the bench. Before you rely on this number, measure with Mojo with the full CAN subsystem downstream of the Mojo LOAD pin.
Cellular session (LTE-M outbound sync)~250 mA average with the Notecard modem active (NOTE-NBGLWX datasheet). The NOTE-NBGLWX is LTE-M/NB-IoT/GPRS, so there's no GSM 2 A burst. Add the assembled idle draw for the length of the session.
WiFi session (only if you change card.transport to a WiFi method)~80 mA average with Notecard WiFi active. Add the assembled idle draw for the length of the session.
Satellite NTN session (Skylo)The modem is active during NTN acquisition and transmit. The NOTE-NBGLWX datasheet has confirmed NTN-mode current figures. Setting up a session usually takes 1–3 min.

Determining 24-hour energy consumption. Use Mojo to measure the integrated charge over one complete sample cycle (deep sleep, host wake, and any radio activity), and separately over one complete cellular session. With the firmware defaults (5-minute sample, hourly cellular sync), the 24-hour total comes out to roughly (sleep + wake charge per cycle × 288 cycles/day) + (cellular session charge × 24 sessions/day). Don't plug estimated per-component figures into this calculation. The assembled quiescent current swings a lot with buck-regulator selection, PCB routing, and peripheral choices, so measure the real assembled build with Mojo before you specify battery backup capacity. If the Mojo trace shows a steady elevated baseline instead of brief wake pulses, the host isn't entering STOP2. Check the ATTN→D6 jumper, and confirm the firmware was built with the usb=none board option (USB support set to None). Hourly bursts that last 2 minutes or more usually point to marginal cellular signal, so move the antenna or check Skylo coverage at the test site.

A satellite session uses an order of magnitude more energy per kilobyte than LTE-M. Summaries never go over satellite (battery_status.qo is a non-NTN template), so the satellite path should only ever carry alert-sized payloads, usually under 100 bytes each. One or two alerts per satellite session is a reasonable budget for a critical low-SoC event. If a session sends a burst of alerts back to back, alert dedup probably isn't working, and you should check the cooldown.

Mojo doesn't go out with the field unit. Its job is done once a firmware revision passes the bench trace check.

Common bench issues

SymptomLikely causeWhat to check
Device never shows up in the Notehub Devices tab.PRODUCT_UID is wrong or empty, or the antenna isn't outdoors.Re-check PRODUCT_UID. Move the unit outdoors with the antenna pointed up. Look for _session.qo events. If there aren't any, the radio isn't connecting.
_session.qo appears but no battery_status.qo.The first report_interval_m hasn't elapsed yet, or summ_count stayed at zero because every sensor read was rejected.Check the serial output at 115200 baud for [meas] lines. Confirm the INA228 is powered and pack_v is non-zero.
pack_v reads ~0 V or firmware reports INA228 not found.INA228 not found at address 0x40, or SDA/SCL not connected.Check Qwiic continuity. Send {"req":"card.status"} in the In-Browser Terminal to confirm the Notecard is alive, then check the INA228 I²C wiring.
pack_v reads out of plausible range (>85 V or <10 V).INA228 VIN+ isn't connected to the pack positive terminal, the wiring is reversed, or the pack voltage is over the INA228's 85 V common-mode limit.Use a DMM to confirm the VIN+ wire reaches pack positive and that GND is at pack negative. If a pack's full-charge voltage may exceed 85 V, swap in a voltage-sensing front end with a higher common-mode rating (see §10).
temp_c is -9999.0 with otherwise valid readings.The NTC thermistor is unplugged or the series resistor is missing.Check continuity at A0 to the NTC junction. With a 10 kΩ NTC at room temperature, A0 should read ~1.65 V.
soc_pct shows 0% with a full pack.Wrong chemistry env var for this pack type.Check the chemistry variable in Notehub. A lithium OCV table gives wrong readings on a lead-acid pack because the voltage ranges differ by several volts.
CAN data always absent (can_ok never shows up as true).ENABLE_CAN_BMS is 0 (default), or BMS_CELL_GROUP_ID doesn't match the machine.Set ENABLE_CAN_BMS to 1 in lift_battery_monitor_config.h and recompile. Use a CAN analyzer to confirm the BMS is actually transmitting, and capture the correct CAN ID.
Alerts fire every sample with no cooldown.Alert cooldown state is getting lost. Either the host is resetting between wakes instead of resuming from STOP2, or the Notecard clock isn't set, so now == 0 and the epoch-based cooldown can't run.Watch the debug serial output. If you see [boot] first wake on every cycle, the host is rebooting (check power, the ATTN→D6 jumper, and the usb=none build). Confirm that card.time returns a valid epoch.

10. Limitations and Next Steps

A pack-health monitor makes calls about safety-critical equipment, so the line between this reference build and a production fleet rollout needs to be clear. Shunt sizing has to match real traction currents, and SoC needs both OCV and Coulomb counting before you can trust it under load. CAN integration is per-vendor work. The device also has to stay powered while the machine sits in storage, since that's exactly when dead-on-arrival problems develop. The list below covers each of those limits and the natural production path for each one.

Simplified for this Proof-of-Concept

These are the scope choices that keep the proof of concept small. Each one marks a spot where a production fleet will want something more: a different shunt, a blended SoC estimate, vendor-specific CAN work, or an always-on power feed.

The INA228 onboard shunt is bench-only; the default production path uses an external precision shunt. Electric aerial lifts commonly draw 50–200 A during lift operation, and the INA228's onboard 15 mΩ shunt is rated to ~10 A continuous, so it must not go inline on a traction-pack conductor at those currents. The firmware defaults to ENABLE_ACS758 0, where readPackVI() reads pack current with INA228 readCurrent() through an external precision shunt wired to VIN+/VIN– (see §4 and §5). Update DEFAULT_SHUNT_MOHM and DEFAULT_SHUNT_MAX_A to match the installed shunt before building. The alternative field path (ENABLE_ACS758 1) uses an ACS758 Hall-effect sensor on A1 instead. Use that path when you want galvanic isolation from the traction bus. For bench testing only, set ENABLE_ACS758 0 and BENCH_ONLY 1 to use the onboard 15 mΩ shunt at ≤10 A. In every case, shunt configuration has no effect on the INA228's bus-voltage path (up to 85 V common-mode).

OCV-based SoC is inaccurate under load. Open-circuit voltage only tracks SoC when the pack has been at rest (no load or charge current) for at least several minutes. In operation, the internal resistance drop pulls the terminal voltage down, so the firmware's SoC estimate reads lower than the real SoC while the lift is moving. For production, blend the OCV lookup with a Coulomb-counting depth-of-discharge estimate. Coulomb counting holds up during dynamic operation, and OCV is accurate at rest. The throughput_ah field is a bidirectional accumulator, not a true DoD value, so a production Coulomb counter has to track the net discharged deficit below full charge separately.

SoC tables are sized for 48V packs. The firmware's lookup tables assume a 16S LiFePO4 or 24-cell lead-acid 48V pack. Machines with 24V, 36V, 72V, or 80V packs need tables scaled to their cell count. The clean production extension is a rated_voltage_v environment variable plus a scaling pass on the OCV table entries. Note: for 72V and 80V packs, you also have to check the INA228 common-mode limit before deployment. See below.

INA228 bus-voltage input is limited to 85 V common-mode. The Adafruit INA228 breakout that handles bus-voltage sensing has an 85 V common-mode input limit. That's comfortably above the full-charge voltage of the 48 V-class packs the firmware defaults target (16S LiFePO4 full charge ≈ 58.4 V, 24-cell lead-acid ≈ 51.8 V). Higher-voltage packs leave a lot less headroom, though. A 20S LiFePO4 pack (64 V nominal) can reach up to 73 V fully charged, which is still within limits. A 22S or 24S LiFePO4 pack sold as "72 V" may reach 80–87.6 V fully charged, depending on cell count. Many 80 V-class packs (for example, 24S LiFePO4 at 3.65 V/cell = 87.6 V) will exceed the INA228's 85 V limit when fully charged, and that could damage the device. Before deploying this design on any pack above 60 V nominal, measure or calculate the full-charge bus voltage and confirm it stays below 85 V. If a pack can't meet that, replace the INA228 for the pack-voltage measurement with a voltage-sensing front end that has a higher common-mode rating (such as an isolated bus-voltage monitor). The rest of the architecture doesn't change.

CAN BMS integration is a placeholder, not deployable without vendor-specific work. The CAN frame parser ships with a hardcoded example BMS_CELL_GROUP_ID of 0x18FF50E5 (a 29-bit extended J1939-style ID), a fixed 250 kbps bitrate, single-frame parsing only, and a simple layout that reads however many 2-byte groups fit in one frame. Without customization, it won't match any real BMS. Real BMS vendors use proprietary CAN IDs, scaling factors, multi-frame protocols, and message formats. You'll need the BMS vendor's CAN DBC file or protocol documentation so you can update BMS_CELL_GROUP_ID and parseCellGroupFrame() to match, and you'll need to check the bitrate against the machine's CAN bus configuration. Some rental-equipment BMS implementations use SAE J1939 PGNs, while others use fully proprietary protocols with no public documentation. Don't deploy ENABLE_CAN_BMS 1 to a machine until you've captured and decoded its actual CAN traffic with an analyzer.

SoH requires a full discharge cycle to calibrate. SoH starts at 100% on first power-on. The first meaningful reading comes after the device sees one complete discharge cycle below 30% SoC followed by a charge back above 90% SoC. Until then the field still shows up in summaries, but it may be misleading.

No temperature compensation on OCV. Battery OCV shifts with temperature. At the same SoC, a cold lead-acid pack at 0°C reads several tenths of a volt lower than it does at 25°C, and the firmware doesn't correct the OCV lookup for that. For accurate low-temperature SoC on lead-acid packs, add a ΔV/ΔT correction factor. The firmware already has the pack temperature.

Aux-rail power dependency. The design powers the monitor from the machine's 12 V auxiliary circuit. On some machines that rail drops when the key is turned off, the charger is disconnected, or the master disconnect is opened. On those machines the monitor goes blind during storage, transport, and the gaps between jobs, which is exactly when low SoC, deep discharge, and thermal excursions are most likely to develop unnoticed. The §5 commissioning check covers this. If the field team hasn't confirmed that the chosen aux rail is always-on, though, the monitor gives you no coverage while the machine is in storage. For full coverage, the power feed has to come from the main traction pack terminals through an appropriately rated HV DC/DC stage.

Satellite data budget. Notecard for Skylo includes 10 KB of satellite data. A compact alert Note is usually under 100 bytes on the wire, so 10 KB covers roughly 100 satellite alert events per billing period. Inbound syncs also run over satellite at the hub.set cadence (every 2 hours), and each one costs about 50 bytes, so set the _sync_inbound_mins_ntn reserved environment variable to stretch that out while the Notecard is on NTN. The firmware doesn't define an env.template, which Notecard needs before it can receive environment variables over satellite, so threshold changes reach the device on its next cellular session. If you expect a deployment to run on NTN for long stretches, watch satellite usage in Notehub and think about upgrading the data plan. Skylo coverage also needs an unobstructed view of the equatorial sky, so the antenna has to be mounted on the outside of the machine, not inside an enclosed battery bay.

Mojo does not read from the Notecard in firmware. The sketch doesn't query the LTC2959 coulomb counter over Qwiic for runtime power telemetry. Adding a mojo_mah field to the summary would be an easy extension.

Production Next Steps

After the basic monitor is running on a machine, these extensions get it ready for a real rental fleet. They're listed roughly from most immediately useful to most integration-dependent.

Commission the current sensor on each deployed unit. On the default external-shunt field path (ENABLE_ACS758 0), confirm that DEFAULT_SHUNT_MOHM and DEFAULT_SHUNT_MAX_A in the firmware match the installed shunt before flashing. After installation, with the machine powered but the main contactor open, the [meas] i= line in the serial output should read near 0 A. On the ACS758 alternative path (ENABLE_ACS758 1), the firmware ships with nominal datasheet values (acs758_zero_v = 2.5 V, acs758_mv_per_a = 10.0 mV/A). Hall-sensor zero-offset error can bias the Ah accumulator by several amps, though, and corrupt throughput and SoH over time. After installation, with zero traction current flowing, check the [meas] i= line. If the offset is significant, calculate the true zero-current VOUT = analogRead(A1) × 3.3/4095 × 1.5 and set the acs758_zero_v Notehub environment variable to that measured value. If the installed sensor variant isn't the ACS758LCB-200B (10 mV/A), update acs758_mv_per_a to match.

Add temperature compensation to the OCV lookup table and to the Coulomb counting (charge efficiency drops at low temperature).

Expand the SoC table to cover all the common pack voltages (24V, 36V, 48V). For 72V and 80V packs, first verify or replace the INA228 voltage-sensing front end (see the INA228 common-mode limit note above), then add the higher-voltage table entries and pick the table from a rated_voltage_v environment variable.

Implement vendor-specific CAN BMS parsers for the packs most common in your fleet. The charger often shares that bus too (Zivan and Delta-Q chargers are common on electric lifts), so its CAN messages are worth parsing as well.

Add a battery_command.qi inbound Notefile for fleet-triggered actions like "reset SoH accumulator after a pack swap" or "clear alert cooldowns after a maintenance visit."

Deploy Notecard Outboard DFU so the rental company can push OCV table updates, threshold recipes, and new BMS parsers across the fleet without a service visit.

Store a rolling history of the last N cycle SoH measurements in the summary Note, so the fleet dashboard shows a degradation trend instead of a single current value.

11. Summary

The rental scissor lift that used to show up at a job site with a quietly failing pack now reports its own health every hour. A precision I²C power monitor, a thermistor, and an optional CAN BMS interface turn the pack from a black box into something you can watch continuously. The device samples locally every five minutes and transmits hourly over LTE-M, with Skylo satellite as the automatic fallback when the lift is on a site with no cell coverage. Threshold trips reach the rental company's fleet desk within seconds over whatever radio path the machine can reach, whether that's low SoC before a job-site delivery, a thermal excursion in storage, or SoH sliding toward end-of-life. A dead-on-arrival delivery becomes something you can diagnose and prevent, instead of a $2,000 emergency swap and an angry customer.

The same architecture carries over to any electric machine in a rental fleet whose fully charged pack voltage stays within the INA228's 85 V common-mode limit, like compact forklifts, electric floor scrubbers, pallet jacks, and utility vehicles. Only the OCV lookup tables and the CAN BMS parser change from one machine type to the next. Higher-voltage variants (packs above ~60 V nominal, where the full-charge voltage may approach or exceed 85 V) also need a voltage-sensing front end with a higher common-mode rating on top of the table and parser updates.

On This Page

  • 1. Project Overview
  • 2. System Architecture
  • 3. Technical Summary
  • 4. Hardware Requirements
  • 5. Wiring and Assembly
  • 6. Notehub Setup
    • What you should see in Notehub
  • 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 1: satellite-aware template definition
    • 7.8 Key code snippet 2: immediate-sync alert
    • 7.9 Key code snippet 3: sleeping until the Notecard raises ATTN
    • 7.10 Key code snippet 4: SoC interpolation from OCV lookup table
  • 8. Data Flow
  • 9. Validation and Testing
    • Common bench issues
  • 10. Limitations and Next Steps
    • Simplified for this Proof-of-Concept
    • Production Next Steps
  • 11. Summary

Resources

  • GitHub
  • Request More Info
Can we improve this page? Send us feedback
© 2026 Blues Inc.
© 2026 Blues Inc.
AboutDocsAPI ReferenceTermsPrivacy