Demand-Response Solar + Battery Dispatcher

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/solar_battery_dispatcher/ so it can learn how the device works and help you adapt it to your hardware.
This project is an energy savings reference design that gives a commercial solar + battery installation its own independent cellular control channel. With it, the asset owner can discharge the battery during expensive peak-rate hours, charge it during cheap overnight hours, and curtail grid export when the utility calls a demand-response (DR) event. None of that depends on the building's IT network or a vendor's proprietary cloud portal. The device reads live operating state from the inverter and the battery's management system (BMS) over the industrial bus they already share. It then signals each one to enter the right mode at the right time, following a schedule or live commands routed through the Blues Notehub cloud service.
Scope. This is a mode enable / curtail controller, not a power dispatcher. It opens and closes four dry-contact relays wired to digital control inputs the inverter and battery already expose. From there, the field devices' own firmware decides the actual charge, discharge, and export behavior (ramp rates, power limits, setpoints) based on their pre-commissioning configuration. The controller doesn't command power setpoints, verify that a requested dispatch profile was executed, or close any control loop around site load.
1. Project Overview
The problem. On paper, the economics of commercial solar-plus-storage look great. You sell excess solar generation at peak rates, cover load from the battery so you don't draw from the grid during expensive peak windows, and join utility DR programs that pay you to curtail export or reduce load on short notice. The money is real. Collecting it takes a reliable, low-latency control channel between the utility signal and the physical system on the roof or in the equipment yard.
That's where most deployments get stuck. The inverter and BMS speak Modbus, an industrial serial protocol from 1979 that's still the common language of commercial power electronics. That bus lives in an outdoor equipment cabinet or a rooftop shed with no direct connection to the building's IT network. Even when WiFi does reach the equipment, the solar asset often belongs to a third-party power purchase agreement provider with no credentials on the building's network and no relationship with its IT team. Whatever control exists today is the inverter's local display, a proprietary cloud portal, or a laptop plugged in at the cabinet. You can't reliably automate, audit, or connect any of those to a utility DR program.
This project fills that gap. A Blues Wireless for OPTA expansion snaps onto a Finder OPTA PLC (programmable logic controller) on the DIN rail inside the equipment cabinet. The OPTA reads the inverter and BMS over the RS-485 Modbus bus already present in every commercial solar installation, and the Notecard inside the Blues expansion provides a cellular uplink that's completely independent of the building network. Nobody has to coordinate with IT. When the utility issues a DR event, or the local time-of-use peak window opens, the cloud-side system routes a dispatch command to the device through Notehub. The OPTA then opens or closes four relay outputs (grid export enable, battery discharge, battery charge, and a DR-active indicator) and reports what it did and why. That typically happens within one to six minutes of the command reaching Notehub, bounded by the 5-minute inbound sync cadence plus the next 1-minute sample cycle.
Why Notecard. The inverter and battery sit outdoors or in a rooftop equipment yard where WiFi is poor. Ownership is the bigger problem, though. The solar asset often belongs to a PPA provider who has no access to the building's network and can't ask a tenant's IT department for VPN credentials. Cellular gives the asset owner an independent control channel that works the same way at a strip mall, a warehouse, or a multi-tenant office park. There are no network forms, no AP to pair with, and no IT ticket to chase. The asset owner has to be able to send a discharge command at 4:57 PM no matter what the building's network is doing, and that's why the independence matters so much.
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. One OPTA RS485 with Wireless for OPTA mounts on the DIN rail inside the solar equipment cabinet, next to the inverter's communication interface. The RS-485 bus daisy-chains from the OPTA to the inverter and on to the BMS, and four relay output wires run to the matching digital control inputs on the inverter and BMS. The cellular antenna goes out through a cable gland to the outside of the cabinet. Power comes from the 24 VDC supply that's already in the equipment cabinet, and nothing in the setup needs a PC, a gateway, or the building LAN.
⚠ Commissioning safety: TOU windows are disabled by default. Both the peak
window (peak_start_utc / peak_end_utc) and the off-peak charge window
(charge_start_utc / charge_end_utc) ship with equal start and end hours
(both 0), which disables autonomous TOU dispatch. The controller stays in
normal mode until an operator explicitly sets non-equal window hours in the
Notehub fleet environment variables. Enable and tune the TOU windows only
after you've confirmed that wiring, Modbus addressing, and SOC thresholds are
correct for the site. Cloud dispatch.qi commands take effect immediately
regardless of TOU configuration. Section 6 has the full
environment variable reference.
2. System Architecture
Device-side responsibilities. Inside the equipment cabinet, the OPTA's
Cortex-M7 acts as the Modbus RTU client (master) to the inverter and BMS,
which are the Modbus servers (slaves). Once a minute it polls four holding
registers from each one through the onboard RS-485 transceiver. On every cycle
the host works out which operating mode applies (normal, peak_discharge,
overnight_charge, dr_curtail, or low_soc_protect), checks live SOC against
the configured guards, and drives the four relays to match. It also checks the
dispatch.qi inbound
Notefile each
cycle for anything the cloud has queued. Outbound telemetry and mode-change
events go to the Notecard over I²C through the AUX connector on the Wireless for
OPTA expansion, so the host never handles modem AT commands, serial buffers, or
a session state machine.
Notecard
responsibilities. The Notecard queues the
Notes (the JSON-based events), ships them on
the configured hub.set
periodic outbound cadence, and checks Notehub for inbound dispatch.qi
commands every five minutes. That five-minute window keeps dispatch latency
comfortably inside the ten-minute response envelope most utility DR programs
require. On each inbound sync the Notecard also pulls environment
variables,
so a fleet operator can re-tune the TOU schedule, SOC thresholds, or Modbus
addresses from a browser without ever opening the cabinet.
Notehub responsibilities. Events flow over the Notecard's embedded global
SIM into Notehub, which timestamps and stores every one
and applies project-level
routes. On the cloud
side, whatever runs dispatch (a utility rate scheduler, an energy management
system, or a webhook bridge to the utility DR API) queues commands by posting a
Note to the device's dispatch.qi Notefile through the Notehub
API. The Notecard pulls it in on the next inbound
sync. Environment
variables
set at the Fleet level carry the
site's TOU schedule, so the device can act on its own once the Notecard has a
valid UTC time from at least one earlier sync. Before that, on a cold boot,
resolveMode() sees epoch = 0 and stays in normal instead of misapplying
the schedule. The TOU windows engage as soon as the clock is available.
Routing to the cloud (high level only). Notehub can route to HTTP, MQTT, AWS IoT Core, Azure IoT Hub, GCP, Snowflake, and several other destinations. Route setup depends on your project, and this one doesn't ship a specific downstream endpoint, so see the Notehub routing docs.
3. Technical Summary
Here's how to compile and flash the firmware to the OPTA:
-
Claim a Notehub project and copy its ProductUID. Sign up at notehub.io, create a new project, and copy the ProductUID.
-
Edit the firmware. Open
firmware/solar_battery_dispatcher/solar_battery_dispatcher.inoand replace the placeholder string in thePRODUCT_UIDline with your project's actual ProductUID:#define PRODUCT_UID "com.your-company.your-name:solar_battery_dispatcher" -
Install dependencies in the Arduino IDE. Install these from the Boards Manager and Library Manager:
- Boards: Arduino Mbed OS Opta Boards (search "opta")
- Libraries: Blues Wireless Notecard, ArduinoModbus, ArduinoRS485
-
Compile and upload with arduino-cli (optional, for CI/headless builds):
arduino-cli core install "arduino:mbed_opta" arduino-cli lib install "Blues Wireless Notecard" arduino-cli lib install ArduinoModbus ArduinoRS485 arduino-cli compile -b "arduino:mbed_opta:opta" firmware/solar_battery_dispatcher arduino-cli upload -b "arduino:mbed_opta:opta" -p /dev/ttyACM0 firmware/solar_battery_dispatcher(Replace
/dev/ttyACM0with your OPTA's USB serial port, which you can find witharduino-cli board list.)
The Finder OPTA Codesys ships running the CODESYS runtime. The first time you upload Arduino firmware to it, you have to put it into bootloader mode yourself by double-tapping its RESET button, as described in the Wireless for OPTA Quickstart. That upload replaces the CODESYS runtime, and later uploads don't need the double tap.
-
Power the OPTA. Apply 24 VDC to the board's power terminals. The Notecard associates itself with your Notehub project within the first inbound sync window (up to 5 minutes).
-
Verify Notehub registration. Open the In-Browser Terminal and run
card.statusto confirmconnected:true. Then runhub.getto check the ProductUID (product) andhub.sync.statusto confirm at least one sync has completed.
Here's a sample Note this device emits:
{
"file": "solar_telemetry.qo",
"body": {
"pv_w": 4820.0,
"ac_out_w": 2340.0,
"grid_w": -680.0,
"batt_soc_pct": 73.4,
"batt_v": 51.2,
"batt_a": -12.8,
"mode": "peak_discharge"
}
}4. Hardware Requirements
| Part | Qty | Rationale |
|---|---|---|
| Finder OPTA Codesys (8A.04.9.024.832C) | 1 | Industrial PLC with an onboard RS-485 transceiver, DIN-rail mount, 12–24 VDC supply, and four relay outputs, included in the Wireless for OPTA Bundle. This project flashes it with Arduino firmware. The Arduino Opta RS485 and Arduino Opta WiFi also work, but the Arduino Opta Lite has no RS-485, so it won't work here. |
| Blues Wireless for OPTA (NA, SKU 992-00155-C) | 1 | Snaps onto the OPTA's expansion port and adds a Notecard Cell+WiFi (NOTE-WBNAW) over I²C. For EMEA deployments, use SKU 992-00156-C. |
| External cellular antenna(s) with SMA, ~3 m lead (e.g. SparkFun CEL-16432) | 1 primary, 1 diversity recommended | Metal equipment cabinets kill rubber-duck antennas. Route at least the primary antenna outside through a cable gland. The diversity antenna improves LTE Cat-1 performance at sites with marginal signal. |
| Blues Mojo | 1 | Bench-only coulomb counter for checking the per-session energy of the Wireless for OPTA + Notecard subsystem during commissioning. It isn't deployed to the field. |
| 24 VDC DIN-rail supply, ≥15 W (e.g. MeanWell HDR-15-24) | 1 | Powers the OPTA and expansion. Most solar equipment cabinets already have one, so only buy it if yours doesn't. |
| 120 Ω, ¼ W resistor | 2 | RS-485 line termination at each physical end of the Modbus bus. The OPTA's transceiver has no permanent terminator, and a floating bus line causes spurious framing errors. |
| Shielded twisted pair, 22 AWG, RS-485 rated | 1–3 m | A → A, B → B, shield → drive ground. Use a daisy-chain topology (not a star), shield and ground the cable per the inverter and BMS vendor installation guides, and terminate at both physical ends of the bus. Check each vendor's documentation for the maximum recommended bus length at your chosen baud rate. |
| DIN rail, ~15 cm | 1 | Mount for the OPTA + expansion. The equipment cabinet probably has one already. |
The Blues hardware ships with an active SIM that includes 500 MB of data and 10 years of service, with no activation fees and no monthly commitment.
5. Wiring and Assembly
Safety. Solar equipment cabinets contain hazardous DC and AC voltages. Installation must be done by qualified personnel following site lockout/tagout procedures, the inverter and BMS manufacturers' instructions, and applicable electrical codes. The relay outputs in this reference design provide contact closures to the inverter's and BMS's digital control inputs. They don't switch high-voltage circuits directly. Confirm the input voltage and current ratings before wiring.
Power and DIN rail
- Mount and power. Snap the OPTA onto the DIN rail, then snap the Wireless
for OPTA onto the OPTA's right-hand expansion port. Join the two with the
supplied solderless AUX connector, which carries the I²C bus the Notecard
talks over. Wire 24 VDC from the panel supply to the OPTA's
+and−terminals. As the Wireless for OPTA Quickstart describes, jumper the OPTA's+24Vto the expansion's+24Vterminal so both devices share the same supply.
Antennas
- Antennas. Thread a male-SMA-to-female-bulkhead lead through a weatherproof cable gland in the cabinet and screw it onto the primary cellular antenna port on the Wireless for OPTA. Repeat for the diversity port. The bundled rubber-duck antennas are for bench testing only, so don't leave them inside a metal cabinet.
Modbus RS-485 bus
-
Modbus bus. The OPTA is the Modbus client (master), and the inverter and BMS are Modbus servers (slaves). Run shielded twisted-pair from the OPTA's RS-485 terminals to the inverter's communication port, then daisy-chain from the inverter to the BMS:
- OPTA
A (+)→ inverterA (+)→ BMSA (+) - OPTA
B (−)→ inverterB (−)→ BMSB (−) - OPTA
COM→ inverter communication ground → BMS communication ground - Put a 120 Ω termination resistor across
A/Bat the OPTA end and another at the far end of the chain (the BMS, if the inverter is in the middle). It's a three-node daisy-chain (OPTA → inverter → BMS) with terminators only at its two physical ends.
- OPTA
-
Modbus device configuration. Configure the inverter as Modbus slave ID
1(defaultmodbus_slave_inv) and the BMS as slave ID2(defaultmodbus_slave_bms). Use themodbus_baud,modbus_parity, andmodbus_stop_bitsenvironment variables to match the serial configuration your hardware needs. The firmware default is 9600 baud, no parity, and 1 stop bit (9600 8N1). The Modbus communication or RS-485 configuration section of each vendor's inverter and BMS manual lists the correct slave IDs and serial settings for that device.note Hard constraint: single shared bus. The firmware uses one RS-485 physical segment with one set of serial parameters (
modbus_baud,modbus_parity,modbus_stop_bits) for both the inverter and the BMS. They're polled one after the other on the same electrical bus, so both devices must use the same baud rate, parity, and stop bits. Treat that as a commissioning prerequisite. If your inverter and BMS ship with different default serial settings and can't both be reconfigured to a common one, this reference topology won't work as-is. You'd need a second RS-485 port or a USB-to-RS-485 adapter on a second UART, plus matching firmware changes to address each device on its own serial port. Check that your chosen inverter and BMS can share serial settings before you commit to this wiring layout.
Relay output wiring
-
Relay outputs. The OPTA has four electromechanical relay outputs. Each relay exposes two field-wiring terminals, COM (common) and NO (Normally Open: open when the relay is de-energized, closed when energized). There's no NC terminal on the OPTA terminal block. The relay contacts are volt-free (dry), meaning they're only an isolated switch, and the relay itself never drives voltage onto the field wiring. Instead, the field device's own internal supply provides the reference voltage for its digital input, and the relay contact simply completes or breaks that circuit. Check the OPTA datasheet for the exact terminal block pin assignments and relay contact ratings before wiring.
Fail-safe behavior. When the OPTA de-energizes on power loss, a hardware fault, or startup, all NO contacts open and every controlled function drops to its safe default state. Wire all four relay paths with COM and NO, as the table below shows.
How to wire a dry-contact digital input through the relay. Most inverter and BMS digital inputs built for dry-contact operation supply their own excitation voltage internally. Run two wires from the relay's COM and NO terminals directly to the two designated dry-contact terminals on the field device. Polarity doesn't matter, because the relay is just a switch. When the relay energizes, COM and NO close, which completes the circuit and asserts the input. De-energizing the relay opens the circuit and de-asserts the input. Check the input terminal designations, drive voltage, and maximum contact current against the field device's installation manual before wiring.
Relay OPTA contacts to use Mapped function De-energized (open) fail state RELAY1COM+NOInverter Grid Export Enable Export curtailed (a conservative fail-safe design choice). Verify the required fail state against the inverter manual, site protection scheme, and utility interconnect agreement before commissioning RELAY2COM+NOBMS Battery Discharge Enable Discharge blocked, so the battery is protected against uncontrolled discharge during a fault RELAY3COM+NOBMS Battery Charge Enable Charge inhibited. The BMS's own protection circuits stay responsible, and charging halts cleanly RELAY4COM+NOSCADA / indicator lamp DR Active DR indicator off, so there's no false active signal during startup or a fault Per-path field wiring:
- RELAY1 (Grid Export Enable): Run two wires from the RELAY1
COMandNOterminals to the inverter's Grid Export Enable dry-contact input terminals. The firmware asserts this relay HIGH innormal,peak_discharge,overnight_charge, andlow_soc_protectmodes. It opens (de-energizes) only indr_curtailmode, which curtails export per the utility DR instruction. - RELAY2 (Battery Discharge Enable): Run two wires from RELAY2
COMandNOto the BMS's Discharge Enable dry-contact input. The relay closes only whenpeak_dischargeis the active mode and SOC is at or abovesoc_min_pct. It opens in every other mode and whenever the SOC guard latches, and once latched it stays open until SOC recovers tosoc_min_pct + soc_hyst_pct. - RELAY3 (Battery Charge Enable): Run two wires from RELAY3
COMandNOto the BMS's Charge Enable dry-contact input. The relay closes innormal,overnight_charge, andlow_soc_protectmodes when the upper-SOC latch isn't active. It opens once SOC reachessoc_max_pctand doesn't close again until SOC drops tosoc_max_pct − soc_max_hyst_pct(default 92 %). That deadband keeps the charge relay from chattering when BMS readings bounce around the charge ceiling between consecutive sample cycles. The relay also opens unconditionally duringpeak_discharge(never charge while discharging) anddr_curtail(follow DR instructions). - RELAY4 (DR Active indicator): Run two wires from RELAY4
COMandNOto the SCADA dry-contact input or indicator lamp. The relay closes only indr_curtailmode. If it drives a resistive or LED lamp load, confirm that the lamp's rated current is within the relay's contact current rating in the OPTA datasheet.
- RELAY1 (Grid Export Enable): Run two wires from the RELAY1
Bench power validation
- Mojo placement. During commissioning, splice the Mojo inline between the 24 VDC supply and the Wireless for OPTA power input to measure the per-session energy of the expansion + Notecard subsystem. Section 9 has the expected figures.
6. Notehub Setup
-
Create a project. Sign up at notehub.io and create a project. Copy the ProductUID and paste it into
firmware/solar_battery_dispatcher/solar_battery_dispatcher.inoasPRODUCT_UID. -
Claim the Notecard. Power up the panel supply. The Notecard associates itself with your Notehub project on its first cellular session.
-
Create a Fleet per site. Fleets group devices for shared configuration. The natural unit here is one fleet per installation site. Inverters at the same site run on the same TOU schedule and SOC thresholds, so those values belong at the fleet level. Smart Fleets can split a fleet further by inverter capacity class or PPA contract type.
-
Set environment variables. Every variable is optional, and the table shows the firmware defaults. The Notecard picks up variable changes from Notehub on its next inbound sync and caches them on-device. The firmware reads and applies them at the start of the next report cycle, since
fetchEnvOverrides()runs once perreport_minutes. Changingreport_minutesalso makes the firmware re-issuehub.setduring that same cycle, which updates the Notecard's outbound sync cadence without a reboot. That way, operators can push a new TOU schedule or tighten the minimum SOC without touching firmware.Variable Default Purpose sample_minutes1Modbus poll cadence (minutes). DR dispatch latency is inbound cadence (5 min) + sample_minutes. At the default of1, end-to-end relay response is at most 6 minutes. Increasingsample_minutesadds the same amount to dispatch latency. A value of5puts worst-case latency at exactly 10 minutes, the outer edge of most utility DR response envelopes. The firmware clamps this variable to 1–5 to enforce that bound.report_minutes15Telemetry Note cadence (minutes). soc_min_pct20.0Minimum SOC (%) below which battery discharge is blocked, regardless of dispatch mode. soc_max_pct95.0Maximum SOC (%) above which battery charging is blocked. soc_hyst_pct5.0SOC hysteresis band (%) above soc_min_pct. Oncelow_soc_protectlatches, discharge doesn't resume until SOC rises tosoc_min_pct + soc_hyst_pct(e.g., 25 % with the default 20 % minimum). This prevents relay chatter and repeateddr_event.qotraffic when SOC readings bounce around the lower threshold between consecutive 1-minute sample cycles. Range: 0–20.soc_max_hyst_pct3.0SOC hysteresis band (%) below soc_max_pct. Once charge-inhibit latches (SOC reachessoc_max_pct), charging doesn't resume until SOC drops tosoc_max_pct − soc_max_hyst_pct(e.g., 92 % with the default 95 % ceiling and 3 % band). Without this deadband, a noisy SOC reading near the charge ceiling would toggle RELAY3 on every 1-minute sample cycle. The charge-inhibit latch works insideapplyRelays()without changing the resolved mode, so it doesn't generatedr_event.qoevents. It only stops relay chatter. Range: 0–20.peak_start_utc0Start hour of the TOU peak window (UTC, 0–23). Defaults to 0(window disabled, start equals end). Set a non-equal pair (e.g.peak_start_utc=21,peak_end_utc=2) to turn on autonomous peak discharge during that window.peak_end_utc0End hour of the TOU peak window (UTC, 0–23). Wrap-around supported (e.g. 21 → 02 spans midnight). When peak_start_utcequalspeak_end_utc, the peak window is disabled, following the same equal-values rule as the charge window.charge_start_utc0Start hour of the off-peak charge window (UTC, 0–23). When charge_start_utcequalscharge_end_utc(the default), autonomous overnight charging is disabled andovernight_chargeis only available through cloud dispatch.charge_end_utc0End hour of the off-peak charge window (UTC, 0–23). Wrap-around supported. Set a nonzero window (e.g. charge_start_utc=0, charge_end_utc=6) to have the TOU schedule enterovernight_chargeautonomously during off-peak hours.modbus_slave_inv1Modbus slave ID for the inverter. modbus_slave_bms2Modbus slave ID for the battery BMS. modbus_baud9600RS-485 baud rate for both devices (must match hardware configuration). modbus_parity0RS-485 parity: 0= none (N),1= even (E),2= odd (O). Must match hardware configuration.modbus_stop_bits1RS-485 stop bits: 1or2. Must match hardware configuration.reg_inv_base100Starting holding-register address (0-based, wire-level) for the inverter block. reg_bms_base200Starting holding-register address for the BMS block. -
Configure routes. Route
solar_telemetry.qoto a long-term analytics or historian destination. It's relatively high frequency, at 96 records per device per day with the 15-minute default. Givedr_event.qoits own route to a real-time notification channel like a CMMS, a SCADA system, or an operator dashboard. The two Notefiles are separate at the source, so each one can be routed differently without any filter logic in the route itself. -
Inbound dispatch path. Cloud-side systems send dispatch commands by posting a Note to the device's
dispatch.qiNotefile through the Notehub API (POST /v1/projects/{projectUID}/devices/{deviceUID}/notes/dispatch.qi). The Note body carries amodestring and an optionalexpires_epochUnix timestamp, and the Notecard delivers it to the device within the next inbound sync window (≤5 minutes).modevalueEffect peak_dischargeImmediately enables battery discharge and grid export, overriding the TOU schedule. overnight_chargeImmediately enables grid-charging the battery, overriding the TOU schedule. dr_curtailCurtails grid export and asserts the DR-indicator relay. normalTOU-suppressing override. Clears any active peak_discharge,overnight_charge, ordr_curtailcommand and holds the system in normal relay state, suppressing autonomous TOU transitions, for the duration of anyexpires_epochwindow (or until another command supersedes it). Dispatchingmode:"normal"isn't a no-op, because it's the right way to force the controller back to normal operation from the cloud during an active TOU peak or DR window. On the wire (telemetry and events) this state is reported as"normal". Both the idleMODE_NORMALand the activeMODE_FORCED_NORMALserialize to"normal", so dispatchingmode:"normal"while the system is already in normal state (no active TOU window) doesn't produce adr_event.qo. Events only go out when the wire-visible name changes.When
expires_epochis set, the device automatically reverts to its TOU-scheduled mode once the epoch arrives. That could bepeak_discharge,overnight_charge, ornormal, depending on the current UTC hour. It gives you built-in recovery if the cloud system never sends an explicit release command.Posting a dispatch command with curl: Replace
{projectUID},{deviceUID}, and{apiToken}with your actual Notehub credentials. You can create an API token in Notehub → Settings → API keys.curl -X POST https://api.notefile.io/v1/projects/{projectUID}/devices/{deviceUID}/notes/dispatch.qi \ -H "Authorization: Bearer {apiToken}" \ -H "Content-Type: application/json" \ -d '{ "body": { "mode": "dr_curtail", "expires_epoch": '$(( $(date +%s) + 600 ))' } }'This example issues a DR curtailment that expires on its own after 10 minutes. On the next sample cycle, the device closes RELAY4 (DR indicator), opens RELAY1 (grid export), and responds with a
dr_event.qoNote.
7. Firmware Design
The firmware is five files in
firmware/solar_battery_dispatcher/:
| File | Role |
|---|---|
solar_battery_dispatcher.ino | Entry point: PRODUCT_UID define (edit this), setup(), loop(), and all global variable definitions |
dispatcher.h | Shared types (DispatchMode, InverterSample, BmsSample), relay-pin defines, extern declarations for module-global variables, and function prototypes used by all compilation units |
notecard_helpers.cpp | Notecard configuration (hub.set, templates), environment-variable fetch and validation (fetchEnvOverrides), outbound sync-cadence management, and inbound dispatch-Note polling (checkDispatch) |
modbus_helpers.cpp | Modbus RTU bus initialization (applyModbusIfChanged) and device polling (pollInverter, pollBms) |
mode_helpers.cpp | UTC time utilities, mode resolution (resolveMode), relay output control (applyRelays), and outbound Note emission (sendTelemetry, sendModeEvent) |
Dependencies:
- Arduino Mbed OS Opta Boards core (install it from the Arduino IDE Boards Manager by searching "opta").
Blues Wireless Notecard(note-arduinolibrary). Install it from the Arduino Library Manager or witharduino-cli lib install "Blues Wireless Notecard". The note-arduino releases page has the latest version.ArduinoModbusandArduinoRS485(official Arduino libraries, also from the Library Manager).
Modules
| Responsibility | Function | File |
|---|---|---|
Notecard configuration (hub.set, templates) | notecardConfigure, defineTemplates | notecard_helpers.cpp |
| Environment-variable fetch + clamp | fetchEnvOverrides | notecard_helpers.cpp |
| Notecard outbound cadence update | applyHubSetIfChanged | notecard_helpers.cpp |
| Modbus serial config update | applyModbusIfChanged | modbus_helpers.cpp |
| Modbus polling | pollInverter, pollBms | modbus_helpers.cpp |
| Inbound dispatch Note consumption | checkDispatch | notecard_helpers.cpp |
| UTC time query with extrapolation | currentUtcEpoch | mode_helpers.cpp |
| Mode resolution (cloud dispatch + TOU schedule + lower-SOC guard) | resolveMode | mode_helpers.cpp |
| Relay output control (upper-SOC latch; fail-safe on BMS comm loss) | applyRelays | mode_helpers.cpp |
| Outbound telemetry emission | sendTelemetry | mode_helpers.cpp |
| Immediate mode-change event | sendModeEvent | mode_helpers.cpp |
Sensor reading strategy
The firmware polls the inverter and BMS with one four-register requestFrom
block each, so it's one Modbus transaction per device. The inverter block (at
reg_inv_base) gives PV generation, AC output to load, grid exchange power
(negative = importing, positive = exporting), and inverter state. From the BMS
block (at reg_bms_base) come SOC percentage, battery voltage, battery current
(negative = discharging), and BMS state. The demo register map is contiguous
and uses simple integer scaling (the firmware comments have the exact scaling),
but real inverters and BMS units have vendor-specific maps. See
Limitations.
Each device gets up to three attempts per poll cycle. If all three fail, valid
stays false for that device. The telemetry Note reflects the most recent
sample at reporting time. If the poll just before the report boundary failed
and left the sample invalid, the firmware emits the -9999 sentinel for all of
that device's fields, so downstream analytics can tell "no reading" apart from
a real zero or negative value. A transient failure that clears before the next
reporting boundary won't show up as -9999 in that report. During a Modbus
outage, mode decisions fall back to the last dispatched command or the TOU
schedule, and the device doesn't stall waiting for the bus to recover.
Event payload design
The device uses two Notefiles, each with its own urgency and destination.
solar_telemetry.qo is
template-encoded
and goes out on the report_minutes cadence (15 minutes by default). Templates
compress these Notes from free-form JSON into fixed-length records. At 96
records per day over a multi-year deployment on a prepaid SIM, that 3–5×
bandwidth savings makes a real difference. Here's an example body when all
devices are reachable:
{
"file": "solar_telemetry.qo",
"body": {
"pv_w": 4820.0,
"ac_out_w": 2340.0,
"grid_w": -680.0,
"batt_soc_pct": 73.4,
"batt_v": 51.2,
"batt_a": -12.8,
"mode": "peak_discharge"
}
}When a Modbus device (inverter or BMS) becomes unreachable, the sentinel value
-9999 replaces all of that device's fields until communications recover. For
example, if the BMS is unreachable:
{
"file": "solar_telemetry.qo",
"body": {
"pv_w": 4820.0,
"ac_out_w": 2340.0,
"grid_w": -680.0,
"batt_soc_pct": -9999.0,
"batt_v": -9999.0,
"batt_a": -9999.0,
"mode": "low_soc_protect"
}
}Downstream consumers can filter on -9999 to spot communication loss and alert
on it.
dr_event.qo goes out with sync:true on every mode transition, whether that's
peak_discharge kicking in at the start of the TOU peak window, a DR
curtailment arriving from the cloud, or a command expiring and the system
returning to normal. Because it uplinks immediately, operators and downstream
systems see the transition in under a minute. For example:
{
"file": "dr_event.qo",
"body": {
"new_mode": "dr_curtail",
"prev_mode": "normal",
"batt_soc_pct": 67.5,
"pv_w": 3100.0,
"grid_w": 420.0
},
"sync": true
}Power and sync strategy
The OPTA and expansion are line-powered (24 VDC), so the goal is bus efficiency
and inbound latency, not MCU deep sleep. The Notecard runs in
hub.set periodic mode
with outbound equal to report_minutes (15 minutes by default) and inbound
fixed at 5 minutes. If a Notehub environment variable changes report_minutes,
the firmware re-issues hub.set at the following report boundary
(applyHubSetIfChanged()). That keeps the Notecard's outbound cadence lined up
with the emission interval without a reboot. Since inbound is the shorter
interval, the Notecard wakes the radio every 5 minutes to check for inbound
dispatch commands and pick up environment variable updates. Every third inbound
session, at the 15-minute outbound boundary, also flushes the outbound queue
and ships the accumulated telemetry Notes in one batch. Mode-change events use
sync:true to skip the outbound timer, so they go out as soon as a session can
be established after the trigger, usually within 15–60 seconds. Between
sessions, the Notecard idles at ~8–18 µA at 5 V.
Sampling and transmission are kept separate on purpose. The firmware polls Modbus every minute but only sends a telemetry Note every 15 minutes, which keeps transmission sessions short and predictable and still gives the mode-resolution logic fresh SOC and power readings every 60 seconds.
Retry and error handling
- The first
hub.setrecords the applied outbound cadence only when it returns successfully. If the call fails,s_last_report_minutesstays at its sentinel (0), andapplyHubSetIfChanged()retrieshub.setfrom the main loop at every report boundary until it succeeds. - The firmware makes up to three attempts at each Modbus read per poll cycle,
with 100 milliseconds between attempts. A failed poll logs to the serial
debug port, and the
-9999sentinel shows up in the telemetry Note if the failure is still there at the next report boundary (see Sensor reading strategy above). BMS comm loss triggers two independent fail-safe responses. Ifpeak_dischargeis the active mode,resolveMode()returnsMODE_LOW_SOC_PROTECTand the discharge relay opens. The device can't confirm battery state, so it protects the battery and emits adr_event.qomode-change event. In every mode,applyRelays()also forces the upper-SOC charge-inhibit latch active, which keeps RELAY3 from closing until BMS comms recover and a live SOC reading confirms the battery has cleared the charge ceiling. The two guards fail safe symmetrically, so losing battery comms blocks both discharge and charge. fetchEnvOverrides()clamps every environment variable to a safe range before applying it, so a pathological value (e.g.,sample_minutes = 0) can't create a tight loop or an invalid Modbus slave address.- Dispatch commands that include an
expires_epochrevert on their own when the epoch arrives, which protects against a stuck DR state if the cloud system never sends a recovery command.currentUtcEpoch()extrapolates from the last known-good epoch through any transient failure. Once time has been acquired at least once, it never returns0again, so expiry and TOU scheduling stay accurate through brief Notecard API or connectivity disruptions. - Every
requestAndResponse()call checks for both aNULLreturn and anerrfield on the response before trusting the data.
Key code snippet 1: inbound dispatch polling
On every sample cycle, the firmware calls checkDispatch(), which pops the
dispatch.qi queue and translates the mode string into an internal enum. An
empty queue comes back as a {note-noexist} error, which isQueueEmpty()
treats as normal. Any other error goes to the serial debug port:
J *req = notecard.newRequest("note.get");
if (!req) return;
JAddStringToObject(req, "file", "dispatch.qi");
JAddBoolToObject (req, "delete", true); // pop — consumed once
J *rsp = notecard.requestAndResponse(req);
if (!rsp) return;
if (notecard.responseError(rsp)) {
// ...
notecard.deleteResponse(rsp);
return;
}
J *body = JGetObject(rsp, "body");
if (body) {
const char *mode_str = JGetString(body, "mode");
double expires = JGetNumber(body, "expires_epoch");
// Resolve the mode string before committing. An unrecognised mode must
// not alter g_dr_expires_epoch and thereby corrupt the lifetime of the
// currently active valid command.
DispatchMode new_cmd = g_commanded_mode; // keep current if invalid
bool valid_mode = true;
if (mode_str && strcasecmp(mode_str, "peak_discharge") == 0) new_cmd = MODE_PEAK_DISCHARGE;
else if (mode_str && strcasecmp(mode_str, "overnight_charge") == 0) new_cmd = MODE_OVERNIGHT_CHARGE;
else if (mode_str && strcasecmp(mode_str, "dr_curtail") == 0) new_cmd = MODE_DR_CURTAIL;
// "normal" maps to MODE_FORCED_NORMAL — a distinct commanded state that
// is != MODE_NORMAL and therefore suppresses TOU schedule evaluation for
// the duration of any expires_epoch window rather than silently releasing
// back to the schedule while a TOU window is still active.
else if (mode_str && strcasecmp(mode_str, "normal") == 0) new_cmd = MODE_FORCED_NORMAL;
else {
valid_mode = false;
// ...
}
if (valid_mode) {
g_commanded_mode = new_cmd;
g_dr_expires_epoch = (expires > 0) ? (uint32_t)expires : 0;
// ...
}
}
notecard.deleteResponse(rsp);Key code snippet 2: mode resolution with TOU schedule and SOC guard
resolveMode() layers cloud dispatch, the TOU schedule, and lower-SOC
protection. Three design points matter here:
-
TOU suppression.
g_commanded_modeis the only input that suppresses TOU evaluation. Any value other thanMODE_NORMAL, includingMODE_FORCED_NORMAL(stored when the cloud dispatchesmode:"normal"), makes the function skip the TOU window checks. BecauseMODE_FORCED_NORMAL != MODE_NORMAL, a cloudmode:"normal"command really does hold the system in normal operation instead of quietly releasing it back to a TOU peak-discharge window that may still be active. The resolved active mode from the previous cycle never gets fed back, so TOU transitions, dispatch expiry, and SOC-guard recovery all happen cleanly on every poll cycle.MODE_FORCED_NORMALandMODE_NORMALboth serialize to"normal"throughmodeName(), so mode-change events inloop()are gated onstrcmp(modeName(new_mode), modeName(s_prev_mode)) != 0instead of a raw enum comparison. That keeps a no-opdr_event.qowithprev_mode:"normal"/new_mode:"normal"from going out when a forced-normal command arrives while the system is already in normal state, or when it expires back to normal.s_prev_modeis updated unconditionally each cycle, so the comparison always reflects the current resolved state. -
Lower SOC guard. Whenever SOC is critically low, or the BMS is unreachable while
peak_dischargeis active, the function returnsMODE_LOW_SOC_PROTECT, which shows up in telemetry and events as"low_soc_protect". The guard uses a hysteresis latch (s_in_soc_protect). It engages when SOC drops belowsoc_min_pctand releases only after SOC recovers tosoc_min_pct + soc_hyst_pct(default +5 %). Without the hysteresis band, a noisy SOC reading near the threshold would flip the discharge relay and emit adr_event.qomode-change Note on every 1-minute sample cycle. -
Upper SOC guard (charge inhibit).
applyRelays()uses a matching deadband latch (s_in_charge_inhibit, private tomode_helpers.cpp) for the charge path. It engages when SOC reachessoc_max_pctand releases only after SOC drops tosoc_max_pct − soc_max_hyst_pct(default 92 %). Without this band, a noisy SOC reading near the charge ceiling would toggle RELAY3 on every 1-minute sample cycle. Unlike the lower-SOC guard, the charge-inhibit latch works entirely insideapplyRelays()and never changes the resolved mode. That means it doesn't affect the wire-name mode-change comparison inloop()and doesn't generatedr_event.qoevents. Its only job is relay anti-chatter. BMS comm loss also counts as charge-inhibit: whenbms_validis false, the latch is forced active no matter what the sentinel SOC value says, because the firmware can't confirm the battery has cleared the ceiling. This mirrors the lower guard's discharge fail-safe and makes both ends of the SOC operating range symmetric:
DispatchMode resolveMode(uint32_t utc_epoch, bool bms_valid, float soc_pct) {
// Expire a timed dispatch; device falls back to the TOU schedule.
if (g_dr_expires_epoch > 0 && utc_epoch >= g_dr_expires_epoch) {
g_commanded_mode = MODE_NORMAL;
g_dr_expires_epoch = 0;
usbSerial.println("[dispatch] timed dispatch expired -> scheduled");
}
// Determine the candidate mode. Any g_commanded_mode != MODE_NORMAL
// (including MODE_FORCED_NORMAL) suppresses TOU evaluation. TOU windows
// default to disabled (start == end == 0) and engage only after an
// operator sets them via env vars.
DispatchMode candidate = MODE_NORMAL;
if (g_commanded_mode != MODE_NORMAL) {
candidate = g_commanded_mode;
} else if (utc_epoch > 0 && isInPeakWindow(utcHour(utc_epoch), g_peak_start_utc, g_peak_end_utc)) {
candidate = MODE_PEAK_DISCHARGE;
} else if (utc_epoch > 0 && isInPeakWindow(utcHour(utc_epoch), g_charge_start_utc, g_charge_end_utc)) {
candidate = MODE_OVERNIGHT_CHARGE;
}
bool would_discharge = (candidate == MODE_PEAK_DISCHARGE);
if (would_discharge) {
if (!bms_valid || soc_pct < g_soc_min_pct) {
s_in_soc_protect = true; // enter or hold protect
} else if (s_in_soc_protect && soc_pct >= (g_soc_min_pct + g_soc_hyst_pct)) {
s_in_soc_protect = false; // SOC recovered past hysteresis band
}
if (s_in_soc_protect) return MODE_LOW_SOC_PROTECT;
} else {
// Not a discharge mode — clear latch so the next discharge-window entry
// starts with a fresh SOC check.
s_in_soc_protect = false;
}
return candidate;
}Key code snippet 3: relay control
applyRelays() turns the resolved mode into four relay states. The primary
lower-SOC guard lives in resolveMode(), which returns MODE_LOW_SOC_PROTECT
when needed, and applyRelays() keeps a belt-and-suspenders discharge check for
peak_discharge. The upper-SOC deadband latch controls charge_en for all
charging modes. dr_curtail only opens the export relay and asserts the
DR-indicator relay, so it doesn't force battery discharge during a DR event.
BMS comm loss (bms_valid == false) forces the charge-inhibit latch active,
mirroring the discharge fail-safe in resolveMode():
void applyRelays(DispatchMode mode, float soc_pct, bool bms_valid) {
bool low_soc = (soc_pct < g_soc_min_pct);
if (!bms_valid) {
// BMS unreachable: cannot confirm SOC has cleared the charge ceiling.
// Engage inhibit (or hold it if already active). The latch releases
// only once BMS comms are restored and SOC confirms it has dropped
// through the soc_max_hyst_pct deadband on the same poll cycle.
s_in_charge_inhibit = true;
} else if (soc_pct >= g_soc_max_pct) {
s_in_charge_inhibit = true; // engage: SOC at or above ceiling
} else if (s_in_charge_inhibit && soc_pct <= (g_soc_max_pct - g_soc_max_hyst_pct)) {
s_in_charge_inhibit = false; // release: SOC dropped through deadband
}
bool full_soc = s_in_charge_inhibit;
bool export_en = true;
bool discharge_en = false;
bool charge_en = false;
bool dr_active = false;
switch (mode) {
case MODE_FORCED_NORMAL: // cloud-commanded normal; identical relay map to MODE_NORMAL
case MODE_NORMAL:
export_en = true; discharge_en = false; charge_en = !full_soc; break;
case MODE_PEAK_DISCHARGE:
export_en = true; discharge_en = !low_soc; charge_en = false; break;
case MODE_OVERNIGHT_CHARGE:
export_en = true; discharge_en = false; charge_en = !full_soc; break;
case MODE_DR_CURTAIL:
export_en = false; discharge_en = false; charge_en = false; dr_active = true; break;
case MODE_LOW_SOC_PROTECT:
default:
export_en = true; discharge_en = false; charge_en = !full_soc; break;
}
if (low_soc) discharge_en = false; // belt-and-suspenders SOC hard-guard
digitalWrite(RELAY_GRID_EXPORT, export_en ? HIGH : LOW);
digitalWrite(RELAY_BATT_DISCHARGE, discharge_en ? HIGH : LOW);
digitalWrite(RELAY_BATT_CHARGE, charge_en ? HIGH : LOW);
digitalWrite(RELAY_DR_INDICATOR, dr_active ? HIGH : LOW);
}8. Data Flow
Collected. Every sample_minutes (1 minute by default): PV generation in
watts, AC output to load in watts, grid exchange power in watts, battery SOC in
percent, battery voltage in volts, battery current in amps, and inverter/BMS
state codes (inv_state, bms_state). The firmware reads the state codes over
Modbus and stores them in the sample structs, but in this reference design
they're not included in outbound telemetry or mode-change events. They're
placeholder fields for future vendor-specific logic (e.g., detecting inverter
fault codes or BMS protection states and triggering mode overrides), and they
don't affect the current control logic. To use them, add them to the
solar_telemetry.qo template and note.add body in sendTelemetry(), or wire
them into resolveMode() for vendor-specific fault handling.
Transmitted.
solar_telemetry.qo: one template-encoded Note everyreport_minutes(15 minutes by default, or 96 records/day). Each record carries the most recently polled values from both devices plus the current operating mode string.dr_event.qo: sent immediately withsync:trueon every mode transition. It includes the new and previous mode names, battery SOC, PV generation, and grid exchange at the moment of the transition. Route this Note to a real-time notification channel.
Routed. Notehub sends solar_telemetry.qo to a long-term analytics or
historian system and dr_event.qo to an operational channel. Because the two
Notefiles are separate at the source, they can go to different places without
any filter logic in the route.
Inbound dispatch path. An external energy management system, a utility DR
bridge, or a simple scheduled script sends an HTTP POST to the Notehub API to
queue a dispatch.qi Note on the target device. The Notecard delivers the
command on its next inbound sync (≤5 minutes), and the device acts on it in the
following sample cycle (up to sample_minutes later). End to end, the latency
from cloud dispatch to relay closure is inbound cadence (5 minutes) +
sample_minutes. At the default sample_minutes = 1, that's at most 6
minutes, inside the 10-minute DR response envelope most utilities require. The
firmware clamps sample_minutes to 5, so the worst-case relay response stays
within the 10-minute DR boundary no matter what value is configured.
What triggers mode changes.
- TOU schedule:
peak_dischargeengages automatically when UTC time enters thepeak_start_utc→peak_end_utcwindow. If a non-zerocharge_start_utc→charge_end_utcoff-peak window is configured,overnight_chargealso engages on its own during that window. Outside all configured windows, the mode isnormal. Autonomous TOU transitions depend on valid Notecard UTC time. On first boot, or after a power event before the Notecard re-acquires time,resolveMode()getsepoch = 0and stays innormal. It doesn't evaluate TOU windows until a valid epoch is available from at least one earlier Notehub sync. - Cloud dispatch: any
modevalue in adispatch.qiNote overrides the TOU schedule immediately. That includesmode:"normal", which holds the controller in normal relay state and suppresses autonomous TOU transitions for the duration of anyexpires_epochwindow (or until something supersedes it). Use it to force a return to normal operation from the cloud during an active peak-discharge or DR window. On the wire, the state is reported as"normal"in bothsolar_telemetry.qoanddr_event.qo. - Dispatch expiry: a
dispatch.qiNote with anexpires_epochreverts to the TOU schedule (which may bepeak_discharge,overnight_charge, ornormal, depending on the current UTC hour) when the epoch arrives. That's your built-in recovery if the cloud system goes silent without sending an explicit release. - SOC guard: when SOC drops below
soc_min_pct, or the BMS becomes unreachable whilepeak_dischargeis active, the device switches tolow_soc_protectmode. The discharge relay opens, and adr_event.qomode-change event goes out so operators can see why discharge stopped. The lower-guard latch engages atsoc_min_pctand releases only after SOC recovers tosoc_min_pct + soc_hyst_pct(default: 25 % with a 20 % minimum and 5 % band), which prevents relay chatter when SOC readings hover near the threshold. BMS communication loss also latches the lower protect state, and it clears only when comms are restored and SOC clears the hysteresis band on the same poll cycle. On top of that, BMS comm loss forces the upper-SOC charge-inhibit latch active inapplyRelays()regardless of the current mode. Charging stays blocked until the BMS is reachable again and a live SOC reading confirms the battery has dropped below thesoc_max_pct − soc_max_hyst_pctthreshold. So on BMS comm loss, both ends of the SOC operating range fail safe symmetrically, with the lower guard blocking discharge and the upper guard blocking charging.
9. Validation and Testing
Pre-wiring check. The shipped firmware has both TOU windows disabled by
default (peak_start_utc and peak_end_utc both default to 0, and equal
values mean the window is off). The discharge relay won't assert on its own
until an operator explicitly sets a non-equal peak window in the Notehub fleet
environment variables. Before you connect relay outputs to live inverter or BMS
control inputs, check that the fleet environment variables are at their
defaults (or set on purpose). Enable and tune the peak window only after you've
finished validating wiring, Modbus addressing, and SOC thresholds.
Cellular first-light. Before you connect Modbus hardware or send dispatch
commands, make sure the Notecard has registered with Notehub. Send a
card.status request
from the In-Browser Terminal and confirm connected:true. Then
send hub.get to confirm
the project association (product) and
hub.sync.status
to confirm that at least one sync has completed. You only need a browser for
all three. Move on to Modbus and dispatch
testing once cellular is confirmed.
Expected steady-state behavior. In normal operation, the device sends one
solar_telemetry.qo Note every 15 minutes and one dr_event.qo Note each time
the mode changes (usually twice a day, at peak-window entry and exit). During
commissioning, simulate a DR event by posting a dispatch.qi Note through the
Notehub API with "mode": "dr_curtail" and "expires_epoch" set 10 minutes in
the future. Within 5 min + sample_minutes of queuing the Note (the next
inbound sync plus the following sample cycle), the RELAY_DR_INDICATOR relay
should close and a dr_event.qo Note should show up in Notehub. At the default
sample_minutes = 1, that's at most 6 minutes. If sample_minutes is set
to 5 (the firmware-enforced maximum), allow up to 10 minutes for the relay to
close. Ten minutes after you queue the Note, the relay should release, and a
second dr_event.qo should record the switch back to the current scheduled
mode. That's usually normal if you run the bench test outside any configured
TOU window, so run it outside any active peak or charge window if you want a
predictable normal reversion.
Modbus first-light. Before you connect real inverter and BMS hardware, validate the Modbus link with a USB-to-RS-485 adapter and a software Modbus simulator. Two widely used open-source simulators are:
- ModRSsim2 (sourceforge.net/projects/modrssim2), a Windows app that simulates Modbus RTU slaves on a serial port, so you can point it at the adapter's COM port
- pymodbus simulator: install it with
pip install "pymodbus[simulator,serial]", define a server with"comm": "serial"and"framer": "rtu"on the adapter's port in a JSON setup file, and start it withpymodbus.simulator, passing that file with--json_fileand picking your server and device entries with--modbus_serverand--modbus_device
Connect the USB-to-RS-485 adapter to the OPTA's RS-485 terminals and set the OPTA's Modbus address and serial settings to match the simulator. Confirm that four-register reads from slave IDs 1 and 2 return the values the simulator is publishing. It's faster and safer than commissioning against live solar hardware.
Using Mojo to validate power behavior. The Wireless for OPTA expansion runs from the 24 VDC supply. Splice the Mojo inline between that supply and the expansion's power input to capture session energy data. Keep two measurement layers separate here:
Layer 1: Published Notecard supply-rail figures (from the NOTE-WBNAW datasheet and the Notecard low-power design guide). These describe the Notecard's own internal supply rail and are here for reference. They are not what Mojo will read at the 24 V expansion input:
| Phase | Notecard supply-rail current |
|---|---|
| Idle (radio off, between syncs) | ~8–18 µA |
| Cellular session (LTE Cat-1) | ~230–250 mA average, ≤2 A peak during transmit burst |
| WiFi session (when WiFi fallback engaged) | ~80 mA average |
Layer 2: What Mojo validates at the 24 V expansion input. The Wireless for OPTA expansion uses a switching regulator to step the 24 V supply down to the Notecard's operating voltage, so the current Mojo measures on the 24 V rail is much lower than the supply-rail figures above. The conversion ratio and regulator efficiency change both the size and the shape of the current waveform. Validate the expansion subsystem in terms of input power (V × I as Mojo displays it) and session energy (Mojo's coulomb counter integrated over each session) instead of expecting to read the supply-rail milliamp figures directly at the 24 V input.
Power acceptance criteria (target): At 24 VDC input with the default sync cadence (5-minute inbound, 15-minute outbound), the Notecard opens 288 sessions a day. The NOTE-WBNAW uses roughly 0.9 mAh per session at 5.5 V in the Blues power usage estimates, so that's about 250 mAh (1.4 Wh) a day. At the 24 V expansion input, after regulator losses, expect the Wireless for OPTA expansion to use roughly 60–70 mAh per 24-hour period, almost all of it from inbound polling. Peak current during a cellular session is ≤200 mA at 24 V. These figures leave out the OPTA MCU itself, which adds a constant baseline draw of ~50–100 mA depending on firmware activity.
On the bench, the thing to confirm is the pattern. The 5-minute inbound cadence
should produce an energy pulse about every five minutes on the Mojo trace, and
every third pulse, at the 15-minute outbound boundary, should be visibly larger
because it also flushes the outbound queue. A sync:true mode-change event
should produce an extra pulse outside the normal cadence. If you see
continuously elevated power draw instead, with no return to a lower idle level
between pulses, the cellular session isn't ending correctly, so check the
inbound and outbound values in your hub.set configuration.
Keep in mind that the OPTA itself is an always-on 24 VDC device, and its
steady-state draw is much higher than the Wireless for OPTA expansion's. With
Mojo spliced into the expansion's supply lead as described above, the OPTA's
draw doesn't show up on the trace. If you move Mojo upstream so it measures
both devices, the OPTA's constant baseline dominates and the Notecard pulses
ride on top of it. Either way, read the Mojo trace as a periodic pulse
pattern instead of absolute current figures. Look for the regular ~5-minute
inbound pulses, the larger ~15-minute outbound pulses, and any out-of-cadence
sync:true pulses. You won't see the supply-rail figures from the Notecard
low-power design guide (table above) directly on the 24 V rail, but they help
you estimate the Notecard's share of each pulse.
10. Troubleshooting
No cellular connection or "Offline" in Notehub.
- Check that the external antenna is properly connected through the SMA lead. A rubber-duck antenna inside a metal cabinet won't work.
- Make sure the Wireless for OPTA is fully seated on the OPTA's expansion port.
- Confirm the 24 VDC supply voltage is stable by checking it with a multimeter at the OPTA power terminals.
- Look for error messages on the USB serial debug port (
115200 baud), and send acard.statusfrom the Blues In-Browser Terminal to check registration.
Modbus polling shows -9999 (device unreachable).
- Check that the inverter and BMS are configured with the same baud rate,
parity, and stop bits. Section 5 covers
modbus_baud,modbus_parity, andmodbus_stop_bits. - Make sure the RS-485 A and B lines are wired correctly and that terminators (120 Ω resistors) are in place at both ends of the daisy-chain.
- Confirm the inverter and BMS slave IDs match the configured
modbus_slave_invandmodbus_slave_bmsenvironment variables (defaults: 1 and 2). - Use a USB-to-RS-485 adapter and a Modbus simulator to test the wiring in isolation before connecting to real hardware.
Relay doesn't respond to DR dispatch command.
- Allow for dispatch latency. At
sample_minutes=1, expect the relay to close within 5–6 minutes, and longer ifsample_minutesis higher. - Confirm the Notehub API actually queued the command by checking the device's
dispatch.qiNotefile in the Notehub UI. - Make sure the firmware is reading dispatch commands.
checkDispatch()should be called in the loop, and you should see its debug output on the serial port. - Check that the relay output wires go to the correct field-device digital input terminals and that the input voltage rating matches.
Dispatch command expires too soon or TOU window doesn't transition as expected.
- Make sure the Notecard has valid UTC time from at least one Notehub sync. Run
card.timefrom the Blues In-Browser Terminal and confirmtimeis non-zero. - Check that TOU window hours (
peak_start_utc,peak_end_utc, etc.) are in UTC. The firmware doesn't do any local-time conversion. - Confirm the matching environment variables are set to non-equal values, since equal values disable the window.
11. Limitations and Next Steps
This is a control-channel reference design, not a finished energy-management product. Each simplification below was a deliberate scope choice. Once the basic relay-and-uplink architecture is in place, a real PPA operator or utility-integration partner will want to add vendor-specific register maps, a deeper safety review, or a richer dispatch policy at these points.
Simplified for this POC
Demo Modbus register map. The firmware reads four contiguous 16-bit
registers from each device with hardcoded scaling and signedness. Real
commercial inverters (SolarEdge, Fronius, SMA, Huawei SUN2000, Sungrow) and BMS
units (BYD Battery-Box, PYLON, CATL) each publish their own Modbus maps, with
vendor-specific register addresses, scaling factors, and word orders, so
commissioning against real hardware takes a vendor-specific firmware build. The
Notehub reg_inv_base / reg_bms_base environment variables are a first step,
but per-vendor register maps will need code changes for scaling and
signedness.
Single shared Modbus RTU bus. The firmware uses one RS-485 physical segment
with one set of serial parameters (modbus_baud, modbus_parity,
modbus_stop_bits) shared by the inverter and the BMS. Both devices must be
configurable to the same baud rate, parity, and stop bits, and that's a hard
commissioning prerequisite. Real sites often break this rule when the inverter
and BMS come from different vendors with different factory defaults. If you
can't bring the two devices to the same serial settings, this reference
topology won't work without hardware changes (a second UART or USB-to-RS-485
adapter) and matching firmware changes to address each device on its own
independent serial port.
SOC guard hysteresis defaults are proof-of-concept values. The firmware
applies anti-chatter hysteresis at both ends of the SOC operating range.
soc_hyst_pct (default 5 %) prevents discharge-relay chatter near the
soc_min_pct floor, and soc_max_hyst_pct (default 3 %) prevents charge-relay
chatter near the soc_max_pct ceiling. Treat both as starting points that
haven't been tuned in the field. BMS units with noisy SOC reporting or slow
charge recovery may need wider bands, and units with tightly regulated
reporting may need narrower ones. Tune both variables for each site based on
the SOC reading jitter you see during commissioning. Also note that the
belt-and-suspenders hard guard in applyRelays() uses the plain soc_min_pct
with no hysteresis, so on its own it only blocks discharge below that floor.
The over-protection window between soc_min_pct and
soc_min_pct + soc_hyst_pct, where discharge stays blocked after SOC first
drops below the floor, comes from the latch in resolveMode(). It's intentional, and it keeps
the discharge relay from toggling on a borderline reading.
Relay wiring is application-specific. This reference design assumes the inverter and BMS each expose a digital control input for the functions mapped to the relay outputs. Not every commercial product does. Many inverters take grid-export enable/disable as a Modbus write command instead of a contact input. In that case the relay output is redundant, and you'd need to add the right command to the firmware.
TOU schedule is UTC-only. You configure the peak window's start and end in
UTC hours, so operators have to convert their local TOU peak window to UTC.
Adding a tz_offset_hours environment variable and a proper local-time
calculation would be a simple improvement.
Autonomous TOU dispatch requires valid Notecard UTC time. resolveMode()
calls currentUtcEpoch(), which issues a card.time request and returns 0 if
the Notecard hasn't completed a Notehub sync yet. On a cold boot, or after a
power event before the Notecard re-acquires time, TOU window evaluation is
suspended and the controller stays in normal mode. The TOU windows resume
automatically once a non-zero epoch is available. Build your commissioning
workflow so the Notecard has synced at least once, with card.time returning a
non-zero time field, before you rely on autonomous TOU behavior in the field.
Relay contacts assert enable inputs only, and actual dispatch behavior is vendor-defined. All control happens through relay contact closures that assert or de-assert digital enable inputs on the field devices. Closing a relay enables a mode on the field device, but the actual charge, discharge, and export behavior (ramp rates, power limits, setpoints) is entirely up to the inverter and BMS firmware and their pre-commissioning configuration. This proof of concept doesn't command power setpoints, verify that a requested profile was executed, or close any control loop around actual site load or grid flow. When you commission against a specific inverter and BMS model, verify that asserting each control input produces the expected operational response.
No Modbus writes. The firmware reads inverter and BMS state, but all control goes through relay contact closures. Writing Modbus setpoints to the devices (target SOC, charge current limit, export cap) is out of scope on purpose. It needs a safety review for each device type, which makes it a real production enhancement and a poor starting point for a proof-of-concept.
Single inverter and BMS per OPTA. The firmware reads one inverter and one BMS. Sites with multiple strings or battery racks need either multiple OPTAs or firmware that round-robins across slave IDs on the same RS-485 bus.
One dispatch command consumed per sample cycle. checkDispatch() pops
exactly one Note from dispatch.qi per sample cycle. If commands arrive faster
than sample_minutes (say, a utility DR event and a rate-schedule override
queued in the same 5-minute inbound sync window), they back up in the Notecard
queue and get processed one per cycle, up to sample_minutes apart. At the
default 1-minute sample cadence the gap is small. At longer cadences, or during
rapid command sequences, the backlog can cause a string of unexpected mode
transitions. Production systems with higher command rates should drain the
queue within a single cycle (loop checkDispatch() until it returns no Note) or
use idempotent commands with expiry timestamps.
Dispatch state held in RAM only. g_commanded_mode and g_dr_expires_epoch
live in RAM. A power cycle, watchdog reset, or MCU fault clears the active
dispatch, and the device reverts to the TOU schedule (or normal if it's
outside all configured windows) until the cloud system resends the command. The
Notehub telemetry stream will show the mode change, but nothing re-delivers the
original command automatically. For dispatch-critical deployments where a
commanded relay state must survive power events, persist the active mode and
expiry epoch to the Notecard's nonvolatile storage (a note.add to a .db
Notefile on the Notecard) and restore them on boot before the first
resolveMode() call. Be careful to discard an expired epoch instead of
re-applying a stale command.
Dispatch commands have no authentication. Any Notehub API caller with a
valid token can queue a dispatch.qi Note. Production deployments should scope
API tokens to the minimum required permissions and consider note-level signing
or encryption for extra assurance.
The TOU autonomous schedule alone is not a full energy management strategy. The firmware dispatches peak discharge from the configured hour window and doesn't account for forecast PV generation, real-time spot prices, battery degradation, or multi-day SOC planning. That's fine for a starting point, but a finished product needs more.
Production Next Steps
Getting to production means hardening the device for the specific inverters, batteries, and reporting obligations of a real PPA portfolio. These extensions are listed roughly in the order you'd take them on, from the most immediately useful to the ones that depend most on infrastructure.
Vendor-specific inverter profile builds are the first thing most deployments will need. Instead of the demo register map, ship dedicated profiles for SolarEdge SunSpec, the Fronius Solar API, Huawei SUN2000, and Sungrow, each with the correct register addresses, scaling factors, and Modbus function codes for that manufacturer's map.
Modbus write support is the biggest functional jump. Inverters that accept export-cap or charge-limit commands over the bus could be commanded directly instead of only through relay enable inputs. That has to sit behind appropriate safety interlocks and a per-device safety review.
Local-time conversion is a small change that makes the device much easier to
use. A configurable tz_offset_hours environment variable would let operators
write the TOU schedule in local hours instead of UTC, which is how they actually
think about peak windows.
Multi-battery support extends the controller to sites with more than one battery rack. Round-robin polling of up to N BMS units on the same RS-485 bus, with per-unit SOC tracking and aggregate discharge allocation, would let a single OPTA handle a larger installation.
Fleet-level telemetry aggregation turns the raw event stream into reporting. Rolling SOC and energy telemetry up through Notehub into a historian supports PPA performance reporting, like total kWh discharged per DR event, aggregate daily export, battery cycle count, and similar settlement-grade metrics.
Over-the-air host firmware updates are the furthest off, because they depend on a hardware path that doesn't exist yet. Notecard Outboard Firmware Update supports the STM32H7 MCU family (the OPTA's processor), but it needs AUX wiring that Blues Wireless for OPTA doesn't break out today, so host firmware updates stay local-only over USB-C until that hardware path changes. Once it does, Outboard DFU could push a new vendor register map or TOU algorithm to the whole fleet without a truck roll.
12. Summary
The PPA asset owner who used to depend on the building's IT department and a vendor's cloud portal now has a direct line to the inverter and the battery. A DIN-rail PLC reads their state over Modbus once a minute, four relays put them in the right mode at the right time, and the cellular uplink doesn't care whose network is on the other side of the cabinet door. When the utility calls a 4 PM DR event, the dispatch reaches the device within minutes and the export relay opens. The telemetry goes straight to whoever needs it, whether that's the energy manager, the settlement system, or the utility, and nobody has to agree on a dashboard vendor first.