Skip to content

Releases

v1.4.2

Choose a tag to compare

@github-actions github-actions released this 10 Oct 08:28
docs(meter_registers): explain the PF_SF deviation from the source pr…

v1.4.1

Choose a tag to compare

@github-actions github-actions released this 31 Jul 16:15
fix: correct float encoding into a slave mapping

packToModbus() wrote floats with modbus_set_float_abcd(), which stores
the four raw wire bytes into the register array. modbus_reply() reads
that array back as host-order uint16 values and serialises each one high
byte first, so on a little-endian host every float left the server with
its bytes swapped inside each word.

Writing 6.25 produced tab_registers[40071] = 0xC840 where the wire needs
0x40C8. Every float register served was wrong, which is why a client
decoding them saw values like 4.2e11 for a power factor.

Route the bit pattern through packInteger<uint32_t>() instead. It already
produces most-significant word first in host order, which is the
convention the reply path and every other multi-register type in this
function use, so the float case now matches its neighbours.

Only the write side was affected. getModbusDouble() decodes floats from a
buffer filled by modbus_read_registers(), a different convention, and
reads real hardware correctly.

Found by serving a float meter model and reading it back through this
library's own Meter: the integer model round-tripped, the float model
returned garbage.

v1.4.0

Choose a tag to compare

@github-actions github-actions released this 27 Jul 17:47
fix!: correct meter decoding, nameplate reads and TCP reconnection

Collects the work since v1.3.6: four wire-format and lifecycle faults
found against a Primo 4.0-1 and a Smart Meter TS 65A-3 sharing one RS485
bus, one breaking callback change, and the documentation and header
hygiene needed for release.

Meter, proprietary register map
-------------------------------
REG::SN was declared with NB=1 while carrying a UINT32 payload, so the
read fetched one register and the decode consumed two; the low word was
never transferred. It was also the only 32-bit read in the map not
passing word_swap, though the Carlo Gavazzi EM/ET300 protocol the map
derives from stores every 32-bit value least-significant word first. A
meter reporting [0x08EC, 0x05B1] decoded as 149684224 against a barcode
reading 95488236.

The firmware version register is not a numeric major component. The same
protocol defines it as a letter index -- 0 is "A" -- paired with a
decimal revision, so a meter reporting [1, 5] runs B5, not 1.5. Report
the raw numeric pair when the code exceeds 'Z', since a device that far
outside the documented encoding is better described by its register
contents than by a wrong letter. VR_MAJOR and VR_MINOR become FWVER and
FWREV to match: the old names asserted the numeric versioning this
removes, and the underscore form reads as the scale-factor suffix used
throughout the SunSpec models.

REG::A claimed register 270 was the total AC current. It is the phase
sequence: 0 for L1-L2-L3, 1 for L1-L3-L2. Nothing decoded it, so no
reported value was ever wrong -- getAcCurrent() already refused
Phase::TOTAL on this map -- but the declaration read like a limitation
to lift rather than the absence of a register, and lifting it would have
reported a rotation code as amperes. The map has no system current at
all; say so in the namespace documentation and expose 270 as REG::PHSEQ.

Inverter nameplate block
------------------------
validateNameplateRegisters() fetched only the two-register block header
at 40121/40122, leaving the payload from DERTYP onwards untouched, and
nothing else covers that range. Every getAcPowerRating() call therefore
indexed a region of regs_ that no transaction had written; because regs_
is zero-filled, all four outputs returned a clean 0 rather than failing.
Read header and payload in one transaction, as I160 already does. On a
Primo 4.0-1 the block holds WRtg=400 sf=1 and VARtg=400 sf=1, i.e. 4000
W and 4000 VA, where the library previously reported 0.

