Skip to content

External Interfaces

Beyond its own on-board sensors, the XREG-010 listens to 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. This page covers what each interface carries, how it is wired, and where the parsing happens in the firmware.

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. Two details matter for contributors:

  • 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 dropped off 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 tripwire for bus health.
  • A short transaction timeout (Wire.setTimeOut()) bounds how long a wedged device can stall a bus read, so an electrical glitch 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 pull in 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):

  • NMEA2KDatareceive. Off by default. Admits bus data to the handler table.
  • n2kTxEnabletransmit. Off by default. Makes the regulator a node on the bus.
  • rvcTxEnableRV-C transmit. Off by default. RV-C rides 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.
  • dvccEncharge-limit follow. Off by default. Needs to keep hearing the charge authority 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 the bus volunteered while the regulator was listening.

Data consumed from the bus:

  • Position and satellites (GNSS() handler) — latitude, longitude, satellite count, with sanity checks before anything is stored.
  • 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 one-sample GPS glitch 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 prefers 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 rides the telemetry stream behind a staleness gate.
  • GPS time (SystemTime() handler) — feeds syncTimeFromGPS(), the GPS leg of the time-sync chain. In the default automatic mode (timeSourceMode = TSRC_AUTO) the chain is arbitrated by freshness, boat-network first: NMEA2000 GPS time wins 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 snaps to 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 are display and telemetry only — never an input to field control, protections, targets, or the regulator's own state-of-charge calculation. 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, battery configuration status) are parsed but not stored. Their handlers exist only to print to the serial console, and they are gated behind the NMEA2KVerbose flag, which is off by default — unconditional printing firehosed the console at bus rate (attitude alone arrives at 10 Hz from common compasses). With the flag off those handlers return immediately. 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 chatty bus cannot dominate the loop.

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 — no address claim, no bus presence at all. Either switch on makes the regulator a node: initializeHardware() 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. The switch is read at boot only: initializeHardware() configures the node before NMEA2000.Open(), and the /get handler tells the user that a change needs a reboot. Everything else — the per-message enables and the instance numbers below — applies live.

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 the transmit cost never stacks inside a control tick. 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

Two rules hold across all of them. 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 fall into the core library's retry ring. Accepted and dropped counts (n2kTxCount / n2kTxDropCount) and the claimed address ride 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 recognises. 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.

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 hearing nothing 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 recognises, so the Victron path is voltage-only in practice; the RV-C dialect publishes both limits.

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 outranks it. A trust state machine (dvccTick(), 1 Hz) stands between the decode and the control path: limits must arrive in-range for a settling period before the first clamp applies, going silent reverts to local targets, and an authority publishing implausible values latches untrusted until the user resets it. dvccTick() runs unconditionally — outside the hardware gate above — precisely so that losing the bus reverts the clamps instead of leaving the control loop obeying a frozen limit forever. When a clamp is what is actually holding output back, the dashboard limiter banner says so (codes 8 and 9, distinct from the regulator's own current and voltage limits).

One extra value rides the same decoder. 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 garbage, not silence).

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. Two details in that loop are load-bearing:

  • 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 actually landed. The parser's public table keeps its last values forever, so reading it on every tick — and stamping the values fresh — defeated the staleness machinery 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.

Three of the decoded fields carry real weight:

  • Battery voltage (VE.Direct VVictronVoltage) — display and cross-check only. The regulator's own voltage reading always comes from its battery monitor; the Victron value is never used for control.
  • Battery current (IVictronCurrent) — can serve as the battery-current source for display and accounting, but only when the user explicitly selects it (BatteryCurrentSource).
  • Panel power (PPV) — feeds session and lifetime solar energy accumulators. Fractional watt-hours carry over between calls so slow trickles are not rounded away, and lifetime totals persist across reboots.
  • Battery temperature (TVictronBattTempF) — 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 (H20H23 — today's and yesterday's yield and peak power). The Victron device's own state-of-charge field is deliberately not read; the regulator computes state of charge itself from current.

The three weighted values each pass a plausibility check before they are 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 the talker wants. 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 blew the budget on its own, 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 polices itself and complains to the console at most once a minute if it ever crosses the budget; its worst observed cost is published as ft_ReadNMEA0183 in the Function Timing table.

Framing is hand-rolled rather than library-driven: 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 belongs to the NMEA2000 receiver, and silently combining the two would make "which source am I looking at" unanswerable. Anything that wants to consume 0183 heading for real 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 flaky 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 sanity 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 gone quiet — so a disconnected wind transducer shows up as visibly stale data rather than a frozen number.