From 176667c32f27380fa495c8f1ad1f0c547a08e767 Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 20:40:11 +0000 Subject: [PATCH 01/14] Fix invalid default preset/fan/swing mode on entity startup _attr_preset_mode/_attr_fan_mode/_attr_swing_mode were initialised to hardcoded DEFAULT_* constants (DEFAULT_PRESET_MODE = "comfort", DEFAULT_FAN_MODE = "low", DEFAULT_SWING_MODE = "off") without ever validating them against the configured modes lists. When a configured list didn't include the default, the entity reported -- and on restart tried to restore -- an attribute value Home Assistant rejects with "attribute 'X' returned invalid value". Reconcile each attribute to None when the hardcoded default is absent from the configured list. hvac_mode is intentionally excluded: it is the entity STATE and cannot be None. DEFAULT_HVAC_MODE is HVACMode.OFF, and HA itself requires OFF in hvac_modes (entities omitting OFF are rejected at registration due to TURN_ON/TURN_OFF feature enforcement), so the "invalid default" scenario cannot arise for a loadable entity. Originally found and fixed in mikopp/hass-template-climate while building an integration test suite against a real Home Assistant; split out here as its own PR per litinoveweedle/hass-template-climate#35. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- .../climate_template/Changelog.md | 6 +++++ custom_components/climate_template/climate.py | 25 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index a4761d5..4f524c5 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,12 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-09 — Fix invalid default preset/fan/swing mode on startup + +- **Author:** [@mikopp](https://github.com/mikopp) +- `preset_mode`, `fan_mode`, and `swing_mode` were initialised to hardcoded defaults (`"comfort"`, `"low"`, `"off"`) without checking them against the configured `preset_modes`/`fan_modes`/`swing_modes` lists. When a configured list omitted the default, Home Assistant rejected the entity's state with `attribute 'X' returned invalid value`, and restoring it after a restart failed the same way. Each attribute now falls back to `None` when its hardcoded default isn't one of the configured values. `hvac_mode` is intentionally not reconciled: it is the entity's state, its default is always `HVACMode.OFF`, and Home Assistant already requires `off` in `hvac_modes`. + + ### 2026-09-03 — Document translations startup race limitation - **Author:** [@litinoveweedle](https://github.com/litinoveweedle) diff --git a/custom_components/climate_template/climate.py b/custom_components/climate_template/climate.py index 77ea8a7..847a78d 100644 --- a/custom_components/climate_template/climate.py +++ b/custom_components/climate_template/climate.py @@ -543,6 +543,16 @@ def __init__(self, hass: HomeAssistant, config: ConfigType, unique_id: str | Non ) self._presets = {} + # Reconcile the initial preset_mode with the configured preset_modes. + # The hardcoded default (DEFAULT_PRESET_MODE = "comfort") is only a valid + # value when "comfort" is one of the configured preset_modes. Otherwise the + # entity would report -- and on restart try to restore -- a preset_mode + # that is not an allowed value, which Home Assistant rejects with + # "attribute 'preset_mode' returned invalid value: 'comfort'". Fall back + # to no preset (None) when the default is not a configured preset_mode. + if self._attr_preset_mode not in self._attr_preset_modes: + self._attr_preset_mode = None + if self._attr_fan_modes and len(self._attr_fan_modes) >= 2: self._attr_supported_features |= ClimateEntityFeature.FAN_MODE if not ( @@ -605,6 +615,21 @@ def __init__(self, hass: HomeAssistant, config: ConfigType, unique_id: str | Non ) self._presets_features ^= ClimateEntityPresetFeature.SWING_MODE + # Note: hvac_mode is intentionally NOT reconciled here. It is the climate + # entity's state and the hardcoded DEFAULT_HVAC_MODE is HVACMode.OFF, so + # the only way for the default to be "invalid" is for hvac_modes to omit + # OFF -- but such an entity is rejected by Home Assistant anyway, because + # the TURN_ON/TURN_OFF features are only enabled when OFF is configured. + + if self._attr_fan_modes and self._attr_fan_mode not in self._attr_fan_modes: + self._attr_fan_mode = None + + if ( + self._attr_swing_modes + and self._attr_swing_mode not in self._attr_swing_modes + ): + self._attr_swing_mode = None + if HVACMode.HEAT_COOL in self._attr_hvac_modes: if ( not self._template_target_temperature_high From 540d76dc082c4fef1da4ff20527877b384e8fcc2 Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 20:41:48 +0000 Subject: [PATCH 02/14] Docs: document attributes/variables config options and legacy keys The modern template-entity schema (make_template_entity_common_schema) already accepts attributes: and variables:, but the README never documented either. Add both to the configuration table with worked examples, and add a Deprecated Keys section listing the legacy template-entity keys that rewrite_legacy_to_modern_config() already rewrites on load (availability_template, icon_template, entity_picture_template, friendly_name, value_template), plus the removed entity_id option. Docs only; no code changes. Split out of litinoveweedle/hass-template-climate#35. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- README.md | 46 +++++++++++++++++++ .../climate_template/Changelog.md | 6 +++ 2 files changed, 52 insertions(+) diff --git a/README.md b/README.md index cbb7dda..6571e95 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ All configuration variables are optional. If you do not define a `template` or i | ------------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | name | `string` | The name of the climate device. | "Template Climate" | | unique_id | `string` | The [unique id](https://developers.home-assistant.io/docs/entity_registry_index/#unique-id) of the climate entity. | None | +| attributes | `dict` | Dictionary of `name: template` pairs defining extra state attributes to expose on the entity. Each value is rendered as a template. See [example](#example-extra-state-attributes). | | +| variables | `dict` | Additional variables available in all action scripts (e.g. `set_hvac_mode`). See [example](#example-variables-in-actions). | | | mode_action | `string` | Possible values: `parallel`, `queued`, `restart`, `single`. For explanation, see the [`script`](https://www.home-assistant.io/integrations/script/#script-modes) documentation. | single | | max_action | `positive_int` | Limits the number of concurrent runs of actions. Used together with `parallel` and `queued` `mode_action`, set to a positive number greater than 1. For explanation, see the [`script`](https://www.home-assistant.io/integrations/script/#max) documentation. | 1 | | presets_features | `positive_int` | Define the feature flags supported by the `preset_mode` feature as bit flags. See [example](#presets_features) for options. Default value `0` means presets are disabled. | 0 | @@ -228,6 +230,21 @@ climate: > [!WARNING] > **Known limitation:** Home Assistant preloads an integration's `translations/.json` in the background *before* this integration's own startup code runs, so there is a narrow window where Home Assistant can read the file before it has been regenerated for the current configuration. Once loaded, translations are cached in memory for the rest of that Home Assistant session and are not re-read even after we finish rewriting the file. In practice this only becomes visible right after the on-disk file was reset to a stale/placeholder state just before that particular restart (for example, immediately after updating this integration via HACS, which reinstalls the file shipped in the release). When it happens, entity/preset/state text falls back to the raw, untranslated value for that session only — icons are unaffected, since they are loaded on demand rather than preloaded at startup. **Restarting Home Assistant a second time resolves it**, since the file already holds the correct, current content by then. +## Deprecated Keys + +The following config keys still work but log a deprecation warning naming the +affected entity and the migration target. They are automatically rewritten to +their replacement on load. + +| Deprecated Key | Replacement | Notes | +| -------------------------- | -------------- | ----------------------------------------------------------- | +| `availability_template` | `availability` | HA template-entity standard key. Rewritten automatically. | +| `icon_template` | `icon` | HA template-entity standard key. Rewritten automatically. | +| `entity_picture_template` | `picture` | HA template-entity standard key. Rewritten automatically. | +| `friendly_name` | `name` | HA template-entity standard key. Rewritten automatically. | +| `value_template` | `state` | HA template-entity standard key. Rewritten automatically. | +| `entity_id` | *(removed)* | No longer used; remove it from the configuration. | + ## Example Configuration ```yaml @@ -275,6 +292,35 @@ climate: # could also send IR command via broadlink service calls etc. ``` +### Example: extra state attributes + +```yaml +climate: + - platform: climate_template + name: Airflow Controller + hvac_modes: + - "off" + - "cool" + attributes: + outdoor_temp: "{{ states('sensor.outdoor_temp') | float(none) }}" + indoor_dew: "{{ states('sensor.indoor_dew') | float(none) }}" + free_cooling_available: "{{ is_state('binary_sensor.free_cooling_available', 'on') }}" +``` + +### Example: variables in actions + +```yaml +climate: + - platform: climate_template + name: My Climate + variables: + device_id: "my_esphome_device" + set_hvac_mode: + - service: esphome.{{ device_id }}_set_mode + data: + mode: "{{ hvac_mode }}" +``` + ### Example action to control existing Home Assistant devices ```yaml diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index a4761d5..68446ec 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,12 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-09 — Document `attributes`, `variables`, and deprecated keys + +- **Author:** [@mikopp](https://github.com/mikopp) +- Documented the existing `attributes` and `variables` config options in the configuration table, with worked examples. Added a "Deprecated Keys" section listing the legacy template-entity keys (`availability_template`, `icon_template`, `entity_picture_template`, `friendly_name`, `value_template`) that are rewritten automatically, and the removed `entity_id` option. + + ### 2026-09-03 — Document translations startup race limitation - **Author:** [@litinoveweedle](https://github.com/litinoveweedle) From 2227cf57f238b3abc062e7d33e475cf286213d45 Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 20:44:32 +0000 Subject: [PATCH 03/14] Restore deprecated 'modes' alias for 'hvac_modes' jcwillox-era configs used 'modes'; this fork renamed it to 'hvac_modes' as a breaking change. Accept the old key again and rewrite it inside rewrite_legacy_to_modern_config(), alongside the other legacy-key rewrites, with the same startup deprecation warning naming the affected entity. hvac_modes' schema default (DEFAULT_HVAC_MODE_LIST) is moved out of PLATFORM_SCHEMA and applied inside the rewrite instead: with the default staying on the schema, hvac_modes would always be present by the time the rewrite runs, so it could never tell a user-supplied value apart from an applied default -- and 'modes' would never actually take effect. Split out of litinoveweedle/hass-template-climate#35. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- README.md | 4 +++- .../climate_template/Changelog.md | 6 +++++ custom_components/climate_template/climate.py | 22 ++++++++++++++++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index cbb7dda..17af9ea 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,9 @@ This is a fork of the original repository jcwillox/hass-template-climate, which ## Breaking changes from jcwillox versions -- Config parameter `modes` renamed to `hvac_modes` +- Config parameter `modes` renamed to `hvac_modes`. The old `modes` key still + works (with a startup deprecation warning naming the affected entity) and + is automatically mapped to `hvac_modes` on load. - `hvac_modes` list is set only to `["off", "heat"]` by default. - `preset_modes`, `fan_modes` and `swing_modes` are now not set by default and shall be configured **only** if being used and set to the used miminum list of modes. diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index a4761d5..4821f3f 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,12 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-09 — Deprecated `modes` alias for `hvac_modes` + +- **Author:** [@mikopp](https://github.com/mikopp) +- Restored back-compat for the jcwillox-era `modes` config key: it is now accepted again and automatically rewritten to `hvac_modes` on load, alongside the other legacy keys in `rewrite_legacy_to_modern_config()`, with the same startup deprecation warning naming the affected entity. + + ### 2026-09-03 — Document translations startup race limitation - **Author:** [@litinoveweedle](https://github.com/litinoveweedle) diff --git a/custom_components/climate_template/climate.py b/custom_components/climate_template/climate.py index 77ea8a7..d670065 100644 --- a/custom_components/climate_template/climate.py +++ b/custom_components/climate_template/climate.py @@ -240,9 +240,12 @@ class ClimateEntityPresetValues(TypedDict, total=False): vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_SWING_MODE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_PRESETS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional( - CONF_HVAC_MODE_LIST, default=DEFAULT_HVAC_MODE_LIST - ): cv.ensure_list, + # Deprecated: renamed to hvac_modes (see rewrite_legacy_to_modern_config). + # No default here (unlike the other mode lists) so the rewrite can tell + # whether the user actually configured hvac_modes before it applies + # DEFAULT_HVAC_MODE_LIST itself. + vol.Optional("modes"): cv.ensure_list, + vol.Optional(CONF_HVAC_MODE_LIST): cv.ensure_list, vol.Optional( CONF_PRESET_MODE_LIST, default=DEFAULT_PRESET_MODE_LIST ): cv.ensure_list, @@ -330,6 +333,19 @@ def rewrite_legacy_to_modern_config( ) entity_cfg.pop(ATTR_ENTITY_ID, None) + # Map deprecated 'modes' (jcwillox-era config) to 'hvac_modes'. + if "modes" in entity_cfg and CONF_HVAC_MODE_LIST not in entity_cfg: + _LOGGER.warning( + "Entity '%s' uses legacy configuration option '%s'; migrate to '%s'.", + entity_name, + "modes", + CONF_HVAC_MODE_LIST, + ) + entity_cfg[CONF_HVAC_MODE_LIST] = entity_cfg.pop("modes") + else: + entity_cfg.pop("modes", None) + entity_cfg.setdefault(CONF_HVAC_MODE_LIST, DEFAULT_HVAC_MODE_LIST) + for from_key, to_key in LEGACY_FIELDS.items(): if from_key not in entity_cfg or to_key in entity_cfg: continue From b49244bb5ed72822d48f25cfa232bf0cf865b56a Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 20:45:51 +0000 Subject: [PATCH 04/14] Pin GitHub Actions to commit SHAs; add Dependabot actions/checkout@v2, psf/black@stable, home-assistant/actions/hassfest@master, and hacs/action@main were all pinned to floating branch/tag refs, so a compromised or rewritten ref would run in CI and in the release workflow (which has repo write access via GITHUB_TOKEN) without any change to this repo. Pin each to its current commit SHA, keeping the original ref as a trailing comment, and add dependabot.yml to open a weekly PR bumping any SHA whose ref has moved. Two of the four SHAs used in litinoveweedle/hass-template-climate#35 had already moved since that PR (actions/checkout's v2 tag and home-assistant/actions' master branch); psf/black@stable and hacs/action@main had not. Re-verified all four against their current refs before pinning here. Split out of litinoveweedle/hass-template-climate#35 as its own PR, since CI-pipeline changes were flagged there as bundled in with unrelated work. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- .github/dependabot.yml | 6 ++++++ .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yaml | 2 +- .github/workflows/validate.yaml | 8 ++++---- custom_components/climate_template/Changelog.md | 6 ++++++ 5 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ca79ca5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f4389d1..ed944ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout the repository" - uses: actions/checkout@v2 + uses: actions/checkout@0717577d45739eb3c851188b29f50ed6c0b2194e # v2 - name: "Check format" - uses: psf/black@stable + uses: psf/black@87928e6d6761a4a6d22250e1fee5601b3998086e # stable diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 23a2a9a..2f6998a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: "Checkout the repository" - uses: actions/checkout@v2 + uses: actions/checkout@0717577d45739eb3c851188b29f50ed6c0b2194e # v2 - name: "Update version" run: | diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index 74742a5..646a000 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -17,20 +17,20 @@ jobs: name: "Hassfest" steps: - name: "Checkout the repository" - uses: actions/checkout@v2 + uses: actions/checkout@0717577d45739eb3c851188b29f50ed6c0b2194e # v2 - name: "Validate Hassfest" - uses: home-assistant/actions/hassfest@master + uses: home-assistant/actions/hassfest@a7c616ce81ccda50150bf1595786c71b1883fabb # master validate-hacs: runs-on: ubuntu-latest name: "HACS" steps: - name: "Checkout the repository" - uses: actions/checkout@v2 + uses: actions/checkout@0717577d45739eb3c851188b29f50ed6c0b2194e # v2 - name: "Validate HACS" - uses: hacs/action@main + uses: hacs/action@1ebf01c408f29afcb6406bd431bc98fd8cbb15aa # main with: category: integration ignore: brands diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index a4761d5..730650b 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,12 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-09 — Pin GitHub Actions to commit SHAs + +- **Author:** [@mikopp](https://github.com/mikopp) +- Pinned `actions/checkout`, `psf/black`, `home-assistant/actions/hassfest`, and `hacs/action` in the CI/release/validate workflows to their current commit SHA (each ref's own comment preserved), and added `.github/dependabot.yml` to bump those SHAs weekly. Reduces exposure to a compromised or rewritten tag/branch on any of these actions. + + ### 2026-09-03 — Document translations startup race limitation - **Author:** [@litinoveweedle](https://github.com/litinoveweedle) From 9b11f7d6c4eccd76dbe57bc7c5f8e5972f3cd655 Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 20:48:06 +0000 Subject: [PATCH 05/14] Add integration test suite running against a real Home Assistant Boots a real Home Assistant container (via the HomeAssistant-Test-Harness pytest plugin) and runs a climate_template scenario suite against it over the REST/WebSocket API: - airflow: temperature/humidity control, dynamic min/max/step templates - presets: preset-profile boiler setup with independent heating circuits - e2m: self-attribute-read case (state written before the action script that reads it back runs) - mode_init: a static-only / template-only / static+template matrix for preset_mode, fan_mode, and swing_mode initialization -- this is the regression test for the invalid-default-value bug fixed separately ('Fix invalid default preset/fan/swing mode on entity startup') - roommind: a multi-room setup with per-room overrides Adds .github/workflows/test-integration.yaml, running the suite as a matrix against hacs.json's minimum-supported HA version and the latest stable release, on every push and pull request. Also appends .pytest_cache/ and /.venv/ to .gitignore (existing entries kept as-is). Squashed from the incremental commits that built this suite in mikopp/hass-template-climate, including the intermediate test-only fixes. Split out of litinoveweedle/hass-template-climate#35, which predated this suite. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- .github/workflows/test-integration.yaml | 108 +++++++ .gitignore | 4 + .../climate_template/Changelog.md | 7 + pyproject.toml | 7 + requirements_test.txt | 6 + tests/conftest.py | 109 +++++++ tests/ha_config/.gitignore | 14 + tests/ha_config/configuration.yaml | 29 ++ tests/ha_config/packages/airflow_climate.yaml | 269 ++++++++++++++++ tests/ha_config/packages/e2m_climate.yaml | 47 +++ .../ha_config/packages/mode_init_climate.yaml | 266 ++++++++++++++++ tests/ha_config/packages/presets_climate.yaml | 126 ++++++++ .../ha_config/packages/roommind_climate.yaml | 158 ++++++++++ tests/test_airflow_climate.py | 296 ++++++++++++++++++ tests/test_e2m_climate.py | 41 +++ tests/test_mode_init_climate.py | 254 +++++++++++++++ tests/test_presets_climate.py | 76 +++++ tests/test_roommind_climate.py | 148 +++++++++ 18 files changed, 1965 insertions(+) create mode 100644 .github/workflows/test-integration.yaml create mode 100644 pyproject.toml create mode 100644 requirements_test.txt create mode 100644 tests/conftest.py create mode 100644 tests/ha_config/.gitignore create mode 100644 tests/ha_config/configuration.yaml create mode 100644 tests/ha_config/packages/airflow_climate.yaml create mode 100644 tests/ha_config/packages/e2m_climate.yaml create mode 100644 tests/ha_config/packages/mode_init_climate.yaml create mode 100644 tests/ha_config/packages/presets_climate.yaml create mode 100644 tests/ha_config/packages/roommind_climate.yaml create mode 100644 tests/test_airflow_climate.py create mode 100644 tests/test_e2m_climate.py create mode 100644 tests/test_mode_init_climate.py create mode 100644 tests/test_presets_climate.py create mode 100644 tests/test_roommind_climate.py diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml new file mode 100644 index 0000000..8727e15 --- /dev/null +++ b/.github/workflows/test-integration.yaml @@ -0,0 +1,108 @@ +name: "Integration tests" + +# Boots a real Home Assistant container (via the ha_integration_test_harness pytest +# plugin) and runs the climate_template scenario suite under tests/ against it. +# Mirrors the harness-based approach used in mikopp/homeassistent-config. + +on: + push: + branches: + - "main" + - "feat**" + tags-ignore: + - "**" + pull_request: + workflow_dispatch: + +concurrency: + group: integration-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration: + name: "HA ${{ matrix.ha_label }}" + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # hacs.json "homeassistant" minimum supported version. + - ha_label: "2025.9.0 (min)" + ha_image_tag: "2025.9.0" + # Latest published stable release. + - ha_label: "stable (latest)" + ha_image_tag: "stable" + + env: + # The harness boots HA with this directory mounted as /config. + HOME_ASSISTANT_CONFIG_ROOT: ${{ github.workspace }}/tests/ha_config + + steps: + - name: "Checkout the repository" + uses: actions/checkout@v4 + + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: "Install test dependencies" + run: pip install -r requirements_test.txt + + # The harness's bundled docker-compose always launches the + # homeassistant/home-assistant:stable image. Pull the version under test and + # re-tag it as :stable so the matrix selects the HA version (no env override + # for the image tag exists in the harness compose file). + - name: "Pull Home Assistant image (${{ matrix.ha_image_tag }})" + run: | + docker pull "homeassistant/home-assistant:${{ matrix.ha_image_tag }}" + docker tag "homeassistant/home-assistant:${{ matrix.ha_image_tag }}" \ + "homeassistant/home-assistant:stable" + + # The harness mounts the config root as /config, so the custom integration + # under test must live underneath it. The mount cannot follow a symlink out of + # the tree, so copy the component in (tests/ha_config/custom_components is + # git-ignored). + - name: "Stage the integration under test" + run: | + mkdir -p tests/ha_config/custom_components + cp -r custom_components/climate_template \ + tests/ha_config/custom_components/climate_template + + - name: "Home Assistant config check" + run: | + output=$(docker run --rm \ + -v "${HOME_ASSISTANT_CONFIG_ROOT}:/config" \ + homeassistant/home-assistant:stable \ + python -m homeassistant --config /config --script check_config 2>&1) + echo "$output" + if echo "$output" | grep -qE "Failed config|Invalid config"; then + echo "::error::Home Assistant reported configuration errors (see above)" + exit 1 + fi + + - name: "Run integration tests" + id: run_tests + continue-on-error: true + run: | + set -o pipefail + pytest tests/ -v 2>&1 | tee /tmp/pytest_output.txt + + - name: "Write test results to job summary" + if: always() + run: | + { + echo "## climate_template integration tests — HA ${{ matrix.ha_label }}" + echo "" + if [ -f /tmp/pytest_output.txt ]; then + echo '```' + cat /tmp/pytest_output.txt + echo '```' + else + echo "No pytest output produced (container or setup failure — see step logs)." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: "Fail if tests failed" + if: steps.run_tests.outcome == 'failure' + run: exit 1 diff --git a/.gitignore b/.gitignore index 84bc596..de1aaa3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ __pycache__/ /custom_components/climate_template/icons.json /custom_components/climate_template/translations/*.json !/custom_components/climate_template/translations/en.json + +# Test-only virtualenvs and pytest cache (tests/) +.pytest_cache/ +/.venv/ diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index a4761d5..0c32987 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,13 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-09 — Add integration test suite against a real Home Assistant + +- **Author:** [@mikopp](https://github.com/mikopp) +- Added `tests/`: a pytest suite that boots a real Home Assistant container (via the [`HomeAssistant-Test-Harness`](https://github.com/HeadlessTarry/HomeAssistant-Test-Harness) pytest plugin) and exercises five `climate_template` scenarios end-to-end over its REST/WebSocket API — airflow control, a preset-profile boiler setup, an "E2M" self-attribute-read case, a fan/swing/preset mode-init matrix, and a multi-room ("RoomMind") setup. Added `.github/workflows/test-integration.yaml`, running the suite as a matrix against the `hacs.json` minimum-supported HA version and the latest stable release on every push and pull request. +- The `mode_init` suite reproduces the `preset_mode`/`fan_mode`/`swing_mode` invalid-default-value bug fixed separately in this changelog's "Fix invalid default preset/fan/swing mode on entity startup" entry — it fails against unpatched `climate.py` and passes with that fix applied. + + ### 2026-09-03 — Document translations startup race limitation - **Author:** [@litinoveweedle](https://github.com/litinoveweedle) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ecfbb76 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[tool.pytest.ini_options] +# HOME_ASSISTANT_CONFIG_ROOT must point at tests/ha_config so the harness finds +# configuration.yaml. The test workflow sets it; to run locally: +# export HOME_ASSISTANT_CONFIG_ROOT="$(pwd)/tests/ha_config" +# pytest tests/ -v +addopts = "-v" +testpaths = ["tests"] diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..1f41136 --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,6 @@ +# Test-only dependencies for the integration test suite (tests/). +# Requires Python >= 3.12 and a running Docker daemon. +pyyaml +requests +pytest-github-actions-annotate-failures +git+https://github.com/HeadlessTarry/HomeAssistant-Test-Harness.git diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dbb25b2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,109 @@ +"""Shared fixtures for the climate_template integration test suite. + +The ``ha_integration_test_harness`` plugin provides the session-scoped +``home_assistant`` fixture (a live HA REST/WebSocket client backed by a real +Home Assistant Docker container). These autouse fixtures reset the input helpers +and seed the CI-only stub entities (sensors/binary_sensors/switches that have no +integration inside the container) to known values before every test, so each test +starts from a deterministic baseline. +""" + +import pytest + +from ha_integration_test_harness import HomeAssistant + + +# ── Per-test baseline: input helpers ───────────────────────────────────────── +@pytest.fixture(autouse=True) +def baseline_inputs(home_assistant: HomeAssistant) -> None: + """Reset every input_* helper the climate entities read/write to a known value.""" + ha = home_assistant + + ha.call_action( + "input_boolean", + "turn_off", + {"entity_id": "input_boolean.airflow_cooling_automatic_enabled"}, + ) + + for entity_id, value in { + "input_number.airflow_cooling_target_temperature": 21.5, + "input_number.airflow_cooling_target_temp_low": 20.0, + "input_number.airflow_cooling_target_temp_high": 24.0, + "input_number.airflow_target_humidity": 55, + "input_number.airflow_temp_step": 0.5, + "input_number.airflow_min_temp": 16.0, + "input_number.airflow_max_temp": 28.0, + # Presets package + "input_number.hc1_target_temperature": 21.0, + "input_number.hc1_comfort_setpoint": 22.0, + "input_number.hc1_reduced_setpoint": 18.0, + "input_number.hc1_protection_setpoint": 8.0, + # RoomMind package + "input_number.roommind_override_target": 21.0, + # E2M package + "input_number.e2m_setpoint_raw": 0, + "input_number.e2m_setpoint_temp": 8.0, + }.items(): + ha.call_action( + "input_number", "set_value", {"entity_id": entity_id, "value": value} + ) + + for entity_id, option in { + "input_select.comfoconnect_pro_temperature_profile": "comfort", + "input_select.airflow_fan_mode": "auto", + "input_select.airflow_swing_mode": "off", + "input_select.hc1_operating_mode": "comfort", + "input_select.roommind_override_mode": "auto", + # Mode-init matrix test helpers — seeded to values matching the + # DEFAULT_* constants so static-only entities report their defaults. + "input_select.mode_init_hvac_source": "off", + "input_select.mode_init_fan_source": "low", + "input_select.mode_init_swing_source": "off", + "input_select.mode_init_preset_source": "comfort", + }.items(): + ha.call_action( + "input_select", "select_option", {"entity_id": entity_id, "option": option} + ) + + +# ── Per-test baseline: external stub entities ──────────────────────────────── +@pytest.fixture(autouse=True) +def baseline_states(home_assistant: HomeAssistant, baseline_inputs: None) -> None: + """Seed CI-only stub entities (no backing integration) before each test. + + Runs after ``baseline_inputs`` so template entities that read both helpers and + stubs settle on consistent values. + """ + ha = home_assistant + temp = {"unit_of_measurement": "°C", "device_class": "temperature"} + hum = {"unit_of_measurement": "%", "device_class": "humidity"} + + # Airflow read-only source sensors. + ha.set_state("sensor.airflow_avg_indoor_temp_5min", "22.0", temp) + ha.set_state("sensor.airflow_avg_indoor_humidity_5min", "55.0", hum) + ha.set_state("sensor.airflow_outdoor_temp_5min", "16.0", temp) + ha.set_state("sensor.airflow_outdoor_dew_5min", "8.5", temp) + # Airflow hvac_action inputs. + ha.set_state("binary_sensor.airflow_free_cooling_available", "off", {}) + ha.set_state("binary_sensor.airflow_humidity_flush_needed", "off", {}) + ha.set_state("switch.comfoconnect_pro_boost", "off", {}) + + # Presets package. + ha.set_state("sensor.hc1_room_temperature", "20.5", temp) + + # RoomMind package. + ha.set_state( + "sensor.temperatur_luftfeuchtigkeit_badezimmer_temperature", "23.4", temp + ) + ha.set_state("sensor.temperatur_luftfeuchtigkeit_badezimmer_humidity", "48", hum) + ha.set_state("sensor.roommind_badezimmer_target_temp", "22.0", temp) + ha.set_state("sensor.roommind_badezimmer_mode", "heating", {}) + # Local RoomMind device — carries the min/max temp bounds the template reads, + # and its mere presence (state != 'unavailable') gates availability_template. + ha.set_state( + "climate.fussbodenheizung_badezimmer_local", + "heat", + {"min_temp": 5, "max_temp": 30}, + ) + ha.set_state("binary_sensor.fenster_badezimmer_contact", "off", {}) + ha.set_state("switch.roommind_badezimmer_cover_auto", "on", {}) diff --git a/tests/ha_config/.gitignore b/tests/ha_config/.gitignore new file mode 100644 index 0000000..cc6b336 --- /dev/null +++ b/tests/ha_config/.gitignore @@ -0,0 +1,14 @@ +# The integration under test is copied in by the test workflow (and when running +# locally) — it is not committed here. +/custom_components/ + +# Home Assistant runtime artefacts created when the container boots. +/.storage/ +/home-assistant.log* +/home-assistant_v2.db* +/.HA_VERSION +/automations.yaml +/deps/ +/tts/ +/.cloud/ +__pycache__/ diff --git a/tests/ha_config/configuration.yaml b/tests/ha_config/configuration.yaml new file mode 100644 index 0000000..ba216bc --- /dev/null +++ b/tests/ha_config/configuration.yaml @@ -0,0 +1,29 @@ +# Minimal Home Assistant configuration used only by the integration test suite. +# +# The ha_integration_test_harness pytest plugin boots a real Home Assistant +# container with this directory mounted as /config (see HOME_ASSISTANT_CONFIG_ROOT +# in .github/workflows/test-integration.yaml). The climate_template custom +# integration under test is copied into ./custom_components/ by the workflow +# before the container starts. +# +# default_config provides the REST/WebSocket APIs, onboarding endpoints and the +# input_* helper integrations the harness and the test packages rely on. The +# climate_template platform pulls in the `template` integration automatically via +# its manifest "dependencies", so it does not need to be listed here. + +default_config: + +homeassistant: + # Deterministic location/timezone so the sun integration and the harness + # time_machine fixture behave consistently across CI runs. + latitude: 48.3069 + longitude: 14.2858 + elevation: 266 + time_zone: Europe/Vienna + # Each climate_template test scenario lives in its own package file. + packages: !include_dir_named packages + +logger: + default: warning + logs: + custom_components.climate_template: debug diff --git a/tests/ha_config/packages/airflow_climate.yaml b/tests/ha_config/packages/airflow_climate.yaml new file mode 100644 index 0000000..20e8fb1 --- /dev/null +++ b/tests/ha_config/packages/airflow_climate.yaml @@ -0,0 +1,269 @@ +# ── Airflow climate test package ───────────────────────────────────────────── +# +# Based on the "Airflow Climate" entity from the homeassistent-config repository +# (packages/airflow_cooling.yaml). The surrounding automation, filter sensors and +# dew-point/bypass math have been stripped — only the climate_template entity and +# the helpers it reads/writes remain. +# +# The entity has then been EXTENDED so that this single package exercises every +# configurable option, getter template and setter/action of the climate_template +# platform. Crucially, every action writes to a real input_* helper defined below +# (rather than the absent ComfoConnect integration used in production), so each +# setter produces an observable state change the tests can assert end-to-end. +# +# Read-only source sensors (sensor.airflow_*_5min, the free-cooling / flush +# binary_sensors and the boost switch) have no integration in CI; tests/conftest.py +# seeds them via the REST API before each test. + +input_boolean: + airflow_cooling_automatic_enabled: + name: Airflow automatic enabled + +input_number: + airflow_cooling_target_temperature: + name: Airflow target temperature + min: 18 + max: 26 + step: 0.5 + unit_of_measurement: "°C" + airflow_cooling_target_temp_low: + name: Airflow target temperature low + min: 18 + max: 26 + step: 0.5 + unit_of_measurement: "°C" + airflow_cooling_target_temp_high: + name: Airflow target temperature high + min: 18 + max: 26 + step: 0.5 + unit_of_measurement: "°C" + airflow_target_humidity: + name: Airflow target humidity + min: 30 + max: 70 + step: 1 + unit_of_measurement: "%" + # Backs the dynamic temp_step / min_temp / max_temp templates. + airflow_temp_step: + name: Airflow temperature step + min: 0.1 + max: 1 + step: 0.1 + airflow_min_temp: + name: Airflow minimum temperature + min: 7 + max: 18 + step: 0.5 + unit_of_measurement: "°C" + airflow_max_temp: + name: Airflow maximum temperature + min: 26 + max: 35 + step: 0.5 + unit_of_measurement: "°C" + +input_select: + # Replaces select.comfoconnect_pro_temperature_profile (absent in CI) with a real + # helper so set_hvac_mode writes are observable. + comfoconnect_pro_temperature_profile: + name: ComfoConnect temperature profile + options: + - warm + - comfort + - cool + airflow_fan_mode: + name: Airflow fan mode + options: + - auto + - low + - medium + - high + airflow_swing_mode: + name: Airflow swing mode + options: + - "off" + - vertical + - horizontal + - both + +climate: + # ── Primary entity: full getter/setter/action coverage ──────────────────── + - platform: climate_template + name: Airflow Climate + unique_id: airflow_climate + + # Common template-entity options. + icon_template: >- + {% if is_state('input_boolean.airflow_cooling_automatic_enabled', 'on') %} + mdi:fan-auto + {% else %} + mdi:fan + {% endif %} + availability_template: >- + {{ states('sensor.airflow_avg_indoor_temp_5min') not in ['unknown', 'unavailable'] }} + attributes: + outdoor_dew: "{{ states('sensor.airflow_outdoor_dew_5min') | float(none) }}" + outdoor_temp: "{{ states('sensor.airflow_outdoor_temp_5min') | float(none) }}" + free_cooling_available: "{{ is_state('binary_sensor.airflow_free_cooling_available', 'on') }}" + + # ── Getters ────────────────────────────────────────────────────────────── + current_temperature_template: "{{ states('sensor.airflow_avg_indoor_temp_5min') | float(20) }}" + current_humidity_template: "{{ states('sensor.airflow_avg_indoor_humidity_5min') | float(50) }}" + target_temperature_template: "{{ states('input_number.airflow_cooling_target_temperature') | float }}" + target_temperature_low_template: "{{ states('input_number.airflow_cooling_target_temp_low') | float }}" + target_temperature_high_template: "{{ states('input_number.airflow_cooling_target_temp_high') | float }}" + target_humidity_template: "{{ states('input_number.airflow_target_humidity') | float }}" + min_temp_template: "{{ states('input_number.airflow_min_temp') | float(18) }}" + max_temp_template: "{{ states('input_number.airflow_max_temp') | float(26) }}" + temp_step_template: "{{ states('input_number.airflow_temp_step') | float(0.5) }}" + fan_mode_template: "{{ states('input_select.airflow_fan_mode') }}" + swing_mode_template: "{{ states('input_select.airflow_swing_mode') }}" + hvac_mode_template: >- + {% if is_state('input_boolean.airflow_cooling_automatic_enabled', 'on') %} + auto + {% elif is_state('input_select.comfoconnect_pro_temperature_profile', 'warm') %} + heat + {% elif is_state('input_select.comfoconnect_pro_temperature_profile', 'comfort') %} + heat_cool + {% elif is_state('input_select.comfoconnect_pro_temperature_profile', 'cool') %} + cool + {% else %} + off + {% endif %} + hvac_action_template: >- + {% set profile = states('input_select.comfoconnect_pro_temperature_profile') %} + {% set free_cool = is_state('binary_sensor.airflow_free_cooling_available', 'on') %} + {% set boost_on = is_state('switch.comfoconnect_pro_boost', 'on') %} + {% set flush_needed = is_state('binary_sensor.airflow_humidity_flush_needed', 'on') %} + {% if free_cool and boost_on %} + drying + {% elif profile == 'cool' and flush_needed %} + drying + {% elif profile == 'cool' %} + cooling + {% elif profile == 'warm' %} + heating + {% elif profile == 'comfort' %} + fan + {% else %} + idle + {% endif %} + + # ── Static bounds / behaviour flags ─────────────────────────────────────── + min_temp: 18 + max_temp: 26 + min_humidity: 30 + max_humidity: 70 + temp_step: 0.5 + precision: 0.1 + mode_action: queued + max_action: 3 + + # ── Mode lists ──────────────────────────────────────────────────────────── + hvac_modes: + - "off" + - "heat" + - "heat_cool" + - "cool" + - "auto" + fan_modes: + - auto + - low + - medium + - high + swing_modes: + - "off" + - vertical + - horizontal + - both + + # ── Actions (each writes to an observable helper) ───────────────────────── + set_temperature: + - choose: + # Single-setpoint modes (heat/cool/auto) send `temperature`. + - conditions: "{{ temperature is defined }}" + sequence: + - action: input_number.set_value + target: + entity_id: input_number.airflow_cooling_target_temperature + data: + value: "{{ temperature }}" + # heat_cool sends a low/high range. + - conditions: "{{ target_temp_low is defined and target_temp_high is defined }}" + sequence: + - action: input_number.set_value + target: + entity_id: input_number.airflow_cooling_target_temp_low + data: + value: "{{ target_temp_low }}" + - action: input_number.set_value + target: + entity_id: input_number.airflow_cooling_target_temp_high + data: + value: "{{ target_temp_high }}" + set_humidity: + - action: input_number.set_value + target: + entity_id: input_number.airflow_target_humidity + data: + value: "{{ humidity }}" + set_fan_mode: + - action: input_select.select_option + target: + entity_id: input_select.airflow_fan_mode + data: + option: "{{ fan_mode }}" + set_swing_mode: + - action: input_select.select_option + target: + entity_id: input_select.airflow_swing_mode + data: + option: "{{ swing_mode }}" + set_hvac_mode: + - choose: + - conditions: "{{ hvac_mode == 'auto' }}" + sequence: + - action: input_boolean.turn_on + target: + entity_id: input_boolean.airflow_cooling_automatic_enabled + - conditions: "{{ hvac_mode == 'off' }}" + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.airflow_cooling_automatic_enabled + - action: input_select.select_option + target: + entity_id: input_select.comfoconnect_pro_temperature_profile + data: + option: comfort + - conditions: "{{ hvac_mode == 'heat' }}" + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.airflow_cooling_automatic_enabled + - action: input_select.select_option + target: + entity_id: input_select.comfoconnect_pro_temperature_profile + data: + option: warm + - conditions: "{{ hvac_mode == 'heat_cool' }}" + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.airflow_cooling_automatic_enabled + - action: input_select.select_option + target: + entity_id: input_select.comfoconnect_pro_temperature_profile + data: + option: comfort + - conditions: "{{ hvac_mode == 'cool' }}" + sequence: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.airflow_cooling_automatic_enabled + - action: input_select.select_option + target: + entity_id: input_select.comfoconnect_pro_temperature_profile + data: + option: cool diff --git a/tests/ha_config/packages/e2m_climate.yaml b/tests/ha_config/packages/e2m_climate.yaml new file mode 100644 index 0000000..3077c04 --- /dev/null +++ b/tests/ha_config/packages/e2m_climate.yaml @@ -0,0 +1,47 @@ +# ── E2M thermostat test package ─────────────────────────────────────────────── +# +# Reproduces the set_temperature action pattern from real-world E2M Fußbodenheizung +# entities, where the action script reads the entity's OWN freshly-set temperature +# attribute (state_attr(self, 'temperature')) rather than the {{ temperature }} +# script variable. This tests the integration's ordering contract: +# _async_set_attribute commits the attribute (climate.py:1625) and calls +# async_write_ha_state() (climate.py:1650) BEFORE running the action script +# (climate.py:1652). If that order were reversed, the action would derive +# values from the stale default rather than the requested temperature. + +input_number: + e2m_setpoint_raw: + name: E2M setpoint raw + min: 0 + max: 9999 + step: 1 + e2m_setpoint_temp: + name: E2M setpoint temp + min: 0 + max: 50 + step: 0.1 + unit_of_measurement: "°C" + +climate: + - platform: climate_template + name: "E2M Test Template" + unique_id: e2m_test_template + hvac_modes: + - "heat" + - "off" + min_temp: 8 + max_temp: 30 + temp_step: 0.5 + precision: 0.1 + # No target_temperature_template: temperature is driven by set_temperature calls only + set_temperature: + - action: input_number.set_value + target: + entity_id: input_number.e2m_setpoint_raw + data: + value: "{{ ((state_attr('climate.e2m_test_template', 'temperature')|float(0)) * 6.375)|round|int }}" + - action: input_number.set_value + target: + entity_id: input_number.e2m_setpoint_temp + data: + value: "{{ state_attr('climate.e2m_test_template', 'temperature')|float() }}" diff --git a/tests/ha_config/packages/mode_init_climate.yaml b/tests/ha_config/packages/mode_init_climate.yaml new file mode 100644 index 0000000..b276f7d --- /dev/null +++ b/tests/ha_config/packages/mode_init_climate.yaml @@ -0,0 +1,266 @@ +# ── Mode initialisation test package ───────────────────────────────────────── +# +# Covers the three-way configuration matrix for each climate mode property +# (hvac_mode, fan_mode, swing_mode, preset_mode): +# +# Scenario 1 — static only: mode list declared, no template. +# 1a. default IS in the modes list → entity reports the hardcoded default. +# 1b. default NOT in the modes list → entity reconciles to None (the fix +# for "attribute returned invalid value: 'comfort'" and equivalents). +# +# Scenario 2 — template only: a template drives the current mode value. +# +# Scenario 3 — static + template: both present; entity initialises from the +# static default then the template overrides it once it evaluates. +# +# Every entity targets input_select helpers so all writes are observable. +# Seeded by tests/conftest.py baseline_inputs to deterministic values before +# each test. + +input_select: + mode_init_hvac_source: + name: Mode init hvac source + options: + - "off" + - heat + - auto + + mode_init_fan_source: + name: Mode init fan source + options: + - high + - medium + - low + + mode_init_swing_source: + name: Mode init swing source + options: + - "off" + - horizontal + - vertical + + mode_init_preset_source: + name: Mode init preset source + options: + - eco + - comfort + - boost + +climate: + + # ── hvac_mode ─────────────────────────────────────────────────────────────── + # DEFAULT_HVAC_MODE = HVACMode.OFF = "off" + + # 1a. Static only — "off" IS in hvac_modes. + - platform: climate_template + name: "Mode Init HvacMode Static Valid" + unique_id: mode_init_hvacmode_static_valid + hvac_modes: ["off", heat, auto] + # No hvac_mode_template → initial hvac_mode: "off" + + # (No "static invalid" hvac_mode entity: DEFAULT_HVAC_MODE is OFF, so omitting + # OFF is the only way to make the default invalid — but HA rejects such an + # entity because the TURN_ON/TURN_OFF features are only enabled when OFF is + # configured. hvac_mode is the entity state and cannot be None.) + + # 2. Template only. + - platform: climate_template + name: "Mode Init HvacMode Template" + unique_id: mode_init_hvacmode_template + hvac_modes: ["off", heat, auto] + hvac_mode_template: "{{ states('input_select.mode_init_hvac_source') }}" + set_hvac_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_hvac_source + data: + option: "{{ hvac_mode }}" + + # 3. Static + template — "off" is a valid static default; template overrides it. + - platform: climate_template + name: "Mode Init HvacMode Static And Template" + unique_id: mode_init_hvacmode_static_and_template + hvac_modes: ["off", heat, auto] + hvac_mode_template: "{{ states('input_select.mode_init_hvac_source') }}" + set_hvac_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_hvac_source + data: + option: "{{ hvac_mode }}" + + # ── fan_mode ──────────────────────────────────────────────────────────────── + # DEFAULT_FAN_MODE = FAN_LOW = "low" + # The FAN_MODE feature flag requires either set_fan_mode or fan_mode_template; + # without one of those the platform clears the modes list and disables the + # feature. All static-only entities therefore include a set_fan_mode action. + + # 1a. Static only — "low" IS in fan_modes. + - platform: climate_template + name: "Mode Init FanMode Static Valid" + unique_id: mode_init_fanmode_static_valid + hvac_modes: ["off", heat] + fan_modes: [high, medium, low] + set_fan_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_fan_source + data: + option: "{{ fan_mode }}" + + # 1b. Static only — "low" NOT in fan_modes. + - platform: climate_template + name: "Mode Init FanMode Static Invalid" + unique_id: mode_init_fanmode_static_invalid + hvac_modes: ["off", heat] + fan_modes: [high, medium] + set_fan_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_fan_source + data: + option: "{{ fan_mode }}" + + # 2. Template only. + - platform: climate_template + name: "Mode Init FanMode Template" + unique_id: mode_init_fanmode_template + hvac_modes: ["off", heat] + fan_modes: [high, medium, low] + fan_mode_template: "{{ states('input_select.mode_init_fan_source') }}" + set_fan_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_fan_source + data: + option: "{{ fan_mode }}" + + # 3. Static + template — "low" is a valid static default; template overrides it. + - platform: climate_template + name: "Mode Init FanMode Static And Template" + unique_id: mode_init_fanmode_static_and_template + hvac_modes: ["off", heat] + fan_modes: [high, medium, low] + fan_mode_template: "{{ states('input_select.mode_init_fan_source') }}" + set_fan_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_fan_source + data: + option: "{{ fan_mode }}" + + # ── swing_mode ────────────────────────────────────────────────────────────── + # DEFAULT_SWING_MODE = SWING_OFF = "off" + # Same feature-flag requirement as fan_mode. + + # 1a. Static only — "off" IS in swing_modes. + - platform: climate_template + name: "Mode Init SwingMode Static Valid" + unique_id: mode_init_swingmode_static_valid + hvac_modes: ["off", heat] + swing_modes: ["off", horizontal, vertical] + set_swing_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_swing_source + data: + option: "{{ swing_mode }}" + + # 1b. Static only — "off" NOT in swing_modes. + - platform: climate_template + name: "Mode Init SwingMode Static Invalid" + unique_id: mode_init_swingmode_static_invalid + hvac_modes: ["off", heat] + swing_modes: [horizontal, vertical] + set_swing_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_swing_source + data: + option: "{{ swing_mode }}" + + # 2. Template only. + - platform: climate_template + name: "Mode Init SwingMode Template" + unique_id: mode_init_swingmode_template + hvac_modes: ["off", heat] + swing_modes: ["off", horizontal, vertical] + swing_mode_template: "{{ states('input_select.mode_init_swing_source') }}" + set_swing_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_swing_source + data: + option: "{{ swing_mode }}" + + # 3. Static + template — "off" is a valid static default; template overrides it. + - platform: climate_template + name: "Mode Init SwingMode Static And Template" + unique_id: mode_init_swingmode_static_and_template + hvac_modes: ["off", heat] + swing_modes: ["off", horizontal, vertical] + swing_mode_template: "{{ states('input_select.mode_init_swing_source') }}" + set_swing_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_swing_source + data: + option: "{{ swing_mode }}" + + # ── preset_mode ───────────────────────────────────────────────────────────── + # DEFAULT_PRESET_MODE = PRESET_COMFORT = "comfort" + # Same feature-flag requirement as fan_mode / swing_mode. + + # 1a. Static only — "comfort" IS in preset_modes. + - platform: climate_template + name: "Mode Init PresetMode Static Valid" + unique_id: mode_init_presetmode_static_valid + hvac_modes: ["off", heat] + preset_modes: [eco, comfort, boost] + set_preset_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_preset_source + data: + option: "{{ preset_mode }}" + + # 1b. Static only — "comfort" NOT in preset_modes. + - platform: climate_template + name: "Mode Init PresetMode Static Invalid" + unique_id: mode_init_presetmode_static_invalid + hvac_modes: ["off", heat] + preset_modes: [eco, away, boost] + set_preset_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_preset_source + data: + option: "{{ preset_mode }}" + + # 2. Template only. + - platform: climate_template + name: "Mode Init PresetMode Template" + unique_id: mode_init_presetmode_template + hvac_modes: ["off", heat] + preset_modes: [eco, comfort, boost] + preset_mode_template: "{{ states('input_select.mode_init_preset_source') }}" + set_preset_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_preset_source + data: + option: "{{ preset_mode }}" + + # 3. Static + template — "comfort" is a valid static default; template overrides it. + - platform: climate_template + name: "Mode Init PresetMode Static And Template" + unique_id: mode_init_presetmode_static_and_template + hvac_modes: ["off", heat] + preset_modes: [eco, comfort, boost] + preset_mode_template: "{{ states('input_select.mode_init_preset_source') }}" + set_preset_mode: + - action: input_select.select_option + target: + entity_id: input_select.mode_init_preset_source + data: + option: "{{ preset_mode }}" diff --git a/tests/ha_config/packages/presets_climate.yaml b/tests/ha_config/packages/presets_climate.yaml new file mode 100644 index 0000000..61eaf59 --- /dev/null +++ b/tests/ha_config/packages/presets_climate.yaml @@ -0,0 +1,126 @@ +# ── Presets climate test package ───────────────────────────────────────────── +# +# Covers the climate_template preset feature set, which the airflow entity does +# not use: preset_modes, preset_mode_template, set_preset_mode, and the +# presets_features / presets_template / set_presets trio (the "Heating Circuit" +# example from the README). +# +# presets_features = 35 = EDITABLE(1) + PRESERVED(2) + TARGET_TEMPERATURE(32): +# - selecting a preset applies that preset's target_temperature, and +# - editing the target temperature while a preset is active writes the new value +# back through set_presets. +# Every value is backed by a real input_* helper so the round-trips are observable. + +input_select: + hc1_operating_mode: + name: HC1 operating mode + options: + - automatic + - comfort + - reduced + - protection + +input_number: + hc1_target_temperature: + name: HC1 live target temperature + min: 10 + max: 30 + step: 0.5 + unit_of_measurement: "°C" + hc1_comfort_setpoint: + name: HC1 comfort setpoint + min: 10 + max: 30 + step: 0.5 + unit_of_measurement: "°C" + hc1_reduced_setpoint: + name: HC1 reduced setpoint + min: 10 + max: 30 + step: 0.5 + unit_of_measurement: "°C" + hc1_protection_setpoint: + name: HC1 protection setpoint + min: 5 + max: 30 + step: 0.5 + unit_of_measurement: "°C" + +climate: + - platform: climate_template + name: Heating Circuit 1 + unique_id: climate_template_heating_hc1 + mode_action: queued + max_action: 3 + + hvac_modes: + - "heat" + hvac_mode_template: "heat" + + preset_modes: + - "automatic" + - "comfort" + - "reduced" + - "protection" + presets_features: 35 + + min_temp: 10 + max_temp: 30 + temp_step: 0.5 + + current_temperature_template: "{{ states('sensor.hc1_room_temperature') | float(20) }}" + target_temperature_template: "{{ states('input_number.hc1_target_temperature') | float }}" + preset_mode_template: "{{ states('input_select.hc1_operating_mode') }}" + presets_template: >- + {{ { + 'automatic': { 'target_temperature': states('input_number.hc1_comfort_setpoint') | float }, + 'comfort': { 'target_temperature': states('input_number.hc1_comfort_setpoint') | float }, + 'reduced': { 'target_temperature': states('input_number.hc1_reduced_setpoint') | float }, + 'protection': { 'target_temperature': states('input_number.hc1_protection_setpoint') | float } + } }} + + set_temperature: + - action: input_number.set_value + target: + entity_id: input_number.hc1_target_temperature + data: + value: "{{ temperature }}" + + set_preset_mode: + - action: input_select.select_option + target: + entity_id: input_select.hc1_operating_mode + data: + option: "{{ preset_mode }}" + + # Fired when a preset attribute is edited while EDITABLE is enabled. `changed` + # holds only the presets whose values changed; write each new comfort/reduced/ + # protection target back to its backing helper. + set_presets: + - if: + - condition: template + value_template: "{{ 'comfort' in changed and 'target_temperature' in changed.comfort }}" + then: + - action: input_number.set_value + target: + entity_id: input_number.hc1_comfort_setpoint + data: + value: "{{ changed.comfort.target_temperature }}" + - if: + - condition: template + value_template: "{{ 'reduced' in changed and 'target_temperature' in changed.reduced }}" + then: + - action: input_number.set_value + target: + entity_id: input_number.hc1_reduced_setpoint + data: + value: "{{ changed.reduced.target_temperature }}" + - if: + - condition: template + value_template: "{{ 'protection' in changed and 'target_temperature' in changed.protection }}" + then: + - action: input_number.set_value + target: + entity_id: input_number.hc1_protection_setpoint + data: + value: "{{ changed.protection.target_temperature }}" diff --git a/tests/ha_config/packages/roommind_climate.yaml b/tests/ha_config/packages/roommind_climate.yaml new file mode 100644 index 0000000..acd3d37 --- /dev/null +++ b/tests/ha_config/packages/roommind_climate.yaml @@ -0,0 +1,158 @@ +# ── RoomMind climate test package ──────────────────────────────────────────── +# +# Real-world second test case: the "Fußbodenheizung Badezimmer" climate_template +# entity from jcwillox/hass-template-climate PR #134 (comment 4619227343). It +# exercises the DEPRECATED config aliases (availability_template, min_temp_template / +# max_temp_template), branching hvac_mode_template / hvac_action_template, attributes, +# and chained climate→climate actions. +# +# The config below is kept faithful to the comment so the reported bug reproduces. +# The reported error is: +# +# Entity 'Fußbodenheizung Badezimmer Template' attribute 'preset_mode' returned +# invalid value: 'comfort'. Expected one of: '['Aus', 'Boost 5 min', ...]'. +# +# Root cause (custom_components/climate_template/climate.py): preset_modes is +# declared WITHOUT "comfort" and there is no preset_mode_template, so the entity +# keeps its hardcoded default _attr_preset_mode = DEFAULT_PRESET_MODE = "comfort". +# It therefore reports a preset_mode that is not among its own preset_modes, and +# the restore path (async_added_to_hass) logs the error above. This is independent +# of the set_preset_mode action. See tests/test_roommind_climate.py. +# +# One deliberate adaptation of the comment YAML so the chained actions are +# observable: climate.roommind_badezimmer_override — the target of set_temperature +# and set_hvac_mode — is implemented as a small helper-backed climate_template +# "echo" entity (instead of a RoomMind device absent in CI), so the chained writes +# land in input_number.roommind_override_target / input_select.roommind_override_mode. +# +# Seeded by tests/conftest.py: sensor.temperatur_luftfeuchtigkeit_badezimmer_*, +# sensor.roommind_badezimmer_target_temp, sensor.roommind_badezimmer_mode, +# climate.fussbodenheizung_badezimmer_local (with min_temp/max_temp attributes), +# binary_sensor.fenster_badezimmer_contact, switch.roommind_badezimmer_cover_auto. + +input_select: + roommind_override_mode: + name: RoomMind override mode + options: + - auto + - heat + - "off" + +input_number: + roommind_override_target: + name: RoomMind override target + min: 5 + max: 30 + step: 0.5 + unit_of_measurement: "°C" + +climate: + # ── Observable stub for the RoomMind override device ────────────────────── + - platform: climate_template + name: RoomMind Badezimmer Override + unique_id: roommind_badezimmer_override + hvac_modes: + - "auto" + - "heat" + - "off" + min_temp: 5 + max_temp: 30 + temp_step: 0.5 + hvac_mode_template: "{{ states('input_select.roommind_override_mode') }}" + target_temperature_template: "{{ states('input_number.roommind_override_target') | float }}" + set_temperature: + - action: input_number.set_value + target: + entity_id: input_number.roommind_override_target + data: + value: "{{ temperature }}" + set_hvac_mode: + - action: input_select.select_option + target: + entity_id: input_select.roommind_override_mode + data: + option: "{{ hvac_mode }}" + + # ── The PR #134 entity under test ───────────────────────────────────────── + - platform: climate_template + name: "Fußbodenheizung Badezimmer Template" + unique_id: "fussbodenheizung_badezimmer_template" + attributes: + override_state: >- + {% if states('climate.roommind_badezimmer_override') == 'auto' %} + Manuell + {% else %} + Auto + {% endif %} + window_open: >- + {% if is_state('binary_sensor.fenster_badezimmer_contact', 'on') %} + open + {% else %} + closed + {% endif %} + cover_automatic: >- + {% if is_state('switch.roommind_badezimmer_cover_auto', 'on') %} + on + {% else %} + off + {% endif %} + availability_template: >- + {{ states('climate.fussbodenheizung_badezimmer_local') != 'unavailable' }} + hvac_modes: + - "heat" + - "off" + - "auto" + preset_modes: + - "Aus" + - "Boost 5 min" + - "Boost 10 min" + - "Boost 15 min" + - "Boost 20 min" + - "Boost 25 min" + - "Boost 30 min" + min_temp_template: >- + {{ state_attr('climate.fussbodenheizung_badezimmer_local', 'min_temp') }} + max_temp_template: >- + {{ state_attr('climate.fussbodenheizung_badezimmer_local', 'max_temp') }} + temp_step: 0.5 + precision: 0.1 + current_temperature_template: >- + {{ states('sensor.temperatur_luftfeuchtigkeit_badezimmer_temperature') }} + current_humidity_template: >- + {{ states('sensor.temperatur_luftfeuchtigkeit_badezimmer_humidity') }} + target_temperature_template: >- + {{ states('sensor.roommind_badezimmer_target_temp') }} + set_temperature: + - action: climate.set_temperature + target: + entity_id: climate.roommind_badezimmer_override + data: + temperature: "{{ temperature | float }}" + hvac_mode: "auto" + hvac_mode_template: >- + {% if is_state('climate.roommind_badezimmer_override', 'auto') %} + auto + {% elif is_state('climate.roommind_badezimmer_override', 'off') and is_state('sensor.roommind_badezimmer_mode', 'heating') %} + heat + {% elif is_state('climate.roommind_badezimmer_override', 'off') and is_state('sensor.roommind_badezimmer_mode', 'idle') %} + off + {% endif %} + hvac_action_template: >- + {{ states('sensor.roommind_badezimmer_mode') }} + set_hvac_mode: + - action: climate.set_hvac_mode + target: + entity_id: climate.roommind_badezimmer_override + data: + hvac_mode: "{{ hvac_mode }}" + # Verbatim from PR #134: the original calls climate.set_preset_mode on the + # entity ITSELF. The reported preset_mode='comfort' error is independent of + # this action (it stems from the default preset_mode never being reconciled + # with the configured preset_modes), so this block is left faithful and is + # not exercised by the tests. + set_preset_mode: + - action: climate.set_preset_mode + target: + entity_id: climate.fussbodenheizung_badezimmer_template + data: + preset_mode: "{{ preset_mode }}" diff --git a/tests/test_airflow_climate.py b/tests/test_airflow_climate.py new file mode 100644 index 0000000..c32ace4 --- /dev/null +++ b/tests/test_airflow_climate.py @@ -0,0 +1,296 @@ +"""Integration tests for the Airflow climate_template entity. + +Exercises every getter template, mode list and setter/action of the platform +against a real Home Assistant instance. Actions are asserted by checking the +backing input_* helper actually changed — proving the action script ran — rather +than only the entity's own (optimistically updated) attribute. + +The entity has an ``hvac_mode_template``, so its reported hvac_mode is always +derived from the helpers (the optimistic value set by a service call is +immediately overwritten by the template re-render). Tests therefore assert the +helper writes for set_hvac_mode and the template-derived state that follows. +""" + +import pytest + +from ha_integration_test_harness import HomeAssistant + +CLIMATE = "climate.airflow_climate" + +TEMP_ATTRS = {"unit_of_measurement": "°C", "device_class": "temperature"} +HUM_ATTRS = {"unit_of_measurement": "%", "device_class": "humidity"} + + +def _profile(ha: HomeAssistant, option: str) -> None: + ha.call_action( + "input_select", + "select_option", + { + "entity_id": "input_select.comfoconnect_pro_temperature_profile", + "option": option, + }, + ) + + +def _enable_auto(ha: HomeAssistant) -> None: + ha.call_action( + "input_boolean", + "turn_on", + {"entity_id": "input_boolean.airflow_cooling_automatic_enabled"}, + ) + + +def _approx(value: float): + return ( + lambda s: s not in ("unknown", "unavailable") and abs(float(s) - value) < 1e-6 + ) + + +# ── Getters ────────────────────────────────────────────────────────────────── +def test_current_temperature_and_humidity(home_assistant: HomeAssistant) -> None: + home_assistant.set_state("sensor.airflow_avg_indoor_temp_5min", "23.5", TEMP_ATTRS) + home_assistant.set_state( + "sensor.airflow_avg_indoor_humidity_5min", "60.0", HUM_ATTRS + ) + home_assistant.assert_entity_state( + CLIMATE, + expected_attributes={"current_temperature": 23.5, "current_humidity": 60.0}, + ) + + +def test_target_temperature_getter(home_assistant: HomeAssistant) -> None: + # 'auto' is a single-setpoint mode, so target_temperature is exposed. + _enable_auto(home_assistant) + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_cooling_target_temperature", "value": 23.0}, + ) + home_assistant.assert_entity_state( + CLIMATE, "auto", expected_attributes={"temperature": 23.0} + ) + + +def test_target_temperature_range_getter(home_assistant: HomeAssistant) -> None: + # Baseline (auto off, profile comfort) → heat_cool, which exposes the range. + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_cooling_target_temp_low", "value": 19.5}, + ) + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_cooling_target_temp_high", "value": 25.5}, + ) + home_assistant.assert_entity_state( + CLIMATE, + "heat_cool", + expected_attributes={"target_temp_low": 19.5, "target_temp_high": 25.5}, + ) + + +def test_target_humidity_getter(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_target_humidity", "value": 62}, + ) + home_assistant.assert_entity_state(CLIMATE, expected_attributes={"humidity": 62.0}) + + +def test_min_max_temp_and_step_getters(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_min_temp", "value": 15.0}, + ) + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_max_temp", "value": 27.0}, + ) + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_temp_step", "value": 0.5}, + ) + home_assistant.assert_entity_state( + CLIMATE, + expected_attributes={ + "min_temp": 15.0, + "max_temp": 27.0, + "target_temp_step": 0.5, + }, + ) + + +def test_fan_and_swing_getters(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "input_select", + "select_option", + {"entity_id": "input_select.airflow_fan_mode", "option": "high"}, + ) + home_assistant.call_action( + "input_select", + "select_option", + {"entity_id": "input_select.airflow_swing_mode", "option": "vertical"}, + ) + home_assistant.assert_entity_state( + CLIMATE, expected_attributes={"fan_mode": "high", "swing_mode": "vertical"} + ) + + +def test_custom_attributes(home_assistant: HomeAssistant) -> None: + home_assistant.set_state("sensor.airflow_outdoor_dew_5min", "9.1", TEMP_ATTRS) + home_assistant.set_state("sensor.airflow_outdoor_temp_5min", "15.2", TEMP_ATTRS) + home_assistant.set_state("binary_sensor.airflow_free_cooling_available", "on", {}) + home_assistant.assert_entity_state( + CLIMATE, + expected_attributes={ + "outdoor_dew": 9.1, + "outdoor_temp": 15.2, + "free_cooling_available": True, + }, + ) + + +# ── hvac_mode getter matrix ────────────────────────────────────────────────── +@pytest.mark.parametrize( + "auto, profile, expected", + [ + (True, "comfort", "auto"), + (False, "warm", "heat"), + (False, "comfort", "heat_cool"), + (False, "cool", "cool"), + ], +) +def test_hvac_mode_template( + home_assistant: HomeAssistant, auto, profile, expected +) -> None: + if auto: + _enable_auto(home_assistant) + else: + home_assistant.call_action( + "input_boolean", + "turn_off", + {"entity_id": "input_boolean.airflow_cooling_automatic_enabled"}, + ) + _profile(home_assistant, profile) + home_assistant.assert_entity_state(CLIMATE, expected) + + +# ── hvac_action getter matrix ──────────────────────────────────────────────── +@pytest.mark.parametrize( + "profile, free_cool, boost, flush, expected", + [ + ("cool", "off", "off", "off", "cooling"), + ("warm", "off", "off", "off", "heating"), + ("comfort", "off", "off", "off", "fan"), + ("cool", "off", "off", "on", "drying"), + ("comfort", "on", "on", "off", "drying"), + ], +) +def test_hvac_action_template( + home_assistant, profile, free_cool, boost, flush, expected +) -> None: + _profile(home_assistant, profile) + home_assistant.set_state( + "binary_sensor.airflow_free_cooling_available", free_cool, {} + ) + home_assistant.set_state("switch.comfoconnect_pro_boost", boost, {}) + home_assistant.set_state("binary_sensor.airflow_humidity_flush_needed", flush, {}) + home_assistant.assert_entity_state( + CLIMATE, expected_attributes={"hvac_action": expected} + ) + + +# ── Setters / actions (assert the helper actually changed) ─────────────────── +def test_set_temperature_action(home_assistant: HomeAssistant) -> None: + _enable_auto(home_assistant) # single-setpoint mode + home_assistant.call_action( + "climate", "set_temperature", {"entity_id": CLIMATE, "temperature": 23.0} + ) + home_assistant.assert_entity_state( + "input_number.airflow_cooling_target_temperature", _approx(23.0) + ) + + +def test_set_temperature_range_action(home_assistant: HomeAssistant) -> None: + # Baseline state is heat_cool, which accepts a low/high range. + home_assistant.call_action( + "climate", + "set_temperature", + {"entity_id": CLIMATE, "target_temp_low": 19.0, "target_temp_high": 25.0}, + ) + home_assistant.assert_entity_state( + "input_number.airflow_cooling_target_temp_low", _approx(19.0) + ) + home_assistant.assert_entity_state( + "input_number.airflow_cooling_target_temp_high", _approx(25.0) + ) + + +def test_set_humidity_action(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "climate", "set_humidity", {"entity_id": CLIMATE, "humidity": 48} + ) + home_assistant.assert_entity_state( + "input_number.airflow_target_humidity", _approx(48.0) + ) + + +def test_set_fan_mode_action(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "climate", "set_fan_mode", {"entity_id": CLIMATE, "fan_mode": "low"} + ) + home_assistant.assert_entity_state("input_select.airflow_fan_mode", "low") + + +def test_set_swing_mode_action(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "climate", "set_swing_mode", {"entity_id": CLIMATE, "swing_mode": "both"} + ) + home_assistant.assert_entity_state("input_select.airflow_swing_mode", "both") + + +@pytest.mark.parametrize( + "hvac_mode, expect_auto, expect_profile, expect_state", + [ + ("auto", "on", None, "auto"), + ("heat", "off", "warm", "heat"), + ("cool", "off", "cool", "cool"), + ("heat_cool", "off", "comfort", "heat_cool"), + ("off", "off", "comfort", "heat_cool"), # 'off' maps to the comfort profile + ], +) +def test_set_hvac_mode_action( + home_assistant, hvac_mode, expect_auto, expect_profile, expect_state +) -> None: + # Start from a different state so each write is an observable change. + _enable_auto(home_assistant) + _profile(home_assistant, "warm") + home_assistant.call_action( + "climate", "set_hvac_mode", {"entity_id": CLIMATE, "hvac_mode": hvac_mode} + ) + home_assistant.assert_entity_state( + "input_boolean.airflow_cooling_automatic_enabled", expect_auto + ) + if expect_profile is not None: + home_assistant.assert_entity_state( + "input_select.comfoconnect_pro_temperature_profile", expect_profile + ) + home_assistant.assert_entity_state(CLIMATE, expect_state) + + +def test_turn_off_action(home_assistant: HomeAssistant) -> None: + # turn_off → set_hvac_mode(off): disables automatic and selects the comfort profile. + _enable_auto(home_assistant) + home_assistant.assert_entity_state(CLIMATE, "auto") + home_assistant.call_action("climate", "turn_off", {"entity_id": CLIMATE}) + home_assistant.assert_entity_state( + "input_boolean.airflow_cooling_automatic_enabled", "off" + ) + home_assistant.assert_entity_state( + "input_select.comfoconnect_pro_temperature_profile", "comfort" + ) diff --git a/tests/test_e2m_climate.py b/tests/test_e2m_climate.py new file mode 100644 index 0000000..b7d832c --- /dev/null +++ b/tests/test_e2m_climate.py @@ -0,0 +1,41 @@ +"""Integration test: set_temperature action reads entity's own attribute. + +The E2M Fußbodenheizung pattern: the set_temperature action script references +state_attr('climate.e2m_test_template', 'temperature') — the entity's OWN +freshly-committed temperature attribute — instead of the {{ temperature }} script +variable. It also derives a second value from it (raw setpoint = temp * 6.375). + +This test verifies the integration's ordering contract: the attribute is written +to HA state BEFORE the action script runs, so state_attr(self, 'temperature') +inside the script always sees the new value, not the previous one. +""" + +from ha_integration_test_harness import HomeAssistant + +CLIMATE = "climate.e2m_test_template" + + +def test_set_temperature_derives_from_self_attribute( + home_assistant: HomeAssistant, +) -> None: + """set_temperature writes the attribute to state before the action runs. + + Calling set_temperature(21.5) must: + - update climate.e2m_test_template temperature to 21.5 + - write input_number.e2m_setpoint_temp = 21.5 (from state_attr(self,'temperature')) + - write input_number.e2m_setpoint_raw = 137 (round(21.5 * 6.375)) + + If the action ran BEFORE the state write, state_attr(self,'temperature') would + return the stale default and both helpers would get wrong values. + """ + home_assistant.call_action( + "climate", + "set_temperature", + {"entity_id": CLIMATE, "temperature": 21.5}, + ) + home_assistant.assert_entity_state( + CLIMATE, + expected_attributes={"temperature": 21.5}, + ) + home_assistant.assert_entity_state("input_number.e2m_setpoint_temp", "21.5") + home_assistant.assert_entity_state("input_number.e2m_setpoint_raw", "137.0") diff --git a/tests/test_mode_init_climate.py b/tests/test_mode_init_climate.py new file mode 100644 index 0000000..6bac935 --- /dev/null +++ b/tests/test_mode_init_climate.py @@ -0,0 +1,254 @@ +"""Tests for the static / template / static+template configuration matrix. + +Each climate mode property (hvac_mode, fan_mode, swing_mode, preset_mode) +has a hardcoded DEFAULT_* and an optional *_template. The bug class: when +only the static configuration is used (no template), the default was never +reconciled with the configured modes list, so an entity could initialise +with — and try to restore — a mode value that is not in its own list. + +Three scenarios are exercised for each property: + + 1. Static only (1a + 1b) + 1a. Default IS in the configured modes list → entity reports the default. + 1b. Default NOT in the configured modes list → fan/swing/preset reconcile + to None (null attribute). (hvac_mode has no such case — see the note + above test_hvacmode_template_follows_source.) + + 2. Template only + Entity's current mode tracks the backing input_select in real time. + + 3. Static + template + Entity initialises from the valid static default; once the template + evaluates it takes over as the active driver. + +Entities and helpers are defined in ha_config/packages/mode_init_climate.yaml. +The conftest baseline_inputs fixture resets every helper to its default-aligned +value before each test. +""" + +from ha_integration_test_harness import HomeAssistant + + +def _set_source(ha: HomeAssistant, entity_id: str, option: str) -> None: + ha.call_action( + "input_select", + "select_option", + {"entity_id": entity_id, "option": option}, + ) + + +# ── hvac_mode ────────────────────────────────────────────────────────────────── + + +def test_hvacmode_static_default_valid(home_assistant: HomeAssistant) -> None: + """Static-only: DEFAULT_HVAC_MODE ('off') in hvac_modes → state 'off'.""" + home_assistant.assert_entity_state("climate.mode_init_hvacmode_static_valid", "off") + + +# Note: there is no hvac_mode "static invalid" case. DEFAULT_HVAC_MODE is +# HVACMode.OFF, so the default is only "invalid" when hvac_modes omits OFF — +# and Home Assistant rejects such an entity outright (TURN_ON/TURN_OFF features +# are only enabled when OFF is configured). hvac_mode is the entity state and +# cannot be reconciled to None like the fan/swing/preset attributes below. + + +def test_hvacmode_template_follows_source(home_assistant: HomeAssistant) -> None: + """Template-only: hvac_mode tracks the backing input_select in real time.""" + _set_source(home_assistant, "input_select.mode_init_hvac_source", "heat") + home_assistant.assert_entity_state("climate.mode_init_hvacmode_template", "heat") + _set_source(home_assistant, "input_select.mode_init_hvac_source", "auto") + home_assistant.assert_entity_state("climate.mode_init_hvacmode_template", "auto") + + +def test_hvacmode_static_and_template_init_with_static( + home_assistant: HomeAssistant, +) -> None: + """Static + template: at baseline (source = 'off' = default) entity is in + the valid static-default state.""" + home_assistant.assert_entity_state( + "climate.mode_init_hvacmode_static_and_template", "off" + ) + + +def test_hvacmode_static_and_template_overrides( + home_assistant: HomeAssistant, +) -> None: + """Static + template: template value ('auto') overrides static default ('off').""" + _set_source(home_assistant, "input_select.mode_init_hvac_source", "auto") + home_assistant.assert_entity_state( + "climate.mode_init_hvacmode_static_and_template", "auto" + ) + + +# ── fan_mode ─────────────────────────────────────────────────────────────────── + + +def test_fanmode_static_default_valid(home_assistant: HomeAssistant) -> None: + """Static-only: DEFAULT_FAN_MODE ('low') in fan_modes → entity reports 'low'.""" + home_assistant.assert_entity_state( + "climate.mode_init_fanmode_static_valid", + expected_attributes={"fan_mode": "low"}, + ) + + +def test_fanmode_static_default_invalid(home_assistant: HomeAssistant) -> None: + """Static-only: DEFAULT_FAN_MODE ('low') not in [high, medium] + → reconciled to None.""" + state = home_assistant.get_state("climate.mode_init_fanmode_static_invalid") + assert state is not None, "climate.mode_init_fanmode_static_invalid not found" + fan_mode = state["attributes"].get("fan_mode") + assert ( + fan_mode is None + ), f"Expected fan_mode=None after reconciliation, got {fan_mode!r}" + + +def test_fanmode_template_follows_source(home_assistant: HomeAssistant) -> None: + """Template-only: fan_mode tracks the backing input_select in real time.""" + _set_source(home_assistant, "input_select.mode_init_fan_source", "high") + home_assistant.assert_entity_state( + "climate.mode_init_fanmode_template", + expected_attributes={"fan_mode": "high"}, + ) + _set_source(home_assistant, "input_select.mode_init_fan_source", "medium") + home_assistant.assert_entity_state( + "climate.mode_init_fanmode_template", + expected_attributes={"fan_mode": "medium"}, + ) + + +def test_fanmode_static_and_template_init_with_static( + home_assistant: HomeAssistant, +) -> None: + """Static + template: at baseline (source = 'low' = default) entity is in + the valid static-default state.""" + home_assistant.assert_entity_state( + "climate.mode_init_fanmode_static_and_template", + expected_attributes={"fan_mode": "low"}, + ) + + +def test_fanmode_static_and_template_overrides( + home_assistant: HomeAssistant, +) -> None: + """Static + template: template value ('high') overrides static default ('low').""" + _set_source(home_assistant, "input_select.mode_init_fan_source", "high") + home_assistant.assert_entity_state( + "climate.mode_init_fanmode_static_and_template", + expected_attributes={"fan_mode": "high"}, + ) + + +# ── swing_mode ───────────────────────────────────────────────────────────────── + + +def test_swingmode_static_default_valid(home_assistant: HomeAssistant) -> None: + """Static-only: DEFAULT_SWING_MODE ('off') in swing_modes → reports 'off'.""" + home_assistant.assert_entity_state( + "climate.mode_init_swingmode_static_valid", + expected_attributes={"swing_mode": "off"}, + ) + + +def test_swingmode_static_default_invalid(home_assistant: HomeAssistant) -> None: + """Static-only: DEFAULT_SWING_MODE ('off') not in [horizontal, vertical] + → reconciled to None.""" + state = home_assistant.get_state("climate.mode_init_swingmode_static_invalid") + assert state is not None, "climate.mode_init_swingmode_static_invalid not found" + swing_mode = state["attributes"].get("swing_mode") + assert ( + swing_mode is None + ), f"Expected swing_mode=None after reconciliation, got {swing_mode!r}" + + +def test_swingmode_template_follows_source(home_assistant: HomeAssistant) -> None: + """Template-only: swing_mode tracks the backing input_select in real time.""" + _set_source(home_assistant, "input_select.mode_init_swing_source", "horizontal") + home_assistant.assert_entity_state( + "climate.mode_init_swingmode_template", + expected_attributes={"swing_mode": "horizontal"}, + ) + _set_source(home_assistant, "input_select.mode_init_swing_source", "vertical") + home_assistant.assert_entity_state( + "climate.mode_init_swingmode_template", + expected_attributes={"swing_mode": "vertical"}, + ) + + +def test_swingmode_static_and_template_init_with_static( + home_assistant: HomeAssistant, +) -> None: + """Static + template: at baseline (source = 'off' = default) entity is in + the valid static-default state.""" + home_assistant.assert_entity_state( + "climate.mode_init_swingmode_static_and_template", + expected_attributes={"swing_mode": "off"}, + ) + + +def test_swingmode_static_and_template_overrides( + home_assistant: HomeAssistant, +) -> None: + """Static + template: template value overrides static default ('off').""" + _set_source(home_assistant, "input_select.mode_init_swing_source", "horizontal") + home_assistant.assert_entity_state( + "climate.mode_init_swingmode_static_and_template", + expected_attributes={"swing_mode": "horizontal"}, + ) + + +# ── preset_mode ──────────────────────────────────────────────────────────────── + + +def test_presetmode_static_default_valid(home_assistant: HomeAssistant) -> None: + """Static-only: DEFAULT_PRESET_MODE ('comfort') in preset_modes → reports 'comfort'.""" + home_assistant.assert_entity_state( + "climate.mode_init_presetmode_static_valid", + expected_attributes={"preset_mode": "comfort"}, + ) + + +def test_presetmode_static_default_invalid(home_assistant: HomeAssistant) -> None: + """Static-only: DEFAULT_PRESET_MODE ('comfort') not in [eco, away, boost] + → reconciled to None.""" + state = home_assistant.get_state("climate.mode_init_presetmode_static_invalid") + assert state is not None, "climate.mode_init_presetmode_static_invalid not found" + preset_mode = state["attributes"].get("preset_mode") + assert ( + preset_mode is None + ), f"Expected preset_mode=None after reconciliation, got {preset_mode!r}" + + +def test_presetmode_template_follows_source(home_assistant: HomeAssistant) -> None: + """Template-only: preset_mode tracks the backing input_select in real time.""" + _set_source(home_assistant, "input_select.mode_init_preset_source", "eco") + home_assistant.assert_entity_state( + "climate.mode_init_presetmode_template", + expected_attributes={"preset_mode": "eco"}, + ) + _set_source(home_assistant, "input_select.mode_init_preset_source", "boost") + home_assistant.assert_entity_state( + "climate.mode_init_presetmode_template", + expected_attributes={"preset_mode": "boost"}, + ) + + +def test_presetmode_static_and_template_init_with_static( + home_assistant: HomeAssistant, +) -> None: + """Static + template: at baseline (source = 'comfort' = default) entity is + in the valid static-default state.""" + home_assistant.assert_entity_state( + "climate.mode_init_presetmode_static_and_template", + expected_attributes={"preset_mode": "comfort"}, + ) + + +def test_presetmode_static_and_template_overrides( + home_assistant: HomeAssistant, +) -> None: + """Static + template: template value overrides static default ('comfort').""" + _set_source(home_assistant, "input_select.mode_init_preset_source", "eco") + home_assistant.assert_entity_state( + "climate.mode_init_presetmode_static_and_template", + expected_attributes={"preset_mode": "eco"}, + ) diff --git a/tests/test_presets_climate.py b/tests/test_presets_climate.py new file mode 100644 index 0000000..2917b3f --- /dev/null +++ b/tests/test_presets_climate.py @@ -0,0 +1,76 @@ +"""Integration tests for the climate_template preset feature set. + +Covers preset_modes, preset_mode_template, set_preset_mode, and the +presets_features / presets_template / set_presets trio on the Heating Circuit 1 +entity (presets_features=35 → editable + preserved + target_temperature). +""" + +from ha_integration_test_harness import HomeAssistant + +CLIMATE = "climate.heating_circuit_1" + + +def _approx(value: float): + return ( + lambda s: s not in ("unknown", "unavailable") and abs(float(s) - value) < 1e-6 + ) + + +def test_preset_mode_getter(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "input_select", + "select_option", + {"entity_id": "input_select.hc1_operating_mode", "option": "reduced"}, + ) + home_assistant.assert_entity_state( + CLIMATE, expected_attributes={"preset_mode": "reduced"} + ) + + +def test_presets_attribute_exposed(home_assistant: HomeAssistant) -> None: + # presets_template feeds the `presets` state attribute with each mode's values. + home_assistant.assert_entity_state( + CLIMATE, + expected_attributes={ + "preset_modes": ["automatic", "comfort", "reduced", "protection"], + "presets": lambda p: isinstance(p, dict) + and p.get("reduced", {}).get("target_temperature") == 18.0 + and p.get("comfort", {}).get("target_temperature") == 22.0, + }, + ) + + +def test_set_preset_mode_applies_target_temperature( + home_assistant: HomeAssistant, +) -> None: + # Selecting 'reduced' (18°C) must both flip the operating-mode helper and apply + # the preset's target_temperature through the set_temperature chain. + home_assistant.call_action( + "climate", "set_preset_mode", {"entity_id": CLIMATE, "preset_mode": "reduced"} + ) + home_assistant.assert_entity_state("input_select.hc1_operating_mode", "reduced") + home_assistant.assert_entity_state( + "input_number.hc1_target_temperature", _approx(18.0) + ) + home_assistant.assert_entity_state( + CLIMATE, expected_attributes={"preset_mode": "reduced", "temperature": 18.0} + ) + + +def test_set_presets_writes_back_edited_setpoint(home_assistant: HomeAssistant) -> None: + # With an editable preset active, changing the target temperature fires + # set_presets, which writes the new value back to the comfort setpoint helper. + home_assistant.call_action( + "input_select", + "select_option", + {"entity_id": "input_select.hc1_operating_mode", "option": "comfort"}, + ) + home_assistant.assert_entity_state( + CLIMATE, expected_attributes={"preset_mode": "comfort"} + ) + home_assistant.call_action( + "climate", "set_temperature", {"entity_id": CLIMATE, "temperature": 23.5} + ) + home_assistant.assert_entity_state( + "input_number.hc1_comfort_setpoint", _approx(23.5) + ) diff --git a/tests/test_roommind_climate.py b/tests/test_roommind_climate.py new file mode 100644 index 0000000..93a0bcc --- /dev/null +++ b/tests/test_roommind_climate.py @@ -0,0 +1,148 @@ +"""Integration tests for the RoomMind "Fußbodenheizung Badezimmer" entity. + +Real-world second scenario from jcwillox/hass-template-climate PR #134. Covers the +deprecated config aliases (availability_template, min_temp_template / +max_temp_template), branching hvac_mode_template / hvac_action_template, custom +attributes, and chained climate→climate set_temperature / set_hvac_mode actions +landing on the observable override echo entity. +""" + +from ha_integration_test_harness import HomeAssistant + +CLIMATE = "climate.fussbodenheizung_badezimmer_template" +OVERRIDE = "climate.roommind_badezimmer_override" + +TEMP_ATTRS = {"unit_of_measurement": "°C", "device_class": "temperature"} + + +def _approx(value: float): + return ( + lambda s: s not in ("unknown", "unavailable") and abs(float(s) - value) < 1e-6 + ) + + +def _override_mode(ha: HomeAssistant, option: str) -> None: + ha.call_action( + "input_select", + "select_option", + {"entity_id": "input_select.roommind_override_mode", "option": option}, + ) + + +def test_getters_and_min_max_templates(home_assistant: HomeAssistant) -> None: + home_assistant.set_state( + "sensor.temperatur_luftfeuchtigkeit_badezimmer_temperature", "24.1", TEMP_ATTRS + ) + home_assistant.set_state( + "sensor.roommind_badezimmer_target_temp", "21.5", TEMP_ATTRS + ) + # min/max come from the seeded local device attributes via the deprecated templates. + home_assistant.assert_entity_state( + CLIMATE, + expected_attributes={ + "current_temperature": 24.1, + "temperature": 21.5, + "min_temp": 5.0, + "max_temp": 30.0, + }, + ) + + +def test_custom_attributes(home_assistant: HomeAssistant) -> None: + home_assistant.set_state("binary_sensor.fenster_badezimmer_contact", "on", {}) + home_assistant.set_state("switch.roommind_badezimmer_cover_auto", "off", {}) + home_assistant.assert_entity_state( + CLIMATE, + expected_attributes={"window_open": "open", "cover_automatic": "off"}, + ) + + +def test_availability_template(home_assistant: HomeAssistant) -> None: + # When the backing local device is unavailable, the template entity is too. + home_assistant.set_state( + "climate.fussbodenheizung_badezimmer_local", "unavailable", {} + ) + home_assistant.assert_entity_state(CLIMATE, "unavailable") + + +def test_hvac_mode_template_auto(home_assistant: HomeAssistant) -> None: + _override_mode(home_assistant, "auto") + home_assistant.assert_entity_state(CLIMATE, "auto") + + +def test_hvac_mode_template_heat(home_assistant: HomeAssistant) -> None: + _override_mode(home_assistant, "off") + home_assistant.set_state("sensor.roommind_badezimmer_mode", "heating", {}) + home_assistant.assert_entity_state( + CLIMATE, "heat", expected_attributes={"hvac_action": "heating"} + ) + + +def test_hvac_mode_template_off(home_assistant: HomeAssistant) -> None: + # Set the idle source state BEFORE switching the override to "off". The + # template entity's hvac_mode_template reads the override's state, while its + # set_hvac_mode action writes back to that same override. If the override + # were flipped to "off" while the mode sensor still read "heating", the + # template would transiently render "heat" and the chained action would + # push the override to "heat", breaking the is_state(override, 'off') + # condition that the "off" branch depends on. Driving the sensor to "idle" + # first lets the template go straight from "auto" to "off". + home_assistant.set_state("sensor.roommind_badezimmer_mode", "idle", {}) + _override_mode(home_assistant, "off") + home_assistant.assert_entity_state( + CLIMATE, "off", expected_attributes={"hvac_action": "idle"} + ) + + +def test_set_temperature_chains_to_override(home_assistant: HomeAssistant) -> None: + # set_temperature on the template calls climate.set_temperature on the override + # (with hvac_mode=auto), which writes the override's backing helper. + home_assistant.call_action( + "climate", "set_temperature", {"entity_id": CLIMATE, "temperature": 22.5} + ) + home_assistant.assert_entity_state( + "input_number.roommind_override_target", _approx(22.5) + ) + home_assistant.assert_entity_state( + OVERRIDE, expected_attributes={"temperature": 22.5} + ) + + +def test_set_hvac_mode_chains_to_override(home_assistant: HomeAssistant) -> None: + home_assistant.call_action( + "climate", "set_hvac_mode", {"entity_id": CLIMATE, "hvac_mode": "heat"} + ) + home_assistant.assert_entity_state("input_select.roommind_override_mode", "heat") + home_assistant.assert_entity_state(OVERRIDE, "heat") + + +def test_preset_mode_default_is_valid(home_assistant: HomeAssistant) -> None: + """Regression guard for the bug reported in PR #134 (comment 4619227343). + + The entity declares:: + + preset_modes: ["Aus", "Boost 5 min", ... , "Boost 30 min"] + + with no preset_mode_template and no way to ever select one of them. The + platform used to initialise ``_attr_preset_mode`` to its hardcoded default + ``DEFAULT_PRESET_MODE = "comfort"`` and never reconcile it with the configured + ``preset_modes``, so the entity reported (and on restart tried to restore) a + preset_mode that is not one of its own preset_modes. Home Assistant logged:: + + Entity 'Fußbodenheizung Badezimmer Template' attribute 'preset_mode' + returned invalid value: 'comfort'. Expected one of: + '['Aus', 'Boost 5 min', ...]'. + + The fix reconciles the default: preset_mode must now be either None (no preset + selected) or one of the declared preset_modes — never an invalid value. + """ + state = home_assistant.get_state(CLIMATE) + assert state is not None, f"{CLIMATE} not found" + attributes = state["attributes"] + preset_modes = attributes.get("preset_modes") + preset_mode = attributes.get("preset_mode") + assert preset_mode is None or preset_mode in preset_modes, ( + f"Entity '{CLIMATE}' reported preset_mode {preset_mode!r}, which is " + f"neither None nor one of its declared preset_modes {preset_modes!r} " + f"(PR #134 default-'comfort' bug)" + ) From 7ea54db6a6212c7ebe8ceb629cd5123cba0aa8c7 Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 21:40:40 +0000 Subject: [PATCH 06/14] Bump CI Python to 3.14 for integration tests ha-integration-test-harness now requires Python >=3.14.2, but the workflow was still setting up 3.12, so pip install failed with: ERROR: Package 'ha-integration-test-harness' requires a different Python: 3.12.14 not in '>=3.14.2' Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VRC8Ceijjcdcks6LPTzA6K --- .github/workflows/test-integration.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 8727e15..734c059 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -44,7 +44,7 @@ jobs: - name: "Set up Python" uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: "3.14" - name: "Install test dependencies" run: pip install -r requirements_test.txt diff --git a/requirements_test.txt b/requirements_test.txt index 1f41136..20dfe7d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,5 +1,5 @@ # Test-only dependencies for the integration test suite (tests/). -# Requires Python >= 3.12 and a running Docker daemon. +# Requires Python >= 3.14.2 (per ha-integration-test-harness) and a running Docker daemon. pyyaml requests pytest-github-actions-annotate-failures From ed294c04aa1e17de36f863e8b32a996ab064b8e1 Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 21:54:17 +0000 Subject: [PATCH 07/14] Fix every entity's name collapsing to 'Template Climate' TemplateClimate.__init__ set self._attr_name from the legacy 'friendly_name' config key, immediately after super().__init__() had already correctly resolved it from the modern 'name' key (TemplateEntity's own __init__ renders CONF_NAME -- static names immediately, templated names via its tracker -- and sets self._attr_name from that). rewrite_legacy_to_modern_config() already rewrites 'friendly_name' to 'name' before the entity is constructed, so config.get(CONF_FRIENDLY_NAME) is always None by the time this line runs -- for both legacy and modern configs. Every entity's name, regardless of its configured 'name', silently collapsed to the hardcoded default "Template Climate". On a fresh install (no entity-registry history yet) this also produces colliding entity_ids: Home Assistant slugs the initial entity_id from the display name, so every climate_template entity in a config lands on climate.template_climate, climate.template_climate_2, and so on, instead of the name/unique_id-derived id a user would expect. Found via mikopp/hass-template-climate's integration test suite: every one of its 60 tests failed with 'Entity climate. not found' when run against this code, because none of the expected entity_ids were ever created. Removing the overriding line restores TemplateEntity's own correct handling; verified no other code in __init__ depends on this line having run (self._attr_name is set by super().__init__() before this point). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- custom_components/climate_template/Changelog.md | 6 ++++++ custom_components/climate_template/climate.py | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index a4761d5..deb12fe 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,12 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-09 — Fix every entity's name collapsing to "Template Climate" + +- **Author:** [@mikopp](https://github.com/mikopp) +- `TemplateClimate.__init__` set `self._attr_name` from the legacy `friendly_name` config key, overriding the name `TemplateEntity`'s base `__init__` had already correctly resolved from `name` (`CONF_NAME`) a few lines earlier. Since `rewrite_legacy_to_modern_config()` already rewrites `friendly_name` to `name` before the entity is constructed, `config.get(CONF_FRIENDLY_NAME)` is always `None` by this point — so every entity, regardless of its configured `name`, silently fell back to the hardcoded default `"Template Climate"`. On a fresh install this also means colliding `entity_id`s (`climate.template_climate`, `climate.template_climate_2`, ...), since Home Assistant slugs the initial `entity_id` from the display name. Removed the overriding line; `TemplateEntity.__init__` already handles both static and templated `name` correctly on its own. + + ### 2026-09-03 — Document translations startup race limitation - **Author:** [@litinoveweedle](https://github.com/litinoveweedle) diff --git a/custom_components/climate_template/climate.py b/custom_components/climate_template/climate.py index 77ea8a7..901cfc3 100644 --- a/custom_components/climate_template/climate.py +++ b/custom_components/climate_template/climate.py @@ -402,7 +402,6 @@ def __init__(self, hass: HomeAssistant, config: ConfigType, unique_id: str | Non super().__init__(hass, config, unique_id) self._config = config - self._attr_name: str = config.get(CONF_FRIENDLY_NAME) or "Template Climate" self._attr_translation_key = derive_translation_key(config) self._attr_supported_features = ClimateEntityFeature(0) self._attr_temperature_unit = hass.config.units.temperature_unit From 7c65758a11cd9d541d4be5119831f000ab3f0d7f Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Wed, 9 Sep 2026 21:54:17 +0000 Subject: [PATCH 08/14] Fix every entity's name collapsing to 'Template Climate' TemplateClimate.__init__ set self._attr_name from the legacy 'friendly_name' config key, immediately after super().__init__() had already correctly resolved it from the modern 'name' key (TemplateEntity's own __init__ renders CONF_NAME -- static names immediately, templated names via its tracker -- and sets self._attr_name from that). rewrite_legacy_to_modern_config() already rewrites 'friendly_name' to 'name' before the entity is constructed, so config.get(CONF_FRIENDLY_NAME) is always None by the time this line runs -- for both legacy and modern configs. Every entity's name, regardless of its configured 'name', silently collapsed to the hardcoded default "Template Climate". On a fresh install (no entity-registry history yet) this also produces colliding entity_ids: Home Assistant slugs the initial entity_id from the display name, so every climate_template entity in a config lands on climate.template_climate, climate.template_climate_2, and so on, instead of the name/unique_id-derived id a user would expect. Found via mikopp/hass-template-climate's integration test suite: every one of its 60 tests failed with 'Entity climate. not found' when run against this code, because none of the expected entity_ids were ever created. Removing the overriding line restores TemplateEntity's own correct handling; verified no other code in __init__ depends on this line having run (self._attr_name is set by super().__init__() before this point). Cherry-picked from feat/fix-name-override, which carries this fix as its own separate PR to litinoveweedle. It is temporarily duplicated here only so this branch's own CI (test-integration.yaml) can run end-to-end; drop this commit from this branch once the fix PR merges upstream, and rebase before opening the integration-tests PR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- custom_components/climate_template/Changelog.md | 7 +++++++ custom_components/climate_template/climate.py | 1 - 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index 0c32987..b1b66b0 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,13 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-09 — Fix every entity's name collapsing to "Template Climate" + +- **Author:** [@mikopp](https://github.com/mikopp) +- `TemplateClimate.__init__` set `self._attr_name` from the legacy `friendly_name` config key, overriding the name `TemplateEntity`'s base `__init__` had already correctly resolved from `name` (`CONF_NAME`) a few lines earlier. Since `rewrite_legacy_to_modern_config()` already rewrites `friendly_name` to `name` before the entity is constructed, `config.get(CONF_FRIENDLY_NAME)` is always `None` by this point — so every entity, regardless of its configured `name`, silently fell back to the hardcoded default `"Template Climate"`. On a fresh install this also means colliding `entity_id`s (`climate.template_climate`, `climate.template_climate_2`, ...), since Home Assistant slugs the initial `entity_id` from the display name. Removed the overriding line; `TemplateEntity.__init__` already handles both static and templated `name` correctly on its own. +- **Note:** this fix is carried on this branch only so the integration test suite below can actually run; it belongs to and is tracked by a separate PR/branch (`feat/fix-name-override`) and should be dropped from this branch once that PR merges upstream. + + ### 2026-09-09 — Add integration test suite against a real Home Assistant - **Author:** [@mikopp](https://github.com/mikopp) diff --git a/custom_components/climate_template/climate.py b/custom_components/climate_template/climate.py index 77ea8a7..901cfc3 100644 --- a/custom_components/climate_template/climate.py +++ b/custom_components/climate_template/climate.py @@ -402,7 +402,6 @@ def __init__(self, hass: HomeAssistant, config: ConfigType, unique_id: str | Non super().__init__(hass, config, unique_id) self._config = config - self._attr_name: str = config.get(CONF_FRIENDLY_NAME) or "Template Climate" self._attr_translation_key = derive_translation_key(config) self._attr_supported_features = ClimateEntityFeature(0) self._attr_temperature_unit = hass.config.units.temperature_unit From 33e44486c40a685d49d287965dd7f03b5b51465f Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Thu, 10 Sep 2026 06:33:55 +0000 Subject: [PATCH 09/14] Fix min/max temp and humidity templates unable to widen the range _update_min_temp/_update_max_temp validated the incoming template value via _validate_value(..., "target_temperature"), which bound-checks the value against self._attr_min_temp/self._attr_max_temp -- the very attribute the callback is trying to update. A new min_temp below the entity's current min_temp (or a new max_temp above the current max_temp) was rejected as out of range against its own stale value, so min_temp_template/max_temp_template could only ever narrow the range from its initial static/default value, never widen it. _update_min_humidity/_update_max_humidity had the identical bug via "target_humidity". Added dedicated "min_max_temperature" and "min_max_humidity" validate formats: same type coercion (float / round), no self-referential bound check, since these templates define the allowed range itself rather than a setpoint within it. Switched all four callbacks to use them. Found via mikopp/hass-template-climate's integration test suite: test_airflow_climate.py::test_min_max_temp_and_step_getters and test_roommind_climate.py::test_getters_and_min_max_templates both expected a template-driven range change that never took effect. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- .../climate_template/Changelog.md | 6 +++ custom_components/climate_template/climate.py | 40 +++++++++++++++++-- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index a4761d5..62ec73b 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,12 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-10 — Fix min_temp_template/max_temp_template (and humidity) unable to widen the range + +- **Author:** [@mikopp](https://github.com/mikopp) +- `_update_min_temp`/`_update_max_temp` validated the incoming template value with the same `"target_temperature"` format used for actual setpoints, which bound-checks the value against `self._attr_min_temp`/`self._attr_max_temp` — the very attribute being updated. A new `min_temp` below the entity's current `min_temp` (or a new `max_temp` above the current `max_temp`) was rejected as "out of range" against its own stale value, so `min_temp_template`/`max_temp_template` could only ever narrow the range from its initial static/default value, never widen it. `_update_min_humidity`/`_update_max_humidity` had the identical bug via `"target_humidity"`. Added dedicated `"min_max_temperature"`/`"min_max_humidity"` validation formats that parse the value without bound-checking it against itself, and switched all four callbacks to use them. + + ### 2026-09-03 — Document translations startup race limitation - **Author:** [@litinoveweedle](https://github.com/litinoveweedle) diff --git a/custom_components/climate_template/climate.py b/custom_components/climate_template/climate.py index 77ea8a7..0e40101 100644 --- a/custom_components/climate_template/climate.py +++ b/custom_components/climate_template/climate.py @@ -1241,6 +1241,23 @@ def _validate_value(self, attr, value, format): self._attr_min_temp, ) return None + elif format == "min_max_temperature": + # Used by min_temp_template/max_temp_template: these define the + # allowed range itself, so (unlike "target_temperature") the new + # value must NOT be bound-checked against self._attr_min_temp / + # self._attr_max_temp -- doing so would compare the incoming + # min_temp/max_temp against its own stale value and reject any + # change that widens the range. + try: + value = float(value) + except (TypeError, ValueError): + _LOGGER.error( + "Entity '%s' attribute '%s' returned invalid value: '%s'. Expected integer or float.", + self._attr_name, + attr, + value, + ) + return None elif format == "current_humidity": try: value = round(value) @@ -1281,6 +1298,21 @@ def _validate_value(self, attr, value, format): self._attr_min_humidity, ) return None + elif format == "min_max_humidity": + # Used by min_humidity_template/max_humidity_template: same + # reasoning as "min_max_temperature" above -- these define the + # allowed range itself and must not be bound-checked against + # self._attr_min_humidity / self._attr_max_humidity. + try: + value = round(value) + except (TypeError, ValueError): + _LOGGER.error( + "Entity '%s' attribute '%s' returned invalid value: '%s'. Expected integer of float.", + self._attr_name, + attr, + value, + ) + return None elif format == "precision": if value not in (PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE): _LOGGER.error( @@ -1660,7 +1692,7 @@ def _update_min_temp(self, min_temp: float): min_temp, ) if ( - value := self._validate_value("min_temp", min_temp, "target_temperature") + value := self._validate_value("min_temp", min_temp, "min_max_temperature") ) is not None: self._attr_min_temp = value self.async_write_ha_state() @@ -1674,7 +1706,7 @@ def _update_max_temp(self, max_temp: float): max_temp, ) if ( - value := self._validate_value("max_temp", max_temp, "target_temperature") + value := self._validate_value("max_temp", max_temp, "min_max_temperature") ) is not None: self._attr_max_temp = value self.async_write_ha_state() @@ -1689,7 +1721,7 @@ def _update_min_humidity(self, min_humidity: int): ) if ( value := self._validate_value( - "min_humidity", min_humidity, "target_humidity" + "min_humidity", min_humidity, "min_max_humidity" ) ) is not None: self._attr_min_humidity = value @@ -1705,7 +1737,7 @@ def _update_max_humidity(self, max_humidity: int): ) if ( value := self._validate_value( - "max_humidity", max_humidity, "target_humidity" + "max_humidity", max_humidity, "min_max_humidity" ) ) is not None: self._attr_max_humidity = value From b0713a68f339846af6ac7b08faeffd85f7b6831c Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Thu, 10 Sep 2026 06:33:55 +0000 Subject: [PATCH 10/14] Fix min/max temp and humidity templates unable to widen the range _update_min_temp/_update_max_temp validated the incoming template value via _validate_value(..., "target_temperature"), which bound-checks the value against self._attr_min_temp/self._attr_max_temp -- the very attribute the callback is trying to update. A new min_temp below the entity's current min_temp (or a new max_temp above the current max_temp) was rejected as out of range against its own stale value, so min_temp_template/max_temp_template could only ever narrow the range from its initial static/default value, never widen it. _update_min_humidity/_update_max_humidity had the identical bug via "target_humidity". Added dedicated "min_max_temperature" and "min_max_humidity" validate formats: same type coercion (float / round), no self-referential bound check, since these templates define the allowed range itself rather than a setpoint within it. Switched all four callbacks to use them. Found via mikopp/hass-template-climate's integration test suite: test_airflow_climate.py::test_min_max_temp_and_step_getters and test_roommind_climate.py::test_getters_and_min_max_templates both expected a template-driven range change that never took effect. Cherry-picked from feat/fix-min-max-validation, which carries this fix as its own separate PR to litinoveweedle. It is temporarily duplicated here only so this branch's own CI (test-integration.yaml) can run end-to-end; drop this commit from this branch once the fix PR merges upstream, and rebase before opening the integration-tests PR. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- .../climate_template/Changelog.md | 7 ++++ custom_components/climate_template/climate.py | 40 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index b1b66b0..722e776 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,13 @@ All notable changes across all fork generations are hopefully documented here. --- +### 2026-09-10 — Fix min_temp_template/max_temp_template (and humidity) unable to widen the range + +- **Author:** [@mikopp](https://github.com/mikopp) +- `_update_min_temp`/`_update_max_temp` validated the incoming template value with the same `"target_temperature"` format used for actual setpoints, which bound-checks the value against `self._attr_min_temp`/`self._attr_max_temp` — the very attribute being updated. A new `min_temp` below the entity's current `min_temp` (or a new `max_temp` above the current `max_temp`) was rejected as "out of range" against its own stale value, so `min_temp_template`/`max_temp_template` could only ever narrow the range from its initial static/default value, never widen it. `_update_min_humidity`/`_update_max_humidity` had the identical bug via `"target_humidity"`. Added dedicated `"min_max_temperature"`/`"min_max_humidity"` validation formats that parse the value without bound-checking it against itself, and switched all four callbacks to use them. +- **Note:** this fix is tracked by its own separate PR/branch (`feat/fix-min-max-validation`); it's carried here too only so the integration test suite below can actually run. + + ### 2026-09-09 — Fix every entity's name collapsing to "Template Climate" - **Author:** [@mikopp](https://github.com/mikopp) diff --git a/custom_components/climate_template/climate.py b/custom_components/climate_template/climate.py index 901cfc3..de0bcbe 100644 --- a/custom_components/climate_template/climate.py +++ b/custom_components/climate_template/climate.py @@ -1240,6 +1240,23 @@ def _validate_value(self, attr, value, format): self._attr_min_temp, ) return None + elif format == "min_max_temperature": + # Used by min_temp_template/max_temp_template: these define the + # allowed range itself, so (unlike "target_temperature") the new + # value must NOT be bound-checked against self._attr_min_temp / + # self._attr_max_temp -- doing so would compare the incoming + # min_temp/max_temp against its own stale value and reject any + # change that widens the range. + try: + value = float(value) + except (TypeError, ValueError): + _LOGGER.error( + "Entity '%s' attribute '%s' returned invalid value: '%s'. Expected integer or float.", + self._attr_name, + attr, + value, + ) + return None elif format == "current_humidity": try: value = round(value) @@ -1280,6 +1297,21 @@ def _validate_value(self, attr, value, format): self._attr_min_humidity, ) return None + elif format == "min_max_humidity": + # Used by min_humidity_template/max_humidity_template: same + # reasoning as "min_max_temperature" above -- these define the + # allowed range itself and must not be bound-checked against + # self._attr_min_humidity / self._attr_max_humidity. + try: + value = round(value) + except (TypeError, ValueError): + _LOGGER.error( + "Entity '%s' attribute '%s' returned invalid value: '%s'. Expected integer of float.", + self._attr_name, + attr, + value, + ) + return None elif format == "precision": if value not in (PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE): _LOGGER.error( @@ -1659,7 +1691,7 @@ def _update_min_temp(self, min_temp: float): min_temp, ) if ( - value := self._validate_value("min_temp", min_temp, "target_temperature") + value := self._validate_value("min_temp", min_temp, "min_max_temperature") ) is not None: self._attr_min_temp = value self.async_write_ha_state() @@ -1673,7 +1705,7 @@ def _update_max_temp(self, max_temp: float): max_temp, ) if ( - value := self._validate_value("max_temp", max_temp, "target_temperature") + value := self._validate_value("max_temp", max_temp, "min_max_temperature") ) is not None: self._attr_max_temp = value self.async_write_ha_state() @@ -1688,7 +1720,7 @@ def _update_min_humidity(self, min_humidity: int): ) if ( value := self._validate_value( - "min_humidity", min_humidity, "target_humidity" + "min_humidity", min_humidity, "min_max_humidity" ) ) is not None: self._attr_min_humidity = value @@ -1704,7 +1736,7 @@ def _update_max_humidity(self, max_humidity: int): ) if ( value := self._validate_value( - "max_humidity", max_humidity, "target_humidity" + "max_humidity", max_humidity, "min_max_humidity" ) ) is not None: self._attr_max_humidity = value From 6d208980ca44c69c4389ea793a14b6502f748098 Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Thu, 10 Sep 2026 06:57:04 +0000 Subject: [PATCH 11/14] Document temp_step-snapping and single-sided behavior in changelog Two details worth calling out explicitly about the min/max validation fix that weren't spelled out in the original entry: - The old (buggy) target_temperature-reusing code also snapped min_temp/max_temp to the nearest temp_step multiple before its bound check. The new dedicated format doesn't: bounds aren't setpoints and have no reason to sit on a step grid, so min_temp/max_temp now reflects the template's value exactly. This is the one intentional behavior difference from what narrowing-only configs saw before. - min_temp_template/max_temp_template (and the humidity equivalents) are fully independent of each other post-fix, so templating only one side works identically to templating both. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- custom_components/climate_template/Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index 62ec73b..004974f 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -8,6 +8,7 @@ All notable changes across all fork generations are hopefully documented here. - **Author:** [@mikopp](https://github.com/mikopp) - `_update_min_temp`/`_update_max_temp` validated the incoming template value with the same `"target_temperature"` format used for actual setpoints, which bound-checks the value against `self._attr_min_temp`/`self._attr_max_temp` — the very attribute being updated. A new `min_temp` below the entity's current `min_temp` (or a new `max_temp` above the current `max_temp`) was rejected as "out of range" against its own stale value, so `min_temp_template`/`max_temp_template` could only ever narrow the range from its initial static/default value, never widen it. `_update_min_humidity`/`_update_max_humidity` had the identical bug via `"target_humidity"`. Added dedicated `"min_max_temperature"`/`"min_max_humidity"` validation formats that parse the value without bound-checking it against itself, and switched all four callbacks to use them. +- **Behavior change:** the old `"target_temperature"` format also snapped the value to the nearest `temp_step` multiple before the (buggy) bound check, so a narrowing `min_temp`/`max_temp` update was previously silently rounded (e.g. `7.3` → `7.5` with `temp_step: 0.5`). The new format does not snap to `temp_step` — bounds are not setpoints and have no reason to sit on the step grid — so `min_temp`/`max_temp` now reports the template's value exactly. `min_temp_template` and `max_temp_template` (likewise `min_humidity_template`/`max_humidity_template`) are also fully independent of each other: each is validated and applied on its own, so configuring only one side (e.g. `max_temp_template` with a static `min_temp`) works identically to configuring both. ### 2026-09-03 — Document translations startup race limitation From 6fcdde3ec77638c678696c87a62ae83769d14cda Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Thu, 10 Sep 2026 06:57:04 +0000 Subject: [PATCH 12/14] Document temp_step-snapping and single-sided behavior in changelog Two details worth calling out explicitly about the min/max validation fix that weren't spelled out in the original entry: - The old (buggy) target_temperature-reusing code also snapped min_temp/max_temp to the nearest temp_step multiple before its bound check. The new dedicated format doesn't: bounds aren't setpoints and have no reason to sit on a step grid, so min_temp/max_temp now reflects the template's value exactly. This is the one intentional behavior difference from what narrowing-only configs saw before. - min_temp_template/max_temp_template (and the humidity equivalents) are fully independent of each other post-fix, so templating only one side works identically to templating both. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- custom_components/climate_template/Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/climate_template/Changelog.md b/custom_components/climate_template/Changelog.md index 722e776..0f385bc 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -8,6 +8,7 @@ All notable changes across all fork generations are hopefully documented here. - **Author:** [@mikopp](https://github.com/mikopp) - `_update_min_temp`/`_update_max_temp` validated the incoming template value with the same `"target_temperature"` format used for actual setpoints, which bound-checks the value against `self._attr_min_temp`/`self._attr_max_temp` — the very attribute being updated. A new `min_temp` below the entity's current `min_temp` (or a new `max_temp` above the current `max_temp`) was rejected as "out of range" against its own stale value, so `min_temp_template`/`max_temp_template` could only ever narrow the range from its initial static/default value, never widen it. `_update_min_humidity`/`_update_max_humidity` had the identical bug via `"target_humidity"`. Added dedicated `"min_max_temperature"`/`"min_max_humidity"` validation formats that parse the value without bound-checking it against itself, and switched all four callbacks to use them. +- **Behavior change:** the old `"target_temperature"` format also snapped the value to the nearest `temp_step` multiple before the (buggy) bound check, so a narrowing `min_temp`/`max_temp` update was previously silently rounded (e.g. `7.3` → `7.5` with `temp_step: 0.5`). The new format does not snap to `temp_step` — bounds are not setpoints and have no reason to sit on the step grid — so `min_temp`/`max_temp` now reports the template's value exactly. `min_temp_template` and `max_temp_template` (likewise `min_humidity_template`/`max_humidity_template`) are also fully independent of each other: each is validated and applied on its own, so configuring only one side (e.g. `max_temp_template` with a static `min_temp`) works identically to configuring both. - **Note:** this fix is tracked by its own separate PR/branch (`feat/fix-min-max-validation`); it's carried here too only so the integration test suite below can actually run. From e81be8730e04dcc9c42a4852b2abec9cf2b17e2b Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Thu, 10 Sep 2026 06:58:50 +0000 Subject: [PATCH 13/14] Add single-sided max_temp_template regression test Adds a second, minimal entity to the airflow fixture (climate.airflow_max_only) that configures only max_temp_template, with a static min_temp and no min_temp_template at all, plus a test asserting max_temp widens correctly on its own while min_temp stays at its static value. Closes the one gap in the min/max validation fix's test coverage: airflow_climate and roommind_climate (the only existing fixtures using these templates) both template both sides together, so neither proved the two callbacks are actually independent of each other -- only that they don't regress when used in combination. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- tests/ha_config/packages/airflow_climate.yaml | 17 +++++++++++++ tests/test_airflow_climate.py | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/tests/ha_config/packages/airflow_climate.yaml b/tests/ha_config/packages/airflow_climate.yaml index 20e8fb1..33c8249 100644 --- a/tests/ha_config/packages/airflow_climate.yaml +++ b/tests/ha_config/packages/airflow_climate.yaml @@ -267,3 +267,20 @@ climate: entity_id: input_select.comfoconnect_pro_temperature_profile data: option: cool + + # ── Secondary entity: single-sided max_temp_template only ───────────────── + # Regression coverage for the min/max validation fix (climate.py + # _update_min_temp/_update_max_temp): min_temp/max_temp used to be + # bound-checked against themselves, so a template-driven value could only + # ever narrow the range from its initial default, never widen it. This + # entity has no min_temp_template at all -- min_temp is static -- to prove + # max_temp_template works fully on its own and doesn't require a + # corresponding min_temp_template to also be configured. + - platform: climate_template + name: Airflow Max Only + unique_id: airflow_max_only + hvac_modes: + - "off" + - "cool" + min_temp: 10 + max_temp_template: "{{ states('input_number.airflow_max_temp') | float(26) }}" diff --git a/tests/test_airflow_climate.py b/tests/test_airflow_climate.py index c32ace4..dc9dfd9 100644 --- a/tests/test_airflow_climate.py +++ b/tests/test_airflow_climate.py @@ -16,6 +16,7 @@ from ha_integration_test_harness import HomeAssistant CLIMATE = "climate.airflow_climate" +MAX_ONLY_CLIMATE = "climate.airflow_max_only" TEMP_ATTRS = {"unit_of_measurement": "°C", "device_class": "temperature"} HUM_ATTRS = {"unit_of_measurement": "%", "device_class": "humidity"} @@ -125,6 +126,29 @@ def test_min_max_temp_and_step_getters(home_assistant: HomeAssistant) -> None: ) +def test_max_only_template_independent_of_min(home_assistant: HomeAssistant) -> None: + """max_temp_template alone (no min_temp_template) must update on its own. + + Regression coverage for the min/max validation fix: min_temp/max_temp used + to be bound-checked against themselves, so a template-driven value could + only narrow the range from its initial default, never widen it. This + entity has no min_temp_template at all -- only a static min_temp -- to + prove the two are fully independent and neither requires the other. + """ + home_assistant.call_action( + "input_number", + "set_value", + {"entity_id": "input_number.airflow_max_temp", "value": 32.0}, + ) + home_assistant.assert_entity_state( + MAX_ONLY_CLIMATE, + expected_attributes={ + "min_temp": 10.0, + "max_temp": 32.0, + }, + ) + + def test_fan_and_swing_getters(home_assistant: HomeAssistant) -> None: home_assistant.call_action( "input_select", From 25c478dc14d6b81d3f2068df5ab08c35419623ab Mon Sep 17 00:00:00 2001 From: Michael Kopp Date: Thu, 10 Sep 2026 07:04:50 +0000 Subject: [PATCH 14/14] Fix stale HA min-version in integration test matrix The matrix's 'min' leg still hardcoded ha_image_tag: 2025.9.0, carried over verbatim from the fork's pre-reset test suite, when hacs.json's declared minimum was still 2025.9.0. Since main was reset onto upstream, hacs.json's minimum is 2026.9.0 (upstream bumped it as part of their own 2026.9 compatibility work), but this workflow was never updated to match. Result: HA 2025.9.0 is now well below what the current climate.py actually requires -- make_template_entity_common_schema doesn't exist in that version's homeassistant.components.template.schemas, so every climate_template entity fails ImportError at platform setup and every test fails with 'Entity ... not found'. This is a stale test-matrix value, not a real HA compatibility gap: the declared minimum (2026.9.0) works fine, as the 'stable (latest)' leg (which happens to be the same version right now) already showed passing 61/61. Verified the homeassistant/home-assistant:2026.9.0 image tag exists on Docker Hub before pushing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EG6nBkBsv5jNAzXpiBewUy --- .github/workflows/test-integration.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 734c059..e2a8854 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -27,8 +27,8 @@ jobs: matrix: include: # hacs.json "homeassistant" minimum supported version. - - ha_label: "2025.9.0 (min)" - ha_image_tag: "2025.9.0" + - ha_label: "2026.9.0 (min)" + ha_image_tag: "2026.9.0" # Latest published stable release. - ha_label: "stable (latest)" ha_image_tag: "stable"