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_rightDemand-Response Solar + Battery Dispatcher

Demand-Response Solar + Battery Dispatcher

Demand-Response Solar + Battery Dispatcher 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/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.

warning

⚠ 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

System architecture diagram showing the Notehub API and DR cloud feeding through the Notecard to the OPTA, which polls the inverter and BMS over Modbus and drives four relay outputs

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:

  1. Claim a Notehub project and copy its ProductUID. Sign up at notehub.io, create a new project, and copy the ProductUID.

  2. Edit the firmware. Open firmware/solar_battery_dispatcher/solar_battery_dispatcher.ino and replace the placeholder string in the PRODUCT_UID line with your project's actual ProductUID:

    #define PRODUCT_UID "com.your-company.your-name:solar_battery_dispatcher"
  3. 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
  4. 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/ttyACM0 with your OPTA's USB serial port, which you can find with arduino-cli board list.)

note

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.

  1. 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).

  2. Verify Notehub registration. Open the In-Browser Terminal and run card.status to confirm connected:true. Then run hub.get to check the ProductUID (product) and hub.sync.status to 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

PartQtyRationale
Finder OPTA Codesys (8A.04.9.024.832C)1Industrial 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)1Snaps 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 recommendedMetal 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 Mojo1Bench-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)1Powers the OPTA and expansion. Most solar equipment cabinets already have one, so only buy it if yours doesn't.
120 Ω, ¼ W resistor2RS-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 rated1–3 mA → 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 cm1Mount 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

Wiring and assembly diagram showing DIN-rail layout, RS-485 daisy-chain with terminators, relay output paths, power, and antenna routing

warning

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

  1. 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 +24V to the expansion's +24V terminal so both devices share the same supply.

Antennas

  1. 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

  1. 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 (+) → inverter A (+) → BMS A (+)
    • OPTA B (−) → inverter B (−) → BMS B (−)
    • OPTA COM → inverter communication ground → BMS communication ground
    • Put a 120 Ω termination resistor across A / B at 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.
  2. Modbus device configuration. Configure the inverter as Modbus slave ID 1 (default modbus_slave_inv) and the BMS as slave ID 2 (default modbus_slave_bms). Use the modbus_baud, modbus_parity, and modbus_stop_bits environment 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

  1. 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.

    RelayOPTA contacts to useMapped functionDe-energized (open) fail state
    RELAY1COM + NOInverter Grid Export EnableExport 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 EnableDischarge blocked, so the battery is protected against uncontrolled discharge during a fault
    RELAY3COM + NOBMS Battery Charge EnableCharge inhibited. The BMS's own protection circuits stay responsible, and charging halts cleanly
    RELAY4COM + NOSCADA / indicator lamp DR ActiveDR 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 COM and NO terminals to the inverter's Grid Export Enable dry-contact input terminals. The firmware asserts this relay HIGH in normal, peak_discharge, overnight_charge, and low_soc_protect modes. It opens (de-energizes) only in dr_curtail mode, which curtails export per the utility DR instruction.
    • RELAY2 (Battery Discharge Enable): Run two wires from RELAY2 COM and NO to the BMS's Discharge Enable dry-contact input. The relay closes only when peak_discharge is the active mode and SOC is at or above soc_min_pct. It opens in every other mode and whenever the SOC guard latches, and once latched it stays open until SOC recovers to soc_min_pct + soc_hyst_pct.
    • RELAY3 (Battery Charge Enable): Run two wires from RELAY3 COM and NO to the BMS's Charge Enable dry-contact input. The relay closes in normal, overnight_charge, and low_soc_protect modes when the upper-SOC latch isn't active. It opens once SOC reaches soc_max_pct and doesn't close again until SOC drops to soc_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 during peak_discharge (never charge while discharging) and dr_curtail (follow DR instructions).
    • RELAY4 (DR Active indicator): Run two wires from RELAY4 COM and NO to the SCADA dry-contact input or indicator lamp. The relay closes only in dr_curtail mode. 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.

