Skip to content

feat(modbus): share one TCP session per device and proxy it - #1003

Open
frahlg wants to merge 1 commit into
masterfrom
cursor/modbus-proxy-906e
Open

feat(modbus): share one TCP session per device and proxy it#1003
frahlg wants to merge 1 commit into
masterfrom
cursor/modbus-proxy-906e

Conversation

@frahlg

@frahlg frahlg commented Aug 30, 2026

Copy link
Copy Markdown
Member

Accepted text proposal

Fredrik asked for a Modbus proxy in FTW so other integrations can talk to devices through the box, and for every driver to go through the same engine so several drivers can poll one device while it still holds a single socket.

What changed

  • A process-wide Modbus engine keys TCP sessions by host:port. Drivers, the debug probe, fingerprinting and the proxy all Open() that pool. Unit ID is per request, so two drivers on one RS-485 gateway share the socket and still address different slaves. Dial() stays a private connection for tests and one-shot probes.
  • Optional modbus_proxy listens on the LAN (default :1502 when the site has one Modbus endpoint) and multiplexes client PDUs onto that session. Writes are denied unless allow_write is set. Unknown function codes are illegal-function, not forwarded.
  • Several unique host:port backends each need capabilities.modbus.proxy_listen. Two drivers on the same endpoint share one listen address.
  • Settings → Home Assistant gains a fieldset to turn the proxy on. Enabling it requires a restart.
  • Off by default. No new docs file; config.example.yaml is the operator surface.

Why

Many inverters accept one Modbus TCP client. FTW holding that socket is why Home Assistant’s native Modbus / Sungrow / SolarEdge integrations cannot share the device. A proxy on the box is the usual fix (port 1502 is already treated as a proxy port in discovery). Sharing among FTW’s own drivers is the same problem internally.

Boundaries and safety

  • Planner output is still never sent to hardware. Proxy writes are raw register writes and bypass the control loop, which is why they default off. That is the quantified risk this clamp protects: any LAN host could otherwise set battery/inverter registers.
  • The listener has no Modbus authentication. It is a LAN service, same class as CalDAV’s in-process server.
  • A failed listen does not stop the control loop (slog.Error and continue).
  • Backend down or in reconnect backoff: gateway exception 0x0B, dispatch still uses the driver path and its own backoff.
  • Stale site-meter data is unchanged. A failed driver still gets its autonomous default mode.
  • modbus_proxy.* and, while the proxy is on, driver Modbus host/port/proxy_listen require a restart. Driver add/remove still hot-reloads; they join or leave the shared session.

Out of scope: exposing FTW telemetry as a virtual Modbus map, TLS, unit-id remapping, hot-reloading the listen port.

Verification

  • go test ./internal/modbus/ — shared socket (one Accept, two unit IDs), Dial still private, proxy read multiplex, write denied with exception 0x01, write forwarded when allowed, unknown FC illegal.
  • go test ./internal/config/ — bind map, multi-endpoint proxy_listen required, YAML parse, restart-required.
  • make verify — Go + Python suites, compose/container/release-workflow checks, go vet ./..., go build ./... (exit 0).
  • node --test web/settings/tabs/ha.test.mjs — proxy fieldset on the HA tab.
  • Live stack: sim-sungrow on 127.0.0.1:15502, FTW proxy on 127.0.0.1:11502. Driver poll + a second Modbus TCP client through the proxy both saw Sungrow input 4999 = 3598 (device type). Backend stayed at one ESTABLISHED socket (127.0.0.1:47182 ↔ :15502). FC 06 write returned illegal-function 0x01.
  • Browser: Settings → Home Assistant on the live demo. Enabled checked, Listen 127.0.0.1:11502, Allow writes unchecked. Dashboard stayed live after closing the modal.

Live dashboard with Sungrow telemetry while the proxy is up
Settings Home Assistant tab showing the Modbus TCP proxy fieldset
settings_ha_modbus_proxy_walkthrough.mp4

Open PRs that also touch config.go / main.go / config.example.yaml (notably #999 OCPP, also draft #957 on main.go and #826 on index.html) have right of way on overlap; this adds a new modbus_proxy section and a factory swap.

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 

Drivers, probes and an optional LAN listener now go through one engine
keyed by host:port, so two Lua drivers can poll the same inverter (or
different unit IDs behind one gateway) without each taking a socket the
device will not accept.

modbus_proxy is off by default. When on, other integrations talk to FTW
instead of the device. Writes stay blocked unless allow_write is set —
they would bypass the control loop. Enabling it needs a restart.

Signed-off-by: Cursor Agent <cursoragent@cursor.com>
@frahlg
frahlg marked this pull request as ready for review August 30, 2026 07:44
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-30T07:48:02.836756Z 3890d96 Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 3890d96698

ℹ️ 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".

first = err
}
}
p.wg.Wait()

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 Close active clients before waiting for proxy goroutines

