Skip to content

feat(ctc_ecozenith): read-only CTC EcoZenith i250/i255 over Modbus TCP - #221

Open
hannesb90 wants to merge 5 commits into
srcfl:mainfrom
hannesb90:feat/ctc-ecozenith
Open

hannesb90 wants to merge 5 commits into
srcfl:mainfrom
hannesb90:feat/ctc-ecozenith

Conversation

@hannesb90

Copy link
Copy Markdown

Summary

A new read-only driver for the CTC EcoZenith i250/i255 heat pump controller
over Modbus TCP, using CTC's published BMS register list. Toward srcfl/ftw#520:
a CTC pump today reaches FTW only through the read-only MyUplink cloud driver.

It emits metrics only, never a meter, so no sign conversion is involved:
outdoor, flow, return, radiator, heat-pump in/out and hot-water temperatures,
compressor RPS and status code, immersion heater power (kW converted to W),
the heat curve's room setting, inclination and adjustment, and the room
temperature when a room sensor is fitted. Metric names follow the existing
heat pump drivers (hp_outdoor_temp_c, hp_indoor_temp_c,
hp_hw_top_temp_c, hp_inlet_temp_c, hp_outlet_temp_c).

Driver evidence

  • Driver id: ctc_ecozenith 0.1.0, community tier.
  • Device make/model: CTC EcoZenith i255.
  • Firmware tested: display software 2024-07-19 (read from 62244/62245).
  • Protocol/register source: CTC "BMS via TCP/IP", Swedish and English
    editions, both in upstream_docs. Function code 3, offset 0, the listed
    number is the wire address.
  • Hardware test performed, 2026-11-10:
    • Each register the driver reads was first read on its own from the pump.
    • The driver then ran in FTW's own Lua runtime (drivers.NewLuaDriver with
      modbus.Dial, the production client) against the pump for two polls:
      163 ms and 102 ms, no errors, 19 metrics, display software logged.
    • make test-driver ID=ctc_ecozenith and make check pass.
      absent-register-report: every address settles. invented-number-report:
      nothing invented.
    • A Lua harness test decodes the register values the pump actually returned,
      plus a fitted room sensor, a running heater, negative and 0.0 °C outdoor
      readings, an open sensor, and the main block not answering.
  • Known limits:
    • The controller does not answer an address it lacks. A read of an
      undocumented address gets no reply and no Modbus exception, only a
      timeout, and the next connection may be reset. So only registers in the
      i250 column of CTC's list are read, each through a three-strike
      probe_read.
    • A sensor that is not fitted answers 0 (room sensor, 62203), or
      -999.9 when open. Both count as absent for the room and the hot-water
      tank, where 0.0 °C is not a real reading. Outdoor 0.0 °C is kept.
    • No hp_power_w. CTC lists no register for the compressor's
      electrical input. The current registers are the house's incoming phases
      from the controller's own current transformers, which are not fitted on
      the test site. Estimating power from RPS would be a guess.
    • Product type is reported as the raw code (39 on the test pump). The list
      gives no table for it.
    • One pump, one firmware, one heating system. Heating systems 2-4 and the
      i250 itself are untested.

Safety

  • The driver starts read-only, or this is a separately reviewed control change.
  • Vendor signs are converted at the driver boundary. (No power stream is emitted.)
  • Cached telemetry becomes stale instead of being re-emitted as fresh.
  • No credentials, serial numbers, private addresses or site data are included.

Control evidence

  • HIL evidence or required: not applicable, read-only.
  • Control changes include default-mode, lease-expiry and HIL evidence.

The driver ends with a comment listing CTC's 1000-series control registers,
unused here. CTC states they reset unless rewritten every 5 minutes, and on
restart. That is a device-side lease, relevant to a later, separately
reviewed control change.

Checks

  • Commits include Signed-off-by.
  • make test-driver ID=ctc_ecozenith
  • make check (run on a Raspberry Pi 4, after unshallowing the clone,
    which the history check needs)

🤖 Generated with Claude Code

Reads CTC's BMS register list over Modbus TCP: outdoor, flow, return,
radiator, heat-pump in/out and hot-water temperatures, compressor speed
and status, immersion heater power, and the heat curve settings.

The controller does not answer an address it lacks, so only registers in
the i250 column are read, each bounded to three failed polls. A sensor
that is not fitted reads 0 or -999.9 and is reported as absent. There is
no register for the compressor's electrical input, so no hp_power_w.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
@hannesb90
hannesb90 marked this pull request as ready for review November 10, 2026 15:57

@miravoss26 miravoss26 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.

Adds a read-only CTC EcoZenith i250/i255 Modbus TCP driver (metrics only, no meter, no writes) plus manifest, harness and pytest tests.

  • Security: nothing found. No secrets, no new dependencies, no new hosts beyond the configured pump IP. Control registers are only listed in a trailing comment.
  • Low, ctc_ecozenith.lua tank read: the header says only documented i250 registers are read, but probe_read(REG_TANK, 3) also reads 62275, which the comment admits is not in the i250 column. You measured that the pump gives no reply for an address it lacks, so one missing register would make the whole 3-register read fail and drop the DHW top temp too. It works on your i255; worth a note in the header, or two single reads, in case another firmware behaves differently.
  • Nit: s16(rps[1]) / 10 and the immersion lines assume decode_i16 never returns nil. Fine for number input, no action needed.
  • Note: no CI checks are attached to this PR, so I could not see a green run.