Bench power validation

  1. 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

  1. 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.ino as PRODUCT_UID.

  2. Claim the Notecard. Power up the panel supply. The Notecard associates itself with your Notehub project on its first cellular session.

  3. 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.

  4. 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 per report_minutes. Changing report_minutes also makes the firmware re-issue hub.set during 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.

    VariableDefaultPurpose
    sample_minutes1Modbus poll cadence (minutes). DR dispatch latency is inbound cadence (5 min) + sample_minutes. At the default of 1, end-to-end relay response is at most 6 minutes. Increasing sample_minutes adds the same amount to dispatch latency. A value of 5 puts 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. Once low_soc_protect latches, discharge doesn't resume until SOC rises to soc_min_pct + soc_hyst_pct (e.g., 25 % with the default 20 % minimum). This prevents relay chatter and repeated dr_event.qo traffic 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 reaches soc_max_pct), charging doesn't resume until SOC drops to soc_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 inside applyRelays() without changing the resolved mode, so it doesn't generate dr_event.qo events. 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_utc equals peak_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_utc equals charge_end_utc (the default), autonomous overnight charging is disabled and overnight_charge is 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 enter overnight_charge autonomously 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: 1 or 2. 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.
  5. Configure routes. Route solar_telemetry.qo to a long-term analytics or historian destination. It's relatively high frequency, at 96 records per device per day with the 15-minute default. Give dr_event.qo its 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.

  6. Inbound dispatch path. Cloud-side systems send dispatch commands by posting a Note to the device's dispatch.qi Notefile through the Notehub API (POST /v1/projects/{projectUID}/devices/{deviceUID}/notes/dispatch.qi). The Note body carries a mode string and an optional expires_epoch Unix timestamp, and the Notecard delivers it to the device within the next inbound sync window (≤5 minutes).

    mode valueEffect
    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, or dr_curtail command and holds the system in normal relay state, suppressing autonomous TOU transitions, for the duration of any expires_epoch window (or until another command supersedes it). Dispatching mode:"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 idle MODE_NORMAL and the active MODE_FORCED_NORMAL serialize to "normal", so dispatching mode:"normal" while the system is already in normal state (no active TOU window) doesn't produce a dr_event.qo. Events only go out when the wire-visible name changes.

    When expires_epoch is set, the device automatically reverts to its TOU-scheduled mode once the epoch arrives. That could be peak_discharge, overnight_charge, or normal, 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.qo Note.

7. Firmware Design

The firmware is five files in firmware/solar_battery_dispatcher/:

FileRole
solar_battery_dispatcher.inoEntry point: PRODUCT_UID define (edit this), setup(), loop(), and all global variable definitions
dispatcher.hShared types (DispatchMode, InverterSample, BmsSample), relay-pin defines, extern declarations for module-global variables, and function prototypes used by all compilation units
notecard_helpers.cppNotecard configuration (hub.set, templates), environment-variable fetch and validation (fetchEnvOverrides), outbound sync-cadence management, and inbound dispatch-Note polling (checkDispatch)
modbus_helpers.cppModbus RTU bus initialization (applyModbusIfChanged) and device polling (pollInverter, pollBms)
mode_helpers.cppUTC 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-arduino library). Install it from the Arduino Library Manager or with arduino-cli lib install "Blues Wireless Notecard". The note-arduino releases page has the latest version.
  • ArduinoModbus and ArduinoRS485 (official Arduino libraries, also from the Library Manager).

Modules