Bus reconnection and callbacks
-------------------------------
Pulling the inverter's ethernet cable left the bridge stuck until it was
restarted. The kernel holds a TCP connection open across a link-down, so
no ECONNRESET or EPIPE arrives; the only signal is ETIMEDOUT, classified
TRANSIENT, which never clears connected_ and so never re-enters the only
phase that calls tryConnect(). The timed-out request also stayed
outstanding, so the late reply answered the next request and every read
after it was one frame behind. Escalate ETIMEDOUT to RECONNECT in
executeTransaction() when the transport is TCP, and only there:
tryConnect() sees the same code from an unanswered SYN, where it is a
genuine transient that Phase 1 already retries.

onBusConnect_ fired when the state became connected while
onBusDisconnect_ fired when an attempt to restore it failed, so the names
implied a pair the fire sites did not form. An outage recovering on its
first attempt left no trace beyond an errno, and the bus offered no
analogue of setDeviceRetryCallback() despite having exactly that
behaviour. Split the two meanings: onBusDisconnect_ becomes void() and
fires once from the Phase 2 exit, where connected_ actually changed;
onBusRetry_ takes over the old signature and fire site. The delay stays
off the disconnect callback deliberately -- naming one at drop time is a
prediction, since the attempt has not run and may never wait at all.

Headers and comments
---------------------
modbus_utils.h used std::reverse without <algorithm> and Register
without register_base.h, compiling only because every consumer happened
to include both first; on its own, or through registers.h, it failed
outright. Every public header now compiles standalone.

Comments that grew during the shared-bus and reconnect debugging into
records of how a conclusion was reached are shortened to the operative
rule and its reason, and those restating the statement below them are
dropped. Where a rule is stated in full on the public API the
implementation now refers to it rather than repeating it. Also drop the
"[--] guard done, queue free" trace, which has announced the completion
of a guard that no longer runs at that point since 634d926, and correct
two stale declarations: getFwVersion()'s documented return format, and
cbMutex_ guarding four callback vectors rather than three.

BREAKING CHANGE: addBusDisconnectCallback() takes std::function<void()>
instead of std::function<void(int)> and fires on the transition rather
than per failed attempt. Consumers wanting the old per-attempt delay must
register addBusRetryCallback() instead. REG::VR_MAJOR and REG::VR_MINOR
are renamed to REG::FWVER and REG::FWREV, and REG::A is removed in favour
of REG::PHSEQ. Consumers persisting the meter serial number or firmware
version must migrate stored values: both differ from every previously
reported one.

fix: read the meter serial number as a full 32-bit value

REG::SN was declared with NB=1 while carrying a UINT32 payload, so the
read fetched one register and the decode consumed two. The low word was
never transferred and the zero-initialised buffer was used instead.

The word order was wrong as well. Every 32-bit value in this map is
stored least-significant word first -- the Carlo Gavazzi EM/ET300
protocol the map derives from states this explicitly -- but the serial
was the only 32-bit read not passing word_swap.

On a Smart Meter TS 65A-3 reporting registers [0x08EC, 0x05B1] the old
code produced 149684224; the meter's barcode reads 95488236.

Consumers persisting the serial number need to migrate stored values:
the corrected result differs from every previously reported one.

fix: format the meter firmware version as a Carlo Gavazzi code

The version register is not a numeric major component. The Carlo Gavazzi
EM/ET300 protocol this register map derives from defines it as a letter
index -- 0 is "A", 1 is "B" -- paired with a decimal revision code, so a
meter reporting [1, 5] runs firmware B5, not 1.5.

Report the raw numeric pair when the version code exceeds 'Z', since a
device that far outside the documented encoding is better described by
its actual register contents than by a wrong letter.

Rename VR_MAJOR and VR_MINOR to FWVER and FWREV to match: the old names
asserted the numeric versioning this commit removes, and the underscore
form reads as the scale-factor suffix used throughout the SunSpec models
(A_SF, W_SF).

Consumers persisting the firmware version will see the string change on
the next device read.

fix: reconnect a TCP bus when a read on an established socket times out

