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/test-integration.yaml b/.github/workflows/test-integration.yaml new file mode 100644 index 0000000..e2a8854 --- /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: "2026.9.0 (min)" + ha_image_tag: "2026.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.14" + + - 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/.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/.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/README.md b/README.md index cbb7dda..0ca4982 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. @@ -87,6 +89,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 +232,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 +294,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..854b8d7 100644 --- a/custom_components/climate_template/Changelog.md +++ b/custom_components/climate_template/Changelog.md @@ -4,6 +4,48 @@ 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. +- **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-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-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-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-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-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-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/custom_components/climate_template/climate.py b/custom_components/climate_template/climate.py index ef7ba51..27e5ff2 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 @@ -402,7 +418,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 @@ -543,6 +558,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 +630,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 @@ -1219,6 +1259,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) @@ -1259,6 +1316,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( @@ -1638,7 +1710,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() @@ -1652,7 +1724,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() @@ -1667,7 +1739,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 @@ -1683,7 +1755,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 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..20dfe7d --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,6 @@ +# Test-only dependencies for the integration test suite (tests/). +# Requires Python >= 3.14.2 (per ha-integration-test-harness) 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..33c8249 --- /dev/null +++ b/tests/ha_config/packages/airflow_climate.yaml @@ -0,0 +1,286 @@ +# ── 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 + + # ── 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/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..dc9dfd9 --- /dev/null +++ b/tests/test_airflow_climate.py @@ -0,0 +1,320 @@ +"""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" +MAX_ONLY_CLIMATE = "climate.airflow_max_only" + +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_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", + "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)" + )