Suspending Modbus — plan¶
Status: PLAN for review. No code. Written 2026-10-12.
Context: CONNECTION_DESIGN.md. The owner asked whether Modbus can be switched off
globally while a new strategy is settled, keeping the code, and handling UI, API and
schedules that try to use it.
1. The switch already exists. It is incomplete, and the gap is the expensive one.¶
This is the main finding, and it changes the job from "build a kill switch" to "finish one and fix what it silently breaks".
What is already there and works:
| Piece | Where |
|---|---|
set_enabled() / is_enabled() / available() |
battery_control.py:262-276 |
Persisted as modbus_enabled in app config, restored at startup |
app.py:785 |
| UI toggle on the Control tab | control.html:435 |
| REST read + write of the flag | app.py:3455, app.py:3475 |
Honoured by status(), cached_ratings(), execute() |
battery_control.py:329, 374, 408 |
| Honoured by the VPP monitor loop | app.py:854 |
So dispatch, ratings and VPP detection already stop. Good.
Gap 1 — the probe ignores the switch, and it is most of the traffic¶
client.summary() line 801:
It calls discover directly and never consults battery_control. So with Modbus
"disabled" today, this bridge still opens TCP to :502 on every poll of every gateway,
mocks included — 1 connect when the port answers, 3 × 3 s when it does not.
Turning the existing switch off therefore does not stop us touching the aGate's Modbus port. Anyone who flipped it and then measured would conclude, wrongly, that we were not the problem.
Gap 2 — a disabled subsystem currently reports schedules as SUCCESSFUL¶
With Modbus off, a scheduled force action runs:
_force_dispatch → battery_control.execute() → {"ok": False, "result": "Modbus
(SunSpec 502) is disabled in Control settings"} → the action returns
"force discharge: Modbus (SunSpec 502) is disabled in Control settings".
That string contains none of failed, not confirmed, error or skipped, so both
the old substring test and phase 3's _classify read it as OK. The occurrence is
marked fired, last_result reads like a success, and the history shows a run that
never happened.
This is pre-existing — phase 3 did not introduce it and did not fix it. It matters far more once the switch is actually used: suspending Modbus would quietly convert every force schedule into a fake success. Fixing this is a precondition of suspending, not a follow-up.
2. What "disabled" should mean, surface by surface¶
The principle, consistent with the rules already settled elsewhere in this bridge: absent with a reason, never silently degraded, and never a failure. "Disabled" is an operator decision, not a fault — it must not page anyone, must not retry, and must not look like a device problem.
| Surface | Behaviour when disabled | Why |
|---|---|---|
| Socket probe | Not performed at all. modbus_502 becomes None |
Same rule as phase 2b's never-measured legs: not measured ≠ down |
Link history (modbus_502 leg) |
Leg recorded as not measured, existing history retained | An open outage must not keep accruing against a leg nobody is probing; the history is still worth keeping |
| Dispatch / force actions | SKIPPED with the reason, never OK, never retried |
Gap 2 |
| UI controls (Battery Control, Release, dispatch) | Visible but disabled, with the reason inline and a link to the toggle | A vanished control invites a hunt; a disabled one with a reason does not. Same judgement as the Advanced-tools gate |
| REST endpoints | 503 with a machine-readable reason (modbus_disabled) |
Distinguishable from 503 unreachable; a caller must be able to tell "off" from "broken" |
cached_ratings |
Return None, fall back to the battery_max_power_kw constant |
Already the documented fallback |
| Derived values that need ratings | Degrade to the constant and say so | They already fail closed |
Deliberately NOT disabled: the modbus_bridge_url dispatch provider. That is an
HTTP call to a separate Modbus Bridge service, not a socket to :502, and it is the one
path that still works when we stop talking Modbus ourselves. Conflating the two would
remove the obvious workaround at the moment it becomes most useful.
3. Scope — settled¶
The switch is "Modbus TCP on/off": all TCP to :502 from this process, full stop. Not a per-activity switch, not a policy about Modbus as a concept. Off means this container opens no socket to port 502 for any reason — no poll, no probe, no dispatch, no on-demand read, no ratings.
That makes the acceptance test trivial to state and to check: with the switch off, the socket census shows zero connections to :502 from this container.
Two things fall outside it, and should be said so they are not assumed either way:
modbus_bridge_urldispatch stays available. That is an HTTP call to a separate Modbus Bridge service, not a socket to :502 from us. It is the path that still works when we stop speaking Modbus ourselves, and switching it off with the same flag would remove the obvious workaround at the moment it becomes most useful.- The other clients are unaffected. The Modbus Bridge and Energipay keep their own connections. This switch only silences us.
What is actually lost — measured, then fixed¶
This section said twice that suspending Modbus would blind the VPP badge. It no longer costs anything, because VPP detection was moved to the local channel on 2026-10-12.
Measured on the reference aGate during a Modbus force discharge, with the mobile app showing VPP Mode:
| Field | Not forced | During VPP |
|---|---|---|
1301 run_status |
2 | 2 — unchanged |
name |
Self-Consumption |
"" |
1707 ibgRunStatus |
85232 | 9 |
ibgRunStatus is the local equivalent of the cloud's tou_mode — a programme id where 9
means VPP, and where normal programmes use large arbitrary ids. vpp_monitor now reads
it from the poller's cached block and opens no Modbus connection at all. See
RUN_STATUS_AND_MODE.md.
So, with Modbus TCP off, what is actually lost:
| Capability | Lost? |
|---|---|
| VPP / external-control detection | No — local 1707 |
| Battery state, power, SoC, voltage, frequency | No — local 1301 / 1707 |
| Off-grid and Debug/support-session detection | No — local run_status |
| Force dispatch and Release | Yes — the only local force path |
| Model-correct max charge/discharge ratings | Yes — falls back to the battery_max_power_kw constant |
| Detection of another controller's M704 force specifically | Partly — ibgRunStatus 9 covers any external control, but not which register was written |
Two capabilities, both write-side or nice-to-have. That is a much cheaper suspension than this document originally described.
4. Plan¶
Phase A — make the switch honest (small, independent, do first)
1. Gate the :502 probe on is_enabled(), and skip it for mocks regardless.
2. modbus_502 becomes tri-state: True / False / None (not measured).
3. Link history: modbus_502 leg records not measured while disabled; close any open
outage with that reason rather than leaving it running.
Phase B — stop the silent success (precondition for using the switch)
4. battery_control returns a distinguishable disabled result, not a generic
{"ok": False} whose text happens to read as success.
5. _classify maps it to SKIPPED; _fire_entry records the occurrence as skipped
with the reason, and does not mark the rule fired.
6. A test that asserts a force rule on a disabled Modbus is recorded as skipped —
because this is exactly the class of bug that survived two implementations already.
Phase C — say so in the UI 7. Disable rather than hide the Modbus-dependent controls, with the reason inline and a link to the toggle; note the VPP-blindness beside the toggle itself.
Phase D — measure 8. Re-run the socket census with the switch off. Acceptance: zero connections to :502 from this container. That is the whole point, and it is a number, not an impression.
Anything beyond this — scheduler pre-flight warnings, MQTT availability semantics — is a separate concern and deliberately not in this plan.
Phases A and B are each small and worth doing whether or not Modbus is suspended. B is a correctness fix that stands on its own.
5. Decisions needed¶
- Suspend now, or after Phase A+B? Flipping the existing switch today stops dispatch and the VPP monitor but leaves the probe running and converts force schedules into fake successes. Recommendation: do A and B first — they are small, and suspending without them produces misleading data and a misleading history.
- ~~Keep the VPP monitor running as an exception?~~ Settled: no exception needed. The VPP monitor no longer uses Modbus at all, so there is nothing to except. "Off" can mean off, and the acceptance test stays "zero connections to :502".
- Default for new installs — unchanged (
on), or off until the strategy is settled?