Pulling the inverter's ethernet cable left the bridge stuck until it was
restarted. The kernel holds a TCP connection open across a link-down, so
no ECONNRESET or EPIPE ever arrives; the only signal is ETIMEDOUT, which
deduceSeverity() classifies as TRANSIENT. TRANSIENT does not clear
connected_, so busLoop() stayed in Phase 2 and never re-entered Phase 1 --
the only place that calls tryConnect(). The socket was never closed.

Worse, the timed-out request stayed outstanding on it. Once the link
returned, the inverter's late reply was read as the answer to the next
request and every read after that was one frame behind, reported as
EMBBADDATA. That is also TRANSIENT, so the device retried the same dead
socket with growing backoff indefinitely.

Escalate ETIMEDOUT to RECONNECT in executeTransaction() when the transport
is TCP. The check sits there rather than in deduceSeverity() because the
rule is narrower than "TCP": tryConnect() also sees ETIMEDOUT on TCP, from
a connect() whose SYN went unanswered, and that is a genuine transient
Phase 1 already retries with backoff. modbus_read_registers() is the only
call in the library that can produce the code on an established transport,
so keeping the rule at that one site makes the wrong classification
unreachable from the wrong one, and leaves modbus_error.h transport-blind.

EMBBADDATA is deliberately not escalated. It is set in
check_confirmation(), which is transport-agnostic and reached as readily by
RTU, where a mismatched frame is what an RS485 collision produces;
libmodbus recovers from it with modbus_flush() under
MODBUS_ERROR_RECOVERY_PROTOCOL and reserves modbus_connect() for link
errors. Escalating it would apply link-class recovery to a protocol-class
fault and, on a shared serial bus, drop every other device for one bad
frame. It also cannot arise here once the timeout escalates, because the
socket is closed before a desync can accumulate.

Also corrects the deduceSeverity() comment, which asserted that a dropped
peer follows ETIMEDOUT with ECONNRESET/EPIPE. That holds for a serial
hangup, not for a TCP link-down.

The missing "Inverter 'primo' disconnected, trying to reconnect in ..."
line needs no separate fix. onBusDisconnect_ fires only from Phase 1 when
tryConnect() fails, so it reappears once the bus actually reconnects.

refactor!: make the bus callbacks mirror the device callbacks

BREAKING CHANGE: addBusDisconnectCallback() takes std::function<void()>
instead of std::function<void(int)> and fires on the transition rather
than per failed attempt. Consumers wanting the old per-attempt delay must
register addBusRetryCallback() instead.

onBusConnect_ fired when the state became connected; onBusDisconnect_
fired when an attempt to restore it failed. The names implied a pair the
fire sites did not form, and the asymmetry had two consequences. An outage
that recovered on its first attempt reported nothing but an errno through
onBusError_, conventionally logged at debug, so a brief drop left no trace.
And the bus offered no analogue of setDeviceRetryCallback() even though it
had exactly that behaviour, which made a bus retry and a device retry
indistinguishable in a consumer's callback set despite meaning different
things: the transport is gone, versus the transport is fine but this slave
will not validate.

Split the two meanings. onBusDisconnect_ becomes void() and fires once
from the Phase 2 exit, where connected_ actually changed -- reached by a
RECONNECT error from executeTransaction() and by triggerReconnect() alike.
onBusRetry_ takes over the old signature and fire site. The bus now
mirrors the device: a state pair, an error channel, a retry notice.

The reconnect delay stays off the disconnect callback deliberately. Naming
one at drop time would be a prediction -- the attempt has not run, and if
it succeeds the wait never happens -- which was the objection in af4010b
that kept onBusDisconnect_ out of this site in the first place. It applies
to the delay, not to the notification, so a callback without one can fire
here without reviving the double reporting af4010b removed.

Shutdown does not fire onBusDisconnect_ even though it is a transition.
Consumers log their own teardown, and firing then would race with the
removeBusCallback() calls their destructors are making.

