Skip to content

External Interfaces

Beyond its own on-board sensors, the XREG-010 receives data from the rest of the boat: the NMEA2000 backbone, an NMEA0183 serial feed, a Victron solar charger, and a 1-Wire bus of digital temperature probes on the alternator and the battery.

Interface summary

Interface Pins Speed / mode Enabled by
Shared sensor bus (I²C) SDA = GPIO9, SCL = GPIO10 400 kHz (in-spec Fast-mode) always on
NMEA2000 (CAN) RX = GPIO16, TX = GPIO17 standard CAN any of NMEA2KData (receive), n2kTxEnable (transmit), rvcTxEnable (RV-C transmit), dvccEn (charge-limit follow)
Victron VE.Direct (serial) RX = GPIO7 on Serial1 19200 8N1, inverted logic VeData user setting
NMEA0183 (serial) RX = GPIO6 on Serial2 8N1; baud (NMEA0183Baud, default 19200) and polarity (NMEA0183Invert, default normal) both user-selectable NMEA0183Data user setting
1-Wire (DS18B20) GPIO13 1-Wire always on

All of these are brought up in initializeHardware().

Shared sensor bus (I²C)

The on-board chips — battery monitor (INA228), analog converter (ADS1115), motion sensor (LSM6DSOX IMU), and barometer (BMP388) — share one I²C bus.

  • The clock is 400 kHz, which is within specification (Fast-mode) for every chip on the bus. An earlier experiment ran the bus faster, out of spec; it was reverted after a sensor stopped responding on the bus, and the comment at the Wire.setClock() call in initializeHardware() records that history. Per-device I²C error counters (for example adsI2CErrorCount) are the indicator of bus health.
  • A short transaction timeout (Wire.setTimeOut()) bounds how long a wedged device can stall a bus read, so a bus fault costs milliseconds instead of hanging the control loop.

The individual chip drivers are documented separately; this page is only about the bus they share.

NMEA2000 (CAN)

The user-facing version of this section — every message, every instance number, what a chartplotter or a Victron GX shows, and what the regulator reads back off the bus — is NMEA 2000 in Detail. What follows is the firmware view of the same thing: pins, switches, setting keys, handler names and timing.

The regulator both listens on the NMEA2000 backbone and — when the user turns it on — transmits its own messages. Four independent user switches each enable a different part of the stack, and NMEA2000.ParseMessages() is pumped from the main loop when any of them is on and real hardware is present ((NMEA2KData == 1 || n2kTxEnable == 1 || rvcTxEnable == 1 || dvccEn == 1) && hardwarePresent == 1):

  • NMEA2KData — receive. Off by default. Admits bus data to the handler table.
  • n2kTxEnable — transmit. Off by default. Makes the regulator a node on the bus.
  • rvcTxEnable — RV-C transmit. Off by default. RV-C uses the same 250 kbps port and the same claimed address, so an RV-C-only producer is still a bus node and forces ParseMessages() on its own.
  • dvccEn — charge-limit follow. Off by default. Needs to keep receiving the charge authority's messages even with receive off, so it forces ParseMessages() on its own.

The CAN driver (tNMEA2000_esp32) delivers every received message to one dispatch function, HandleNMEA2000Msg(), which walks a fixed table (NMEA2000Handlers[]) mapping message types (PGNs) to handler functions. That table walk is gated on the receive switch: with NMEA2KData off the dispatcher drops bus data immediately, so ParseMessages() running purely to service the transmit node's protocol layer (address claim, heartbeat) still means no nav or battery data flows in. Three PGNs are exempt, all under the same (NMEA2KData == 1 || dvccEn == 1) condition, because charge-limit follow has its own master switch and has to be watchable before it is enabled. The Victron proprietary fast-packet carrier 126720 is dispatched ahead of that gate and returns immediately, so it reaches only the charge-limit decoder — and only while the dialect is set to Victron VE.Can (dvccSrcType == 0). ISO address claim 60928 (the NAME field) and product information 126996 are read ahead of the gate too, into the passive bus-node identity roster the charge-limit capture is annotated with; unlike 126720 they fall through, so the verbose print and the handler-table scan behave exactly as they would without them. Both are collected passively only — with transmit off neither can be requested, so the roster holds whatever bus devices broadcast while the regulator was receiving.