Safe to merge from my read once CI has run. A human should confirm the register choice.

The lower tank setpoint (62274) and DHW top temperature (62276) were read
as one three-register block, which also asked for 62275 (DHW lower), not
in the i250 column. The controller gives no reply for an address it lacks,
so on one without 62275 the whole read would fail and take the DHW top
temperature with it. They are now two single reads, so every read is a
documented i250 register, as the header says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: hannesb90 <bruhn90@gmail.com>
@hannesb90

Copy link
Copy Markdown
Author

Thanks. bce65e4 reads 62274 and 62276 singly, so no read spans 62275 any more and every read is an i250 register, as the header says. A harness case makes a read that spans 62275 fail, as the controller would without it, and checks that both values still come through; the old three-register read fails that case. Not re-run on the i255 yet. On the nit: decode_i16 returns a number for number input, so no change there.

@miravoss26 miravoss26 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.

New read-only Lua driver for the CTC EcoZenith i250/i255 over Modbus TCP, plus CHANGELOG, devices.yaml and SUPPORT_STATUS rows.

  • Metrics only, never a meter, so no sign conversion and no double-counting against the grid meter. Declared read_only = true, no control registers used. Good.
  • probe_read bounding (give up after 3 failures per address, pcall around the read) is the right call for a device that times out on unknown addresses.
  • Units look right: kW x 100 to W for immersion, 0.1 factors elsewhere, open-sensor -999.9 and not-fitted 0 handled separately.
  • Minor: hp_poll_ok is tied to the 16-register block only. If that block is given up on after 3 failures it reports 0 permanently while the single-register reads keep working. Probably fine as a health signal, but say so in a comment if intended.
  • Minor: verification_notes honestly says it has not yet been run as an FTW driver on the pump. Keep status experimental until it has.
  • No secrets, no new network destinations (host comes from config).

Safe to merge from my read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hannesb90

Copy link
Copy Markdown
Author

Thanks. c1d7267 adds a comment at hp_poll_ok: it follows the 16-register block on purpose, since that block holds the status and the main temperatures, so once the block is given up on it stays 0 until restart even while the single reads still answer. Status stays experimental until it has run as an FTW driver on the pump.

…ntil restart

On the i255 every read failed for a while after FTW started, and the driver
then left all of them alone until restart while the pump answered again.
A register that misses three polls is now skipped for 1, 2, 4 ... polls,
at most 20 (10 min), and read again. A harness case makes the main block
fail for six polls, then answer, and checks the values come back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hannesb90

Copy link
Copy Markdown
Author

Seen on hardware: after FTW restarted, every read failed for a while (most likely another client held the controller's connection), and the give-up rule then left all 14 reads alone until restart while the pump was answering again. a9d55ed backs off instead: a register that misses three polls is skipped for 1, 2, 4 ... polls, at most 20 (10 min), and then read again. A new harness case fails the main block for six polls, lets it answer, and checks the values come back. It fails on the old code. The absent-register probe now shows SETTLED 0 for these addresses, but with EMITTED 0, so it is not a violation under test_absent_register_settles.py. The cost is one timed-out read per register every 10 minutes on a controller that really lacks it.

…res are

62007, the heating system 1 flow setpoint, is in the block already read and
is now hp_flow_setpoint_c. Checked against the display: 62011 is the flow
after the mixing valve and 62006 'Radiator Water' is the tank water it
draws from. Also documents that the controller serves one Modbus client at
a time, and a curve-adjustment write (61517) that moved the flow setpoint
1:1. index.yaml is regenerated for this and the two earlier driver changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hannesb90

Copy link
Copy Markdown
Author

697f4d5 adds hp_flow_setpoint_c from 62007 and documents what the temperatures are, checked against the display: 62011 is the flow after the mixing valve, and 62006 "Radiator Water" is the tank water it draws from. It also documents that the controller serves one Modbus client at a time, and a curve-adjustment write (61517) that moved the flow setpoint 1:1. index.yaml was stale after the two earlier driver commits and is regenerated.

@miravoss26 miravoss26 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.

Read-only CTC EcoZenith i250/i255 driver over Modbus TCP, metrics only.

  • Security screen: clean. Holding-register reads only (host.modbus_read), no writes, no new network destinations, no secrets.
  • The per-register back-off (3 failures, then skip 1/2/4.. polls capped at 20) is a sensible answer to the timeout-per-missing-register problem, and never giving up permanently matches the observed behaviour when another client holds the connection.
  • Sentinel handling (-999.9 open sensor, 0 for unfitted room/tank sensors) reads correct. Question: the 0.1 kW immersion registers (62168/62169) are metrics here, not a power stream — intended, so they don't count in site balance?

Safe to merge from my read (CI/harness not run by me).

This branch has not been deployed

No deployments
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.

2 participants