Skip to content

fix(drivers): resume Modbus drivers that give up every register after a network blip - #982

Merged
frahlg merged 1 commit into
masterfrom
cursor/pixii-modbus-reprobe-0d20
Aug 27, 2026
Merged

fix(drivers): resume Modbus drivers that give up every register after a network blip#982
frahlg merged 1 commit into
masterfrom
cursor/pixii-modbus-reprobe-0d20

Conversation

@frahlg

@frahlg frahlg commented Aug 26, 2026

Copy link
Copy Markdown
Member

Accepted text proposal

Issue or Discussion: Slack thread “pixii offline efter nätverksblipp” (HannesB / forty-two-watts).

Maintainer comment that accepted this scope: Fredrik asked to fix the self-locking Pixii offline bug so the battery comes back on its own after a network blip.

What changed

The Lua host now reloads a Modbus driver’s VM when that driver used to read registers and then attempts zero reads in a poll — the give-up signature after a transport blip (no route to host), not after a single unimplemented register.

Reload does not call driver_cleanup (that would write setpoint 0). The same Poll() then runs again. If the reload still produces 0 reads, skipReprobe latches so a driver that legitimately stops reading is not reloaded every poll.

An unimplemented register while others still read does not trigger reload.

Why

On site forty-two-watts (Pixii at 192.168.1.52:502):

  • 2026-08-25 ~14:48 UTC the Pi lost the route to Pixii.
  • During the blip, every Modbus register missed 3 retries. pixii.lua logged register X did not answer 3 times; leaving it alone until restart.
  • 14:57:51 Modbus reconnected, but every register was already permanently skipped. Pixii answered ping and port 502 was open. Only driver Lua state was stuck. Container restart was the only recovery.

The catalog pattern (GIVE_UP_AFTER = 3) exists so unimplemented registers (Pixii 40288 meter_energy_sf on firmware below CPU 2.0.23) do not fail every poll. The host already distinguishes transport vs refusal; give-up still treated a dead link like a missing register.

The canonical driver lives in srcfl/device-drivers. This is a host safety net so a blip does not require a process restart.

Boundaries and safety

  • Planner output is still never sent directly to hardware.
  • Reload skips driver_cleanup, so a stuck battery is not written to 0 W as a side effect of recovery.
  • A driver that never successfully read, or that still reads some registers, is not reloaded.
  • After a failed reload, reprobe is skipped until the process restarts, so a permanently silent device is not a reload loop.
  • Sign conversion remains at the driver boundary.

Out of scope: changing pixii.lua itself (bundled snapshot; the catalog should only give up on illegal-address / exception 2). That belongs in srcfl/device-drivers.

Verification

  • go test ./internal/drivers/TestGiveUpDriverRecoversAfterTransportBlip, TestGiveUpOnAbsentRegisterDoesNotReload, TestPixiiRecoversAfterTransportGiveUp (bundled pixii.lua behind a gated Modbus that returns no route to host).
  • Verbose Pixii test: give-up logs, then modbus driver stopped probing registers; reloading to retry, then live telemetry.

Pixii Modbus reprobe test log

Checklist

  • The diff implements one accepted scope and does not add follow-on work.
  • I checked open pull requests that touch the same files.
  • Tests cover the changed behaviour and its failure path.
  • A human reviewed every changed web/UI view in a browser, or no UI changed.
  • A Changeset is included, or the change is exempt.
  • Every commit has a DCO sign-off.

To show artifacts inline, enable in settings.

Open in Web Open in Cursor 

… a blip

A network flap makes pixii.lua (and the same probe_read copy in solis,
huawei, and others) skip every register after three failed reads. The
TCP session can return while the driver has nothing left to ask, so the
battery stays offline until process restart.

The give-up tables are Lua locals. Reload the file — without
driver_cleanup, so a live setpoint is not cleared — when a Modbus
driver that used to read stops probing, then poll again.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>
@frahlg
frahlg marked this pull request as ready for review August 26, 2026 18:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb38ddc069

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +328 to +330
old := d.L
d.L = L
old.Close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the initialized VM when reprobe init fails

When driver_init errors or its lifecycle context expires during a reprobe, these lines replace and close the previously initialized VM before callInitLocked runs. The registry only receives a poll error, so subsequent default-mode calls use the partially initialized state; if the default hook depends on configuration or locals populated during init, the autonomous fallback can fail precisely while the driver is stale. Initialize the candidate successfully before committing the swap, or restore the old VM on failure.

AGENTS.md reference: AGENTS.md:L36-L36

Useful? React with 👍 / 👎.