Data consumed from the bus:

  • Position and satellites (GNSS() handler) — latitude, longitude, satellite count, with validity checks before anything is stored (both coordinates present, nonzero, and in range, and a nonzero satellite count).
  • Course and speed over ground (COGSOG() handler) — speed over ground also feeds updateSustainedSpeed(), which keeps a 60-second rolling ring (sustT / sustV in PSRAM) and publishes sogSust1m, the average speed over the last 60 s. The session and lifetime speed records (MaxSpeed, MaxSpeed_AllTime) are the best value of that average, not a single-sample peak — a single bad GPS sample cannot set a record.
  • Heading (Heading() handler) — the angle itself, plus two things the wind maths needs: which reference frame the sender used (HeadingRefNMEA: true, magnetic, or unknown) and the magnetic variation the same PGN carries (HeadingVariationDeg, east-positive; not-a-number when the sender omits it).
  • Apparent wind (WindSpeed() handler) — apparent wind speed and angle, plus wind maximums. When a sender reports wind in an earth frame instead (true-north or magnetic reference), the handler converts that wind direction into a boat-relative true wind angle by subtracting heading — and shifts heading into the wind's frame first when the two disagree (true = magnetic + east-positive variation). If the heading reference is unknown or no variation was supplied, it falls back to the raw subtraction, which is off by the local variation.
  • Speed through water (Speed() handler) — stored separately from speed over ground because the sailing-performance system can be set to use through-water speed, which excludes current. See Sailing Performance.
  • Depth (WaterDepth() handler) — depth below the transducer plus the sender's offset when one is supplied, stored as WaterDepth_m. A negative result is rejected rather than stored. It feeds anchorage comfort scoring and is sent on the telemetry stream behind a staleness gate.
  • GPS time (SystemTime() handler) — feeds syncTimeFromGPS(), the GPS leg of the time-sync chain. With the default Auto setting (timeSourceMode = TSRC_AUTO) the chain is arbitrated by freshness, boat-network first: NMEA2000 GPS time is used whenever it is fresh; the phone's clock (syncTimeFromPhone(), source label TIME_PHONE) is next; network time (syncTimeFromNTP()) is attempted only when both of those are stale; and below all three, a boot soft-clock estimate (restoreSoftClock(), source label TIME_ESTIMATED) adopts the retained RTC or an NVS-stored epoch (SoftClockEp) so an offline or AP-mode device still has a usable timebase. The estimate is replaced by the real value as soon as any real source reports. The forced modes (TSRC_NMEA, TSRC_PHONE, TSRC_NTP) pin the chain to one source instead. This setting governs the clock only — latitude and longitude follow their own separate setting (gpsPositionSource).
  • Battery status (BatteryStatus() handler, PGN 127508) and DC detailed status (DCStatus() handler, PGN 127506) — voltage, current, temperature, state of charge and state of health from one selected battery instance (n2kRxBattInstance, default 0), typically a shunt, battery monitor, or a BMS "virtual shunt" bridged onto the bus. Voltage, current and state of charge never feed field control, protections, targets, or the regulator's own state-of-charge count. With Battery Voltage Source set to NMEA 2000 (BatteryVoltageSource = 1), the received voltage is what the bulk, absorption, float, and rebulk decisions compare against, and the regulator falls back to its own sensor whenever that value is stale. With Use as Battery Monitor on (BattMonSrc = 1), the received state of charge is the one shown and used, and with no shunt wired to the regulator the received current ends absorption on taper current and triggers the current rebulk. The temperature field is the one exception: it is a candidate in the battery-temperature source chain (source 2), so a bank temperature published on the bus can drive the charge lockouts and the voltage-loop gain derate. Absent fields stay not-a-number rather than being fabricated, and changing the selected instance clears the previous bank's values and their freshness stamps.