ResponsibilityFunctionFile
Notecard configuration (hub.set, templates)notecardConfigure, defineTemplatesnotecard_helpers.cpp
Environment-variable fetch + clampfetchEnvOverridesnotecard_helpers.cpp
Notecard outbound cadence updateapplyHubSetIfChangednotecard_helpers.cpp
Modbus serial config updateapplyModbusIfChangedmodbus_helpers.cpp
Modbus pollingpollInverter, pollBmsmodbus_helpers.cpp
Inbound dispatch Note consumptioncheckDispatchnotecard_helpers.cpp
UTC time query with extrapolationcurrentUtcEpochmode_helpers.cpp
Mode resolution (cloud dispatch + TOU schedule + lower-SOC guard)resolveModemode_helpers.cpp
Relay output control (upper-SOC latch; fail-safe on BMS comm loss)applyRelaysmode_helpers.cpp
Outbound telemetry emissionsendTelemetrymode_helpers.cpp
Immediate mode-change eventsendModeEventmode_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.set records the applied outbound cadence only when it returns successfully. If the call fails, s_last_report_minutes stays at its sentinel (0), and applyHubSetIfChanged() retries hub.set from 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 -9999 sentinel 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. If peak_discharge is the active mode, resolveMode() returns MODE_LOW_SOC_PROTECT and the discharge relay opens. The device can't confirm battery state, so it protects the battery and emits a dr_event.qo mode-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_epoch revert 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 returns 0 again, so expiry and TOU scheduling stay accurate through brief Notecard API or connectivity disruptions.
  • Every requestAndResponse() call checks for both a NULL return and an err field 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:

  1. TOU suppression. g_commanded_mode is the only input that suppresses TOU evaluation. Any value other than MODE_NORMAL, including MODE_FORCED_NORMAL (stored when the cloud dispatches mode:"normal"), makes the function skip the TOU window checks. Because MODE_FORCED_NORMAL != MODE_NORMAL, a cloud mode:"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_NORMAL and MODE_NORMAL both serialize to "normal" through modeName(), so mode-change events in loop() are gated on strcmp(modeName(new_mode), modeName(s_prev_mode)) != 0 instead of a raw enum comparison. That keeps a no-op dr_event.qo with prev_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_mode is updated unconditionally each cycle, so the comparison always reflects the current resolved state.

  2. Lower SOC guard. Whenever SOC is critically low, or the BMS is unreachable while peak_discharge is active, the function returns MODE_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 below soc_min_pct and releases only after SOC recovers to soc_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 a dr_event.qo mode-change Note on every 1-minute sample cycle.

  3. Upper SOC guard (charge inhibit). applyRelays() uses a matching deadband latch (s_in_charge_inhibit, private to mode_helpers.cpp) for the charge path. It engages when SOC reaches soc_max_pct and releases only after SOC drops to soc_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 inside applyRelays() and never changes the resolved mode. That means it doesn't affect the wire-name mode-change comparison in loop() and doesn't generate dr_event.qo events. Its only job is relay anti-chatter. BMS comm loss also counts as charge-inhibit: when bms_valid is 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

Dispatch and data-flow diagram showing inbound dispatch.qi, mode resolution with TOU schedule and SOC guards, and outbound solar_telemetry.qo and dr_event.qo to Notehub

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 every report_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 with sync:true on 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_discharge engages automatically when UTC time enters the peak_start_utc→peak_end_utc window. If a non-zero charge_start_utc→charge_end_utc off-peak window is configured, overnight_charge also engages on its own during that window. Outside all configured windows, the mode is normal. Autonomous TOU transitions depend on valid Notecard UTC time. On first boot, or after a power event before the Notecard re-acquires time, resolveMode() gets epoch = 0 and stays in normal. It doesn't evaluate TOU windows until a valid epoch is available from at least one earlier Notehub sync.
  • Cloud dispatch: any mode value in a dispatch.qi Note overrides the TOU schedule immediately. That includes mode:"normal", which holds the controller in normal relay state and suppresses autonomous TOU transitions for the duration of any expires_epoch window (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 both solar_telemetry.qo and dr_event.qo.
  • Dispatch expiry: a dispatch.qi Note with an expires_epoch reverts to the TOU schedule (which may be peak_discharge, overnight_charge, or normal, 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 while peak_discharge is active, the device switches to low_soc_protect mode. The discharge relay opens, and a dr_event.qo mode-change event goes out so operators can see why discharge stopped. The lower-guard latch engages at soc_min_pct and releases only after SOC recovers to soc_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 in applyRelays() 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 the soc_max_pct − soc_max_hyst_pct threshold. 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

warning

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 with pymodbus.simulator, passing that file with --json_file and picking your server and device entries with --modbus_server and --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:

PhaseNotecard 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 a card.status from 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, and modbus_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_inv and modbus_slave_bms environment 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 if sample_minutes is higher.
  • Confirm the Notehub API actually queued the command by checking the device's dispatch.qi Notefile 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.time from the Blues In-Browser Terminal and confirm time is 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.

On This Page

  • 1. Project Overview
  • 2. System Architecture
  • 3. Technical Summary
  • 4. Hardware Requirements
  • 5. Wiring and Assembly
    • Power and DIN rail
    • Antennas
    • Modbus RS-485 bus
    • Relay output wiring
    • Bench power validation
  • 6. Notehub Setup
  • 7. Firmware Design
    • Modules
    • Sensor reading strategy
    • Event payload design
    • Power and sync strategy
    • Retry and error handling
    • Key code snippet 1: inbound dispatch polling
    • Key code snippet 2: mode resolution with TOU schedule and SOC guard
    • Key code snippet 3: relay control
  • 8. Data Flow
  • 9. Validation and Testing
  • 10. Troubleshooting
  • 11. Limitations and Next Steps
    • Simplified for this POC
    • Production Next Steps
  • 12. Summary

Resources

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