Skip to content

Add CTS400 / ES1077 board support (draft, refs #165)#223

Draft
hugo-brito wants to merge 14 commits into
veista:masterfrom
hugo-brito:hugobrito/cts400-support
Draft

Add CTS400 / ES1077 board support (draft, refs #165)#223
hugo-brito wants to merge 14 commits into
veista:masterfrom
hugo-brito:hugobrito/cts400-support

Conversation

@hugo-brito

@hugo-brito hugo-brito commented Jun 14, 2026

Copy link
Copy Markdown

Why

Adds support for the Nilan CTS400 / ES1077 controller (Comfort 250 Top) alongside the existing CTS602 integration. The CTS400 uses a different Modbus register space, cannot be auto-detected (it has no CTS602 control_type register at holding 1000), and only supports function codes FC03/FC04/FC06. It therefore can't be added as a small CTS602 "device key" and is wired as a distinct board family.

This is a draft for maintainer review, opened from a fork, and is gated on discussion #165. It is additive only - no CTS602 behavior changes.

Provenance and verification

  • Verified live on a Nilan Comfort 250 Top (ES1077 / CTS400 controller, firmware 1.0): Modbus RTU, slave 30, 19200 8E1, over a Waveshare RS485 TO ETH (B) bridge (already in this repo's tested-hardware list).
  • The base register map is proven by a working Home Assistant native modbus: package (26 entities) that has been running in production on this unit; a sanitized copy can be shared via discussion Setup Nilan Comfort 250 via Waveshare RS485 Hat with Veista/Nilan custom integration #165.
  • The regulation setpoints were added from a controlled live experiment (each register changed in isolation with HA as the sole reader, every value restored) - see the per-entity notes below.
  • This directly answers the open Q&A in Setup Nilan Comfort 250 via Waveshare RS485 Hat with Veista/Nilan custom integration #165 ("Setup Nilan Comfort 250 via Waveshare RS485 Hat"), where a Comfort 250 owner gets an invalid response from this integration while the native Modbus integration responds on slave 30 - the exact CTS400-vs-CTS602 mismatch this PR addresses.
  • Not a migration: it does not replace anyone's native modbus: package, it adds first-class integration support.

Approach

A board_type seam selects the register/entity space at setup:

  • registers.py - CTS400InputRegisters / CTS400HoldingRegisters.
  • device.py - board_type on Device plus _setup_cts400(), which builds the entity map, reads holding 53 to learn whether an after-heater is fitted, and gates CO2/VOC on the extra sensor (holding 48). Reads use FC03/FC04; writes use FC06 (write_register, single value).
  • config_flow.py - a board-picker step and board-aware validation (CTS400 has no control_type, so it probes run/stop at holding 70).
  • init.py - reads board_type from the entry, threads it into Device, and registers the fan platform.
  • device_map.py / sensor / binary_sensor / switch / number / button / strings.json / translations - CTS400 entity metadata and names (keys prefixed cts400_ to avoid unique_id collisions when both boards run in one HA instance).

Entities

Telemetry: temperatures T1-T4 (signed, x0.1), humidity / supply / extract / after-heat percentages (x0.1), CO2 / VOC (raw ppm, only when the extra sensor is fitted), bypass, de-icing, filter status, alarm status plus raw codes, and days-to-filter-change.

Controls:

  • Fan (fan.cts400_ventilation) - the primary speed control. Maps HA 0-100 % onto stop / level 1-4 (fan level holding 69 + run/stop holding 70); speed_count = 4 so the tile arrows step exactly one level. Falls back to the setpoint while the live level (input 63) lags for a poll after a start.
  • Climate (climate.cts400_hvac, FAN_ONLY / OFF) - optional thermostat card, disabled by default because it overlaps the fan. It only advertises TARGET_TEMPERATURE when an after-heater is present (holding 53 = 2 water / 3 electric); on a base unit (holding 53 = 1, verified live) the setpoint is inert.
  • Run/stop switch and fan-level number - shipped with entity_registry_enabled_default = False to avoid two identical "Ventilation" controls now that the fan is primary.
  • Wanted room temperature (holding 37) - live-verified write plus exact read-back; the controller itself rejects out-of-range writes, so the software clamp is belt-and-suspenders.
  • Summer/winter threshold (holding 45, CONFIG number 5.0-20.0 C) - live-verified to move winter mode (input 72) against outdoor temperature; entry is fast, exit is strongly hysteretic (documented in the setter).
  • Filter interval (holding 50, CONFIG number 1-360 days; 0 = always due) - live-verified to update days-remaining (input 110) immediately.

Tests

First automated tests for the integration, all hardware-free and HA-import-free (pytest -q tests/):

  • tests/test_cts400_registers.py - CTS400 registers match the protocol doc section 4, with no duplicate addresses within a register space.
  • tests/test_cts400_consistency.py - every CTS400_ENTITY_MAP key resolves to a Device method, and every cts400_* entity name has a translation in both strings.json and en.json (checked in both directions, including the platform modules).
  • conftest.py + requirements_test.txt (pytest + PyYAML). No CI workflow added; the repo's CI is hassfest + HACS-validate.

Notes for reviewers

  • Scale differs by board: CTS400 temps/humidity/air% use scale 0.1 (divide by 10); CTS602 uses 0.01 (divide by 100). This is the main reason a shared entity map isn't feasible.
  • Protocol-doc corrections verified live: run/stop is holding 70 with 1=run / 0=stop (the doc's "1=Stop" is a typo), fan level is holding 69, filter reset is holding 51.
  • The alarm code to name mapping is intentionally left UNCONFIRMED - raw codes are exposed as diagnostic sensors; no names are invented.
  • Deliberately left out: de-icing thresholds (holding 39/40), pending a winter run; bypass stays read-only (verified season-gated, not driven by the temperature setpoint).
  • The "average humidity" register (input 46) reads a constant 0 on this firmware and is not exposed.

refs #165

hugo-brito and others added 5 commits June 14, 2026 00:45
Adds an optional second controller-board family (Nilan CTS400 / ES1077,
Comfort 250 Top) alongside the existing CTS602 integration. The CTS400 uses
a different Modbus register space, cannot be auto-detected (it lacks the
CTS602 control_type @ holding 1000), and only supports FC03/FC04/FC06, so it
is wired as a distinct board_type rather than a CTS602 device key.

- registers.py: CTS400InputRegisters / CTS400HoldingRegisters, verified
  against the ES1077/CTS400 protocol doc and a live reference package.
- device.py: board_type seam on Device + _setup_cts400() that builds the
  entity map and gates CO2/VOC on the fitted extra sensor (holding 48).
  Reads use FC03/FC04; writes use FC06 (write_register). Temps T1-T4 are
  signed x0.1, humidity/air% x0.1, CO2/VOC raw ppm.
- device_map.py: CTS400_DEVICE_TYPES + CTS400_ENTITY_MAP.
- sensor/binary_sensor/switch/number/button: CTS400 entity metadata;
  button.py generalized for write-1 reset actions.
- config_flow.py: board picker step + board-aware validation (CTS400 probes
  run/stop @ holding 70 since it has no control_type register).
- __init__.py: read board_type from the entry and thread it into Device.
- strings.json + translations/en.json: board step + CTS400 entity names.

Corrects upstream protocol-doc errors verified on a live unit: run/stop is
holding 70 (1=run / 0=stop; the doc "1=Stop" is a typo), fan level is
holding 69, filter reset is holding 51. The alarm code->name mapping is left
UNCONFIRMED (raw codes only; no invented names).

Draft for maintainer review; gated on discussion veista#165.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds the integration's first automated tests, both hardware-free and
HA-import-free so they run anywhere with plain pytest:

- tests/test_cts400_registers.py: asserts CTS400InputRegisters /
  CTS400HoldingRegisters match the ES1077/CTS400 protocol doc section 4,
  no duplicate addresses within a space, and that the three shared numbers
  (30/48/51) only collide across the input/holding split. Imports only
  registers.py (no HA deps).
- tests/test_cts400_consistency.py: codifies the PR's manual AST-consistency
  claim - every CTS400_ENTITY_MAP key resolves to a Device method, and every
  cts400_* entity name has a translation in both strings.json and en.json.
  Uses ast + json only (no import of device.py).
- requirements_test.txt: pytest + PyYAML.
- conftest.py: puts the repo root on sys.path so tests can import
  custom_components.nilan without installing the package.

No CI workflow added (the repo's CI is hassfest + HACS-validate only);
the tests are runnable locally with `pytest -q tests/`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Exposes fan.{device}_ventilation for the CTS400 instead of only a run/stop
switch plus a fan-level number, which is the idiomatic Home Assistant entity
for a multi-speed unit and gives a native speed slider with working
increase/decrease arrows.

- fan.py: NilanCTS400Fan (SET_SPEED | TURN_ON | TURN_OFF, speed_count 4).
  is_on / turn_on / turn_off delegate to get/set_cts400_run_state;
  set_percentage maps to level = clamp(round(pct/25), 0..4) where 0 stops the
  unit and 1-4 sets the fan-level setpoint (holding 69) and starts the unit.
  percentage falls back to the setpoint while the live level (input 63) lags
  for a poll or two right after a start.
- device.py: get_cts400_fan() handle for the fan entity-map key.
- device_map.py: CTS400_ENTITY_MAP gains get_cts400_fan -> entity_type "fan".
- __init__.py: register the "fan" platform.
- strings.json + translations/en.json: cts400_ventilation under "fan".

The existing run/stop switch and fan-level number are intentionally left
enabled; the maintainer may prefer to set them entity_registry_enabled_default
off now that the fan is the primary control. Additive only; no CTS602 change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds a 'CTS400 / ES1077 (Comfort 250 Top)' section: how to pick the board in
the config flow, that auto-detect is impossible (no control_type register),
the tested Waveshare RS485 TO ETH (B) bridge, that CO2/VOC appear only if the
extra sensor (holding 48) is fitted, the fan/switch/number controls, and that
the alarm code-to-name mapping is unconfirmed. Links discussion veista#165 and the
debug-log + type-plate-photo ask. Own prose; no vendor text copied.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses PR review feedback:

- The CTS400 fan platform is the primary ventilation control. The equivalent
  run/stop switch (switch.cts400_ventilation) and fan-level number
  (number.cts400_fan_level_setpoint) both drive the same registers, so they
  are now shipped with entity_registry_enabled_default=False to avoid two
  identical 'Ventilation' controls. wanted_room_temperature stays enabled.
  Both shared CTS602 entity classes are otherwise unchanged (the flag is set
  per-entity by name).
- test_cts400_entity_names_have_translations now also checks the entity->
  translation direction: it scans the platform modules for cts400_* entity
  names and asserts each has a translation in BOTH en.json and strings.json,
  instead of only comparing the two files to each other.

Additive only; no CTS602 behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@veista

veista commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Hi, Thanks for the pull request! I see you have done a lot of work. Unfortunately I do not want to merge this until I am finished with version 1.3.0. The code as is is too bloated already. I can try to add this once I get the 1.3.0 to beta testing phase.

hugo-brito and others added 9 commits June 14, 2026 16:49
…fied

Folds in the results of a controlled live experiment run on the Comfort 250
Top (ES1077/CTS400, fw 1.0, slave 30) where each register was changed in
isolation with Home Assistant as the sole reader and every value restored.

- A-1 (holding 37, wanted room temperature): write + exact read-back proven
  live, and the controller itself rejects out-of-range writes (9.0 / 31.0 C
  left the value unchanged). Docstring updated from "not exercised" to
  live-verified; the software clamp is now belt-and-suspenders.
- P2 (holding 45, summer/winter threshold): new CONFIG number (5.0-20.0 C,
  step 0.5). Live-verified that writing it moves winter mode (input 72) vs the
  outdoor temperature; entry is fast (~40 s), exit is strongly hysteretic - the
  setter docstring documents that asymmetry.
- P5 (holding 50, filter interval): new CONFIG number (0-360 days). Live-
  verified that writing it immediately updates filter-days-remaining (input
  110).
- P1 (climate): a minimal CTS400 climate entity (FAN_ONLY / OFF) wrapping the
  wanted-temperature setpoint (holding 37), run/stop (holding 70) and fan level
  (holding 69), with the extract temperature as the current room temperature.
  Modelled FAN_ONLY because this unit has no after-heater (holding 53 = 1,
  verified live); the target temperature drives an after-heater only where one
  is fitted. The fan entity remains the primary speed control; climate is the
  complementary thermostat card.

Bypass stays read-only (live-verified it is season-gated, not driven by the
wanted-temperature setpoint). De-icing thresholds (holding 39/40) are left out
pending a winter run.

registers.py gains summer_winter_threshold (45) and filter_interval (50);
device.py gains the four get/set methods plus a climate handle; device_map.py,
number.py, climate.py and the translations are wired accordingly. The
consistency test now also scans climate.py so the climate name is validated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Documents the FAN_ONLY climate entity and the wanted-temperature /
summer-winter-threshold / filter-interval numbers added for the CTS400,
including the season-threshold hysteresis caveat.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses PR review design points:

- The CTS400 climate entity is now disabled by default
  (entity_registry_enabled_default = False): it overlaps with the dedicated
  fan entity (the primary speed control), so it is opt-in for users who want a
  single thermostat card rather than a second enabled ventilation control.
- TARGET_TEMPERATURE is only advertised when an after-heater is fitted
  (holding 53 = 2 water / 3 electric). On a base unit (holding 53 = 1) the
  wanted-temperature setpoint is inert, so the entity no longer ships a
  thermostat whose headline feature does nothing. device.py reads holding 53
  at setup and exposes Device.cts400_has_heater; climate builds its feature set
  from it.
- filter-interval number now exposes a 1-360 day range (0 means 'always due');
  the setter still accepts the full 0-360 hardware range and documents why.

Additive only; no CTS602 behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Unwrap the hard-wrapped multi-line docstring paragraphs added for the CTS400
work so each paragraph is a single source line, letting the reader/editor wrap
as preferred. No behavior or content change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Ignore __pycache__/, compiled bytecode, packaging output, test/coverage/type-check caches, virtualenvs, and common editor/OS files. None were tracked; this keeps locally generated artifacts out of git status.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The two tests in tests/ use only pytest (one imports registers.py, the other uses ast + json). PyYAML was only needed by an out-of-repo parity validator, so listing it here was misleading.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Each paragraph and bullet is now a single source line, letting the reader/editor wrap as preferred. No content change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses the adversarial review (none were correctness blockers):

- cts400_util.py: new HA-free helper module with percentage_to_level,
  level_to_percentage and decode_signed16, so the behavioural logic is
  unit-testable without a Home Assistant harness. decode_signed16 is verified
  equivalent to the previous int.from_bytes byte-trick across all 65536 values.
- fan.py: use percentage_to_level / level_to_percentage. This fixes the
  banker's-rounding surprise at .5 boundaries (round(62.5/25)=2 -> now 3) by
  using round-half-up; slider multiples of 25 are unchanged.
- device.py: _read_cts400_register decodes signed values via decode_signed16.
- tests/test_cts400_behaviour.py: pure-function tests for the %<->level map
  (incl. .5 boundaries and clamping) and signed-16 decoding (incl. 0x8000 /
  0xFFFF and a real negative temperature).
- tests/test_cts400_registers.py: DOC_HOLDING now includes the new holding
  registers 45 (summer/winter threshold), 50 (filter interval) and 53 (heater
  select); the shared-number test covers 53 too (input alarm_code_3 vs holding
  heater_select).
- climate.py: the CTS400 climate setters now set the local _attr_* optimistically
  before async_write_ha_state(), matching the sibling NilanClimate, so the card
  does not lag until the next poll.
- switch.py / number.py: comment the disabled-by-default coupling to the entity
  name string so a future rename is caught.

Additive only; no CTS602 behavior change. The stale-PR-description finding was
already resolved (the body was refreshed in an earlier update).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Collapse the multi-line code comments and docstring paragraphs added in the prior hardening commit onto single lines per paragraph, matching the repo's established no-hard-wrap style for prose. No behaviour change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@hugo-brito

Copy link
Copy Markdown
Author

@veista Sounds good, thanks! No rush. I'll keep it in Draft and rebase onto 1.3.0 once it's in beta. It's all live-verified on my unit.

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