The remaining message types (rudder, attitude, satellites in view, battery configuration status) are parsed but not stored. The attitude, satellites-in-view, and battery-configuration handlers exist only to print to the serial console, and they are gated behind the NMEA2KVerbose flag, which is off by default because unconditional printing filled the console with lines at bus rate (attitude alone arrives at 10 Hz from common compasses). With the flag off they print nothing. The rudder handler's printout is commented out, so it reports only a parse failure. The two battery handlers above store their values first and only then consult the verbose flag for printing.

High-rate messages — heading, wind, position, course/speed — are individually throttled inside their handlers (roughly every couple of seconds) so a bus with a high message rate cannot take most of the loop time.

From the raw apparent-wind and motion data, calculateDerivedMetrics() computes true wind speed and angle, leeway, and velocity made good (VMG) every tick.

Transmitting on the bus

Transmit is off by default (n2kTxEnable = 0), and with both it and the RV-C transmit switch (rvcTxEnable = 0) off the library keeps its listen-only mode, with no address claim and no bus presence at all. Either switch on makes the regulator a node: n2kApplyNodeConfig() puts the library in node mode and claims the address when n2kTxEnable == 1 || rvcTxEnable == 1, one claim serving both stacks. The list of transmitted PGNs the library answers a 126464 request with (ExtendTransmitMessages()) is still gated on n2kTxEnable alone, so an RV-C-only node never advertises N2K messages it will not send. A change to either switch takes effect without a reboot. The /get handler only raises a flag, and the main loop on Core 1 joins or leaves the bus on its next pass, because the NMEA2000 library is not safe to call from the web task. Leaving puts the library back in listen-only mode, and the next join offers the address claimed last. The per-message enables and the instance numbers below apply live as well; a device-instance change makes a live node announce itself again with the new value.

When it is on, the regulator claims a source address (the last claimed address is remembered in the n2kSrcAddr key and re-offered at boot, defaulting to 22) and identifies itself as device function 141 (DC Generator/Alternator), device class 35 (Electrical Generation), with a 21-bit unique number derived from the ESP32 MAC.

nmea2kTransmitTick() runs from the same place as ParseMessages() and sends at most one message per loop pass, cycling round-robin through nineteen slots — the eleven NMEA 2000 streams below plus the eight RV-C messages described in On an RV-C system — so a control tick never carries more than one message's transmit cost. Each family is gated by its own master switch inside the scan, and the slot's schedule is advanced before that gate so a disabled family cannot re-arm on every pass. Its cost is instrumented as ft_n2kTx. Each stream has its own enable and instance number:

Message PGN Interval Default
Battery status + DC detailed status 127508 + 127506 1.5 s on (n2kBattEnable, instance 0)
Alternator as its own DC instance 127508 + 127506 (DC type = Alternator) 1.5 s on (n2kAltEnable, instance 1)
Alternator temperature 130312 2 s on (n2kAltTempEnable, instance 4, source 3 = Engine Room)
Extra-probe temperature 130312 2 s off (n2kExtraTempEnable, instance 5, source 3 = Engine Room) — the second temperature instance, carrying whichever 1-Wire probe holds the extra role
Charger status 127507 1.5 s on (n2kChgrEnable, instance 0)
Charger configuration — field drive % in the charge-current-limit field, plus charger mode and enabled state 127510 1.5 s on (n2kChgrCfgEnable, shares the charger and battery instances with 127507)
Engine RPM 127488 100 ms off (n2kEngRpmEnable) — collides with a real engine gateway on the same instance
Engine dynamic parameters 127489 500 ms off (n2kEngDynEnable); the discrete warning bits inside it are separately controlled by n2kEngBitsEnable, which is on
Battery configuration 127513 15 s off (n2kBattCfgEnable) — a battery monitor on the bus usually already claims the bank's instance