fix: read the nameplate payload, not just its block header

validateNameplateRegisters() fetched I120::ID.NB + I120::L.NB, which is the
two-register block header at 40121/40122. It checked the model ID and the
block length and returned, leaving the payload from DERTYP (40123) onwards
untouched.

Nothing else covers that range: fetchInverterRegisters() polls the active
state code, the I10X/I11X inverter block and the I160 MPPT extension, and
none of them reach I120. So every getAcPowerRating() call indexed a region
of regs_ that no transaction had ever written. regs_ is resized to 0xFFFF
and zero-filled, so all four outputs - WRtg, VARtg and both VArRtg
quadrants - returned a clean 0 rather than failing, which is why this read
as a plausible value instead of an error.

Read the header and the payload in one transaction, matching what
validateMultiMpptRegisters() already does for I160. The nameplate block is
static, so the single read at validation time serves every later call.

Verified against a Primo 4.0-1 on the integer map: registers 40124-40127
hold WRtg=400 sf=1 and VARtg=400 sf=1, i.e. 4000 W and 4000 VA, where the
library previously reported 0 for both.

fix: 270 is the phase sequence, not a total current

REG::A claimed register 270 was the meter's total AC current. In the
Carlo Gavazzi EM/ET300 map this driver's proprietary path derives from,
270 is the phase sequence: 0 for L1-L2-L3, 1 for L1-L3-L2. An mbpoll
capture of a TS 65A-3 sub-meter confirms it, reading 0 while L1 drew
0.167 A and L2 drew 5.325 A.

Nothing decoded it, so no reported value was ever wrong: getAcCurrent()
already refused Phase::TOTAL on the proprietary map and returned before
reaching the register. The declaration was still a trap - the guard
reads like a limitation to lift rather than the absence of a register,
and lifting it would have silently reported a rotation code as amperes.

That map has no system current at all: three phase currents, a neutral
current on some variants, nothing equivalent to M20X::A. Say so in the
namespace documentation, and expose 270 as REG::PHSEQ, which is what it
is. It sits inside the 16-register system block the driver already
reads, so surfacing it later costs no bus traffic.

getAcCurrent() keeps its guard and its structure; only the now-nameless
proprietary argument changes, to a default Register. That argument is
unreachable behind the guard, and were it ever reached it decodes to
EINVAL rather than to whatever sits at the address.

Verified against the capture: the phase-block base and 14-register
stride, and A/W/VA/var/PF/V for L1 and L2 (V*I, sqrt(P^2+Q^2) and the VA
register agree to 0.1 % on L2). Still unverified: 320-326, the tail of
the L3 block, which read zero throughout because nothing was connected
to L3, and 258-268, which fell outside the captured window.

docs: trim implementation comments to release length

Several comments grew during the shared-bus and reconnect debugging into
multi-paragraph narratives that recorded how a conclusion was reached
rather than what a reader needs to keep the code correct.

Shorten them to the operative rule and its reason, and drop comments that
only restate the statement below them. The load-bearing rationale is kept
in every case: the ETIMEDOUT transport dependence, the RTU pre-send flush,
the EPROTO-over-EINVAL severity choice, the no-join-under-mtx_ deadlock,
and the RetryEntry lifetime invariant.

Where a rule is already stated in full on the public API, the
implementation now refers to it instead of repeating it, so the two cannot
drift apart.

Also fix two inaccuracies found while reading: cbMutex_ guards four
callback vectors, not three, and drop the duplicated callback banner
comment in fronius_bus.h.

No functional change.

docs: describe the meter firmware version as a Carlo Gavazzi code

getFwVersion() has returned a letter-plus-revision code such as "B5" for
the proprietary map since cd9e73d, but the declaration still promised
"major.minor" format, e.g. "1.5". Correct the Doxygen comment to match.

