Repository navigation
feat(ev): add support for CTEK chargers - #116
erikarenhill wants to merge 13 commits into
Conversation
Minimal driver wired around register 4608 (0x1200, holding uint16) for charge-current setpoint — the one register verified against the CCU firmware. Handles ev_set_current / ev_pause / ev_start / ev_resume with an IEC-61851 6 A floor and values below it written as 0 to pause, the same rounding the Easee driver uses. Power / connector-state / session-energy registers are intentionally left unmapped; driver_poll emits a placeholder ev reading with max_a from register readback and a clear TODO block pointing at the CCU automation ModbusTCP range (0x1000+/0x1100+ in v1, 0x2000+/0x2100+ in v2) for the next pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CTEK "Automation interface" doc (v1.0, rev 6b4af7) exposes the
full internal energy-meter register map over Modbus/TCP on CSOS 4.9.3+
— not just the charging-limit register the prior driver was using.
Split into two drivers because the CCU's api_version selector swaps
the base offsets (0x1xxx ↔ 0x2xxx) and CTEK only lets you run the
charge-control API alongside the NANOGRID™ limit-control server if
one of them is on v2.
What both drivers now emit (was placeholder zeros):
- Total active power (W), per-phase current (A) + voltage (V)
- Lifetime energy (Wh, u32 BE)
- Charging limit setpoint + max-assignment ceiling
- Serial number (anchors device_id to the EVSE hardware identity)
Control path is unchanged: write the charging-limit register
(0x1200 / 0x2200) in amperes, 0 to pause. start / pause / resume /
set_current all reach the driver via /api/ev/command — frontend
buttons already POST there, no UI change needed.
Known limitation: the CCU does NOT expose the EVSE state code
("CHRG", "PAUS", "EVRD", …) over Modbus — it's MQTT-only. We derive
`charging`/`connected` from the current + power readings instead,
which is good enough for the dispatch clamp but won't distinguish
"car plugged, awaiting authentication" from "nothing plugged in".
Requires Chargestorm Connected 2 or 3 with CSOS firmware 4.9.3 or
later AND Automation/ModbusTCPEnable=true on the CCU.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds drivers/ctek_mqtt.lua — a read-only companion to the Modbus drivers. CTEK's MQTT automation interface (CSOS ≥ 3.11.15.1) publishes per-outlet energy meter data + a structured EVSE state code, which the Modbus automation interface doesn't expose: on Modbus we can only guess "connected"/"charging" from current/power thresholds, whereas MQTT gives us the authoritative 4-letter code (CHRG / PAUS / EVRD / CONN / NCRQ / …). The driver snaps onto the first CBID it sees (or a configured one), tolerates wildcard / custom base topics, and carries last-known values between low-rate broker updates. Pair with drivers/ctek.lua or drivers/ctek_v2.lua for control — the MQTT interface is documented read-only by CTEK; start/pause/set commands are explicitly refused here with a log line pointing at the Modbus driver. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related onboarding tweaks: * Manual device entry: rename "I know my device IP" to "Add device manually" and flip the order. Clicking it now skips straight to the driver picker (step 4) with the full catalog (no protocol filter), and step 5 renders connection fields based on the chosen driver's declared protocols + connection_defaults. Modbus drivers get host / port / unit_id; MQTT drivers get host / port / username / password; local HTTP drivers get host / port. Cloud HTTP drivers (Easee) show a hint pointing at the Integrations step instead of a broken form. A small registry of driver-specific extras (CTEK MQTT's base_topic / cbid / outlet) rides on top, keyed by DRIVER.id. * EV Charger options: step 7 now lists Easee (cloud), CTEK Chargestorm (Modbus) and CTEK Chargestorm (MQTT telemetry). Easee continues to land in the ev_charger block; the CTEK entries instead materialise as a driver entry in cfg.drivers so they show up in the regular device list + respect the clamp/dispatch stack. * Zero-device exit: let saveConfig POST a config with zero drivers so operators can finish onboarding now and add hardware from /settings later. Backend Validate() matches — only enforces "at least one site meter" when drivers are present, zero drivers is fine. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The driver picker was showing entries in whatever order the catalog API returned them (which sorts by Name server-side), and concatenated manufacturer + name mechanically — the latter made "CTEK Chargestorm" display as "CTEK CTEK Chargestorm (API v1) (modbus, ev)" because the driver's Name already led with its own manufacturer. Client-side now: - alphabetical sort by the final rendered label via localeCompare, defense-in-depth against API ordering changes - only prepend manufacturer when Name doesn't already start with it Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups that make onboarding less sticky: * Drop the "at least one driver must be is_site_meter: true" check from config.Validate(). Operators commonly land in a state where the first driver they add is an EV charger or battery (grid meter comes later over MQTT/Modbus), and hard-failing the whole config at that point forces awkward dummy-meter workarounds. Runtime components that actually need a site meter (dispatch, clamp, control loop) already gate on telemetry presence — parse time is the wrong place to enforce this. Per-driver checks (name, lua path, at least one capability, dupe name) still apply unchanged. * Add a "Skip setup — I'll configure later" button on step 1 of the wizard. Posts a minimal default config (16 A / 3-phase / 230 V, no drivers) to /api/config, which triggers the bootstrap-to-main restart. Returning operators / Pi redeploys can land on the main UI in two clicks and finish configuration from /settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding a site meter via /settings after the initial startup wired the driver correctly (telemetry appears in Advanced + per-driver cards) but never showed up on the hero flow: api.handleStatus reads ctrl.SiteMeterDriver, and that field was captured at boot — when the user started with no meter it stayed "" forever, so the top-level grid_w / load_w in /api/status always read 0 regardless of what the Zap / Ferroamp / Pixii etc. driver was actually emitting. configreload.reload() already applies scalar control-level changes (grid target, tolerance, slew, min interval) under ctrlMu. Adds the same for site meter: re-derive newCfg.SiteMeterDriver() and assign it when it differs from the current value. No restart required to promote / demote a driver to site meter anymore. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5a976ff to
da80cd0
Compare
There was a problem hiding this comment.
Pull request overview
Adds CTEK EV charger support to the driver catalog and setup/onboarding flow, while relaxing config validation to allow completing onboarding before any hardware (or site meter) is configured.
Changes:
- Added three CTEK drivers (Modbus API v1, Modbus API v2, MQTT telemetry-only) and documented them in the driver catalog.
- Reworked the setup wizard’s “add device” and driver configuration steps (manual add path, sorted/cleaner driver labels, dynamic connection/config fields, and a “skip setup” fast path).
- Updated Go config validation + reload behavior to support empty
driversand late site-meter selection via/settings.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
web/setup.js |
Refactors wizard flow (manual add, dynamic connection/config field rendering, EV integration options, skip-setup). |
web/setup.html |
Updates wizard UI to match new flow (manual add button, dynamic driver fields containers, EV charger type options, skip setup button). |
go/internal/configreload/watcher.go |
Updates control state on config reload to promote/demote site meter driver. |
go/internal/config/config_test.go |
Adjusts tests to accept configs with zero drivers and with no site meter. |
go/internal/config/config.go |
Relaxes validation rules (no longer requires drivers/site meter) and documents rationale. |
drivers/ctek.lua |
New Modbus/TCP CTEK Chargestorm Automation API v1 driver (telemetry + current-limit control). |
drivers/ctek_v2.lua |
New Modbus/TCP CTEK Chargestorm Automation API v2 driver (offset register map). |
drivers/ctek_mqtt.lua |
New MQTT telemetry-only CTEK driver (rich per-phase telemetry + EVSE state code). |
docs/driver-catalog.md |
Adds CTEK entries and updates the “at a glance” summary numbers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, | ||
| verification_status = "beta", | ||
| verification_notes = "Register map per CTEK Automation interface v1.0; charging-limit write verified against CSOS 4.9.x. Derived charging/connected flags approximate the real EVSE state since the state code is MQTT-only.", | ||
| connection_defaults = { | ||
| port = 502, | ||
| unit_id = 1, | ||
| }, | ||
| } |
| authors = { "forty-two-watts contributors" }, | ||
| tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, | ||
| verification_status = "beta", | ||
| verification_notes = "Topic layout and payload shapes per CTEK 'Automation interface' PDF v1.0 rev 6b4af7. Telemetry-only — control MUST come from the Modbus driver.", | ||
| connection_defaults = { | ||
| port = 1883, | ||
| }, | ||
| } |
| tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, | ||
| verification_status = "beta", | ||
| verification_notes = "Register map per CTEK Automation interface v1.0 (API v2 offsets); charging-limit write verified against CSOS 4.9.x. Derived charging/connected flags approximate the real EVSE state since the state code is MQTT-only.", | ||
| connection_defaults = { | ||
| port = 502, | ||
| unit_id = 1, | ||
| }, | ||
| } |
| 22 drivers covering 17 manufacturers across 3 protocols (Modbus TCP, | ||
| MQTT, HTTP/REST). Read-only: 13. With control: 9. |
| // Promote / demote the site meter. Without this, adding a meter | ||
| // driver via /settings wires up the driver itself (telemetry flows, | ||
| // shows up in Advanced) but `api.handleStatus` keeps reading | ||
| // `ctrl.SiteMeterDriver` — which was empty at boot when no meter | ||
| // was configured yet — so the hero UI's grid_w stays at 0 and the | ||
| // meter looks "missing from the flow" even though it's emitting. | ||
| newSite := newCfg.SiteMeterDriver() | ||
| if newSite != w.ctrl.SiteMeterDriver { | ||
| slog.Info("config reload: site_meter", "old", w.ctrl.SiteMeterDriver, "new", newSite) | ||
| w.ctrl.SiteMeterDriver = newSite | ||
| } |
| | CTEK Chargestorm (API v1) | CTEK | Modbus | ev | yes | Chargestorm Connected 2/3 (CSOS ≥ 4.9.3) | `drivers/ctek.lua` | | ||
| | CTEK Chargestorm (API v2) | CTEK | Modbus | ev | yes | Chargestorm Connected 2/3 (CSOS ≥ 4.9.3) | `drivers/ctek_v2.lua` | | ||
| | CTEK Chargestorm (MQTT, telemetry) | CTEK | MQTT | ev | no | Chargestorm Connected 2/3 (CSOS ≥ 3.11.15.1) | `drivers/ctek_mqtt.lua` | |
| // Local HTTP driver (e.g. a REST-speaking inverter on the LAN) — | ||
| // CD advertises a host. Cloud drivers (Easee) declare http_hosts | ||
| // but no CD.host, and their creds live in config.* instead; we | ||
| // punt those to the integrations step (step 7). | ||
| if (cd.host !== undefined || (!entry.http_hosts || entry.http_hosts.length === 0)) { | ||
| fields.push({ target: 'capabilities.http.host', key: 'host', label: 'IP address', type: 'text', | ||
| placeholder: '192.168.1.10', required: true }); | ||
| fields.push({ target: 'capabilities.http.port', key: 'port', label: 'Port', type: 'number', | ||
| default: cd.port != null ? cd.port : 80 }); |
| // CD advertises a host. Cloud drivers (Easee) declare http_hosts | ||
| // but no CD.host, and their creds live in config.* instead; we | ||
| // punt those to the integrations step (step 7). | ||
| if (cd.host !== undefined || (!entry.http_hosts || entry.http_hosts.length === 0)) { | ||
| fields.push({ target: 'capabilities.http.host', key: 'host', label: 'IP address', type: 'text', | ||
| placeholder: '192.168.1.10', required: true }); | ||
| fields.push({ target: 'capabilities.http.port', key: 'port', label: 'Port', type: 'number', | ||
| default: cd.port != null ? cd.port : 80 }); | ||
| } else { |
paho.mqtt.golang only falls through to SetDefaultPublishHandler for
messages that don't match any per-subscription callback, and even
then the routing for *live* (non-retained) publishes has historically
been inconsistent when the subscription callback is nil. Symptom we
hit on the CTEK Chargestorm: the driver subscribes to
`CTEK/<CBID>/evse1/{em,status,meterinfo}` successfully (subscribe ACK
comes back), retained messages on neighbouring CCU/... topics route
through DefaultPublishHandler fine, but the live em + status publishes
that fire every ~8 s never surface — `host.mqtt_messages()` returns
empty on every poll, so the driver stays stuck at ev_w=0 / state
unknown while MQTT Explorer shows CHRG + 9 kW.
Fix: give the Capability its own handler closure, wire it to both
SetDefaultPublishHandler (so retained discovery still works) AND
every Subscribe call (so live publishes route through the exact
same path). Drains into cap.incoming the same way either route.
No driver changes required — the fix lives one layer down. CTEK
MQTT driver + any future MQTT driver benefits automatically.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CTEK's MQTT telemetry namespace publishes assigned current on status.assigned — which is the current the station is offering to an active EV session, not the hardware ceiling. When no car is plugged (state=AVAL) or a session is paused, assigned=0, and the driver was mirroring that straight into ev.max_a. The UI shows "0 A max" whenever the charger is idle, which reads as a config error. Mirror the Modbus driver's config shape by adding config.max_a (with config.min_a for consistency and an IEC 61851 floor at 6 A). When there's an active session with assigned > 0, still report that so dispatch math reflects the real offered current; otherwise fall back on max_a. Hardware FuseRating lives in CCU/.../Configuration which this driver intentionally stays out of, so this per-site config value is the clean way to surface the ceiling over pure MQTT. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
In hero mode the numeric summary tiles (incl. #card-ev) are hidden, so clicking anywhere on the hero diagram did nothing — there was no surface to open the EV detail modal from. Backend-side the EV was detected correctly (ctek-chargestorm-mqtt emits ev_w / state_label / ev_max_a), the UI just had no hook to surface it. <ftw-energy-flow> now accepts an optional `kind` per circle and fires a composed / bubbling `ftw-flow-node-click` CustomEvent on the host when a tagged circle is activated (click or Enter/Space). Today only the EV circle opts in via kind="ev" + clickable=true; other node kinds can join by flipping the same two flags. next-app.js listens for the event on the <ftw-energy-flow> element and routes "ev" → the same openEvModal() path the legacy #card-ev tile used, so keyboard / click / backdrop close / refresh-every-5s behaviour is identical between hero and numbers modes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merge the telemetry-only ctek_mqtt.lua into ctek.lua and ctek_v2.lua so a single driver per API version owns both the Modbus control path and the richer MQTT telemetry (EVSE state code, per-phase em, grid frequency). MQTT is optional and opportunistic — the driver falls back to Modbus for both telemetry and cold-start coverage when the broker isn't reachable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the hard-coded Easee/CTEK options and field blocks in the setup wizard. The EV provider list now comes from /api/drivers/catalog filtered to entries whose capabilities include "ev"; fields render from the same connection_defaults + extras helpers used by the main driver step. Adding a new EV driver requires only dropping a .lua file in drivers/ — no HTML/JS edits. Field targets drive save dispatch (ev_charger.* → cfg.ev_charger for cloud providers, everything else → cfg.drivers[]), and hybrid drivers auto-wire their secondary protocol on the primary host. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Modbus interface (per CTEK Automation Interface PDF rev 6b4af7) covers everything the EMS uses: per-phase telemetry, lifetime energy, serial, charging-limit read+write, and max-assignment. The only thing MQTT added was the 4-letter EVSE state code, which the dispatcher doesn't consume — connected/charging is derived from current+power just as well. Dropping MQTT removes the broker dependency and the dual-source state reconciliation, leaving one config knob (host:502) per charger. Setup wizard's per-protocol field renderer keeps the multi-protocol path intact for any future hybrid driver that genuinely needs it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds first-class support for CTEK Chargestorm EV chargers and improves onboarding flows so EV drivers can be configured from the driver catalog (including “skip setup” / “no drivers yet” paths).
Changes:
- Add new CTEK Chargestorm Modbus drivers (API v1 + v2) and document them in the driver catalog.
- Refactor the web setup wizard to render driver connection/config fields dynamically from the catalog, add “skip setup”, and populate EV providers from
/api/drivers/catalog. - Improve runtime robustness: MQTT subscriptions use a consistent publish handler; config reload now updates the active site meter; config validation accepts empty drivers/no site meter.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| web/setup.js | Dynamic connection/config field rendering, EV provider catalog integration, skip setup, relaxed “no drivers” flow |
| web/setup.html | UI updates for manual add, dynamic driver/EV fields, skip setup button |
| web/next-app.js | Open EV modal from hero-mode energy-flow clicks |
| web/components/ftw-energy-flow.js | Make EV node clickable and dispatch node-click events (mouse + keyboard) |
| go/internal/mqtt/client.go | Reuse a shared message handler for default publish + subscriptions |
| go/internal/configreload/watcher.go | Hot-reload now promotes/demotes SiteMeterDriver |
| go/internal/config/config.go | Validation now allows empty drivers and no site meter |
| go/internal/config/config_test.go | Update tests to match relaxed validation rules |
| drivers/ctek.lua | New CTEK Chargestorm Modbus driver (API v1) |
| drivers/ctek_v2.lua | New CTEK Chargestorm Modbus driver (API v2 offsets) |
| docs/driver-catalog.md | Update catalog counts and add CTEK entries |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (f.key === 'host' && selectedDevice.ip) f.default = selectedDevice.ip; | ||
| if (f.key === 'port' && selectedDevice.port) f.default = selectedDevice.port; |
| } else if (proto === 'http') { | ||
| // Local HTTP driver (e.g. a REST-speaking inverter on the LAN) — | ||
| // CD advertises a host. Cloud drivers (Easee) declare http_hosts | ||
| // but no CD.host, and their creds live in config.* instead; we | ||
| // punt those to the integrations step (step 7) via cloudHint. | ||
| if (cd.host !== undefined || (!entry.http_hosts || entry.http_hosts.length === 0)) { | ||
| fields.push({ target: 'capabilities.http.host', key: 'http_host', | ||
| label: prefix + 'IP address', type: 'text', | ||
| placeholder: '192.168.1.10', required: hostRequired }); | ||
| fields.push({ target: 'capabilities.http.port', key: 'http_port', | ||
| label: prefix + 'Port', type: 'number', default: port }); | ||
| } else if (isPrimary) { | ||
| fields.push({ cloudHint: true, | ||
| message: 'This driver is a cloud API. Configure its credentials in the "Optional integrations" step (step 7).' }); | ||
| } |
| if (!entry) return []; | ||
| var out = []; | ||
| if (entry.id === 'ctek-chargestorm' || entry.id === 'ctek-chargestorm-v2') { | ||
| out.push({ target: 'config.outlet', key: 'outlet', label: 'Outlet (passed via unit_id)', type: 'number', |
| var hostInp = document.querySelector('#ev-provider-fields [data-key="host"]'); | ||
| var serialInp = document.querySelector('#ev-provider-fields [data-key="serial"]'); |
| homepage = "https://www.ctek.com", | ||
| authors = { "forty-two-watts contributors" }, | ||
| tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, | ||
| verification_status = "beta", | ||
| verification_notes = "Register map per CTEK Automation interface v1.0; charging-limit write verified against CSOS 4.9.x. Derived charging/connected flags approximate the real EVSE state since the state code is MQTT-only.", | ||
| connection_defaults = { | ||
| port = 502, | ||
| unit_id = 1, | ||
| }, | ||
| } |
| homepage = "https://www.ctek.com", | ||
| authors = { "forty-two-watts contributors" }, | ||
| tested_models = { "Chargestorm Connected 2", "Chargestorm Connected 3" }, | ||
| verification_status = "beta", | ||
| verification_notes = "Register map per CTEK Automation interface v1.0 (API v2 offsets); charging-limit write verified against CSOS 4.9.x. Derived charging/connected flags approximate the real EVSE state since the state code is MQTT-only.", | ||
| connection_defaults = { | ||
| port = 502, | ||
| unit_id = 1, | ||
| }, | ||
| } |
|
Hey @erikarenhill — circling back on this one. A few things have landed on master since this branch was last touched that I think you'll want to fold in before this is ready to merge:
Current state:
When you have a moment, would you mind:
Happy to help review once it's rebased — just ping me. No rush, but flagging so it doesn't drift further. |
Adopts the Modbus-TCP-only EV charger drivers from the CTEK Chargestorm work on PR srcfl#116, branched fresh off master to drop the unrelated MQTT / setup-wizard / energy-flow commits the original PR accumulated. Two drivers because CTEK's CSOS firmware exposes the same Modbus register set at two different base offsets depending on the CCU's api_version setting: - drivers/ctek.lua — API v1 (0x1xxx range) - drivers/ctek_v2.lua — API v2 (0x2xxx range) The semantics, units, and function codes are identical; only the addresses differ. CTEK recommends running either the charge-control API or the NANOGRID™ limit-control on v2 if you want both enabled simultaneously. What both emit: - Total active power (W), per-phase current (A), per-phase voltage (V) - Lifetime energy (Wh, u32 BE) - Charging limit setpoint + max-assignment ceiling - Serial number (anchors device_id to EVSE hardware identity) Control path: writes the charging-limit register (0x1200 / 0x2200) in amperes, 0 to pause. Driver maps the host-API standard EV actions (ev_set_current / ev_pause / ev_start / ev_resume / init / deinit) onto register writes; `last_set_a` is remembered so ev_resume restores the previous setpoint. Known limitation: CTEK does NOT expose the EVSE state code over Modbus — it's MQTT-only. `charging`/`connected` are derived from the live current + power readings, which is sufficient for the dispatch clamp but won't distinguish "car plugged, awaiting authentication" from "nothing plugged in". Requires Chargestorm Connected 2 or 3 with CSOS firmware 4.9.3 or later AND Automation/ModbusTCPEnable=true on the CCU. Verification status: beta. Register map per CTEK Automation interface v1.0 (rev 6b4af7); charging-limit write verified against CSOS 4.9.x. Catalog parses both entries cleanly; internal/drivers tests green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
No description provided.