When a proxy client keeps a persistent connection idle, as Home Assistant commonly does, Close closes only the listeners and then waits while the accepted connection remains blocked in readMBAP until its 90-second read deadline. Because main defers this call on SIGTERM and API-triggered restarts, shutdown can stall for up to 90 seconds; track and close accepted connections or otherwise cancel their reads before waiting.

Useful? React with 👍 / 👎.

Comment thread web/settings/tabs/ha.js
render: function (ctx) {
var field = ctx.field, config = ctx.config;
if (!config.homeassistant) config.homeassistant = {};
if (!config.modbus_proxy) config.modbus_proxy = {};

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 Keep an unused proxy absent when saving HA settings

On an upgraded installation with no modbus_proxy section, merely opening this tab and saving any Home Assistant edit creates a disabled proxy object because the render initializes it and captureCurrentTab serializes all of the new controls. modbusProxyRestartReasons then compares the old nil pointer with this object and reports that the TCP listener requires a restart, so an otherwise hot-reloadable HA edit produces a spurious restart prompt and persists unrelated configuration; preserve the absent disabled state until the operator actually changes a proxy setting.

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 2 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 3890d96. Configure here.

return
}
slog.Warn("modbus proxy accept", "listen", ln.Addr().String(), "err", err)
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Accept errors stop the proxy

Medium Severity

A non-shutdown Accept error returns from serve and never loops again. The listen socket stays bound, so LAN clients cannot reconnect until the process restarts, even though startup logged the proxy as listening.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3890d96. Configure here.

Comment thread web/settings/tabs/ha.js
'<label><input type="checkbox" data-checkbox-path="modbus_proxy.enabled"' + (config.modbus_proxy && config.modbus_proxy.enabled ? ' checked' : '') + '> Enabled</label>' +
field("Listen", "modbus_proxy.listen", "text", ":1502",
"Local bind. Default :1502 when the site has one Modbus device. Several inverters need capabilities.modbus.proxy_listen on each driver.") +
'<label><input type="checkbox" data-checkbox-path="modbus_proxy.allow_write"' + (config.modbus_proxy && config.modbus_proxy.allow_write ? ' checked' : '') + '> Allow writes from the LAN (bypasses FTW control)</label>' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Settings save injects proxy section

Medium Severity

Rendering the Home Assistant tab creates an empty modbus_proxy object and its form fields. A save then persists a disabled proxy (listen defaulted to :1502), and RestartRequiredFor treats nil versus that struct as a startup bind change.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3890d96. 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 finished as skipped and reported two unresolved findings, so this is not approved.

Open in Web View Automation 

Sent by Cursor Approval Agent: Pull Request Router and Approver

@cursor
cursor Bot requested review from Leitet and erikarenhill August 30, 2026 07:51

@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 Modbus proxy + shared-session engine. Two lenses:

Correctness — session pooling in engine.go is ref-counted and mutex-guarded correctly; Capability.mu serializes every Read/Write*/executePDU call including applyUnit, so concurrent drivers/proxy clients sharing one socket can't interleave requests or race on the unit-ID switch. readMBAP/writeMBAP bound the ADU length (≤254 bytes) before allocating, so a malicious length field can't trigger a large alloc. Connection count is capped (proxyMaxClients=16) and idle-timed out (90s), so a LAN client can't hold the proxy open indefinitely or exhaust listener slots.

Security — the write gate (forward(), proxy.go) runs before cap.executePDU, so a denied write never reaches the driver/hardware; unknown function codes default-deny to illegal-function. allow_write is one flag applied to all binds from a single proxy config, matching the docs. Each listener is pinned to exactly one backend host:port at creation, no way for a LAN client to redirect to an arbitrary backend. Off by default.

No blocking findings. Good test coverage on the shared-socket + unit-id-multiplex paths (engine_test.go, proxy_test.go, slave_test.go). Safe to merge from my read — the PR body already flags overlap with #999/#957/#826 on config.go/main.go, worth a rebase-check before merging whichever lands second.

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