fix: make modbus_utils.h self-contained and drop a stale trace line

modbus_utils.h used std::reverse without <algorithm> and Register without
register_base.h. It compiled only because every consumer happened to
include both first; including it on its own, or through registers.h, fails
outright. Add the two includes.

The "[--] guard done, queue free" trace dates from 469ce57, when a guard
delay followed each transaction. 634d926 removed the post-failure sleep and
moved the remaining guard ahead of the send, so the line now announces the
completion of something that no longer happens there. The [tx]/[rx] pair
already brackets the transaction. Remove it.

The 500 ms slave-switch guard is left alone: 634d926 raised it from 100 ms
deliberately, and it is the same on every branch.

clang-format corrections

v1.3.6

Choose a tag to compare

@github-actions github-actions released this 23 Jul 13:58
build: ship a shlibs control file in the runtime package

The runtime package registered no dependency information for the
libfronius SONAME, so dpkg-shlibdeps in a downstream package aborted
with "no dependency information found for libfronius.so.1" instead of
emitting a Depends. Consumers had to either declare the dependency by
hand or leave it out entirely, in which case the resulting package
installed cleanly and then failed at exec with an unresolved SONAME.

CPACK_DEBIAN_PACKAGE_GENERATE_SHLIBS has no per-component variant, but
CPack resolves the package name per component before writing the file,
so only the runtime package gains an entry -- the devel package carries
just the dangling namelink, which is not an ELF object and is skipped.

The default policy is an exact-version match, which would pin every
consumer to the libfronius release it was built against and break the
install on any upgrade. The SOVERSION already guards the ABI, so >= is
the correct constraint.

Generated entry: libfronius 1 libfronius1 (>= 1.3.5)

v1.3.5

Choose a tag to compare

@github-actions github-actions released this 23 Jul 10:35
fix: report a bus disconnect once per reconnect attempt

Every outage produced two identical "reconnect in N seconds" reports a
few milliseconds apart. busLoop() fired onBusDisconnect_ from the
post-drain block when connected_ had been cleared, and Phase 1 fired it
again with the same reconnectDelay as soon as its tryConnect() failed.
An overnight inverter standby logged nine warnings for eight backoff
steps.

Drop the post-drain fire. The delay it carried was only a prediction --
the attempt it referred to had not run yet, and if that attempt succeeds
the wait never happens -- whereas Phase 1 reports a wait it is about to
perform. This also matches what addBusDisconnectCallback() already
documents: fired when a connection attempt fails, carrying the seconds
until the next one.

The drop itself stays visible. Whatever cleared connected_ reported it
through onBusError_, and notifyDevicesDisconnected() still runs, so the
device-level unavailable callbacks fire and availability is published as
before. The one behavioural change is that an outage recovering on the
first reconnect attempt now produces no disconnect report, which the
callback documentation now states.

v1.3.4

Choose a tag to compare

@github-actions github-actions released this 01 Jul 19:23
feat: add fronius.h and registers.h umbrella headers

Consumers currently must include several individual headers to use
the library, e.g.:

  #include <fronius/fronius_bus.h>
  #include <fronius/fronius_device.h>
  #include <fronius/inverter.h>
  #include <fronius/meter.h>
  #include <fronius/modbus_config.h>
  #include <fronius/modbus_error.h>

Add two umbrella headers reflecting the two natural tiers of the
public API:

- fronius.h: the core object API (bus, device, inverter, meter,
  config, error, types) needed by consumers driving devices through
  the high-level Inverter/Meter interface.

- registers.h: raw Modbus register access (SunSpec common block,
  per-device register maps, register_base, and the ModbusUtils
  pack/unpack helpers) needed by consumers that build a virtual
  Modbus TCP slave mapping or otherwise touch raw registers directly.

Both are header-only and require no CMake changes, since headers
are already installed flatly under include/fronius/ via the existing
install(DIRECTORY include/ ...) rule.