A value with no live source is sent as the NMEA "not available" code rather than a stale or invented number — and either single-value temperature message is skipped outright while its probe reads not-a-number, so an MFD's own data-lost alarm is what reports a failed sensor. The battery-status message's temperature field follows the same principle from the other direction: it carries the battery-role 1-Wire probe alone, and is sent as "not available" whenever that probe is missing, disabled or stale — the regulator never re-broadcasts a temperature it received from the network as a measured battery temperature. And a charge-stage change sends the charger-status message immediately instead of waiting out its interval.

The driver never blocks: the project's NMEA2000_esp32_xeng fork returns immediately when the CAN transmit queue is full instead of waiting up to 100 ms per frame (which is what happens with no bus attached), and refused frames are placed in the core library's retry ring. Accepted and dropped counts (n2kTxCount / n2kTxDropCount) and the claimed address are sent on the 5-second diagnostics channel; drops are normal and expected on a bench device with no backbone connected.

Following an external charge authority (DVCC-style)

With dvccEn on (off by default; Setup → Integrations → Charge-Limit Follow), the regulator decodes charge-voltage and charge-current limits (CVL/CCL) published by a managed battery or BMS — RV-C DC_SOURCE_STATUS_4 / BATTERY_STATUS_4, or Victron VE.Can VREG traffic — and clamps its own targets to them. A published charge-current limit of zero — how many managed batteries signal "full, stop" — is obeyed as stop-charging, and the RV-C "do not charge" command is treated the same way.

What arrives differs by dialect: the RV-C frames carry both limits in one broadcast, while on VE.Can the charge-voltage set-point is broadcast bus-wide and the per-charger current allocation is addressed individually to nodes Victron's GX software recognizes. The decoder accepts a Victron register only when it is broadcast or addressed to this node, so an allocation meant for another charger is never mistaken for the battery's own limit. The limits from each Victron sending address are held separately (dvccVicSnd[], four slots), and the merge follows the lowest CVL, the lowest CCL, and a stop-charging command from any sender; a sender silent for the Silence Timeout drops out, a held stop-charging command included. A sender whose address claim (PGN 60928) names a manufacturer other than Victron (358) is ignored (dvccVicForeign()), because an alternator regulator that follows DVCC re-broadcasts the current allocation it receives in Victron's format, which would otherwise read as a battery limit. A sender not yet in the node list is accepted.

On the Victron dialect the followed number is VREG 0x2001 (VE_REG_LINK_VSET, un16 at 0.01 V/bit, 0xFFFF = not available, which is decoded as the limit being withdrawn rather than as a value); VREG 0x2016 (VE_REG_LINK_CHARGE_VOLTAGE_SETPOINT) is decoded identically in case another Victron node uses it. A GX broadcasts it to the whole bus with no device recognition needed, but only while DVCC is enabled and a battery service is supplying a charge voltage — in dvcc.py the VE.Can /Link/ChargeVoltage write is gated on a non-null voltage, which exists only with a managed battery or an ESS Multi; the DVCC menu's Maximum Charge Voltage clamps that value and does not originate one. So receiving no charge voltage is a GX configuration state, not a decode failure. The charge-current allocation a GX computes is addressed to one charger at a time and is sent only to products Victron recognizes, so over VE.Can the Victron path is voltage-only in practice; the RV-C dialect publishes both limits.

The current reaches the regulator over WiFi instead. The X Engineering GX app publishes a com.victronenergy.alternator service on the GX, which dvcc.py controls like any alternator with /Link/ChargeCurrent, and forwards what dvcc.py writes there (and to /Link/ChargeVoltage, written only with a managed battery) to the regulator as GET /gxLimits?gx=1&uid=<16 hex>[&cvl=<V>][&ccl=<A>] every 3 s. The firmware treats VE.Can and WiFi as two carriers of one Victron source (dvccSrcType 0): each counts while it is fresh within the Silence Timeout, the lower fresh value of each limit applies (dvccVicIngestMerged()), and the trust state machine judges the merged stream. The WiFi CCL is applied as a ceiling on alternator current with no shunt load offset: what dvcc.py writes is already this alternator's output ceiling, the battery limit plus the DC-system current when the GX measures it, minus the solar chargers' current, times this alternator's share.

The clamp is request-only and one-directional: a received limit can lower the effective voltage target or current ceiling below the local values, never raise either, and every local protection still takes priority over it. A trust state machine (dvccTick(), 1 Hz) checks decoded limits before they reach the control path: limits must arrive in-range for a settling period before the first clamp applies, silence longer than the Silence Timeout (30 s by default, adjustable from 5 to 600 s) reverts to local targets, and an authority publishing implausible values latches untrusted until the user resets it. With dvccLostStop on (Stop Charging Without Limits), every state other than following holds charging at zero instead of reverting to local targets (dvccStopForNoLimits()): the alternator current target is capped at the filtered load current with a live battery shunt, so the battery current is zero, and at 0 A without one. It reports as limiter code 8 and CSV2 dvccNoLimitStop, and is skipped while commissioning owns the battery. dvccTick() runs unconditionally — outside the hardware gate above — precisely so that losing the bus reverts the clamps instead of leaving the control loop applying the last received limit indefinitely. When a clamp is what limits output, the dashboard limiter banner says so (codes 8 and 9, distinct from the regulator's own current and voltage limits).

With the RV-C dialect selected (dvccSrcType = 1), the frame tap also reads the source temperature out of DC_SOURCE_STATUS_2 for the followed instance (rvcRxBattTempF, 0.03125 °C per bit with a −273 °C offset, accepted between −40 and +100 °C). Unlike the limits it is not a clamp — it is simply another candidate in the battery-temperature source chain (source 4).

Victron VE.Direct (serial)

A Victron solar charge controller broadcasts plain-text status frames continuously on its VE.Direct port. The regulator receives them on Serial1 with inverted logic (note the final argument in the Serial1.begin() call — getting this wrong yields corrupted characters instead of no data).

ReadVEData() runs every couple of seconds when the user has enabled Victron input (VeData). It drains the serial buffer through the VE.Direct frame parser with a per-call byte budget, then picks named fields out of the decoded frame.

  • The budget has to exceed what arrives between ticks. A battery monitor emits roughly 900 bytes a second, so a two-second gap can leave ~1800 bytes waiting; the drain budget is 2048 bytes and the UART receive buffer is enlarged to 2048 before Serial1.begin() (the 256-byte default overflowed and most frames then failed checksum). The per-tick cost is still bounded, because the field-extraction pass below runs once per tick, not once per byte.
  • Fields are only re-read when a new checksum-valid frame arrived. The parser's public table keeps its last values forever, so reading it on every tick — and stamping the values fresh — defeated the staleness check entirely (and integrated phantom solar power) whenever bytes arrived but no frame validated. The tick compares the parser's frame counter before and after the drain and skips the whole extraction if it did not move.

These decoded fields feed charging decisions, energy totals, or protections:

  • Battery voltage (VE.Direct V → VictronVoltage): shown on the dashboard, and with Battery Voltage Source set to Victron (BatteryVoltageSource = 3) it is what the bulk, absorption, float, and rebulk decisions compare against, with a fallback to the onboard sensor whenever it is stale. Control loops and protections always read the regulator's own battery monitor.
  • Battery current (I → VictronCurrent) — with no shunt wired to the regulator and Use as Battery Monitor on (BattMonSrc = 3), it ends absorption on taper current and triggers the current rebulk (stageBattCurrentA()). It counts only while the device is also sending SOC, which proves it is a battery monitor: an MPPT's I is its own output.
  • State of charge (SOC → VictronSoc) — per mille on the wire, --- while the monitor is unsynchronized. With Use as Battery Monitor on it is the state of charge shown and used, in place of the regulator's own count, which is never re-integrated from the monitor's current.
  • Panel power (PPV) — feeds session and lifetime solar energy accumulators. Fractional watt-hours carry over between calls so low power readings are not rounded to zero, and lifetime totals persist across reboots.
  • Battery temperature (T → VictronBattTempF) — present only on a BMV or SmartShunt with a temperature sensor fitted. Accepted between −40 and +100 °C and stamped with its own freshness index, it is one of the candidates in the battery-temperature source chain (source 3), so it can drive the charge lockouts and the voltage-loop gain derate.

The rest are dashboard readouts, taken as reported: panel voltage (VPV), charge state (CS), tracker mode (MPPT), error code (ERR), and the yield history (H20–H23 — today's and yesterday's yield and peak power).

Each of these values passes a plausibility check before it is accepted.

NMEA0183 (serial)

Serial2 is a receive-only port with nothing else on the board or in firmware attached to it, so it is free to run at whatever baud rate the talker sends. applyNMEA0183Serial() re-opens it whenever the user changes Serial Speed (NMEA0183Baud — 4800, 9600, 19200 or 38400) or Signal Type (NMEA0183Invert), with a 2 KB receive ring; the default is 19200 8N1, normal polarity, which suits the combined output of a multiplexer.

ReadNMEA0183Data() runs from the timed loop only when NMEA0183Data == 1 (off by default). It drains the ring every 50 ms with four independent limits that hold it inside a 500 µs budget: a 512-byte per-pass cap, a 250 µs deadline checked between 64-byte chunks, block reads (Serial2.read(buf, n) takes the UART lock once per chunk — byte-at-a-time read() costs about 2 µs per byte and exceeded the budget by itself, and readBytes() blocks), and parsing that runs over the RAM chunk rather than the UART. Bytes left unconsumed stay in the ring for the next pass, so backpressure costs latency and never data. The function times itself and prints a console warning at most once a minute if it ever exceeds the budget; its worst observed cost is published as ft_ReadNMEA0183 in the Function Timing table.

Framing is written in the firmware rather than done by a library: a sentence starts at $ or !, ends at CR/LF, and must carry a valid trailing *hh XOR checksum. Failures — bad checksum, missing checksum, or a line longer than the 96-byte buffer — increment n183ChecksumErrCount; every accepted sentence of any type increments n183SentenceCount, so the counters prove the wiring even for talkers whose content is not decoded. Both stream on CSV2 and appear under Live Data → Integrations.

Heading is the one decoded value, matched on the three-character sentence type so any talker prefix works: HDT and THS are true, HDM and HDG magnetic (HDG's deviation and variation fields are ignored, so it is never promoted to true), and THS counts only in autonomous or differential mode. The result is kept in n183HeadingDeg / n183HdgRef and streamed on CSV4, deliberately not merged into HeadingNMEA — that is written only by the NMEA2000 receiver, and silently combining the two would make "which source am I looking at" unanswerable. Any feature that uses 0183 heading in calculations should become an explicit user-selected source, never an automatic fallback. A decoded heading is cleared after DATA_TIMEOUT (10 s) without a fresh sentence, and turning the master switch off clears it too, since nothing would age it out afterwards.

1-Wire temperature probes (DS18B20)

The temperature probes are DS18B20 digital sensors on their own 1-Wire bus (GPIO13). The bus is multi-drop — up to six probes are tracked, and each is bound by ROM code to one of three roles: alternator, battery, or extra. The whole bus is deliberately kept off the main core: a dedicated task (TempTask) on the second core owns it, issues one broadcast convert command per cycle, reads each probe's scratchpad in turn, validates every read with a CRC check, and retries suspect reads. The control loop on the main core only ever reads the resulting shared values (AlternatorTemperatureF, BatteryTempProbeF, ExtraTempF), so a slow or intermittent sensor can never stall field control. Role binding, the registry and the validation checks are covered in Sensors.

Data freshness

Every externally sourced value has a "last updated" timestamp, bumped by the MARK_FRESH macro only after a successful parse and plausibility check. These timestamps stream to the dashboard on their own channel (the TimestampData server-sent event), and the browser greys out any field whose source has stopped reporting — so a disconnected wind transducer shows up as visibly stale data rather than a frozen number.