Add CTS400 / ES1077 board support (draft, refs #165)#223
Draft
hugo-brito wants to merge 14 commits into
Draft
Conversation
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>
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. |
…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>
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_typeregister 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
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.modbus:package, it adds first-class integration support.Approach
A
board_typeseam selects the register/entity space at setup:CTS400InputRegisters/CTS400HoldingRegisters.board_typeonDeviceplus_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).control_type, so it probes run/stop at holding 70).board_typefrom the entry, threads it intoDevice, and registers thefanplatform.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.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 = 4so 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.cts400_hvac, FAN_ONLY / OFF) - optional thermostat card, disabled by default because it overlaps the fan. It only advertisesTARGET_TEMPERATUREwhen an after-heater is present (holding 53 = 2 water / 3 electric); on a base unit (holding 53 = 1, verified live) the setpoint is inert.entity_registry_enabled_default = Falseto avoid two identical "Ventilation" controls now that the fan is primary.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- everyCTS400_ENTITY_MAPkey resolves to aDevicemethod, and everycts400_*entity name has a translation in bothstrings.jsonanden.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
1=run / 0=stop(the doc's "1=Stop" is a typo), fan level is holding 69, filter reset is holding 51.refs #165