Verified with g++ -std=c++23 and a warning-free Doxygen run.

v1.3.3

Choose a tag to compare

@github-actions github-actions released this 20 Jun 21:02
fix: join the connect-notify thread instead of detaching it

After a successful connect the bus thread spawned a detached thread to run
notifyDevicesConnected() (onBusConnected() blocks on future.get(), so it cannot
run on the bus thread). That thread locks mtx_ and schedules retries, but the
destructor never waited for it: on a shutdown shortly after a connect it could
lock mtx_ after ~FroniusBus had destroyed it, aborting with
std::system_error "Invalid argument" from pthread_mutex_lock on a freed mutex.

Track it as notifyConnectThread_ (a reconnect joins the previous one first)
and join it in the destructor, after the bus thread and before retries_ is
moved out. running_ is already false by then, so any scheduleDeviceRetry() it
runs returns without queuing work and the join is immediate; retries it did
schedule are still captured by the existing retries_ join.

v1.3.2

Choose a tag to compare

@github-actions github-actions released this 14 Jun 14:15
fix: flush the RTU receive buffer before each transaction

A transient line glitch (a single bad-CRC frame) permanently wedged a
shared RTU bus: every device on it went unavailable and never recovered,
needing a restart. Observed at least daily on a two-slave bus.

Once a corrupted or short frame leaves unread bytes in the serial input
buffer, the bus desynchronises by one frame -- each read returns the
previous request's reply, so every transaction fails "response not from
requested slave" (or bad CRC on the partial frame). The trigger is
TRANSIENT (a CRC/timeout/wrong-slave error is not FATAL, by design, so a
USB-serial hiccup does not tear down the bus), which means the bus is
never reconnected -- and reconnect was the only place modbus_flush() ran.
Nothing drained the stale bytes, so the one-off glitch became permanent.

Flush the input buffer before issuing each request on an RTU bus, so a
transaction always starts from a clean buffer. The flush must precede the
request, not follow a failed read: when a stale-frame read fails it
returns instantly while the genuine reply is still in flight, so flushing
afterwards only discards the current stale frame and lets the in-flight
one become the next -- the lag survives. Flushing before the send drops
the stale frame and lets the read block for this request's own reply,
resynchronising on the next poll. On a healthy bus the buffer is already
empty (a successful read consumes its frame exactly), so this discards
nothing. TCP is unaffected and skipped.

v1.3.1

Choose a tag to compare

@github-actions github-actions released this 28 May 05:37
fix: classify transport hiccups (ENOENT/ENODEV/EBADF/EIO) as TRANSIENT

These four errno values were previously FATAL, which on a shared RTU
bus causes the bus thread to set connected_=false, fire onBusError_
callbacks with FATAL severity, and — via the application-layer
shutdown hook that fronius-bridge registers on each bus error
callback — terminate the whole process.

On RTU these codes are the typical errno values seen when a USB-to-
serial adapter momentarily disappears (device unplug, kernel re-
enumeration, udev settle race, adapter firmware glitch). A simple
reconnect almost always recovers, and busLoop()'s Phase 1 already
implements that reconnect with exponential backoff. Classifying them
as FATAL therefore takes down the entire bridge for a condition the
bus is already designed to handle.

A misconfigured RTU device path (e.g. typo in YAML) also surfaces as
ENOENT, but TRANSIENT remains the right call there too: it produces
a repeated, visible warning every backoff cycle rather than a silent
startup exit during a boot-time enumeration race.

This change is a prerequisite for the upcoming EBZ Easymeter
integration in fronius-bridge, where the USB-IR dongle is known to
glitch occasionally and must recover without taking down the rest
of the bridge. The fix also benefits existing Fronius RTU users on
shared buses.

v1.3.0

Choose a tag to compare

@github-actions github-actions released this 26 May 14:48
feat: added support for multiple inverters and meters