I designed and built a proof-of-concept remote battery monitoring system that uses a Victron Energy SmartShunt with cellular connectivity provided by a Blues Notecard.
The system can monitor battery banks in remote locations and immediately alert you to critical conditions like low voltage or overheating—even when there's no Wi-Fi for miles.
The main driving factor for this project was to be able to remotely monitor my boat's battery without needing to invest in all the complex and expensive Victron telemetry ecosystem. While their Cerbo GX product range is great, it requires almost $500 of hardware to monitor the battery over cellular, and you need a cellular data plan!
In addition to the physical application, I built a VE.Direct simulator CLI so you can develop and test this project safely at your desk, without risking damage to expensive equipment.
Modern battery systems, in particular lithium-based chemistry, are fussy about how and why they are charged. Not abiding by the correct charging procedures can result in degraded battery capacity, overheating or, in the worst-case scenario, thermal runaway!
Traditional monitoring solutions assume you have reliable internet, but in the real world, battery installation can be messy:
- Cellular coverage varies throughout the day; it can often be highly weather-dependent.
- Power is precious—your monitor shouldn't drain the battery it's protecting.
- Sites might be hard to reach; think of a boat on a mooring buoy!
- Critical alarms need to get through immediately, but routine data can wait.
For my specific use case, I wanted to monitor the health of a battery bank hidden under the back bunk of my boat. I'm often far away from the boat, and knowing that the battery is charging correctly and can run any emergency measures, like pumps and alarms, is critical to the boat's safety.
I already had a Victron SmartShunt on the battery monitoring it locally, so it made sense to give it connectivity!
PrerequisitesThis guide makes a few assumptions about your setup:
- You're already familiar with Notecard and Notehub (If not, check out the quick start guides)
- You already have a VE.Direct-based system locally monitoring the health of your batteries, or you are confident in installing such a system yourself
Note: This guide does not contain professional electrical installation advice. If you are not confident tampering with high-current systems like house batteries, consult a professional. I built the VE.Direct simulator specifically to address this.
How It Works: Alarms vs TelemetryRather than run a cellular radio continuously, transmitting at high frequency (at the cost of high power and data consumption), my system stores the last X minutes of telemetry data and sends it at every Y minute interval, unless the data is an alarm.
Critical Alarms (e.g. low voltage, overheating) → Immediate transmission
Routine Telemetry (voltage trends, charge cycles) → Queued for periodic sync
This approach keeps power and data consumption low while ensuring you never miss a critical alert.
As the SmartShunt emits a VE.Direct data stream, which is a TTL serial protocol, it's straightforward to connect it to a microcontroller, such as Blues Swan or Cygnet. We can parse this to aggregate telemetry about the battery and its health.
Since working with live battery systems can be dangerous and costly if you get it wrong, I used Claude Code to help me build a simulator that generates realistic VE.Direct data streams. The simulator also allows you to create mock events like low-voltage and over-temperature alarms, something challenging to verify otherwise.
Installing the Simulator
The simulator is a Golang CLI that emits a mock VE.Direct stream to your shell, which can be pointed at a serial port. Currently, this works on Linux & macOS, but it's open to contribution if you want to support Windows!
# Clone the repository and build the binary
git clone https://github.com/bucknalla/ve-direct-simulator
cd ve-direct-simulator
go build -o ve-direct-simulator
Running Different Device Simulations
The simulator supports a number of different device types, including BMS, Solar MPPTs and Inverters.
# Simulate a BMV-712 Smart battery monitor
./ve-direct-simulator --device bmv712 --port /dev/ttyUSB0
# Simulate alarm conditions for testing
./ve-direct-simulator --device bmv712 --alarm low-voltage --port /dev/ttyUSB0
# Simulate an MPPT solar charge controller
./ve-direct-simulator --device mppt7515 --port /dev/ttyUSB0
It outputs a realistic VE.Direct protocol stream:
V 12800 // Battery voltage (mV)
I -1230 // Current (mA)
P -15 // Power (W)
SOC 990 // State of charge (‰)
Alarm OFF // Alarm state
The VE.Direct protocol itself can be found on Victron Energy's website.
VE.Direct Pinout
Unfortunately, some of Victron's VE.Direct compatible devices run at 5 V and some of them run at 3.3 V, so you should ALWAYS check the power requirements before connecting to real hardware.
- Pin 1: GND (Black) → Connect to GND
- Pin 2: RX (Yellow) → Connect to F_TX
- Pin 3: TX (Orange) → Connect to F_RX
- Pin 4: Power (Red) - Not used
A useful piece of advice in a blog post about building a VE.Direct data-logger suggests using an optocoupler if you're unsure or want to make your monitor compatible with all Victron devices. Although be aware, it will invert your signal.
Setting Up the Hardware
Connect your Swan to the Notecarrier-F, then either:
- Connect to a USB-to-serial adapter running the Go CLI simulator
- When you're ready to test for real, connect to a real VE.Direct device using a 4-pin JST PH connector
For this project, I used a 12 V - 5 V step-down converter to power the Notecarrier-F from the 12 V lead acid battery.
On the Notecarrier side, you'll need to connect the VE.Direct RX to F_TX and VE.Direct TX to F_RX. Remember to connect up GND as well!
Now that we know we can simulate a SmartShunt, we can move onto reading the serial stream from Arduino. For this project I used an STM32, the Blues Swan MCU, although the Arduino code should work on your own MCU with a little modification.
Arduino Code
#include <Notecard.h>
#include <VEDirect.h>
#define VEDIRECT_RX_PIN 16
#define VEDIRECT_TX_PIN 17
Notecard notecard;
VEDirect vedirect(VEDIRECT_RX_PIN, VEDIRECT_TX_PIN);
bool alarmActive = false;
unsigned long lastTelemetryTime = 0;
void setup() {
Serial.begin(115200);
// Initialize Notecard
notecard.begin();
// Configure for your Notehub project
J * req = notecard.newRequest("hub.set");
JAddStringToObject(req, "product", "com.your-company.your-name:battery_monitor");
JAddStringToObject(req, "mode", "periodic");
JAddNumberToObject(req, "outbound", 60); // Sync every 60 minutes
notecard.sendRequest(req);
// Start VE.Direct communication
vedirect.begin();
Serial.println("Battery Monitor Started");
}
void loop() {
// Check for VE.Direct data
if (vedirect.read()) {
processVEDirectData();
vedirect.clearBlock();
}
delay(100);
}
The setup:
- Initialises serial communication for debugging output
- Connects to the Blues Notecard and configures it for your Notehub project
- Sets the Notecard to
"periodic"
mode, syncing data every 60 minutes to conserve cellular data - Initializes the VE.Direct serial connection on pins 16 and 17 to communicate with Victron devices
The main loop:
- Continuously checks for incoming VE.Direct data packets from the battery monitor
- When a complete data block is received, it calls
processVEDirectData()
to handle the battery information - Clears the data buffer and waits 100 ms before checking again
This basic structure establishes the communication channels between your Arduino, the battery system (via VE.Direct), and the cloud (via Notecard). The real intelligence happens in the processVEDirectData()
function, which we'll implement next to distinguish between routine telemetry and critical alarms.
Handling Alarms vs Telemetry
As we mentioned previously, we don't need the system to constantly transmit data, so we should decide if the incoming VE.Direct data concerns telemetry or if it is an alarm.
void processVEDirectData() {
String alarmState = vedirect.getAlarmState();
long alarmBits = vedirect.getFieldAsLong("Alarm");
// NEW ALARM DETECTED
if (alarmBits != 0 && !alarmActive) {
sendImmediateAlarm(alarmBits);
alarmActive = true;
}
// ALARM CLEARED
else if (alarmBits == 0 && alarmActive) {
sendAlarmCleared();
alarmActive = false;
}
// ROUTINE TELEMETRY (every 5 minutes)
if (millis() - lastTelemetryTime > 300000) {
queueTelemetryData();
lastTelemetryTime = millis();
}
}
void sendImmediateAlarm(long alarmBits) {
Serial.println("ALARM DETECTED - Sending immediately");
J * req = notecard.newRequest("note.add");
JAddStringToObject(req, "file", "alarms.qo");
JAddBoolToObject(req, "sync", true); // IMMEDIATE SYNC
J * body = JCreateObject();
JAddStringToObject(body, "event", "battery_alarm");
JAddNumberToObject(body, "alarm_bits", alarmBits);
JAddStringToObject(body, "severity", classifyAlarmSeverity(alarmBits));
JAddNumberToObject(body, "voltage", vedirect.getBatteryVoltage());
JAddNumberToObject(body, "current", vedirect.getBatteryCurrent());
JAddNumberToObject(body, "soc", vedirect.getStateOfCharge());
JAddItemToObject(req, "body", body);
notecard.sendRequest(req);
}
void queueTelemetryData() {
J * req = notecard.newRequest("note.add");
JAddStringToObject(req, "file", "telemetry.qo");
// No sync flag - queued for next periodic transmission
J * body = JCreateObject();
JAddNumberToObject(body, "voltage", vedirect.getBatteryVoltage());
JAddNumberToObject(body, "current", vedirect.getBatteryCurrent());
JAddNumberToObject(body, "power", vedirect.getPower());
JAddNumberToObject(body, "soc", vedirect.getStateOfCharge());
JAddNumberToObject(body, "timestamp", millis());
JAddItemToObject(req, "body", body);
notecard.sendRequest(req);
}
How the Data Handling Works
This part of the code implements the logic of the system. It distinguishes between critical alarms that need immediate attention and routine telemetry data that can wait.
processVEDirectData()—The Decision Engine
- Reads the alarm status from the VE.Direct data stream using
getFieldAsLong("Alarm")
- Detects alarm state changes (new alarm triggered or existing alarm cleared)
- Manages a routine telemetry collection every 5 minutes (300, 000 milliseconds)
- Uses a state variable (
alarmActive
) to prevent duplicate alarm notifications
sendImmediateAlarm()—Critical Path
- Triggers when
alarmBits != 0
(any alarm condition detected) - Creates a note in the
alarms.qo
file withsync: true
for immediate cellular transmission - Includes alarm context: classification, current voltage, current draw, and state of charge
- Send over cellular modem immediately, regardless of the normal sync schedule
queueTelemetryData()—Scheduled Path
- Runs every 5 minutes to collect routine battery metrics
- Creates a note in the
telemetry.qo
file - Data waits in the local queue until the next scheduled sync (every 60 minutes)
- Conserves cellular data and battery power by batching routine measurements
Alarms: Immediate delivery ensures you know about critical conditions within seconds
Telemetry: Batched delivery provides historical trends without draining your battery
Result: Real-time alerts but efficient long-term monitoring
Part 3: Testing with the Simulator1. Start the simulator:
./ve-direct-simulator --device bmv712 --port /dev/ttyUSB0
2. Upload Arduino code and monitor serial output
3. You should see telemetry data being queued every 5 minutes
Alarm Testing
1. Trigger a low-voltage alarm
./ve-direct-simulator --device bmv712 --alarm low-voltage --port /dev/ttyUSB0
2. Watch Arduino serial output for immediate alarm transmission
3. Check Notehub for alarm data in alarms.qo
Notefile
Simulating Network Conditions
The simulator can help test various scenarios:
# Test with fluctuating battery levels
./ve-direct-simulator --device bmv712 --scenario discharge-cycle
# Test multiple alarm conditions
./ve-direct-simulator --device bmv712 --alarm high-temperature,low-soc
Part 4: Power OptimisationFor real deployments, there are some considerations to be made:
Voltage-Variable Sync
// Automatically adjust sync frequency based on battery voltage
J *req = notecard.newRequest("hub.set");
JAddStringToObject(req, "voutbound", "high:30;normal:60;low:240;dead:0");
JAddStringToObject(req, "vinbound", "high:120;normal:240;low:480;dead:0");
What this does
This mechanism allows you to adjust your sync behaviour based upon the battery voltage seen on the Swan.
- High voltage: Sync every 30 minutes (battery healthy)
- Normal voltage: Sync every 60 minutes (standard operation)
- Low voltage: Sync every 4 hours (preserve power)
- Dead voltage: Stop syncing (emergency conservation)
Host MCU Sleep
// Put Arduino to sleep between readings
J *req = notecard.newRequest("card.attn");
JAddStringToObject(req, "mode", "sleep");
JAddNumberToObject(req, "seconds", 300); // Sleep 5 minutes
notecard.sendRequest(req);
// Arduino will wake automatically via the attention pin
We can use the Notecarrier to remove power to the Swan's Power EN (enable) pins, physically turning it off for 5 minutes to increase energy efficiency.
Part 5: Data OptimisationReduce cellular data costs with Notecard templates:
// Define compact template (saves ~70% bandwidth)
J *req = notecard.newRequest("note.template");
JAddStringToObject(req, "file", "telemetry.qo");
JAddStringToObject(req, "format", "compact");
J *body = JCreateObject();
JAddStringToObject(body, "event", "20"); // Max 20 chars
JAddNumberToObject(body, "voltage", 14.1); // 4-byte float
JAddNumberToObject(body, "current", 14.1); // 4-byte float
JAddNumberToObject(body, "soc", 11); // 1-byte integer
JAddItemToObject(req, "body", body);
notecard.sendRequest(req);
Notecard templates are generally seen as a critical design pattern and should ALWAYS be used for real-world deployments. This helps to both reduce data consumption but prevent erroneous data from making its way to your cloud application.
Conclusion & Lessons LearnedTesting with real battery equipment is risky and inconvenient. For me, my Victron system is under the bunk of my back cabin on the boat. There was no easy way to test the system without tearing the bunk apart and having to carefully avoid touching the battery's high-current terminals.
The simulator let me:
- Develop safely from the comfort of my desk
- Test edge cases (multiple alarms, network failures)
- Validate the complete system before real-world deployment
Smart Data Handling Matters
The difference between immediate alarm sync and periodic telemetry sync is crucial:
- Alarms: Need immediate delivery, worth the power cost
- Telemetry: Can wait; batch transmission saves significant power
Notecard'sMulti-RAT makesit simple to deploy anywhere
Real deployment sites may have mixed levels of coverage. Being able to use multi-radio access technologies (RAT) means that you can select the technology based on what's best for the scenario. For example, if you have a private deployment on a remote location such as a farm, you might want to use Notecard LoRa with a gateway or even something that goes to sea, which might require satellite failover.
Potential Enhancements
- GPS location tracking for mobile installations
- Satellite failover for remote installations
- Use Smart Fleets to group installations for alerts and monitoring
- Historical data analytics and trend detection
- Integration with visual monitoring platforms via Notehub routes
If you want to try this for yourself, all the Blues hardware you need is available in the Blues Starter Kit for Cell+WiFi.
Comments