Comment on lines +242 to +243
if d.Env.lastPollEvidence.Attempts == 0 {
d.skipReprobe = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Latch reprobe after retrying an ongoing outage

When the link remains down beyond the first give-up threshold, the fresh VM's immediate retry makes Modbus attempts, so this condition is false; once that VM gives up again, shouldReprobe reloads it again. A prolonged outage therefore reruns the file and driver_init every few polls, repeatedly executing initialization side effects and generating avoidable connection/log churn. Latch after the reload attempt rather than only when its immediate retry also makes zero calls.

Useful? React with 👍 / 👎.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit eb38ddc. Configure here.

// retry, not a shutdown.
d.Env.Logger.Info("modbus driver stopped probing registers; reloading to retry")
if rerr := d.reprobeLocked(ctx); rerr != nil {
return 0, fmt.Errorf("reprobe: %w", rerr)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing latch after failed reload

High Severity

When reprobeLocked fails, Poll returns without setting skipReprobe. A give-up quiet poll keeps Attempts at 0, so every later tick retries reload. That contradicts the safety boundary that a failed reload latches until process restart and turns a bad path or missing driver file into a per-poll reload loop with repeated error logs.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eb38ddc. Configure here.

if driverDeclaresReadOnlyBattery(L) {
d.Env.BatteryTelemetryOnly = true
}
return d.callInitLocked(ctx)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VM replaced before init succeeds

Medium Severity

reprobeLocked closes the previous VM and assigns the new one before callInitLocked finishes. If driver_init fails, the prior state is already gone and the live driver is left on an uninitialized VM, so recovery from a transient init error is worse than staying on the old give-up state.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eb38ddc. Configure here.

if d.Env.lastPollEvidence.Attempts > 0 {
d.sawModbusRead = true
d.skipReprobe = false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed attempts arm reprobe

Medium Severity

notePollModbusActivity sets sawModbusRead whenever Attempts is greater than zero, including pure transport failures. The stated boundary is that a driver that never successfully read is not reloaded, but a never-online device still arms reprobe after its first failed polls and then reload-cycles after give-up.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit eb38ddc. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a non-blocking comment and assigned reviewers. Cursor Bugbot completed as skipped and reported 3 unresolved issues that need human attention, so this PR is not approved.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor
cursor Bot requested a review from erikarenhill August 26, 2026 18:43

@miravoss26 miravoss26 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the diff. Fixes a real reliability bug: several catalog Lua drivers (pixii, solis, huawei, ...) permanently give up on a register after 3 failed reads so an unimplemented point doesn't fail every poll — but a transient Modbus transport blip makes every register look absent the same way, and the driver then has nothing left to ask even once the link recovers. Previously that required a process restart to clear.

  • Fix reloads the Lua VM (fresh file read, fresh driver_init with the stored config) only when a driver that had previously read successfully (sawModbusRead) comes back with zero attempts — and latches (skipReprobe) so a legitimately absent register doesn't trigger a reload loop. TestGiveUpOnAbsentRegisterDoesNotReload confirms the two cases are distinguished correctly.
  • Deliberately skips driver_cleanup on reprobe (that path writes the hardware default, e.g. setpoint 0 on a battery) — good call, documented in the comment, and this is a probe retry, not a shutdown.
  • Poll() stays fully under d.mu, so the reload-then-repoll happens atomically from the caller's view; the old VM is closed after the swap, no leak.
  • Well-tested for the Pixii case specifically (recovers SoC telemetry, IsOnline() true, no device fault after the blip) as well as the generic give-up pattern.

One non-blocking question: reprobeLocked re-reads the driver file from disk (os.ReadFile(d.Path)), so if the file changed between the original load and the blip (a driver update landing mid-session), the reload would pick up the new version rather than the one the process started with. If driver-file updates already trigger a full runtime restart elsewhere, this is moot — otherwise worth a sentence confirming that's intended.

Safe to merge from my read.

@frahlg
frahlg merged commit 983c7ab into master Aug 27, 2026
17 checks passed
cursor Bot pushed a commit that referenced this pull request Aug 27, 2026
PR #982 reloaded a give-up driver, but a missing file retried every
poll, failed attempts armed reprobe on a never-online device, and
driver_init ran after the previous VM was already closed. Latch on
reload failure, arm only after a successful read, and initialize the
candidate before swapping so default-mode still has its locals.

A long outage still retries: latching after every reload would miss
the 9-minute Pixii blip that this path exists to recover.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>

Co-authored-by: Fredrik Ahlgren <fredrik@sourceful-labs.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants