From 9d23754916a15681df75db421c2ff23ba6ccde8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:16:08 +0000 Subject: [PATCH 01/15] Add CLAUDE.md with codebase guide for AI assistants Documents the integration architecture (control loop, modes, presence/away delay, schedule helper, presence sensor, services, frontend cards), the repository layout, development/test workflow, and key conventions and gotchas. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- CLAUDE.md | 218 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a60b0d6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,218 @@ +# CLAUDE.md + +Guidance for AI assistants (and humans) working in this repository. + +## What this is + +**Smart Climate Controller** is a [Home Assistant](https://www.home-assistant.io/) +custom integration, distributed via [HACS](https://hacs.xyz/). It wraps an +*existing* climate entity (e.g. `climate.living_room`) and layers on: + +- **Presence-aware control** — reads a zone entity's occupancy count and picks + between "home" and "away" target temperatures. +- **Override modes** — timer, infinity (permanent), and next-node (until the + next schedule slot). +- **A time-of-day schedule** — daily, 5/2 (weekday/weekend), or per-day. +- **Built-in Lovelace cards** — control, config, and an interactive schedule + editor, all shipped in-repo and auto-registered. + +The integration never talks to the wrapped device directly; it issues +`climate.set_temperature` / `climate.set_hvac_mode` service calls and reads the +wrapped entity's state back. `iot_class` is `local_push`, and there are **no +external network calls and no stored credentials**. + +## Repository layout + +``` +custom_components/smart_climate/ # the integration (all backend + frontend assets) + __init__.py # config-entry + YAML setup, platform forwarding, card registration + climate.py # SmartClimateEntity — core state machine & control loop + sensor.py # SmartClimatePresenceSensor — mirrors presence to a sensor for history + services.py # registers all smart_climate.* HA services (idempotent) + services.yaml # service metadata/selectors shown in the HA UI + config_flow.py # UI config + reconfigure flow + schedule_helper.py # PURE, HA-independent schedule math (unit-testable) + frontend.py # copies + registers the main Lovelace card as a resource + const.py # DOMAIN, modes, config keys, attribute names, service names + manifest.json # integration manifest (domain, version, codeowners) + strings.json / translations/ # en.json, nl.json — config-flow + entity translations + smart-climate-card.js # main control card (LitElement) + smart-climate-config-card.js # settings card (LitElement) + its editor + smart-climate-schedule-card.js # interactive schedule editor card + its editor + smart-climate-base-editor.js # shared base class for card GUI editors + smart-climate-card.new.js # EMPTY placeholder — future work, ignore + smart-climate-card-clean.js # EMPTY placeholder — future work, ignore +tests/ + conftest.py # stubs out homeassistant + voluptuous so tests run without HA + test_presence_interrupt_override.py +.github/ + workflows/ci.yml # ruff lint + pytest + copilot-instructions.md # short project-guidelines note (kept in sync with this file) +hacs.json # HACS metadata +README.md # user-facing install/usage docs +``` + +## Core concepts + +### The control loop (`climate.py`) + +`SmartClimateEntity` is the heart of the integration. It does **not** poll +(`_attr_should_poll = False`). Instead it drives itself from two sources: + +1. **A 10-second timer** (`async_track_time_interval` → `_update_state`) — + re-evaluates presence, expires override timers/next-node overrides, ticks the + away-delay countdown, and re-writes the target temperature. +2. **Zone state-change events** (`async_track_state_change_event` → + `_on_zone_change`) — reacts immediately when occupancy changes. + +Every path ends in `_update_target_temperature()`, which computes the target and +issues a `climate.set_temperature` call to the wrapped entity. + +**Target-temperature resolution** (in `_update_target_temperature`): +- Any override mode → `_override_temperature`. +- Auto + present + schedule set → `schedule_helper.get_scheduled_temperature(...)`. +- Auto + present + no schedule → `_auto_temperature`. +- Auto + away → `_away_temperature`. + +### Modes (`const.py`) + +Internal `_mode` is one of `MODE_AUTO`, `MODE_OVERRIDE_TIMER`, +`MODE_OVERRIDE_INFINITY`, `MODE_OVERRIDE_NEXT_NODE`. These map to HA **preset +modes** (`auto` / `timer` / `infinity` / `next_node`) so the native climate card +can display and set them, and are *also* published as a custom `mode` extra state +attribute for backward compatibility — **do not remove the `mode` attribute.** + +### Presence & away delay + +- Presence is `"home"` or `"away"`, derived from the zone entity's integer state + (occupancy count). It starts `"away"`. +- When everyone leaves, an **away delay** timer starts (`_start_away_delay`); + presence only flips to `"away"` after `away_delay_minutes` elapse. The delay is + timestamp-based (`_away_delay_start`) to avoid tick drift over the 10s loop. +- Coming home cancels the away delay immediately. +- **Interruptible**: when `_interruptible` is `True`, a presence change back home + cancels *any* active override (timer, infinity, next-node) and returns to auto. + When `False`, overrides survive presence changes. This behavior is enforced in + three code paths — `_on_zone_change`, `_update_state`, and + `_transition_to_away` — and is the subject of the regression test. + +### Schedule (`schedule_helper.py`) + +Pure functions, no HA imports — that's deliberate so they can be unit-tested +directly. A schedule is a dict with a `mode` key: +- `"daily"` → `schedule["daily"]` (a list of `{time, temp}` nodes). +- `"5/2"` → `schedule["weekday"]` / `schedule["weekend"]`. +- `"individual"` → per-day keys `monday`…`sunday`. + +`get_scheduled_temperature` picks the last node whose `HH:MM` time is ≤ now, +wrapping to the previous day's last node before the first slot. +`compute_next_node_datetime` / `get_next_node_minutes` find the next upcoming +node (wrapping to tomorrow). All parse `time` as `%H:%M` and fall back / warn on +malformed nodes. + +### The presence sensor (`sensor.py`) + +HA does not record entity *attributes* to history, only state. To let the +schedule card draw the home/away bar, `SmartClimatePresenceSensor` mirrors the +climate entity's `presence` attribute into a dedicated sensor's *state*, which HA +does record. It finds its paired climate entity via the entity registry using the +`smart_climate_{entry_id}` unique-id convention. + +### Services (`services.py` + `services.yaml`) + +All services live under the `smart_climate` domain and are registered **once** +(guarded by `hass.services.has_service`). Each handler resolves the target +entity from `hass.data[DOMAIN]["entities"][entity_id]`, which entities register +in `async_added_to_hass`. When adding a service: +1. Add the `SERVICE_*` constant to `const.py`. +2. Add the handler + `async_register` call in `services.py`. +3. Add the entity method it calls in `climate.py`. +4. Add UI metadata to `services.yaml`. + +### Frontend cards + +`frontend.py` copies `smart-climate-card.js` into HA's HACS community dir and +registers it as a Lovelace resource with an md5-hash cache-buster. Cards are +LitElement classes that `window.customCards.push({...})` to appear in the picker. +Config/schedule card GUI editors extend `SmartClimateBaseEditor`. Cards are thin +clients: they call `smart_climate.*` services and re-render on state change — all +logic stays in the backend. + +## Development workflow + +### Branch & git conventions + +- Default branch is **`develop`** (not `main`). +- Do all work on the assigned feature branch; commit with clear messages; push + with `git push -u origin `. +- Do **not** open a PR unless explicitly asked. + +### Running tests & lint + +Tests run **without a Home Assistant install** — `tests/conftest.py` registers +stub modules for `homeassistant.*` and `voluptuous` in `sys.modules` before +collection. This is why `schedule_helper.py` and the entity's mode logic can be +imported and tested in isolation. + +```bash +pip install pytest +pytest tests/ --tb=short + +pip install ruff +ruff check custom_components/ +``` + +CI (`.github/workflows/ci.yml`) runs both jobs. **Note:** CI is currently +triggered on the `main` branch only, while the repo's default branch is +`develop` — pushes/PRs to `develop` will not trigger it as written. Flag this if +touching CI. + +### Manual / integration testing + +There is no way to exercise the full integration outside a running HA instance. +To test end-to-end: copy `custom_components/smart_climate/` into an HA config, +restart HA, add the integration via **Settings → Devices & Services**, and drive +it from the Lovelace cards or Developer Tools → Services. + +## Conventions & gotchas + +- **Formatting**: 4-space indent for Python, 2-space for YAML/JS. Match the + surrounding file. +- **HA conventions**: entity methods are `async_*`; state is pushed with + `self.async_write_ha_state()`; time uses `homeassistant.util.dt` (`dt_util`), + not stdlib `datetime.now()`. +- **Constants over strings**: mode names, config keys, attribute names, and + service names all live in `const.py`. Reference the constants, don't hardcode. +- **Persistence**: runtime-changeable settings are saved to HA's config-entry + storage (`.storage/core.config_entries`) and survive restarts. Config keys are + the `CONF_*` values in `const.py`. +- **Config-flow vs YAML schema drift**: `config_flow.py` offers `next_node` as a + default-override option, but the `CONFIG_SCHEMA` in `__init__.py` restricts + YAML to `vol.In(["timer", "infinity"])`. Keep these in mind if you change the + allowed override modes. +- **Empty placeholder JS files** (`smart-climate-card.new.js`, + `smart-climate-card-clean.js`) are intentionally empty scaffolds — don't treat + them as broken or wire them up unless that's the task. + +### ⚠️ Test/constructor signature drift + +`tests/test_presence_interrupt_override.py` constructs `SmartClimateEntity` with +keyword args `auto_temp=` and `schedule=` and sets `_last_written_temperature`, +**none of which exist** in the current `climate.py` constructor +(`__init__(hass, entry, name, wrapped_climate, zone_home, away_temp, +away_delay_minutes, interruptible, default_override_mode, +default_override_duration)`). As written, that test file will raise a +`TypeError` against the current entity. If you touch this area, reconcile the +test's `_make_entity` helper with the real constructor signature (or update the +constructor) rather than assuming the suite is green. + +## Where to look first + +| I want to change… | Start in | +|---|---| +| Target-temperature / presence / override logic | `climate.py` | +| Schedule math | `schedule_helper.py` | +| Add/modify a service | `services.py`, `services.yaml`, `const.py` | +| Setup flow / config fields | `config_flow.py`, `strings.json`, `translations/` | +| Card UI | `smart-climate-*.js`, `frontend.py` | +| Names/keys/modes | `const.py` | From ffb52cf0d1d3423a66fed9690f627bdb5e64d2b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:20:23 +0000 Subject: [PATCH 02/15] Fix failing test suite, CI triggers, and config schema drift - tests: add homeassistant.util.dt and ServiceCall stubs to conftest so the integration imports under the test harness; reconcile _make_entity with the real SmartClimateEntity constructor (drop nonexistent auto_temp/schedule args and the stray _last_written_temperature attribute). - tests: add pytest.ini (asyncio_mode = auto) so async tests run. - ci: trigger on develop as well as main, and install pytest-asyncio so the async tests actually execute. - lint: remove unused imports (climate.py callback, frontend.py DOMAIN) that were failing ruff. - config: accept next_node in the YAML CONFIG_SCHEMA default_override_mode to match the config-flow UI options. - docs: update CLAUDE.md to reflect the resolved state. All 15 tests pass and ruff is clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- .github/workflows/ci.yml | 6 ++-- CLAUDE.md | 36 ++++++++++----------- custom_components/smart_climate/__init__.py | 2 +- custom_components/smart_climate/climate.py | 2 +- custom_components/smart_climate/frontend.py | 2 -- pytest.ini | 3 ++ tests/conftest.py | 8 +++++ tests/test_presence_interrupt_override.py | 4 +-- 8 files changed, 34 insertions(+), 29 deletions(-) create mode 100644 pytest.ini diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa17cf1..83b94ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: - branches: [main] + branches: [main, develop] jobs: lint: @@ -28,6 +28,6 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install pytest + - run: pip install pytest pytest-asyncio - run: pytest tests/ --tb=short if: ${{ hashFiles('tests/**/*') != '' }} diff --git a/CLAUDE.md b/CLAUDE.md index a60b0d6..8b20095 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,10 +162,10 @@ pip install ruff ruff check custom_components/ ``` -CI (`.github/workflows/ci.yml`) runs both jobs. **Note:** CI is currently -triggered on the `main` branch only, while the repo's default branch is -`develop` — pushes/PRs to `develop` will not trigger it as written. Flag this if -touching CI. +The async tests rely on `pytest-asyncio` (installed in CI; `asyncio_mode = auto` +is set in `pytest.ini`, so `async def` tests run without needing a per-test +mark). CI (`.github/workflows/ci.yml`) runs both jobs on pushes/PRs to `main` +and `develop`. ### Manual / integration testing @@ -186,25 +186,23 @@ it from the Lovelace cards or Developer Tools → Services. - **Persistence**: runtime-changeable settings are saved to HA's config-entry storage (`.storage/core.config_entries`) and survive restarts. Config keys are the `CONF_*` values in `const.py`. -- **Config-flow vs YAML schema drift**: `config_flow.py` offers `next_node` as a - default-override option, but the `CONFIG_SCHEMA` in `__init__.py` restricts - YAML to `vol.In(["timer", "infinity"])`. Keep these in mind if you change the - allowed override modes. +- **Default-override modes** are `timer`, `infinity`, and `next_node`. Both the + UI (`config_flow.py`) and the YAML `CONFIG_SCHEMA` in `__init__.py` accept all + three — keep them in sync if you add another mode. - **Empty placeholder JS files** (`smart-climate-card.new.js`, `smart-climate-card-clean.js`) are intentionally empty scaffolds — don't treat them as broken or wire them up unless that's the task. -### ⚠️ Test/constructor signature drift - -`tests/test_presence_interrupt_override.py` constructs `SmartClimateEntity` with -keyword args `auto_temp=` and `schedule=` and sets `_last_written_temperature`, -**none of which exist** in the current `climate.py` constructor -(`__init__(hass, entry, name, wrapped_climate, zone_home, away_temp, -away_delay_minutes, interruptible, default_override_mode, -default_override_duration)`). As written, that test file will raise a -`TypeError` against the current entity. If you touch this area, reconcile the -test's `_make_entity` helper with the real constructor signature (or update the -constructor) rather than assuming the suite is green. +### Entity constructor signature + +`SmartClimateEntity.__init__` takes `(hass, entry, name, wrapped_climate, +zone_home, away_temp, away_delay_minutes, interruptible, default_override_mode, +default_override_duration)`. `auto_temperature` (default 21) and `schedule` +(default `None`) are **not** constructor args — they start at their defaults and +are changed at runtime via the `set_auto_temperature` / `set_schedule` services. +Tests that build an entity directly (see `_make_entity` in +`tests/test_presence_interrupt_override.py`) set `_auto_temperature` / +`_schedule` as attributes after construction rather than passing them in. ## Where to look first diff --git a/custom_components/smart_climate/__init__.py b/custom_components/smart_climate/__init__.py index 51e0433..a8c419f 100644 --- a/custom_components/smart_climate/__init__.py +++ b/custom_components/smart_climate/__init__.py @@ -29,7 +29,7 @@ vol.Optional(CONF_AWAY_TEMPERATURE, default=14): vol.Coerce(float), vol.Optional(CONF_AWAY_DELAY_MINUTES, default=5): vol.Coerce(int), vol.Optional(CONF_INTERRUPTIBLE, default=True): cv.boolean, - vol.Optional(CONF_DEFAULT_OVERRIDE_MODE, default="timer"): vol.In(["timer", "infinity"]), + vol.Optional(CONF_DEFAULT_OVERRIDE_MODE, default="timer"): vol.In(["timer", "infinity", "next_node"]), vol.Optional(CONF_DEFAULT_OVERRIDE_DURATION, default=30): vol.Coerce(int), } ) diff --git a/custom_components/smart_climate/climate.py b/custom_components/smart_climate/climate.py index 1c4208b..d5a21a9 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -1,7 +1,7 @@ from datetime import timedelta from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature, HVACMode from homeassistant.const import UnitOfTemperature, CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval, async_track_state_change_event diff --git a/custom_components/smart_climate/frontend.py b/custom_components/smart_climate/frontend.py index 6c95cb1..45630b4 100644 --- a/custom_components/smart_climate/frontend.py +++ b/custom_components/smart_climate/frontend.py @@ -9,8 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.event import async_call_later -from .const import DOMAIN - _LOGGER = logging.getLogger(__name__) CARD_NAME = "smart-climate-card" HACS_PATH = "www/community/smart-climate-card" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/tests/conftest.py b/tests/conftest.py index a6d472b..397588d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Pytest configuration: register HA stub modules before any collection occurs.""" +import datetime as _datetime import sys import types from unittest.mock import MagicMock @@ -30,6 +31,7 @@ def _ensure_parent(dotted_name: str): _register_stub( "homeassistant.core", HomeAssistant=type("HomeAssistant", (), {}), + ServiceCall=type("ServiceCall", (), {}), callback=lambda f: f, ) @@ -46,6 +48,12 @@ def _ensure_parent(dotted_name: str): ConfigEntry=type("ConfigEntry", (), {}), ) +# homeassistant.util + homeassistant.util.dt (climate.py: `from homeassistant.util import dt`) +_dt_stub = types.ModuleType("homeassistant.util.dt") +_dt_stub.now = lambda: _datetime.datetime.now() +_register_stub("homeassistant.util", dt=_dt_stub) +sys.modules["homeassistant.util.dt"] = _dt_stub + # homeassistant.components _register_stub("homeassistant.components") diff --git a/tests/test_presence_interrupt_override.py b/tests/test_presence_interrupt_override.py index 785f0b0..e6e3784 100644 --- a/tests/test_presence_interrupt_override.py +++ b/tests/test_presence_interrupt_override.py @@ -46,18 +46,16 @@ def _make_entity(interruptible: bool = True, mode: str = MODE_AUTO) -> "object": name="Test Climate", wrapped_climate="climate.wrapped", zone_home="zone.home", - auto_temp=21.0, away_temp=14.0, away_delay_minutes=0, interruptible=interruptible, default_override_mode="timer", default_override_duration=30, - schedule=None, ) entity._mode = mode entity._presence = "away" + entity._auto_temperature = 21.0 entity._override_temperature = 22.0 - entity._last_written_temperature = 22.0 # Patch write_ha_state to be a no-op entity.async_write_ha_state = MagicMock() From f10c9fc17659e87fa08676f4725147485130665c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:34:37 +0000 Subject: [PATCH 03/15] Persist runtime settings to config-entry storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runtime-configurable settings changed via the smart_climate.* services (and the config/schedule cards that call them) were only mutating in-memory entity attributes — nothing was written back to the config entry, so every setting reverted on Home Assistant restart. auto_temperature and schedule were not even read from the entry. - Add CONF_AUTO_TEMPERATURE and CONF_SCHEDULE keys. - Read auto_temperature and schedule from entry.data at construction so persisted values are restored on startup. - Add a _persist() helper and call it from every runtime setter (auto/away temperature, away delay, interruptible, default override mode, schedule) so changes are written to config-entry storage via async_update_entry. - Add tests/test_persistence.py covering write-back, restore-on-construct, and a set-then-rebuild round trip. This makes the persistence behavior documented in the README actually work. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- custom_components/smart_climate/climate.py | 30 ++++- custom_components/smart_climate/const.py | 2 + tests/test_persistence.py | 133 +++++++++++++++++++++ 3 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 tests/test_persistence.py diff --git a/custom_components/smart_climate/climate.py b/custom_components/smart_climate/climate.py index d5a21a9..6077e9f 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -21,6 +21,8 @@ CONF_INTERRUPTIBLE, CONF_DEFAULT_OVERRIDE_MODE, CONF_DEFAULT_OVERRIDE_DURATION, + CONF_AUTO_TEMPERATURE, + CONF_SCHEDULE, ATTR_MODE, ATTR_PRESENCE, ATTR_REMAINING_MINUTES, @@ -103,11 +105,12 @@ def __init__( self._default_override_mode = default_override_mode self._default_override_duration = default_override_duration - # State + # State — auto_temperature and schedule are not constructor args; they are + # restored from persisted config-entry storage (see async_set_* setters). self._mode = MODE_AUTO self._presence = "away" # Start as away self._interruptible = interruptible - self._auto_temperature = 21 + self._auto_temperature = entry.data.get(CONF_AUTO_TEMPERATURE, 21) self._override_temperature = 21 self._override_start_time = None self._override_duration_minutes = 0 @@ -115,7 +118,7 @@ def __init__( self._away_delay_task = None self._away_delay_start = None self._away_delay_remaining = 0 - self._schedule = None + self._schedule = entry.data.get(CONF_SCHEDULE, None) async def async_added_to_hass(self): """Initialize after added to hass.""" @@ -264,6 +267,18 @@ async def _update_target_temperature(self): }, ) + def _persist(self, **changes) -> None: + """Persist runtime-changeable settings to config-entry storage. + + Writes the given config keys into the entry's ``data`` so the values + survive a Home Assistant restart (the constructor and + ``async_setup_entry`` read them back on startup). ``async_update_entry`` + is a synchronous callback despite the ``async_`` prefix. + """ + self.hass.config_entries.async_update_entry( + self.entry, data={**self.entry.data, **changes} + ) + async def async_set_override_timer(self, minutes: int, temperature: float): """Set override timer mode.""" self._mode = MODE_OVERRIDE_TIMER @@ -309,34 +324,43 @@ async def async_clear_override(self): async def async_set_interruptible(self, interruptible: bool): """Set interruptible mode.""" self._interruptible = interruptible + self._persist(**{CONF_INTERRUPTIBLE: interruptible}) self.async_write_ha_state() async def async_set_auto_temperature(self, temperature: float): """Set the target temperature used in auto/home mode.""" self._auto_temperature = temperature + self._persist(**{CONF_AUTO_TEMPERATURE: temperature}) await self._update_target_temperature() self.async_write_ha_state() async def async_set_away_temperature(self, temperature: float): """Set the target temperature used in away mode.""" self._away_temperature = temperature + self._persist(**{CONF_AWAY_TEMPERATURE: temperature}) await self._update_target_temperature() self.async_write_ha_state() async def async_set_away_delay(self, minutes: int): """Set the delay before switching to away mode.""" self._away_delay_minutes = minutes + self._persist(**{CONF_AWAY_DELAY_MINUTES: minutes}) self.async_write_ha_state() async def async_set_default_override_mode(self, mode: str, duration: int): """Set the default mode used when a temperature override is triggered.""" self._default_override_mode = mode self._default_override_duration = duration + self._persist(**{ + CONF_DEFAULT_OVERRIDE_MODE: mode, + CONF_DEFAULT_OVERRIDE_DURATION: duration, + }) self.async_write_ha_state() async def async_set_schedule(self, schedule): """Set the temperature schedule used in auto/home mode.""" self._schedule = schedule + self._persist(**{CONF_SCHEDULE: schedule}) await self._update_target_temperature() self.async_write_ha_state() diff --git a/custom_components/smart_climate/const.py b/custom_components/smart_climate/const.py index 2edca32..b7b614d 100644 --- a/custom_components/smart_climate/const.py +++ b/custom_components/smart_climate/const.py @@ -15,6 +15,8 @@ CONF_INTERRUPTIBLE = "interruptible" CONF_DEFAULT_OVERRIDE_MODE = "default_override_mode" CONF_DEFAULT_OVERRIDE_DURATION = "default_override_duration" +CONF_AUTO_TEMPERATURE = "auto_temperature" +CONF_SCHEDULE = "schedule" # Attributes ATTR_MODE = "mode" diff --git a/tests/test_persistence.py b/tests/test_persistence.py new file mode 100644 index 0000000..92c2775 --- /dev/null +++ b/tests/test_persistence.py @@ -0,0 +1,133 @@ +"""Tests that runtime-configurable settings persist to config-entry storage. + +Regression coverage for: settings changed at runtime (via the config/schedule +cards or the smart_climate.* services) must be written back to the config +entry's ``data`` so they survive a Home Assistant restart, and must be restored +on the next startup. +""" + +import pathlib +import sys +from unittest.mock import AsyncMock, MagicMock + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from custom_components.smart_climate.const import ( + CONF_AUTO_TEMPERATURE, + CONF_AWAY_DELAY_MINUTES, + CONF_AWAY_TEMPERATURE, + CONF_DEFAULT_OVERRIDE_DURATION, + CONF_DEFAULT_OVERRIDE_MODE, + CONF_INTERRUPTIBLE, + CONF_SCHEDULE, +) + + +def _make_entity(entry_data=None): + """Build a SmartClimateEntity whose config entry persists like real HA. + + The mock ``async_update_entry`` mutates ``entry.data`` in place so a + rebuilt entity reads back whatever a setter persisted. + """ + from custom_components.smart_climate.climate import SmartClimateEntity + + hass = MagicMock() + hass.states.get = MagicMock(return_value=None) + hass.services.async_call = AsyncMock() + + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = dict(entry_data or {}) + + def _update_entry(target, data=None, **kwargs): + if data is not None: + target.data = dict(data) + return True + + hass.config_entries.async_update_entry = MagicMock(side_effect=_update_entry) + + entity = SmartClimateEntity( + hass=hass, + entry=entry, + name="Test Climate", + wrapped_climate="climate.wrapped", + zone_home="zone.home", + away_temp=14.0, + away_delay_minutes=0, + interruptible=True, + default_override_mode="timer", + default_override_duration=30, + ) + entity.async_write_ha_state = MagicMock() + return entity, hass, entry + + +async def test_set_auto_temperature_persists(): + entity, hass, entry = _make_entity() + await entity.async_set_auto_temperature(19.5) + assert entry.data[CONF_AUTO_TEMPERATURE] == 19.5 + hass.config_entries.async_update_entry.assert_called() + + +async def test_set_away_temperature_persists(): + entity, _, entry = _make_entity() + await entity.async_set_away_temperature(12.0) + assert entry.data[CONF_AWAY_TEMPERATURE] == 12.0 + + +async def test_set_away_delay_persists(): + entity, _, entry = _make_entity() + await entity.async_set_away_delay(15) + assert entry.data[CONF_AWAY_DELAY_MINUTES] == 15 + + +async def test_set_interruptible_persists(): + entity, _, entry = _make_entity() + await entity.async_set_interruptible(False) + assert entry.data[CONF_INTERRUPTIBLE] is False + + +async def test_set_default_override_mode_persists(): + entity, _, entry = _make_entity() + await entity.async_set_default_override_mode("infinity", 45) + assert entry.data[CONF_DEFAULT_OVERRIDE_MODE] == "infinity" + assert entry.data[CONF_DEFAULT_OVERRIDE_DURATION] == 45 + + +async def test_set_schedule_persists(): + entity, _, entry = _make_entity() + schedule = {"mode": "daily", "daily": [{"time": "06:00", "temp": 21}]} + await entity.async_set_schedule(schedule) + assert entry.data[CONF_SCHEDULE] == schedule + + +def test_constructor_restores_persisted_auto_temperature_and_schedule(): + schedule = {"mode": "daily", "daily": [{"time": "07:00", "temp": 20}]} + entity, _, _ = _make_entity( + entry_data={CONF_AUTO_TEMPERATURE: 23, CONF_SCHEDULE: schedule} + ) + assert entity._auto_temperature == 23 + assert entity._schedule == schedule + + +async def test_setting_then_rebuilding_round_trips(): + """A value set at runtime is visible to a freshly constructed entity.""" + entity, hass, entry = _make_entity() + await entity.async_set_auto_temperature(18.0) + + # Rebuild from the same (now-mutated) entry, mimicking a restart. + from custom_components.smart_climate.climate import SmartClimateEntity + + rebuilt = SmartClimateEntity( + hass=hass, + entry=entry, + name="Test Climate", + wrapped_climate="climate.wrapped", + zone_home="zone.home", + away_temp=14.0, + away_delay_minutes=0, + interruptible=True, + default_override_mode="timer", + default_override_duration=30, + ) + assert rebuilt._auto_temperature == 18.0 From 2620cdb1758e29b117c1334d8a3a4b399cbc0753 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 07:36:46 +0000 Subject: [PATCH 04/15] Correct README instructions and fix manifest documentation URL - Installation: document the HACS custom-repository flow (this integration is not in the HACS default store) plus a manual-install option, replacing the inaccurate "search the default store" steps. - Configuration: list the actual config-flow fields (auto temperature is NOT a setup field); clarify that auto temperature and schedule are runtime-only and that the Configure dialog only changes the wrapped climate and home zone. - Add a "How it works" overview. - Add commented-out screenshot placeholders under each card and a dashboard suggestion (drop real images into an images/ folder to enable them). - manifest.json: point documentation at the real repo (Smart-Climate-Controller, not ha-smart-climate). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- README.md | 78 ++++++++++++++++--- custom_components/smart_climate/manifest.json | 2 +- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d5507f3..e14b76a 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,78 @@ A Home Assistant custom integration that wraps an existing climate entity and adds presence-aware automatic temperature control, timed overrides, and configurable away behaviour. +## How it works + +Smart Climate never talks to your heating hardware directly. It creates a new +`climate.*` entity that *wraps* an existing one and issues standard +`climate.set_temperature` / `climate.set_hvac_mode` calls to it. On top of the +wrapped device it adds: + +- **Presence-aware control** – reads the occupancy count from a `zone` entity and + picks between a **home** and an **away** target temperature. When everyone + leaves, an *away delay* runs before the away temperature is applied; coming + home cancels it immediately. +- **Override modes** – a manual temperature change activates an override: + **timer** (reverts after a set duration), **infinity** (until cleared), or + **next node** (until the next schedule slot). Overrides can optionally be + *interruptible* by presence changes. +- **A time-of-day schedule** – `daily`, `5/2` (weekday/weekend), or `individual` + (per-day), edited visually from the schedule card. + +There are **no external network calls and no stored credentials** — everything +runs locally through Home Assistant's service layer. + + + ## Installation -1. Open your Home Assistant dashboard. -2. Go to **HACS** → **Integrations**. -3. Search for **Smart Climate Controller** and click **Install**. -4. Restart Home Assistant. +### HACS (recommended) + +This integration is distributed as a **custom repository** (it is not in the +HACS default store). + +1. In Home Assistant, open **HACS**. +2. Click the **⋮** menu (top-right) → **Custom repositories**. +3. Add the repository URL `https://github.com/JansenDevelopment/Smart-Climate-Controller` + and select **Integration** as the category, then click **Add**. +4. Search HACS for **Smart Climate Controller** and click **Download**. +5. Restart Home Assistant. + +### Manual + +1. Copy the `custom_components/smart_climate/` folder into your Home Assistant + `config/custom_components/` directory. +2. Restart Home Assistant. ## Configuration -No YAML configuration is required. After installation and restart, add the integration through the Home Assistant UI: +No YAML configuration is required. After installing and restarting, add the +integration through the Home Assistant UI: 1. Go to **Settings** → **Devices & Services** → **Add Integration**. 2. Search for **Smart Climate** and select it. -3. Fill in the required fields: +3. Fill in the setup form: - **Name** – a friendly name for this Smart Climate instance. - - **Wrapped Climate** – the existing climate entity to control (e.g. `climate.living_room`). - - **Zone (home)** – the `zone.home` entity (or equivalent) used for presence detection. - - Optional defaults: auto temperature, away temperature, away delay, override mode, etc. + - **Wrapped Climate Entity** – the existing climate entity to control (e.g. `climate.living_room`). + - **Home Zone** – a `zone` entity whose state is the occupancy count, used for presence detection (e.g. `zone.home`). + - **Away Temperature** – target when nobody is home (default `14`). + - **Away Delay (minutes)** – how long to wait after everyone leaves before applying the away temperature (default `5`). + - **Override is interruptible** – whether a presence change cancels an active override (default on). + - **Default Override Mode** – `timer`, `infinity`, or `next_node` (default `timer`). + - **Default Override Duration (minutes)** – timer length for timer overrides (default `30`). 4. Click **Submit**. -All settings can be changed later via **Settings** → **Devices & Services** → Smart Climate → **Configure**. +The **auto (home) temperature** and the **schedule** are *not* part of the setup +form — they start at their defaults (`21` °C, no schedule) and are set at runtime +from the config/schedule cards or the `smart_climate.set_auto_temperature` / +`smart_climate.set_schedule` services. All runtime settings are saved and +restored across restarts (see [Persistent Storage](#persistent-storage)). + +To change the **wrapped climate** or **home zone** later, use **Settings** → +**Devices & Services** → Smart Climate → **Configure**. All other settings are +adjusted from the Lovelace cards or the `smart_climate.*` services. ## Lovelace Cards @@ -37,6 +88,8 @@ type: custom:smart-climate-card entity: climate.living_room ``` + + | Variable | Required | Description | |----------|----------|-------------| | `entity` | Yes | The Smart Climate entity ID (e.g. `climate.living_room`). | @@ -50,6 +103,8 @@ type: custom:smart-climate-config-card entity: climate.living_room ``` + + | Variable | Required | Description | |----------|----------|-------------| | `entity` | Yes | The Smart Climate entity ID (e.g. `climate.living_room`). | @@ -67,6 +122,9 @@ show_presence: true temp_sensor: sensor.living_room_temperature ``` + + + | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `entity` | Yes | — | The Smart Climate entity ID (e.g. `climate.living_room`). | diff --git a/custom_components/smart_climate/manifest.json b/custom_components/smart_climate/manifest.json index 2d0719b..81202f3 100644 --- a/custom_components/smart_climate/manifest.json +++ b/custom_components/smart_climate/manifest.json @@ -2,7 +2,7 @@ "domain": "smart_climate", "name": "Smart Climate Controller", "version": "1.0.0", - "documentation": "https://github.com/JansenDevelopment/ha-smart-climate", + "documentation": "https://github.com/JansenDevelopment/Smart-Climate-Controller", "dependencies": [], "codeowners": ["@JansenDevelopment"], "requirements": [], From 23744c456628b0c904029d85bad4fa1d0a526930 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:04:00 +0000 Subject: [PATCH 05/15] Add cooling support with HVAC-mode-aware setpoints The wrapper now mirrors the wrapped device's capabilities and manages setpoints per HVAC mode instead of assuming heat-only: - hvac_modes mirror the wrapped device (so fan_only/dry/etc. pass through); min/max temp and target step also mirror the device (fixes the hardcoded 5-25 range) with 5/35/0.5 fallbacks. - _update_target_temperature reads the wrapped device's current HVAC mode: heat/auto use the heating setpoints + schedule; cool uses the new cool_auto/cool_away setpoints; off/fan_only/dry (and an unavailable wrapped entity) write no setpoint at all. This also fixes the previously documented-but-missing behavior where "off" kept receiving temperature writes. - New cool_auto_temperature / cool_away_temperature settings: config-flow fields, YAML schema, set_cool_auto_temperature / set_cool_away_temperature services, extra-state attributes, and en/nl translations. Both persist to config-entry storage like the other runtime settings. - Pass through fan mode (only advertised when the device supports it) and turn_on/turn_off; supported_features is derived from the wrapped device. - Add tests/test_cooling.py (16 tests) covering setpoint family selection, no-write modes, capability mirroring, and cool-setpoint persistence. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- custom_components/smart_climate/__init__.py | 4 + custom_components/smart_climate/climate.py | 150 +++++++++++- .../smart_climate/config_flow.py | 8 + custom_components/smart_climate/const.py | 6 + custom_components/smart_climate/services.py | 18 ++ custom_components/smart_climate/services.yaml | 46 ++++ custom_components/smart_climate/strings.json | 8 +- .../smart_climate/translations/en.json | 8 +- .../smart_climate/translations/nl.json | 8 +- tests/conftest.py | 17 +- tests/test_cooling.py | 219 ++++++++++++++++++ 11 files changed, 473 insertions(+), 19 deletions(-) create mode 100644 tests/test_cooling.py diff --git a/custom_components/smart_climate/__init__.py b/custom_components/smart_climate/__init__.py index a8c419f..2f3abf1 100644 --- a/custom_components/smart_climate/__init__.py +++ b/custom_components/smart_climate/__init__.py @@ -12,6 +12,8 @@ CONF_INTERRUPTIBLE, CONF_DEFAULT_OVERRIDE_MODE, CONF_DEFAULT_OVERRIDE_DURATION, + CONF_COOL_AUTO_TEMPERATURE, + CONF_COOL_AWAY_TEMPERATURE, ) from .frontend import SmartClimateCardRegistration import voluptuous as vol @@ -27,6 +29,8 @@ vol.Required(CONF_WRAPPED_CLIMATE): cv.entity_id, vol.Required(CONF_ZONE_HOME): cv.entity_id, vol.Optional(CONF_AWAY_TEMPERATURE, default=14): vol.Coerce(float), + vol.Optional(CONF_COOL_AUTO_TEMPERATURE, default=24): vol.Coerce(float), + vol.Optional(CONF_COOL_AWAY_TEMPERATURE, default=28): vol.Coerce(float), vol.Optional(CONF_AWAY_DELAY_MINUTES, default=5): vol.Coerce(int), vol.Optional(CONF_INTERRUPTIBLE, default=True): cv.boolean, vol.Optional(CONF_DEFAULT_OVERRIDE_MODE, default="timer"): vol.In(["timer", "infinity", "next_node"]), diff --git a/custom_components/smart_climate/climate.py b/custom_components/smart_climate/climate.py index 6077e9f..33392fe 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -23,6 +23,8 @@ CONF_DEFAULT_OVERRIDE_DURATION, CONF_AUTO_TEMPERATURE, CONF_SCHEDULE, + CONF_COOL_AUTO_TEMPERATURE, + CONF_COOL_AWAY_TEMPERATURE, ATTR_MODE, ATTR_PRESENCE, ATTR_REMAINING_MINUTES, @@ -30,6 +32,8 @@ ATTR_AWAY_DELAY_SECONDS_REMAINING, ATTR_OVERRIDE_TEMPERATURE, ATTR_WRAPPED_CLIMATE, + ATTR_COOL_AUTO_TEMPERATURE, + ATTR_COOL_AWAY_TEMPERATURE, ) from . import schedule_helper from .services import async_register_services @@ -73,7 +77,11 @@ async def async_setup_entry( class SmartClimateEntity(ClimateEntity): """Smart Climate controller entity.""" - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + # HVAC modes for which the wrapper actively manages the target temperature. + # Everything else the wrapped device reports (off, fan_only, dry, …) is + # passed through untouched — the wrapper writes no setpoint for those. + _HEATING_MODES = (HVACMode.HEAT, HVACMode.AUTO) + _COOLING_MODES = (HVACMode.COOL,) def __init__( self, @@ -93,8 +101,6 @@ def __init__( self._attr_name = name self._attr_unique_id = f"smart_climate_{entry.entry_id}" self._attr_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_min_temp = 5 - self._attr_max_temp = 25 self._attr_should_poll = False # Config @@ -111,6 +117,8 @@ def __init__( self._presence = "away" # Start as away self._interruptible = interruptible self._auto_temperature = entry.data.get(CONF_AUTO_TEMPERATURE, 21) + self._cool_auto_temperature = entry.data.get(CONF_COOL_AUTO_TEMPERATURE, 24) + self._cool_away_temperature = entry.data.get(CONF_COOL_AWAY_TEMPERATURE, 28) self._override_temperature = 21 self._override_start_time = None self._override_duration_minutes = 0 @@ -234,12 +242,33 @@ async def _cancel_away_delay(self): self._away_delay_remaining = 0 async def _update_target_temperature(self): - """Calculate and update target temperature to wrapped climate.""" + """Calculate and push the target temperature to the wrapped climate. + + The wrapper only manages a setpoint while the wrapped device is in a + temperature-controlled mode (heat/cool/auto). When the device is off — + or in a mode that has no meaningful setpoint such as ``fan_only`` or + ``dry`` — no temperature is written and the device is left untouched. + Cooling modes use the dedicated ``cool_*`` setpoints; heat/auto use the + heating setpoints and schedule. + """ + wrapped = self.hass.states.get(self._wrapped_climate) + if wrapped is None: + # Wrapped entity unavailable — nothing we can safely control. + return + hvac_mode = wrapped.state + if hvac_mode not in self._HEATING_MODES and hvac_mode not in self._COOLING_MODES: + # off / fan_only / dry / unavailable — pass through, write no setpoint. + return + cooling = hvac_mode in self._COOLING_MODES + if self._mode in (MODE_OVERRIDE_TIMER, MODE_OVERRIDE_INFINITY, MODE_OVERRIDE_NEXT_NODE): target = self._override_temperature elif self._mode == MODE_AUTO: if self._presence == "home": - if self._schedule: + if cooling: + # Cooling has no schedule in v1 — use the flat cool setpoint. + target = self._cool_auto_temperature + elif self._schedule: target = schedule_helper.get_scheduled_temperature( self._schedule, dt_util.now(), self._auto_temperature ) @@ -253,9 +282,9 @@ async def _update_target_temperature(self): else: target = self._auto_temperature else: - target = self._away_temperature + target = self._cool_away_temperature if cooling else self._away_temperature else: - target = self._auto_temperature + target = self._cool_auto_temperature if cooling else self._auto_temperature # Set on wrapped climate await self.hass.services.async_call( @@ -341,6 +370,20 @@ async def async_set_away_temperature(self, temperature: float): await self._update_target_temperature() self.async_write_ha_state() + async def async_set_cool_auto_temperature(self, temperature: float): + """Set the target temperature used when home and the device is cooling.""" + self._cool_auto_temperature = temperature + self._persist(**{CONF_COOL_AUTO_TEMPERATURE: temperature}) + await self._update_target_temperature() + self.async_write_ha_state() + + async def async_set_cool_away_temperature(self, temperature: float): + """Set the target temperature used when away and the device is cooling.""" + self._cool_away_temperature = temperature + self._persist(**{CONF_COOL_AWAY_TEMPERATURE: temperature}) + await self._update_target_temperature() + self.async_write_ha_state() + async def async_set_away_delay(self, minutes: int): """Set the delay before switching to away mode.""" self._away_delay_minutes = minutes @@ -386,29 +429,112 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: "hvac_mode": hvac_mode, }, ) + # Re-evaluate the setpoint for the newly selected mode (heat/cool/auto + # pick different setpoints; off/fan_only/dry write nothing). + await self._update_target_temperature() + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Delegate fan-mode change to the wrapped climate entity.""" + await self.hass.services.async_call( + "climate", + "set_fan_mode", + {"entity_id": self._wrapped_climate, "fan_mode": fan_mode}, + ) + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Turn the wrapped climate off.""" + await self.hass.services.async_call( + "climate", "turn_off", {"entity_id": self._wrapped_climate} + ) self.async_write_ha_state() + async def async_turn_on(self) -> None: + """Turn the wrapped climate on.""" + await self.hass.services.async_call( + "climate", "turn_on", {"entity_id": self._wrapped_climate} + ) + await self._update_target_temperature() + self.async_write_ha_state() + + def _wrapped_state(self): + """Return the wrapped entity's state object, or ``None``.""" + return self.hass.states.get(self._wrapped_climate) + + @property + def supported_features(self): + """Mirror the wrapped device's fan/turn-on/off support.""" + features = ClimateEntityFeature.TARGET_TEMPERATURE + wrapped = self._wrapped_state() + if wrapped and (wrapped.attributes.get("supported_features", 0) & ClimateEntityFeature.FAN_MODE): + features |= ClimateEntityFeature.FAN_MODE + # We can always turn the wrapped device on/off via its hvac mode. + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + return features + @property def hvac_modes(self): - return [HVACMode.HEAT, HVACMode.OFF] + """Mirror the wrapped device's supported HVAC modes.""" + wrapped = self._wrapped_state() + if wrapped: + modes = wrapped.attributes.get("hvac_modes") + if modes: + return list(modes) + return [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO] @property def hvac_mode(self): - wrapped = self.hass.states.get(self._wrapped_climate) + wrapped = self._wrapped_state() if wrapped: return wrapped.state return HVACMode.HEAT + @property + def fan_modes(self): + wrapped = self._wrapped_state() + if wrapped: + return wrapped.attributes.get("fan_modes") + return None + + @property + def fan_mode(self): + wrapped = self._wrapped_state() + if wrapped: + return wrapped.attributes.get("fan_mode") + return None + + @property + def min_temp(self): + wrapped = self._wrapped_state() + if wrapped and wrapped.attributes.get("min_temp") is not None: + return wrapped.attributes["min_temp"] + return 5 + + @property + def max_temp(self): + wrapped = self._wrapped_state() + if wrapped and wrapped.attributes.get("max_temp") is not None: + return wrapped.attributes["max_temp"] + return 35 + + @property + def target_temperature_step(self): + wrapped = self._wrapped_state() + if wrapped and wrapped.attributes.get("target_temp_step") is not None: + return wrapped.attributes["target_temp_step"] + return 0.5 + @property def current_temperature(self): - wrapped = self.hass.states.get(self._wrapped_climate) + wrapped = self._wrapped_state() if wrapped: return wrapped.attributes.get("current_temperature") return None @property def target_temperature(self): - wrapped = self.hass.states.get(self._wrapped_climate) + wrapped = self._wrapped_state() if wrapped: return wrapped.attributes.get("temperature") return 21 @@ -428,4 +554,6 @@ def extra_state_attributes(self): ATTR_OVERRIDE_TEMPERATURE: self._override_temperature, ATTR_AWAY_DELAY_SECONDS_REMAINING: int(self._away_delay_remaining), ATTR_WRAPPED_CLIMATE: self._wrapped_climate, + ATTR_COOL_AUTO_TEMPERATURE: self._cool_auto_temperature, + ATTR_COOL_AWAY_TEMPERATURE: self._cool_away_temperature, } \ No newline at end of file diff --git a/custom_components/smart_climate/config_flow.py b/custom_components/smart_climate/config_flow.py index 7df5586..6935669 100644 --- a/custom_components/smart_climate/config_flow.py +++ b/custom_components/smart_climate/config_flow.py @@ -11,6 +11,8 @@ CONF_INTERRUPTIBLE, CONF_DEFAULT_OVERRIDE_MODE, CONF_DEFAULT_OVERRIDE_DURATION, + CONF_COOL_AUTO_TEMPERATURE, + CONF_COOL_AWAY_TEMPERATURE, ) @@ -55,6 +57,12 @@ async def async_step_user(self, user_input=None): vol.Optional(CONF_AWAY_TEMPERATURE, default=14): selector.NumberSelector( selector.NumberSelectorConfig(min=5, max=35, step=0.5, unit_of_measurement="°C", mode=selector.NumberSelectorMode.BOX) ), + vol.Optional(CONF_COOL_AUTO_TEMPERATURE, default=24): selector.NumberSelector( + selector.NumberSelectorConfig(min=15, max=35, step=0.5, unit_of_measurement="°C", mode=selector.NumberSelectorMode.BOX) + ), + vol.Optional(CONF_COOL_AWAY_TEMPERATURE, default=28): selector.NumberSelector( + selector.NumberSelectorConfig(min=15, max=35, step=0.5, unit_of_measurement="°C", mode=selector.NumberSelectorMode.BOX) + ), vol.Optional(CONF_AWAY_DELAY_MINUTES, default=5): selector.NumberSelector( selector.NumberSelectorConfig(min=0, max=120, step=1, unit_of_measurement="min", mode=selector.NumberSelectorMode.BOX) ), diff --git a/custom_components/smart_climate/const.py b/custom_components/smart_climate/const.py index b7b614d..f94ea54 100644 --- a/custom_components/smart_climate/const.py +++ b/custom_components/smart_climate/const.py @@ -17,6 +17,8 @@ CONF_DEFAULT_OVERRIDE_DURATION = "default_override_duration" CONF_AUTO_TEMPERATURE = "auto_temperature" CONF_SCHEDULE = "schedule" +CONF_COOL_AUTO_TEMPERATURE = "cool_auto_temperature" +CONF_COOL_AWAY_TEMPERATURE = "cool_away_temperature" # Attributes ATTR_MODE = "mode" @@ -27,6 +29,8 @@ ATTR_AWAY_DELAY_SECONDS_REMAINING = "away_delay_seconds_remaining" ATTR_OVERRIDE_TEMPERATURE = "override_temperature" ATTR_WRAPPED_CLIMATE = "wrapped_climate" +ATTR_COOL_AUTO_TEMPERATURE = "cool_auto_temperature" +ATTR_COOL_AWAY_TEMPERATURE = "cool_away_temperature" # Services SERVICE_SET_OVERRIDE_TIMER = "set_override_timer" @@ -36,6 +40,8 @@ SERVICE_SET_INTERRUPTIBLE = "set_interruptible" SERVICE_SET_AUTO_TEMPERATURE = "set_auto_temperature" SERVICE_SET_AWAY_TEMPERATURE = "set_away_temperature" +SERVICE_SET_COOL_AUTO_TEMPERATURE = "set_cool_auto_temperature" +SERVICE_SET_COOL_AWAY_TEMPERATURE = "set_cool_away_temperature" SERVICE_SET_AWAY_DELAY = "set_away_delay" SERVICE_SET_DEFAULT_OVERRIDE_MODE = "set_default_override_mode" SERVICE_SET_SCHEDULE = "set_schedule" diff --git a/custom_components/smart_climate/services.py b/custom_components/smart_climate/services.py index 2a7d506..0515c47 100644 --- a/custom_components/smart_climate/services.py +++ b/custom_components/smart_climate/services.py @@ -22,6 +22,8 @@ SERVICE_SET_INTERRUPTIBLE, SERVICE_SET_AUTO_TEMPERATURE, SERVICE_SET_AWAY_TEMPERATURE, + SERVICE_SET_COOL_AUTO_TEMPERATURE, + SERVICE_SET_COOL_AWAY_TEMPERATURE, SERVICE_SET_AWAY_DELAY, SERVICE_SET_DEFAULT_OVERRIDE_MODE, SERVICE_SET_SCHEDULE, @@ -101,6 +103,16 @@ async def handle_set_away_temperature(call: ServiceCall) -> None: if entity: await entity.async_set_away_temperature(call.data.get("temperature", 14)) + async def handle_set_cool_auto_temperature(call: ServiceCall) -> None: + entity = _get_entity(hass, call) + if entity: + await entity.async_set_cool_auto_temperature(call.data.get("temperature", 24)) + + async def handle_set_cool_away_temperature(call: ServiceCall) -> None: + entity = _get_entity(hass, call) + if entity: + await entity.async_set_cool_away_temperature(call.data.get("temperature", 28)) + async def handle_set_away_delay(call: ServiceCall) -> None: entity = _get_entity(hass, call) if entity: @@ -138,6 +150,12 @@ async def handle_set_schedule(call: ServiceCall) -> None: hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_TEMPERATURE, handle_set_away_temperature ) + hass.services.async_register( + DOMAIN, SERVICE_SET_COOL_AUTO_TEMPERATURE, handle_set_cool_auto_temperature + ) + hass.services.async_register( + DOMAIN, SERVICE_SET_COOL_AWAY_TEMPERATURE, handle_set_cool_away_temperature + ) hass.services.async_register( DOMAIN, SERVICE_SET_AWAY_DELAY, handle_set_away_delay ) diff --git a/custom_components/smart_climate/services.yaml b/custom_components/smart_climate/services.yaml index d62b324..6c50e18 100644 --- a/custom_components/smart_climate/services.yaml +++ b/custom_components/smart_climate/services.yaml @@ -154,6 +154,52 @@ set_away_temperature: step: 0.5 unit_of_measurement: °C +set_cool_auto_temperature: + name: Set Cool Auto Temperature + description: Set the target temperature used when home and the wrapped device is cooling + fields: + entity_id: + name: Entity + description: Climate entity + required: true + selector: + entity: + domain: climate + temperature: + name: Temperature + description: Target temperature when home (cooling) + required: true + default: 24 + selector: + number: + min: 15 + max: 35 + step: 0.5 + unit_of_measurement: °C + +set_cool_away_temperature: + name: Set Cool Away Temperature + description: Set the target temperature used when away and the wrapped device is cooling + fields: + entity_id: + name: Entity + description: Climate entity + required: true + selector: + entity: + domain: climate + temperature: + name: Temperature + description: Target temperature when away (cooling) + required: true + default: 28 + selector: + number: + min: 15 + max: 35 + step: 0.5 + unit_of_measurement: °C + set_away_delay: name: Set Away Delay description: Set the delay in minutes before applying away temperature after everyone leaves diff --git a/custom_components/smart_climate/strings.json b/custom_components/smart_climate/strings.json index 4edc71b..79022c4 100644 --- a/custom_components/smart_climate/strings.json +++ b/custom_components/smart_climate/strings.json @@ -9,6 +9,8 @@ "wrapped_climate": "Wrapped Climate Entity", "zone_home": "Home Zone", "away_temperature": "Away Temperature", + "cool_auto_temperature": "Cooling Home Temperature", + "cool_away_temperature": "Cooling Away Temperature", "away_delay_minutes": "Away Delay (minutes)", "interruptible": "Override is interruptible", "default_override_mode": "Default Override Mode", @@ -17,10 +19,12 @@ "data_description": { "wrapped_climate": "The climate entity to wrap and control", "zone_home": "The zone entity used for presence detection", - "away_temperature": "Target temperature when nobody is home", + "away_temperature": "Target temperature when nobody is home (heating)", + "cool_auto_temperature": "Target temperature when home and the device is cooling", + "cool_away_temperature": "Target temperature when away and the device is cooling", "away_delay_minutes": "Minutes to wait before applying the away temperature", "interruptible": "Whether a presence change can interrupt an active override", - "default_override_mode": "Override mode used when adjusting temperature (timer or infinity)", + "default_override_mode": "Override mode used when adjusting temperature (timer, infinity, or next_node)", "default_override_duration": "Default duration in minutes for timer override mode" } } diff --git a/custom_components/smart_climate/translations/en.json b/custom_components/smart_climate/translations/en.json index 4edc71b..79022c4 100644 --- a/custom_components/smart_climate/translations/en.json +++ b/custom_components/smart_climate/translations/en.json @@ -9,6 +9,8 @@ "wrapped_climate": "Wrapped Climate Entity", "zone_home": "Home Zone", "away_temperature": "Away Temperature", + "cool_auto_temperature": "Cooling Home Temperature", + "cool_away_temperature": "Cooling Away Temperature", "away_delay_minutes": "Away Delay (minutes)", "interruptible": "Override is interruptible", "default_override_mode": "Default Override Mode", @@ -17,10 +19,12 @@ "data_description": { "wrapped_climate": "The climate entity to wrap and control", "zone_home": "The zone entity used for presence detection", - "away_temperature": "Target temperature when nobody is home", + "away_temperature": "Target temperature when nobody is home (heating)", + "cool_auto_temperature": "Target temperature when home and the device is cooling", + "cool_away_temperature": "Target temperature when away and the device is cooling", "away_delay_minutes": "Minutes to wait before applying the away temperature", "interruptible": "Whether a presence change can interrupt an active override", - "default_override_mode": "Override mode used when adjusting temperature (timer or infinity)", + "default_override_mode": "Override mode used when adjusting temperature (timer, infinity, or next_node)", "default_override_duration": "Default duration in minutes for timer override mode" } } diff --git a/custom_components/smart_climate/translations/nl.json b/custom_components/smart_climate/translations/nl.json index baced02..7127b8c 100644 --- a/custom_components/smart_climate/translations/nl.json +++ b/custom_components/smart_climate/translations/nl.json @@ -9,6 +9,8 @@ "wrapped_climate": "Gekoppeld Klimaatapparaat", "zone_home": "Thuiszone", "away_temperature": "Weg Temperatuur", + "cool_auto_temperature": "Koelen Thuis Temperatuur", + "cool_away_temperature": "Koelen Weg Temperatuur", "away_delay_minutes": "Vertrekvertraging (minuten)", "interruptible": "Overschrijving is onderbrekbaar", "default_override_mode": "Standaard Overschrijfmodus", @@ -17,10 +19,12 @@ "data_description": { "wrapped_climate": "Het klimaatapparaat om te beheren", "zone_home": "De zone die gebruikt wordt voor aanwezigheidsdetectie", - "away_temperature": "Doeltemperatuur wanneer niemand thuis is", + "away_temperature": "Doeltemperatuur wanneer niemand thuis is (verwarmen)", + "cool_auto_temperature": "Doeltemperatuur wanneer thuis en het apparaat koelt", + "cool_away_temperature": "Doeltemperatuur wanneer weg en het apparaat koelt", "away_delay_minutes": "Minuten te wachten voordat de weg-temperatuur wordt toegepast", "interruptible": "Of een aanwezigheidswijziging een actieve overschrijving kan onderbreken", - "default_override_mode": "Overschrijfmodus bij het aanpassen van de temperatuur (timer of oneindig)", + "default_override_mode": "Overschrijfmodus bij het aanpassen van de temperatuur (timer, oneindig of volgend moment)", "default_override_duration": "Standaardduur in minuten voor timer-overschrijfmodus" } } diff --git a/tests/conftest.py b/tests/conftest.py index 397588d..c9a30a4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -62,8 +62,21 @@ def _ensure_parent(dotted_name: str): _register_stub( "homeassistant.components.climate", ClimateEntity=_ClimateEntity, - ClimateEntityFeature=MagicMock(TARGET_TEMPERATURE=1, PRESET_MODE=2), - HVACMode=MagicMock(OFF="off", HEAT="heat"), + ClimateEntityFeature=MagicMock( + TARGET_TEMPERATURE=1, + FAN_MODE=8, + PRESET_MODE=16, + TURN_OFF=128, + TURN_ON=256, + ), + HVACMode=MagicMock( + OFF="off", + HEAT="heat", + COOL="cool", + AUTO="auto", + FAN_ONLY="fan_only", + DRY="dry", + ), ) # homeassistant.components.lovelace diff --git a/tests/test_cooling.py b/tests/test_cooling.py new file mode 100644 index 0000000..5d85b30 --- /dev/null +++ b/tests/test_cooling.py @@ -0,0 +1,219 @@ +"""Tests for cooling support and HVAC-mode-aware setpoint resolution. + +The wrapper reads the wrapped device's current HVAC mode and: + - heat / auto → heating setpoints (away/auto/schedule), + - cool → cooling setpoints (cool_away / cool_auto), + - off / fan_only / dry → writes no setpoint at all. +""" + +import pathlib +import sys +from unittest.mock import AsyncMock, MagicMock + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from custom_components.smart_climate.const import ( + CONF_COOL_AUTO_TEMPERATURE, + CONF_COOL_AWAY_TEMPERATURE, + MODE_AUTO, +) + +WRAPPED = "climate.wrapped" + + +def _make_entity(wrapped_mode="heat", wrapped_attrs=None, entry_data=None): + """Build an entity whose wrapped device reports *wrapped_mode*. + + ``hass.states.get`` returns a wrapped-climate state for the wrapped id and + ``None`` for anything else (e.g. the zone), so presence stays put. + """ + from custom_components.smart_climate.climate import SmartClimateEntity + + wrapped_state = MagicMock() + wrapped_state.state = wrapped_mode + wrapped_state.attributes = wrapped_attrs or {} + + hass = MagicMock() + hass.services.async_call = AsyncMock() + + def _states_get(entity_id): + return wrapped_state if entity_id == WRAPPED else None + + hass.states.get = MagicMock(side_effect=_states_get) + + entry = MagicMock() + entry.entry_id = "test_entry" + entry.data = dict(entry_data or {}) + + def _update_entry(target, data=None, **kwargs): + if data is not None: + target.data = dict(data) + return True + + hass.config_entries.async_update_entry = MagicMock(side_effect=_update_entry) + + entity = SmartClimateEntity( + hass=hass, + entry=entry, + name="Test Climate", + wrapped_climate=WRAPPED, + zone_home="zone.home", + away_temp=14.0, + away_delay_minutes=0, + interruptible=True, + default_override_mode="timer", + default_override_duration=30, + ) + entity.async_write_ha_state = MagicMock() + entity._mode = MODE_AUTO + return entity, hass + + +def _last_set_temperature(hass): + """Return the temperature from the last climate.set_temperature call, or None.""" + for call in reversed(hass.services.async_call.call_args_list): + args = call.args + if len(args) >= 2 and args[0] == "climate" and args[1] == "set_temperature": + return args[2]["temperature"] + return None + + +# --------------------------------------------------------------------------- +# Heating vs cooling setpoint family +# --------------------------------------------------------------------------- + +async def test_heat_home_uses_auto_temperature(): + entity, hass = _make_entity(wrapped_mode="heat") + entity._presence = "home" + entity._auto_temperature = 21 + await entity._update_target_temperature() + assert _last_set_temperature(hass) == 21 + + +async def test_heat_away_uses_away_temperature(): + entity, hass = _make_entity(wrapped_mode="heat") + entity._presence = "away" + await entity._update_target_temperature() + assert _last_set_temperature(hass) == 14.0 + + +async def test_cool_home_uses_cool_auto_temperature(): + entity, hass = _make_entity( + wrapped_mode="cool", + entry_data={CONF_COOL_AUTO_TEMPERATURE: 24, CONF_COOL_AWAY_TEMPERATURE: 28}, + ) + entity._presence = "home" + await entity._update_target_temperature() + assert _last_set_temperature(hass) == 24 + + +async def test_cool_away_uses_cool_away_temperature(): + entity, hass = _make_entity( + wrapped_mode="cool", + entry_data={CONF_COOL_AUTO_TEMPERATURE: 24, CONF_COOL_AWAY_TEMPERATURE: 28}, + ) + entity._presence = "away" + await entity._update_target_temperature() + assert _last_set_temperature(hass) == 28 + + +async def test_cool_ignores_heating_schedule(): + """A configured heating schedule must not affect cooling setpoints.""" + entity, hass = _make_entity( + wrapped_mode="cool", entry_data={CONF_COOL_AUTO_TEMPERATURE: 25} + ) + entity._presence = "home" + entity._schedule = {"mode": "daily", "daily": [{"time": "00:00", "temp": 18}]} + await entity._update_target_temperature() + assert _last_set_temperature(hass) == 25 + + +async def test_auto_mode_uses_heating_comfort_target(): + entity, hass = _make_entity(wrapped_mode="auto") + entity._presence = "home" + entity._auto_temperature = 20 + await entity._update_target_temperature() + assert _last_set_temperature(hass) == 20 + + +# --------------------------------------------------------------------------- +# Non-managed modes write no setpoint +# --------------------------------------------------------------------------- + +async def test_off_writes_no_setpoint(): + entity, hass = _make_entity(wrapped_mode="off") + entity._presence = "home" + await entity._update_target_temperature() + assert _last_set_temperature(hass) is None + + +async def test_fan_only_writes_no_setpoint(): + entity, hass = _make_entity(wrapped_mode="fan_only") + entity._presence = "home" + await entity._update_target_temperature() + assert _last_set_temperature(hass) is None + + +async def test_dry_writes_no_setpoint(): + entity, hass = _make_entity(wrapped_mode="dry") + entity._presence = "home" + await entity._update_target_temperature() + assert _last_set_temperature(hass) is None + + +async def test_unavailable_wrapped_writes_no_setpoint(): + entity, hass = _make_entity() + hass.states.get = MagicMock(return_value=None) + await entity._update_target_temperature() + assert _last_set_temperature(hass) is None + + +# --------------------------------------------------------------------------- +# Mirroring the wrapped device's capabilities +# --------------------------------------------------------------------------- + +def test_hvac_modes_mirror_wrapped(): + modes = ["heat", "fan_only", "dry", "cool", "auto", "off"] + entity, _ = _make_entity(wrapped_mode="heat", wrapped_attrs={"hvac_modes": modes}) + assert entity.hvac_modes == modes + + +def test_min_max_step_mirror_wrapped(): + entity, _ = _make_entity( + wrapped_mode="heat", + wrapped_attrs={"min_temp": 7, "max_temp": 35, "target_temp_step": 1}, + ) + assert entity.min_temp == 7 + assert entity.max_temp == 35 + assert entity.target_temperature_step == 1 + + +def test_supported_features_advertise_fan_when_wrapped_supports_it(): + from custom_components.smart_climate.climate import ClimateEntityFeature + + # 393 = TARGET_TEMPERATURE | FAN_MODE | TURN_OFF | TURN_ON (the Qlima airco) + entity, _ = _make_entity( + wrapped_mode="cool", wrapped_attrs={"supported_features": 393} + ) + assert entity.supported_features & ClimateEntityFeature.FAN_MODE + + +def test_supported_features_no_fan_when_wrapped_lacks_it(): + from custom_components.smart_climate.climate import ClimateEntityFeature + + entity, _ = _make_entity( + wrapped_mode="heat", wrapped_attrs={"supported_features": 1} + ) + assert not (entity.supported_features & ClimateEntityFeature.FAN_MODE) + + +async def test_set_cool_auto_temperature_persists(): + entity, _ = _make_entity(wrapped_mode="cool") + await entity.async_set_cool_auto_temperature(23) + assert entity.entry.data[CONF_COOL_AUTO_TEMPERATURE] == 23 + + +async def test_set_cool_away_temperature_persists(): + entity, _ = _make_entity(wrapped_mode="cool") + await entity.async_set_cool_away_temperature(30) + assert entity.entry.data[CONF_COOL_AWAY_TEMPERATURE] == 30 From 5626e3906fac1b6522ef3571838c4c2a7caebcee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:07:51 +0000 Subject: [PATCH 06/15] Add native number/switch/select helper entities (#2) Expose every runtime setting as a standard Home Assistant entity so users get history, automations, and native UI control without the custom cards: - number: Home/Away temperature, Cooling Home/Away temperature, Away Delay, Default Override Duration. - switch: Override Interruptible. - select: Default Override Mode (timer/infinity/next_node). Implementation: - New entity_base.SmartClimateChildEntity mixin resolves the paired climate entity via the entity registry, subscribes to its state changes, reads values from its attributes, and writes back through the smart_climate.* services. - Climate entity now publishes auto/away temperature, away-delay minutes, and default override mode/duration as state attributes so the helpers (and cards) have a source of truth. - All entities (climate, presence sensor, helpers) share device_info so they group under one device. - The duration number and mode select are coupled through set_default_override_mode (the backend sets both together), preserving the other value. - Register number/switch/select platforms; add tests/test_helper_entities.py and conftest stubs for the new component modules. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- custom_components/smart_climate/climate.py | 20 +++ custom_components/smart_climate/const.py | 7 +- .../smart_climate/entity_base.py | 87 +++++++++ custom_components/smart_climate/number.py | 164 +++++++++++++++++ custom_components/smart_climate/select.py | 48 +++++ custom_components/smart_climate/sensor.py | 10 ++ custom_components/smart_climate/switch.py | 37 ++++ tests/conftest.py | 21 +++ tests/test_helper_entities.py | 169 ++++++++++++++++++ 9 files changed, 562 insertions(+), 1 deletion(-) create mode 100644 custom_components/smart_climate/entity_base.py create mode 100644 custom_components/smart_climate/number.py create mode 100644 custom_components/smart_climate/select.py create mode 100644 custom_components/smart_climate/switch.py create mode 100644 tests/test_helper_entities.py diff --git a/custom_components/smart_climate/climate.py b/custom_components/smart_climate/climate.py index 33392fe..a571625 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -34,6 +34,11 @@ ATTR_WRAPPED_CLIMATE, ATTR_COOL_AUTO_TEMPERATURE, ATTR_COOL_AWAY_TEMPERATURE, + ATTR_AUTO_TEMPERATURE, + ATTR_AWAY_TEMPERATURE, + ATTR_AWAY_DELAY_MINUTES, + ATTR_DEFAULT_OVERRIDE_MODE, + ATTR_DEFAULT_OVERRIDE_DURATION, ) from . import schedule_helper from .services import async_register_services @@ -539,6 +544,16 @@ def target_temperature(self): return wrapped.attributes.get("temperature") return 21 + @property + def device_info(self): + """Group all Smart Climate entities for this entry under one device.""" + return { + "identifiers": {(DOMAIN, self.entry.entry_id)}, + "name": self._attr_name, + "manufacturer": "Smart Climate", + "model": "Smart Climate Controller", + } + @property def extra_state_attributes(self): remaining_minutes = 0 @@ -556,4 +571,9 @@ def extra_state_attributes(self): ATTR_WRAPPED_CLIMATE: self._wrapped_climate, ATTR_COOL_AUTO_TEMPERATURE: self._cool_auto_temperature, ATTR_COOL_AWAY_TEMPERATURE: self._cool_away_temperature, + ATTR_AUTO_TEMPERATURE: self._auto_temperature, + ATTR_AWAY_TEMPERATURE: self._away_temperature, + ATTR_AWAY_DELAY_MINUTES: self._away_delay_minutes, + ATTR_DEFAULT_OVERRIDE_MODE: self._default_override_mode, + ATTR_DEFAULT_OVERRIDE_DURATION: self._default_override_duration, } \ No newline at end of file diff --git a/custom_components/smart_climate/const.py b/custom_components/smart_climate/const.py index f94ea54..7d65fe9 100644 --- a/custom_components/smart_climate/const.py +++ b/custom_components/smart_climate/const.py @@ -1,5 +1,5 @@ DOMAIN = "smart_climate" -PLATFORMS = ["climate", "sensor"] +PLATFORMS = ["climate", "sensor", "number", "switch", "select"] # Modes MODE_AUTO = "auto" @@ -31,6 +31,11 @@ ATTR_WRAPPED_CLIMATE = "wrapped_climate" ATTR_COOL_AUTO_TEMPERATURE = "cool_auto_temperature" ATTR_COOL_AWAY_TEMPERATURE = "cool_away_temperature" +ATTR_AUTO_TEMPERATURE = "auto_temperature" +ATTR_AWAY_TEMPERATURE = "away_temperature" +ATTR_AWAY_DELAY_MINUTES = "away_delay_minutes" +ATTR_DEFAULT_OVERRIDE_MODE = "default_override_mode" +ATTR_DEFAULT_OVERRIDE_DURATION = "default_override_duration" # Services SERVICE_SET_OVERRIDE_TIMER = "set_override_timer" diff --git a/custom_components/smart_climate/entity_base.py b/custom_components/smart_climate/entity_base.py new file mode 100644 index 0000000..06db9fd --- /dev/null +++ b/custom_components/smart_climate/entity_base.py @@ -0,0 +1,87 @@ +"""Shared base for Smart Climate helper entities (number/switch/select). + +These entities are thin controls that mirror a value from the paired Smart +Climate *climate* entity and write changes back through the ``smart_climate.*`` +services. They resolve their climate entity via the entity registry using the +``smart_climate_{entry_id}`` unique-id convention (the same trick the presence +sensor uses) and re-render whenever the climate entity's state changes. +""" + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.event import async_track_state_change_event + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class SmartClimateChildEntity: + """Mixin that binds a helper entity to its paired climate entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, key: str) -> None: + self.hass = hass + self._entry = entry + self._key = key + self._attr_unique_id = f"smart_climate_{key}_{entry.entry_id}" + self._climate_entity_id: str | None = None + + @property + def device_info(self): + """Group all Smart Climate entities for this entry under one device.""" + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": self._entry.data.get("name", "Smart Climate"), + "manufacturer": "Smart Climate", + "model": "Smart Climate Controller", + } + + async def async_added_to_hass(self) -> None: + """Resolve the paired climate entity and subscribe to its changes.""" + registry = er.async_get(self.hass) + self._climate_entity_id = registry.async_get_entity_id( + "climate", DOMAIN, f"smart_climate_{self._entry.entry_id}" + ) + if not self._climate_entity_id: + _LOGGER.warning( + "%s: could not find paired climate entity for entry %s", + type(self).__name__, + self._entry.entry_id, + ) + return + + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._climate_entity_id], self._on_climate_change + ) + ) + self.async_write_ha_state() + + async def _on_climate_change(self, event) -> None: + self.async_write_ha_state() + + def _climate_attr(self, attr: str, default=None): + """Read an attribute from the paired climate entity's state.""" + if not self._climate_entity_id: + return default + state = self.hass.states.get(self._climate_entity_id) + if not state: + return default + return state.attributes.get(attr, default) + + async def _call_service(self, service: str, **data) -> None: + """Call a smart_climate service targeting the paired climate entity.""" + if not self._climate_entity_id: + return + await self.hass.services.async_call( + DOMAIN, + service, + {"entity_id": self._climate_entity_id, **data}, + blocking=True, + ) diff --git a/custom_components/smart_climate/number.py b/custom_components/smart_climate/number.py new file mode 100644 index 0000000..2323154 --- /dev/null +++ b/custom_components/smart_climate/number.py @@ -0,0 +1,164 @@ +"""Number platform — native controls for Smart Climate's numeric settings.""" + +from dataclasses import dataclass + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_AUTO_TEMPERATURE, + ATTR_AWAY_DELAY_MINUTES, + ATTR_AWAY_TEMPERATURE, + ATTR_COOL_AUTO_TEMPERATURE, + ATTR_COOL_AWAY_TEMPERATURE, + ATTR_DEFAULT_OVERRIDE_DURATION, + ATTR_DEFAULT_OVERRIDE_MODE, + SERVICE_SET_AUTO_TEMPERATURE, + SERVICE_SET_AWAY_DELAY, + SERVICE_SET_AWAY_TEMPERATURE, + SERVICE_SET_COOL_AUTO_TEMPERATURE, + SERVICE_SET_COOL_AWAY_TEMPERATURE, + SERVICE_SET_DEFAULT_OVERRIDE_MODE, +) +from .entity_base import SmartClimateChildEntity + + +@dataclass(frozen=True) +class SmartClimateNumberDescription: + """Describes one number helper entity.""" + + key: str + name: str + attr: str + service: str + data_key: str + min_value: float + max_value: float + step: float + unit: str | None = None + icon: str | None = None + # When True, the value is the timer duration, which the backend sets + # together with the (current) default override mode. + mode_coupled: bool = False + + +NUMBERS: tuple[SmartClimateNumberDescription, ...] = ( + SmartClimateNumberDescription( + key="auto_temperature", + name="Home Temperature", + attr=ATTR_AUTO_TEMPERATURE, + service=SERVICE_SET_AUTO_TEMPERATURE, + data_key="temperature", + min_value=5, + max_value=35, + step=0.5, + unit=UnitOfTemperature.CELSIUS, + icon="mdi:home-thermometer", + ), + SmartClimateNumberDescription( + key="away_temperature", + name="Away Temperature", + attr=ATTR_AWAY_TEMPERATURE, + service=SERVICE_SET_AWAY_TEMPERATURE, + data_key="temperature", + min_value=5, + max_value=35, + step=0.5, + unit=UnitOfTemperature.CELSIUS, + icon="mdi:home-export-outline", + ), + SmartClimateNumberDescription( + key="cool_auto_temperature", + name="Cooling Home Temperature", + attr=ATTR_COOL_AUTO_TEMPERATURE, + service=SERVICE_SET_COOL_AUTO_TEMPERATURE, + data_key="temperature", + min_value=15, + max_value=35, + step=0.5, + unit=UnitOfTemperature.CELSIUS, + icon="mdi:snowflake-thermometer", + ), + SmartClimateNumberDescription( + key="cool_away_temperature", + name="Cooling Away Temperature", + attr=ATTR_COOL_AWAY_TEMPERATURE, + service=SERVICE_SET_COOL_AWAY_TEMPERATURE, + data_key="temperature", + min_value=15, + max_value=35, + step=0.5, + unit=UnitOfTemperature.CELSIUS, + icon="mdi:snowflake", + ), + SmartClimateNumberDescription( + key="away_delay_minutes", + name="Away Delay", + attr=ATTR_AWAY_DELAY_MINUTES, + service=SERVICE_SET_AWAY_DELAY, + data_key="minutes", + min_value=0, + max_value=120, + step=1, + unit="min", + icon="mdi:timer-sand", + ), + SmartClimateNumberDescription( + key="default_override_duration", + name="Default Override Duration", + attr=ATTR_DEFAULT_OVERRIDE_DURATION, + service=SERVICE_SET_DEFAULT_OVERRIDE_MODE, + data_key="duration", + min_value=1, + max_value=1440, + step=1, + unit="min", + icon="mdi:timer-cog-outline", + mode_coupled=True, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Smart Climate number helpers.""" + async_add_entities( + SmartClimateNumber(hass, entry, description) for description in NUMBERS + ) + + +class SmartClimateNumber(SmartClimateChildEntity, NumberEntity): + """A numeric Smart Climate setting exposed as a native number entity.""" + + def __init__(self, hass, entry, description: SmartClimateNumberDescription) -> None: + super().__init__(hass, entry, description.key) + self._description = description + self._attr_name = description.name + self._attr_native_min_value = description.min_value + self._attr_native_max_value = description.max_value + self._attr_native_step = description.step + self._attr_native_unit_of_measurement = description.unit + self._attr_icon = description.icon + self._attr_mode = NumberMode.BOX + + @property + def native_value(self): + return self._climate_attr(self._description.attr) + + async def async_set_native_value(self, value: float) -> None: + if self._description.mode_coupled: + # Duration is set alongside the current default override mode. + mode = self._climate_attr(ATTR_DEFAULT_OVERRIDE_MODE, "timer") + await self._call_service( + self._description.service, mode=mode, duration=int(value) + ) + else: + await self._call_service( + self._description.service, **{self._description.data_key: value} + ) diff --git a/custom_components/smart_climate/select.py b/custom_components/smart_climate/select.py new file mode 100644 index 0000000..418cbae --- /dev/null +++ b/custom_components/smart_climate/select.py @@ -0,0 +1,48 @@ +"""Select platform — native picker for the default override mode.""" + +from homeassistant.components.select import SelectEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + ATTR_DEFAULT_OVERRIDE_DURATION, + ATTR_DEFAULT_OVERRIDE_MODE, + SERVICE_SET_DEFAULT_OVERRIDE_MODE, +) +from .entity_base import SmartClimateChildEntity + +OVERRIDE_MODES = ["timer", "infinity", "next_node"] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Smart Climate select helpers.""" + async_add_entities([SmartClimateOverrideModeSelect(hass, entry)]) + + +class SmartClimateOverrideModeSelect(SmartClimateChildEntity, SelectEntity): + """Pick the default override mode used when a temperature is changed.""" + + _attr_options = OVERRIDE_MODES + + def __init__(self, hass, entry) -> None: + super().__init__(hass, entry, "default_override_mode") + self._attr_name = "Default Override Mode" + self._attr_icon = "mdi:gesture-tap-button" + + @property + def current_option(self): + mode = self._climate_attr(ATTR_DEFAULT_OVERRIDE_MODE) + return mode if mode in OVERRIDE_MODES else None + + async def async_select_option(self, option: str) -> None: + # The backend sets mode and duration together; preserve the current + # duration when only the mode changes. + duration = self._climate_attr(ATTR_DEFAULT_OVERRIDE_DURATION, 30) + await self._call_service( + SERVICE_SET_DEFAULT_OVERRIDE_MODE, mode=option, duration=int(duration) + ) diff --git a/custom_components/smart_climate/sensor.py b/custom_components/smart_climate/sensor.py index 9222d41..38db4c3 100644 --- a/custom_components/smart_climate/sensor.py +++ b/custom_components/smart_climate/sensor.py @@ -74,6 +74,16 @@ async def _on_climate_change(self, event) -> None: self._native_value = presence self.async_write_ha_state() + @property + def device_info(self): + """Group all Smart Climate entities for this entry under one device.""" + return { + "identifiers": {(DOMAIN, self._entry.entry_id)}, + "name": self._entry.data.get("name", "Smart Climate"), + "manufacturer": "Smart Climate", + "model": "Smart Climate Controller", + } + @property def native_value(self) -> str | None: return self._native_value diff --git a/custom_components/smart_climate/switch.py b/custom_components/smart_climate/switch.py new file mode 100644 index 0000000..58e9d6c --- /dev/null +++ b/custom_components/smart_climate/switch.py @@ -0,0 +1,37 @@ +"""Switch platform — native toggle for the interruptible setting.""" + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_INTERRUPTIBLE, SERVICE_SET_INTERRUPTIBLE +from .entity_base import SmartClimateChildEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Smart Climate switch helpers.""" + async_add_entities([SmartClimateInterruptibleSwitch(hass, entry)]) + + +class SmartClimateInterruptibleSwitch(SmartClimateChildEntity, SwitchEntity): + """Toggle whether a presence change interrupts an active override.""" + + def __init__(self, hass, entry) -> None: + super().__init__(hass, entry, "interruptible") + self._attr_name = "Override Interruptible" + self._attr_icon = "mdi:motion-sensor" + + @property + def is_on(self): + return bool(self._climate_attr(ATTR_INTERRUPTIBLE, False)) + + async def async_turn_on(self, **kwargs) -> None: + await self._call_service(SERVICE_SET_INTERRUPTIBLE, interruptible=True) + + async def async_turn_off(self, **kwargs) -> None: + await self._call_service(SERVICE_SET_INTERRUPTIBLE, interruptible=False) diff --git a/tests/conftest.py b/tests/conftest.py index c9a30a4..fadbeb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,9 +82,30 @@ def _ensure_parent(dotted_name: str): # homeassistant.components.lovelace _register_stub("homeassistant.components.lovelace", MODE_STORAGE="storage") +# homeassistant.components.number / switch / select +_register_stub( + "homeassistant.components.number", + NumberEntity=type("NumberEntity", (), {}), + NumberMode=MagicMock(BOX="box", AUTO="auto", SLIDER="slider"), +) +_register_stub( + "homeassistant.components.switch", + SwitchEntity=type("SwitchEntity", (), {}), +) +_register_stub( + "homeassistant.components.select", + SelectEntity=type("SelectEntity", (), {}), +) + # homeassistant.helpers _register_stub("homeassistant.helpers") +# homeassistant.helpers.entity_registry +_register_stub( + "homeassistant.helpers.entity_registry", + async_get=MagicMock(), +) + # homeassistant.helpers.entity_platform _register_stub( "homeassistant.helpers.entity_platform", diff --git a/tests/test_helper_entities.py b/tests/test_helper_entities.py new file mode 100644 index 0000000..e8a39a5 --- /dev/null +++ b/tests/test_helper_entities.py @@ -0,0 +1,169 @@ +"""Tests for the number/switch/select helper entities. + +They mirror values from the paired climate entity's attributes and write +changes back through the smart_climate.* services. +""" + +import pathlib +import sys +from unittest.mock import AsyncMock, MagicMock + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from custom_components.smart_climate.const import ( + ATTR_AUTO_TEMPERATURE, + ATTR_DEFAULT_OVERRIDE_DURATION, + ATTR_DEFAULT_OVERRIDE_MODE, + ATTR_INTERRUPTIBLE, + DOMAIN, + SERVICE_SET_AUTO_TEMPERATURE, + SERVICE_SET_DEFAULT_OVERRIDE_MODE, + SERVICE_SET_INTERRUPTIBLE, +) + +CLIMATE_ID = "climate.smart" + + +def _hass_with_climate(attrs): + hass = MagicMock() + hass.services.async_call = AsyncMock() + climate_state = MagicMock() + climate_state.attributes = attrs + hass.states.get = MagicMock( + side_effect=lambda eid: climate_state if eid == CLIMATE_ID else None + ) + return hass + + +def _entry(): + entry = MagicMock() + entry.entry_id = "e1" + entry.data = {"name": "Living Room"} + return entry + + +def _bind(entity, hass): + """Attach runtime hooks a real Entity would provide, and bind the climate.""" + entity.hass = hass + entity._climate_entity_id = CLIMATE_ID + entity.async_write_ha_state = MagicMock() + entity.async_on_remove = MagicMock() + return entity + + +def _last_service_call(hass): + call = hass.services.async_call.call_args + return call.args, call.kwargs + + +# --------------------------------------------------------------------------- +# Number +# --------------------------------------------------------------------------- + +async def test_number_reads_value_from_climate_attribute(): + from custom_components.smart_climate.number import NUMBERS, SmartClimateNumber + + desc = next(d for d in NUMBERS if d.key == "auto_temperature") + hass = _hass_with_climate({ATTR_AUTO_TEMPERATURE: 21.5}) + number = _bind(SmartClimateNumber(hass, _entry(), desc), hass) + assert number.native_value == 21.5 + + +async def test_number_set_calls_service(): + from custom_components.smart_climate.number import NUMBERS, SmartClimateNumber + + desc = next(d for d in NUMBERS if d.key == "auto_temperature") + hass = _hass_with_climate({ATTR_AUTO_TEMPERATURE: 21.5}) + number = _bind(SmartClimateNumber(hass, _entry(), desc), hass) + + await number.async_set_native_value(19.0) + + args, _ = _last_service_call(hass) + assert args[0] == DOMAIN + assert args[1] == SERVICE_SET_AUTO_TEMPERATURE + assert args[2] == {"entity_id": CLIMATE_ID, "temperature": 19.0} + + +async def test_duration_number_couples_current_mode(): + from custom_components.smart_climate.number import NUMBERS, SmartClimateNumber + + desc = next(d for d in NUMBERS if d.key == "default_override_duration") + hass = _hass_with_climate( + {ATTR_DEFAULT_OVERRIDE_MODE: "infinity", ATTR_DEFAULT_OVERRIDE_DURATION: 30} + ) + number = _bind(SmartClimateNumber(hass, _entry(), desc), hass) + + await number.async_set_native_value(45) + + args, _ = _last_service_call(hass) + assert args[1] == SERVICE_SET_DEFAULT_OVERRIDE_MODE + assert args[2] == {"entity_id": CLIMATE_ID, "mode": "infinity", "duration": 45} + + +# --------------------------------------------------------------------------- +# Switch +# --------------------------------------------------------------------------- + +async def test_switch_reflects_interruptible(): + from custom_components.smart_climate.switch import SmartClimateInterruptibleSwitch + + hass = _hass_with_climate({ATTR_INTERRUPTIBLE: True}) + switch = _bind(SmartClimateInterruptibleSwitch(hass, _entry()), hass) + assert switch.is_on is True + + +async def test_switch_turn_off_calls_service(): + from custom_components.smart_climate.switch import SmartClimateInterruptibleSwitch + + hass = _hass_with_climate({ATTR_INTERRUPTIBLE: True}) + switch = _bind(SmartClimateInterruptibleSwitch(hass, _entry()), hass) + + await switch.async_turn_off() + + args, _ = _last_service_call(hass) + assert args[1] == SERVICE_SET_INTERRUPTIBLE + assert args[2] == {"entity_id": CLIMATE_ID, "interruptible": False} + + +# --------------------------------------------------------------------------- +# Select +# --------------------------------------------------------------------------- + +async def test_select_reflects_current_mode(): + from custom_components.smart_climate.select import SmartClimateOverrideModeSelect + + hass = _hass_with_climate({ATTR_DEFAULT_OVERRIDE_MODE: "next_node"}) + select = _bind(SmartClimateOverrideModeSelect(hass, _entry()), hass) + assert select.current_option == "next_node" + + +async def test_select_unknown_mode_is_none(): + from custom_components.smart_climate.select import SmartClimateOverrideModeSelect + + hass = _hass_with_climate({ATTR_DEFAULT_OVERRIDE_MODE: "bogus"}) + select = _bind(SmartClimateOverrideModeSelect(hass, _entry()), hass) + assert select.current_option is None + + +async def test_select_option_preserves_duration(): + from custom_components.smart_climate.select import SmartClimateOverrideModeSelect + + hass = _hass_with_climate( + {ATTR_DEFAULT_OVERRIDE_MODE: "timer", ATTR_DEFAULT_OVERRIDE_DURATION: 60} + ) + select = _bind(SmartClimateOverrideModeSelect(hass, _entry()), hass) + + await select.async_select_option("infinity") + + args, _ = _last_service_call(hass) + assert args[1] == SERVICE_SET_DEFAULT_OVERRIDE_MODE + assert args[2] == {"entity_id": CLIMATE_ID, "mode": "infinity", "duration": 60} + + +def test_device_info_groups_by_entry(): + from custom_components.smart_climate.switch import SmartClimateInterruptibleSwitch + + hass = _hass_with_climate({}) + switch = SmartClimateInterruptibleSwitch(hass, _entry()) + info = switch.device_info + assert (DOMAIN, "e1") in info["identifiers"] From 25fa24ee1c12111613426efa1ee613eabc109b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:10:12 +0000 Subject: [PATCH 07/15] Document cooling support and helper entities Update CLAUDE.md and README for the new features: - CLAUDE.md: HVAC-mode-aware target resolution, capability mirroring, the helper-entity platforms + SmartClimateChildEntity, the cool_* setpoints in the constructor note, an "adding a runtime setting" recipe, and where-to-look rows. - README: HVAC Modes now mirror the wrapped device (heat/cool/auto/off/fan_only/ dry); cooling setpoints; the two new cooling services; and a Helper Entities section listing the number/switch/select controls. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- CLAUDE.md | 66 +++++++++++++++++++++++++++++++++++++++++------ README.md | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8b20095..e668725 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,8 @@ custom_components/smart_climate/ # the integration (all backend + frontend a __init__.py # config-entry + YAML setup, platform forwarding, card registration climate.py # SmartClimateEntity — core state machine & control loop sensor.py # SmartClimatePresenceSensor — mirrors presence to a sensor for history + number.py / switch.py / select.py # native helper entities for the runtime settings + entity_base.py # SmartClimateChildEntity — shared base for the helper entities services.py # registers all smart_climate.* HA services (idempotent) services.yaml # service metadata/selectors shown in the HA UI config_flow.py # UI config + reconfigure flow @@ -68,11 +70,22 @@ README.md # user-facing install/usage docs Every path ends in `_update_target_temperature()`, which computes the target and issues a `climate.set_temperature` call to the wrapped entity. -**Target-temperature resolution** (in `_update_target_temperature`): -- Any override mode → `_override_temperature`. -- Auto + present + schedule set → `schedule_helper.get_scheduled_temperature(...)`. -- Auto + present + no schedule → `_auto_temperature`. -- Auto + away → `_away_temperature`. +**Target-temperature resolution** (in `_update_target_temperature`) is +**HVAC-mode-aware** — it reads the *wrapped* device's current HVAC mode first: +- Wrapped is `off` / `fan_only` / `dry`, or unavailable → **write nothing** + (pass-through). This is why selecting `off` genuinely stops setpoint writes. +- Wrapped is `cool` → cooling setpoints: override temp, else `_cool_away_temperature` + (away) or `_cool_auto_temperature` (home; no schedule in cool for v1). +- Wrapped is `heat` / `auto` → heating setpoints (the original logic): + - Any override mode → `_override_temperature`. + - Auto + present + schedule set → `schedule_helper.get_scheduled_temperature(...)`. + - Auto + present + no schedule → `_auto_temperature`. + - Auto + away → `_away_temperature`. + +The wrapper mirrors the wrapped device's capabilities rather than hardcoding +them: `hvac_modes`, `min_temp`/`max_temp`/`target_temperature_step`, `fan_mode` +(only when the device advertises `FAN_MODE`), and `supported_features` are all +derived from the wrapped entity's state, with sensible fallbacks. ### Modes (`const.py`) @@ -82,6 +95,12 @@ modes** (`auto` / `timer` / `infinity` / `next_node`) so the native climate card can display and set them, and are *also* published as a custom `mode` extra state attribute for backward compatibility — **do not remove the `mode` attribute.** +`_mode` (the preset/override axis) is **orthogonal** to the **HVAC mode** +(`heat`/`cool`/`auto`/`off`/`fan_only`/`dry`), which comes from the *wrapped* +device and drives the heating-vs-cooling setpoint family (see the control loop +above). Setting the HVAC mode delegates to the wrapped entity; the wrapper reads +it back rather than owning it. + ### Presence & away delay - Presence is `"home"` or `"away"`, derived from the zone entity's integer state @@ -118,6 +137,24 @@ climate entity's `presence` attribute into a dedicated sensor's *state*, which H does record. It finds its paired climate entity via the entity registry using the `smart_climate_{entry_id}` unique-id convention. +### Helper entities (`number.py`, `switch.py`, `select.py`, `entity_base.py`) + +Every runtime setting is also exposed as a native HA entity so users get +history, automations, and standard UI control without the custom cards: +`number` (home/away temp, cooling home/away temp, away delay, default override +duration), `switch` (interruptible), and `select` (default override mode). They +all extend `SmartClimateChildEntity` (`entity_base.py`), which — like the +presence sensor — resolves the paired climate entity via the registry, subscribes +to its state changes, **reads** the current value from the climate entity's state +*attributes*, and **writes** changes back through the `smart_climate.*` services. +This is why the climate entity publishes `auto_temperature`, `away_temperature`, +`away_delay_minutes`, `default_override_mode/duration`, and the `cool_*` temps as +extra state attributes — they're the helpers' source of truth. All entities share +`device_info` (`identifiers = {(DOMAIN, entry_id)}`) so they group under one +device. The duration `number` and the mode `select` both route through +`set_default_override_mode` (the backend sets mode+duration together), preserving +the other value. + ### Services (`services.py` + `services.yaml`) All services live under the `smart_climate` domain and are registered **once** @@ -197,13 +234,24 @@ it from the Lovelace cards or Developer Tools → Services. `SmartClimateEntity.__init__` takes `(hass, entry, name, wrapped_climate, zone_home, away_temp, away_delay_minutes, interruptible, default_override_mode, -default_override_duration)`. `auto_temperature` (default 21) and `schedule` -(default `None`) are **not** constructor args — they start at their defaults and -are changed at runtime via the `set_auto_temperature` / `set_schedule` services. +default_override_duration)`. `auto_temperature` (default 21), `schedule` +(default `None`), and the cooling setpoints `cool_auto_temperature` (24) / +`cool_away_temperature` (28) are **not** constructor args — they are restored +from `entry.data` on construction and changed at runtime via the +`set_auto_temperature` / `set_schedule` / `set_cool_*_temperature` services. Tests that build an entity directly (see `_make_entity` in `tests/test_presence_interrupt_override.py`) set `_auto_temperature` / `_schedule` as attributes after construction rather than passing them in. +### Adding a runtime setting + +The pattern for a persisted, card- and helper-controllable setting: add the +`CONF_*` key + `ATTR_*` name + `SERVICE_*` to `const.py`; read it from +`entry.data` in the constructor; add an `async_set_*` method that calls +`_persist(...)`; register the service (`services.py` + `services.yaml`); publish +it in `extra_state_attributes`; and, if it should have a native control, add a +`number`/`switch`/`select` entry backed by `SmartClimateChildEntity`. + ## Where to look first | I want to change… | Start in | @@ -211,6 +259,8 @@ Tests that build an entity directly (see `_make_entity` in | Target-temperature / presence / override logic | `climate.py` | | Schedule math | `schedule_helper.py` | | Add/modify a service | `services.py`, `services.yaml`, `const.py` | +| Heating/cooling setpoint selection | `_update_target_temperature` in `climate.py` | +| Native number/switch/select controls | `number.py`, `switch.py`, `select.py`, `entity_base.py` | | Setup flow / config fields | `config_flow.py`, `strings.json`, `translations/` | | Card UI | `smart-climate-*.js`, `frontend.py` | | Names/keys/modes | `const.py` | diff --git a/README.md b/README.md index e14b76a..f0df009 100644 --- a/README.md +++ b/README.md @@ -133,14 +133,38 @@ temp_sensor: sensor.living_room_temperature | `show_presence` | No | `true` | Show presence detection overlay bar on the graph. | | `temp_sensor` | No | — | Entity ID of an external temperature sensor to display on the graph (e.g. `sensor.living_room_temperature`). | -## HVAC Mode +## HVAC Modes -The Smart Climate entity supports two standard HA HVAC modes, controllable from any native climate card or automation: +The Smart Climate entity **mirrors the HVAC modes of the wrapped device**, so +whatever your device supports (`heat`, `cool`, `auto`, `fan_only`, `dry`, `off`, +…) is available from any native climate card or automation. The wrapper's +min/max temperature, step, and fan modes are also taken from the wrapped device. + +How each mode is handled: | HVAC mode | Behaviour | |-----------|-----------| -| `heat` | Normal operation — auto/schedule mode or active override controls the wrapped climate. | -| `off` | Turns the wrapped climate off and suspends all temperature writes until `heat` is selected again. | +| `heat` | Heating setpoints — auto/schedule/away logic (or an active override) drives the wrapped climate. | +| `auto` | The wrapped device heats or cools toward the smart comfort target (same setpoints/schedule as `heat`). | +| `cool` | Cooling setpoints — uses the **Cooling Home** / **Cooling Away** temperatures (see below), with presence and overrides applied. | +| `off` | Turns the wrapped climate off and writes **no** temperature until a heating/cooling mode is selected again. | +| `fan_only` / `dry` / other | Passed through untouched — the wrapper writes no setpoint. | + +### Cooling setpoints + +When the wrapped device is in `cool` mode, Smart Climate uses a dedicated pair +of setpoints instead of the heating temperatures: + +| Setting | Default | Applies when | +|---------|---------|--------------| +| Cooling Home Temperature | `24` °C | cooling and someone is home | +| Cooling Away Temperature | `28` °C | cooling and nobody is home | + +Set them during initial setup, from the **Cooling Home/Away Temperature** number +entities, or via the `smart_climate.set_cool_auto_temperature` / +`smart_climate.set_cool_away_temperature` services. (The time-of-day schedule +applies to heating only in this version; cooling uses the flat Cooling Home +temperature when home.) ## Preset Modes @@ -240,6 +264,28 @@ data: temperature: 14 ``` +### `smart_climate.set_cool_auto_temperature` + +Set the target temperature used when someone is home and the wrapped device is cooling. + +```yaml +service: smart_climate.set_cool_auto_temperature +data: + entity_id: climate.living_room + temperature: 24 +``` + +### `smart_climate.set_cool_away_temperature` + +Set the target temperature used when nobody is home and the wrapped device is cooling. + +```yaml +service: smart_climate.set_cool_away_temperature +data: + entity_id: climate.living_room + temperature: 28 +``` + ### `smart_climate.set_away_delay` Set how long (in minutes) to wait after everyone leaves before switching to the away temperature. @@ -276,9 +322,30 @@ data: When `mode` is `next_node` the `duration` field is ignored; the override automatically ends at the next schedule node. +## Helper Entities + +In addition to the Lovelace cards, every runtime setting is exposed as a +standard Home Assistant entity, grouped under a single **Smart Climate** device. +These give you history, dashboards, and automations without the custom cards — +and can be dropped onto any dashboard with an Entities card. + +| Entity | Type | Setting | +|--------|------|---------| +| Home Temperature | `number` | Auto/home heating target | +| Away Temperature | `number` | Away heating target | +| Cooling Home Temperature | `number` | Home target when cooling | +| Cooling Away Temperature | `number` | Away target when cooling | +| Away Delay | `number` | Minutes before applying the away temperature | +| Default Override Duration | `number` | Timer length for timer overrides | +| Override Interruptible | `switch` | Whether presence changes cancel an override | +| Default Override Mode | `select` | `timer` / `infinity` / `next_node` | + +Changing any of these calls the matching `smart_climate.*` service, so the value +is applied immediately and persisted across restarts. + ## Persistent Storage -Configuration values that can be changed at runtime (away temperature, away delay, interruptible flag, default override mode, default override duration, auto temperature, and schedule) are now saved persistently. +Configuration values that can be changed at runtime (away temperature, cooling home/away temperatures, away delay, interruptible flag, default override mode, default override duration, auto temperature, and schedule) are saved persistently. When you call any of the configuration services (e.g. `set_away_temperature`, `set_schedule`) or use one of the built-in Lovelace cards, the updated values are written immediately to Home Assistant's config entry storage: From 7fcfb9c72ead738bf387a0d0cef43e1193a040e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:31:22 +0000 Subject: [PATCH 08/15] Add multi-device / band / integration-auto design doc Design-first writeup (proposed, for review) covering the coordinator entity model, the devices+roles data model and migration, per-node schedule band, the pure decide/route control pipeline, room-temp sourcing, reported state, overrides, and a phasing plan. Captures five open decisions for sign-off. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- docs/multi-device-design.md | 246 ++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/multi-device-design.md diff --git a/docs/multi-device-design.md b/docs/multi-device-design.md new file mode 100644 index 0000000..2e4724c --- /dev/null +++ b/docs/multi-device-design.md @@ -0,0 +1,246 @@ +# Design: multi-device coordination, per-node band, and integration-driven auto + +Status: **proposed** — for review before implementation. + +This document designs three interlocking features and, importantly, the +foundational change they share. Decisions marked **[OPEN]** need sign-off; the +rest are recommendations with rationale. + +## 1. Goals + +1. **Per-node comfort band** — each schedule node carries a *heat-to* temperature + and a *cool-above* limit, so heating and cooling targets vary by time of day. +2. **Integration-driven auto** — Smart Climate compares the room temperature to + that band and decides the intent (**heat / cool / idle**) itself, instead of + handing one setpoint to a device's native auto. Configurable per instance. +3. **Multiple devices with roles** — one Smart Climate instance can command + several climate devices, each tagged **heat**, **cool**, or **both**; the + intent is routed to the devices that can act on it. + +## 2. The shared foundation: coordinator, not mirror + +Today the Smart Climate entity **mirrors a single wrapped device** (its +`hvac_mode`, `hvac_modes`, min/max, fan, etc. are read straight from that +device). Both integration-driven auto and multi-device make that impossible — +there is no longer a single device whose mode *is* Smart Climate's mode. + +So Smart Climate becomes a **coordinator/brain**: + +- It **owns** its own `hvac_mode` (`off` / `heat` / `cool` / `auto`), persisted. +- Wrapped devices become **actuators** it commands via `climate.set_hvac_mode` + / `climate.set_temperature` / `climate.turn_off`. +- Its reported state (current temp, action, capabilities) is **derived/aggregated** + from the actuators, not mirrored from one. + +This reverses the mirror behavior shipped in the cooling commit. It is the +correct base for everything below and the band/decision logic is identical for +one device or five. + +## 3. Data model + +### Config-entry `data` + +```jsonc +{ + "name": "Living Room", + "zone_home": "zone.home", + "devices": [ + { "entity_id": "climate.radiator", "role": "heat" }, + { "entity_id": "climate.qlima", "role": "both" } + ], + "temperature_sensor": "sensor.living_room_temp", // optional override + "integration_driven_auto": true, // per-instance toggle + + // heating setpoints (existing) + "away_temperature": 14, + "auto_temperature": 21, // runtime, persisted + "schedule": { ... }, // runtime, persisted (now band-capable) + + // cooling setpoints (existing) + "cool_auto_temperature": 24, + "cool_away_temperature": 28, + + // override defaults (existing) + "away_delay_minutes": 5, + "interruptible": true, + "default_override_mode": "timer", + "default_override_duration": 30 +} +``` + +`role` ∈ `heat` | `cool` | `both`. + +### Migration / backward compatibility + +- Old entries have `wrapped_climate: "climate.x"` and no `devices`. On load, + synthesize `devices = [{entity_id: wrapped_climate, role: "both"}]`. +- Keep reading `wrapped_climate` as the fallback; `devices` wins when present. +- `ATTR_WRAPPED_CLIMATE` stays for one-device instances; add a `devices` + attribute (list) for the general case. + +## 4. Config UX **[OPEN]** + +HA **config subentries** would be the natural fit but require a newer HA than +our current floor (`hacs.json` → `2023.1.0`). Recommendation: + +- **Initial config flow** (unchanged shape): name, home zone, **one** device + (defaults to role `both`), and the existing optional defaults. +- **Options flow** manages everything after: a menu to **add device / edit + device / remove device** (entity picker + role select), set the optional + temperature sensor, and toggle integration-driven auto. + +Alternative if we raise the HA floor: model each actuator as a **config +subentry**. Cleaner device/entity association, but drops <2024.x support. + +→ **Decision needed:** keep the 2023.1 floor + Options-flow list, or raise the +floor and use subentries. + +## 5. Schedule: per-node band + +Node format gains an optional cooling limit: + +```jsonc +{ "time": "07:00", "temp": 21, "cool_temp": 25 } +``` + +- `temp` — heat-to target (existing; unchanged meaning). +- `cool_temp` — cool-above limit (new, optional). +- Fallbacks when `cool_temp` is absent: `cool_auto_temperature` (home) — so old + schedules keep working and simply use the flat cooling setpoint. + +`schedule_helper` gains a pure function: + +```python +get_scheduled_band(schedule, now, heat_fallback, cool_fallback) -> (heat, cool) +``` + +`get_scheduled_temperature` stays for the heat-only path / back-compat. + +## 6. Control pipeline + +Every tick / event resolves an **intent** then **routes** it. Pure decision +logic (unit-testable, no HA): + +``` +decide(mode, preset, presence, room_temp, band, setpoints, override) -> Decision + # Decision = { intent: heat|cool|idle|off, target: float|None } + +if mode == off: -> {off} +if preset is an override: + t = override_temp + # single target: heat or cool toward it, deadband via a small hysteresis H + if room_temp < t - H: -> {heat, t} + if room_temp > t + H: -> {cool, t} + else: -> {idle} +if mode == heat: -> {heat, heat_setpoint(presence,schedule)} +if mode == cool: -> {cool, cool_setpoint(presence,schedule)} +if mode == auto: + heat_target, cool_limit = band(presence, schedule) + if room_temp < heat_target: -> {heat, heat_target} + if room_temp > cool_limit: -> {cool, cool_limit} + else: -> {idle} +``` + +Routing the intent to actuators (has HA side effects): + +``` +route(decision, devices): + for d in devices: + if decision.intent == heat and d.role in (heat, both): + set d -> hvac_mode=heat, temperature=decision.target + elif decision.intent == cool and d.role in (cool, both): + set d -> hvac_mode=cool, temperature=decision.target + else: + turn d off # device can't serve this intent, or intent is idle/off +``` + +Notes: +- A small **hysteresis** `H` (e.g. 0.3°C) around switch points prevents rapid + heat/cool flapping. **[OPEN]** default value / make it configurable? +- Only issue a device call when the desired (mode, target) differs from the + device's current state, to avoid command spam every 10 s. + +## 7. Reported state (the coordinator entity) + +- `hvac_modes` = `[off, heat, cool, auto]` (own set; not mirrored). +- `hvac_mode` = owned `_hvac_mode`. +- `hvac_action` = last intent → `off` / `idle` / `heating` / `cooling` (exposed + so cards show what it's actually doing). +- `current_temperature` = room temp (see §8). +- `target_temperature` = the acting target (heat_target while heating/idle, + cool_limit while cooling); both `heat_target` and `cool_limit` also published + as attributes. **[OPEN]** alternatively advertise `TARGET_TEMPERATURE_RANGE` + and expose `target_temp_low/high` in auto so the native card renders the band. +- `min_temp`/`max_temp`/`step` = tightest common range across actuators + (fallback 5 / 35 / 0.5). + +## 8. Room temperature source + +Per the chosen option: **configured sensor if set, else the devices' +`current_temperature`.** With multiple devices, "the devices" resolves as: + +1. `temperature_sensor` entity if configured, else +2. the mean of the actuators' `current_temperature` (ignoring `None`), else +3. `None` → integration-driven auto can't decide → hold last intent and warn. + +**[OPEN]** mean vs a designated "primary device" for step 2 (mean chosen for +zero extra config). + +## 9. Overrides + +Overrides already carry a single target temperature. In the new model an +override becomes a single-setpoint auto: heat or cool toward the override temp +(with the same hysteresis). Timer / infinity / next-node expiry and the +interruptible-on-presence logic are unchanged. + +## 10. Integration-driven vs device-native auto + +`integration_driven_auto` (default **true**): +- **true** → the pipeline above (works for any number of devices/roles). +- **false** → legacy single-setpoint passthrough. Only well-defined with a + single `both` device; with multiple devices we log a warning and fall back to + integration-driven. (Multi-device inherently needs the coordinator to decide.) + +## 11. Fan mode & exotic modes + +Fan mode is per-device and has no single meaning across actuators. v1: +- Expose fan passthrough **only** when there is exactly one actuator that + supports `FAN_MODE`; otherwise omit it from the coordinator. +- `fan_only` / `dry` are not part of the coordinator's own mode set; they remain + reachable by controlling the underlying device directly. (Revisit if needed.) + +## 12. Helper entities + +Unaffected in shape — they still read coordinator attributes and call services. +New candidates (later): a `select` for the coordinator HVAC mode, a `switch` for +integration-driven auto, and per-device role `select`s (Phase 3 polish). + +## 13. Testing + +- `schedule_helper.get_scheduled_band` — pure, table-driven tests. +- `decide(...)` — pure; exhaustive tests over mode × presence × room-vs-band × + override, including hysteresis edges. +- `route(...)` — with mock devices of each role, assert which get heat/cool/off + and that no redundant calls are issued. +- Migration — old `wrapped_climate` entry yields one `both` device. + +## 14. Phasing (after this design is signed off) + +1. **Foundation + decision core** — coordinator owns mode; `devices` model + + migration; `get_scheduled_band`; pure `decide`/`route`; wire the control loop; + integration-driven auto toggle. (Single or multiple devices already work.) +2. **Config/Options UX** — the add/edit/remove-device Options flow + sensor + + toggle. +3. **Schedule card** — dual-line (heat/cool) band editing on the timeline. +4. **Polish** — hvac_action in cards, per-device role selects, docs. + +## 15. Open decisions (need sign-off) + +1. **Config UX / HA floor** (§4): keep 2023.1 + Options-flow list, or raise the + floor and use config subentries. +2. **Band target reporting** (§7): single `target_temperature` + attributes, or + advertise a temperature *range* (`target_temp_low/high`) in auto. +3. **Hysteresis** (§6): default value, and configurable or fixed. +4. **Room temp aggregation** (§8): mean of devices vs a designated primary. +5. **Idle actuation** (§6): idle = turn actuators fully **off**, or set them to a + neutral/eco setpoint (e.g. heat_target for heaters) so recovery is faster. From 5638cd3d1962096b23de0da110f6301a66354b42 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:36:43 +0000 Subject: [PATCH 09/15] Lock multi-device design decisions; raise HA floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the five open design questions: - Config UX: raise the HA floor and model actuator devices as config subentries. - Band reporting: a single target_temperature plus band attributes (no range). - Hysteresis: configurable, default 0.3 C. - Room temperature: selectable source (sensor / primary device / mean). - Idle: never power a device off (kills airflow) — fan_only where supported, else a neutral setpoint. Bump hacs.json minimum Home Assistant version to 2025.1.0 (adjust to the target release at implementation time) to enable config subentries. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- docs/multi-device-design.md | 92 +++++++++++++++++++++---------------- hacs.json | 2 +- 2 files changed, 53 insertions(+), 41 deletions(-) diff --git a/docs/multi-device-design.md b/docs/multi-device-design.md index 2e4724c..81211e0 100644 --- a/docs/multi-device-design.md +++ b/docs/multi-device-design.md @@ -1,10 +1,9 @@ # Design: multi-device coordination, per-node band, and integration-driven auto -Status: **proposed** — for review before implementation. +Status: **decisions locked** — ready to implement (see §14 phasing). This document designs three interlocking features and, importantly, the -foundational change they share. Decisions marked **[OPEN]** need sign-off; the -rest are recommendations with rationale. +foundational change they share. All five open questions are resolved (§15). ## 1. Goals @@ -48,7 +47,10 @@ one device or five. { "entity_id": "climate.radiator", "role": "heat" }, { "entity_id": "climate.qlima", "role": "both" } ], - "temperature_sensor": "sensor.living_room_temp", // optional override + "temperature_source": "mean", // sensor | primary | mean + "temperature_sensor": "sensor.living_room_temp", // used when source = sensor + "primary_device": "climate.qlima", // used when source = primary + "hysteresis": 0.3, // °C, anti-flap deadband "integration_driven_auto": true, // per-instance toggle // heating setpoints (existing) @@ -78,22 +80,22 @@ one device or five. - `ATTR_WRAPPED_CLIMATE` stays for one-device instances; add a `devices` attribute (list) for the general case. -## 4. Config UX **[OPEN]** +## 4. Config UX — **decided: raise HA floor, use config subentries** -HA **config subentries** would be the natural fit but require a newer HA than -our current floor (`hacs.json` → `2023.1.0`). Recommendation: +The HA floor moves to a current release (`hacs.json` / `manifest.json` +`min_version`) so we can use **config subentries**, the idiomatic way to attach a +variable-length set of things to an entry. -- **Initial config flow** (unchanged shape): name, home zone, **one** device - (defaults to role `both`), and the existing optional defaults. -- **Options flow** manages everything after: a menu to **add device / edit - device / remove device** (entity picker + role select), set the optional - temperature sensor, and toggle integration-driven auto. +- **Initial config flow**: name, home zone, room-temperature source (see §8), + and the existing optional defaults. +- **Actuator devices are subentries** — each subentry is one device: an entity + picker (`domain: climate`) + a role select (`heat` / `cool` / `both`). Add / + edit / remove devices from the entry's **Subentries** UI. +- Instance-level tunables (temperature-source choice, hysteresis, + integration-driven-auto toggle) live in an **Options flow**. -Alternative if we raise the HA floor: model each actuator as a **config -subentry**. Cleaner device/entity association, but drops <2024.x support. - -→ **Decision needed:** keep the 2023.1 floor + Options-flow list, or raise the -floor and use subentries. +The `devices` list in §3 is the runtime projection of the device subentries. +Migration (§3) still applies for pre-subentry single-device entries. ## 5. Schedule: per-node band @@ -155,8 +157,16 @@ route(decision, devices): ``` Notes: -- A small **hysteresis** `H` (e.g. 0.3°C) around switch points prevents rapid - heat/cool flapping. **[OPEN]** default value / make it configurable? +- **Hysteresis** `H` around switch points prevents rapid heat/cool flapping. + **Decided:** configurable per instance (Options flow), **default 0.3°C**. +- **Idle never powers a device off** (that kills the airflow on an airco). + **Decided** idle actuation, per device: + - device supports `fan_only` → set `hvac_mode = fan_only` (airflow, no + heat/cool); + - else (e.g. a radiator) → set a **neutral setpoint**: for a heat-capable + device, the heat_target (so it coasts without overshooting); it is not + turned off. + Explicit coordinator `off` is the only path that actually powers devices off. - Only issue a device call when the desired (mode, target) differs from the device's current state, to avoid command spam every 10 s. @@ -167,24 +177,26 @@ Notes: - `hvac_action` = last intent → `off` / `idle` / `heating` / `cooling` (exposed so cards show what it's actually doing). - `current_temperature` = room temp (see §8). -- `target_temperature` = the acting target (heat_target while heating/idle, - cool_limit while cooling); both `heat_target` and `cool_limit` also published - as attributes. **[OPEN]** alternatively advertise `TARGET_TEMPERATURE_RANGE` - and expose `target_temp_low/high` in auto so the native card renders the band. +- `target_temperature` = **single** acting target (heat_target while + heating/idle, cool_limit while cooling). **Decided:** one target, not a range; + `heat_target` and `cool_limit` are also published as attributes so the schedule + card can render the band itself. - `min_temp`/`max_temp`/`step` = tightest common range across actuators (fallback 5 / 35 / 0.5). -## 8. Room temperature source +## 8. Room temperature source — **decided: selectable** -Per the chosen option: **configured sensor if set, else the devices' -`current_temperature`.** With multiple devices, "the devices" resolves as: +A per-instance **temperature-source** setting (config/Options flow) chooses how +the room temperature is measured: -1. `temperature_sensor` entity if configured, else -2. the mean of the actuators' `current_temperature` (ignoring `None`), else -3. `None` → integration-driven auto can't decide → hold last intent and warn. +- **`sensor`** — a dedicated `sensor` entity (most accurate). +- **`primary`** — the `current_temperature` of a chosen primary actuator. +- **`mean`** — the mean of all actuators' `current_temperature` (default; + zero extra config). -**[OPEN]** mean vs a designated "primary device" for step 2 (mean chosen for -zero extra config). +Resolution falls through to the next available option if the selected source +yields no value; if nothing is available, integration-driven auto holds the last +intent and logs a warning. ## 9. Overrides @@ -234,13 +246,13 @@ integration-driven auto, and per-device role `select`s (Phase 3 polish). 3. **Schedule card** — dual-line (heat/cool) band editing on the timeline. 4. **Polish** — hvac_action in cards, per-device role selects, docs. -## 15. Open decisions (need sign-off) +## 15. Resolved decisions -1. **Config UX / HA floor** (§4): keep 2023.1 + Options-flow list, or raise the - floor and use config subentries. -2. **Band target reporting** (§7): single `target_temperature` + attributes, or - advertise a temperature *range* (`target_temp_low/high`) in auto. -3. **Hysteresis** (§6): default value, and configurable or fixed. -4. **Room temp aggregation** (§8): mean of devices vs a designated primary. -5. **Idle actuation** (§6): idle = turn actuators fully **off**, or set them to a - neutral/eco setpoint (e.g. heat_target for heaters) so recovery is faster. +1. **Config UX / HA floor** (§4): **raise the floor to a current HA release** and + model actuator devices as **config subentries**. +2. **Band target reporting** (§7): **single `target_temperature`** + band + attributes (no range). +3. **Hysteresis** (§6): **configurable, default 0.3°C**. +4. **Room temp source** (§8): **selectable** — `sensor` / `primary` / `mean`. +5. **Idle actuation** (§6): **never power off** — `fan_only` where supported, + else a neutral setpoint. Only explicit coordinator `off` powers devices down. diff --git a/hacs.json b/hacs.json index 89d2c2f..c1602c9 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Smart Climate Controller", "render_readme": true, - "homeassistant": "2023.1.0" + "homeassistant": "2025.1.0" } \ No newline at end of file From 635ac0a27b1970ab65fa8007e8fe50955de05cd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 08:59:07 +0000 Subject: [PATCH 10/15] Document conflict-prevention invariant for multi-device Make explicit why no device can heat while another cools: a single global intent per evaluation, the heat/cool deadband, and idle being non-conditioning. Add a config + defensive validation rule (cool_limit >= heat_target + gap) and a property-style invariant test to the plan. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- docs/multi-device-design.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/multi-device-design.md b/docs/multi-device-design.md index 81211e0..12fad7e 100644 --- a/docs/multi-device-design.md +++ b/docs/multi-device-design.md @@ -143,6 +143,27 @@ if mode == auto: else: -> {idle} ``` +### Conflict prevention (no device heats while another cools) + +This is guaranteed structurally, not by coordination between devices: + +1. **Single global intent.** `decide()` returns exactly one intent per + evaluation for the whole instance; `route()` applies that one intent to all + actuators. There is no path that demands heat and cool in the same tick. +2. **Deadband.** Since `heat_target < cool_limit`, temperatures below the band + heat, above the band cool, and inside the band are idle — no single room + temperature satisfies both, so the system never *wants* both. +3. **Idle is not conditioning.** A `both` device at idle goes to `fan_only` / + neutral (§6 idle rule), so it is not cooling in the background while a heater + runs. + +**Guard against misconfiguration:** validate — in the config/Options flow *and* +defensively in `decide()` — that `cool_limit >= heat_target + max(min_gap, +hysteresis)` for every schedule node and for the flat home/away setpoints +(suggested `min_gap` = 1°C). If a node violates it, reject on input; if it ever +slips through, `decide()` clamps `cool_limit = heat_target + min_gap` and logs a +warning so cooling can never be asked for below the heating target. + Routing the intent to actuators (has HA side effects): ``` @@ -234,6 +255,10 @@ integration-driven auto, and per-device role `select`s (Phase 3 polish). override, including hysteresis edges. - `route(...)` — with mock devices of each role, assert which get heat/cool/off and that no redundant calls are issued. +- **Conflict-prevention invariant** — property-style test: for any devices + + room temp + band, the routed commands never contain both a `heat` and a `cool` + action; and an overlapping band (`cool_limit <= heat_target`) is clamped, never + routed as simultaneous heat+cool. - Migration — old `wrapped_climate` entry yields one `both` device. ## 14. Phasing (after this design is signed off) From 54543bddee64cf08594ca005792c57f494b7614f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 09:29:41 +0000 Subject: [PATCH 11/15] Phase 1a: pure decision/routing core + schedule band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the HA-independent control core the coordinator will run on: - control.decide(): resolves one instance-wide intent (heat/cool/idle/off) from mode, room temp, and the heat/cool band, with hysteresis and single-target override handling; clamp_band() enforces cool >= heat + gap. - control.plan_routes(): maps one decision to per-device commands by role; parks non-serving devices on fan_only (airflow) or off, never in the opposite mode — the structural guarantee against simultaneous heat+cool. - schedule_helper.get_scheduled_band(): per-node (heat, cool_temp) resolution with fallbacks. - const: device roles, temp-source options, coordinator config keys, defaults. - Tests: decide/hysteresis/override, routing per role, the band, and a property-style invariant that no routing ever contains both heat and cool. - Doc: correct the idle/park rule (fan_only else off, not a neutral setpoint). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- custom_components/smart_climate/const.py | 22 +++ custom_components/smart_climate/control.py | 185 ++++++++++++++++++ .../smart_climate/schedule_helper.py | 58 ++++++ docs/multi-device-design.md | 18 +- tests/test_control.py | 164 ++++++++++++++++ tests/test_schedule_band.py | 50 +++++ 6 files changed, 489 insertions(+), 8 deletions(-) create mode 100644 custom_components/smart_climate/control.py create mode 100644 tests/test_control.py create mode 100644 tests/test_schedule_band.py diff --git a/custom_components/smart_climate/const.py b/custom_components/smart_climate/const.py index 7d65fe9..5872699 100644 --- a/custom_components/smart_climate/const.py +++ b/custom_components/smart_climate/const.py @@ -19,6 +19,28 @@ CONF_SCHEDULE = "schedule" CONF_COOL_AUTO_TEMPERATURE = "cool_auto_temperature" CONF_COOL_AWAY_TEMPERATURE = "cool_away_temperature" +# Multi-device coordinator (Phase 1) +CONF_DEVICES = "devices" +CONF_HVAC_MODE = "hvac_mode" +CONF_TEMPERATURE_SOURCE = "temperature_source" +CONF_TEMPERATURE_SENSOR = "temperature_sensor" +CONF_PRIMARY_DEVICE = "primary_device" +CONF_HYSTERESIS = "hysteresis" +CONF_INTEGRATION_DRIVEN_AUTO = "integration_driven_auto" + +# Device roles +ROLE_HEAT = "heat" +ROLE_COOL = "cool" +ROLE_BOTH = "both" + +# Room-temperature sources +TEMP_SOURCE_SENSOR = "sensor" +TEMP_SOURCE_PRIMARY = "primary" +TEMP_SOURCE_MEAN = "mean" + +# Defaults +DEFAULT_HYSTERESIS = 0.3 +MIN_BAND_GAP = 1.0 # Attributes ATTR_MODE = "mode" diff --git a/custom_components/smart_climate/control.py b/custom_components/smart_climate/control.py new file mode 100644 index 0000000..6427fe2 --- /dev/null +++ b/custom_components/smart_climate/control.py @@ -0,0 +1,185 @@ +"""Pure, HA-independent control logic for the Smart Climate coordinator. + +This module contains the *decision* and *routing* core: + +- :func:`decide` turns the coordinator's mode, the current room temperature, and + the resolved heat/cool band into a single :class:`Decision` (one intent — + heat / cool / idle / off — for the whole instance). +- :func:`plan_routes` turns that one decision into the per-device commands, + honouring each device's role and whether it can keep airflow (``fan_only``). + +Both are pure functions with no Home Assistant imports so they can be unit +tested directly. The single-intent design is what guarantees no device ever +heats while another cools (see ``docs/multi-device-design.md`` §6). +""" + +from dataclasses import dataclass + +from .const import ( + DEFAULT_HYSTERESIS, + MIN_BAND_GAP, + ROLE_BOTH, + ROLE_COOL, + ROLE_HEAT, +) + +# Intents +HEAT = "heat" +COOL = "cool" +IDLE = "idle" +OFF = "off" + + +@dataclass(frozen=True) +class Decision: + """The single instance-wide control decision for one evaluation.""" + + intent: str + target: float | None = None + + +def clamp_band(heat_target: float, cool_target: float, min_gap: float = MIN_BAND_GAP): + """Return a (heat, cool) band guaranteed to satisfy cool >= heat + min_gap. + + Protects against a misconfigured band (cool limit at or below the heat + target), which is the only way the single-intent model could otherwise be + asked to heat and cool at the same temperature. + """ + if cool_target < heat_target + min_gap: + cool_target = heat_target + min_gap + return heat_target, cool_target + + +def _single_target(room_temp, target, prev_intent, hysteresis): + """Heat below / cool above one setpoint, with hysteresis to avoid flapping.""" + if room_temp is None: + return Decision(prev_intent or IDLE, target if prev_intent in (HEAT, COOL) else None) + # Heating side + if prev_intent == HEAT: + if room_temp < target + hysteresis: + return Decision(HEAT, target) + elif room_temp < target: + return Decision(HEAT, target) + # Cooling side + if prev_intent == COOL: + if room_temp > target - hysteresis: + return Decision(COOL, target) + elif room_temp > target: + return Decision(COOL, target) + return Decision(IDLE, None) + + +def _band(room_temp, heat_target, cool_target, prev_intent, hysteresis): + """Deadband decision: heat below heat_target, cool above cool_target.""" + if room_temp is None: + return Decision(prev_intent or IDLE, None) + # Heating: start below heat_target, keep going until heat_target + H + if prev_intent == HEAT: + if room_temp < heat_target + hysteresis: + return Decision(HEAT, heat_target) + elif room_temp < heat_target: + return Decision(HEAT, heat_target) + # Cooling: start above cool_target, keep going until cool_target - H + if prev_intent == COOL: + if room_temp > cool_target - hysteresis: + return Decision(COOL, cool_target) + elif room_temp > cool_target: + return Decision(COOL, cool_target) + return Decision(IDLE, None) + + +def decide( + *, + mode: str, + room_temp: float | None, + heat_target: float, + cool_target: float, + override_target: float | None = None, + prev_intent: str | None = None, + hysteresis: float = DEFAULT_HYSTERESIS, + min_gap: float = MIN_BAND_GAP, +) -> Decision: + """Resolve the single control decision for this evaluation. + + Args: + mode: coordinator HVAC mode — ``off`` / ``heat`` / ``cool`` / ``auto``. + room_temp: measured room temperature (``None`` if unavailable). + heat_target: heat-to setpoint for the current slot/presence. + cool_target: cool-above setpoint (band upper) for the current slot. + override_target: when set, a manual single-setpoint override is active + and takes precedence over auto/heat/cool (but not over ``off``). + prev_intent: the previous intent, used for hysteresis. + hysteresis: anti-flap deadband in degrees. + min_gap: minimum enforced gap between heat_target and cool_target. + """ + if mode == OFF: + return Decision(OFF, None) + + if override_target is not None: + return _single_target(room_temp, override_target, prev_intent, hysteresis) + + if mode == HEAT: + return Decision(HEAT, heat_target) + if mode == COOL: + return Decision(COOL, cool_target) + + # auto (integration-driven band) + heat_target, cool_target = clamp_band(heat_target, cool_target, min_gap) + return _band(room_temp, heat_target, cool_target, prev_intent, hysteresis) + + +@dataclass(frozen=True) +class DeviceCommand: + """A desired end-state for one actuator device.""" + + entity_id: str + hvac_mode: str + temperature: float | None = None + + +def plan_routes(decision: Decision, devices: list[dict]) -> list[DeviceCommand]: + """Map one :class:`Decision` onto per-device commands. + + ``devices`` is a list of ``{"entity_id", "role", "supports_fan_only"}`` + dicts. A device that cannot serve the current intent — wrong role, or the + intent is ``idle`` — is **parked**: ``fan_only`` if it supports it (so an + airco keeps circulating air), otherwise ``off``. A device is never left in + the *opposite* conditioning mode (e.g. a radiator is never in ``heat`` while + the system is cooling), which is what keeps heat and cool from ever running + at once. Only an explicit ``off`` decision powers a fan-capable device down. + """ + commands: list[DeviceCommand] = [] + for dev in devices: + entity_id = dev["entity_id"] + role = dev.get("role", ROLE_BOTH) + supports_fan_only = dev.get("supports_fan_only", False) + + if decision.intent == OFF: + commands.append(DeviceCommand(entity_id, OFF)) + continue + + serves_heat = decision.intent == HEAT and role in (ROLE_HEAT, ROLE_BOTH) + serves_cool = decision.intent == COOL and role in (ROLE_COOL, ROLE_BOTH) + + if serves_heat: + commands.append(DeviceCommand(entity_id, HEAT, decision.target)) + elif serves_cool: + commands.append(DeviceCommand(entity_id, COOL, decision.target)) + elif supports_fan_only: + # Keep airflow without conditioning (aircos). + commands.append(DeviceCommand(entity_id, "fan_only")) + else: + # No airflow to preserve (e.g. a radiator) → off, so it can never + # counteract the active intent. + commands.append(DeviceCommand(entity_id, OFF)) + return commands + + +def intent_to_hvac_action(intent: str | None) -> str: + """Map an intent to a HA hvac_action string.""" + return { + HEAT: "heating", + COOL: "cooling", + IDLE: "idle", + OFF: "off", + }.get(intent, "idle") diff --git a/custom_components/smart_climate/schedule_helper.py b/custom_components/smart_climate/schedule_helper.py index 3292071..96bf148 100644 --- a/custom_components/smart_climate/schedule_helper.py +++ b/custom_components/smart_climate/schedule_helper.py @@ -110,6 +110,64 @@ def _parse_time(t: str): return target_temp +def get_scheduled_band( + schedule: dict | None, + now: datetime, + heat_fallback: float, + cool_fallback: float, +) -> tuple[float, float]: + """Return the ``(heat_target, cool_target)`` band for the current time. + + Uses the same "last node at or before now" resolution as + :func:`get_scheduled_temperature` for the heat target (the node's ``temp``). + The cool target is the node's optional ``cool_temp``; when a node omits it + (or no schedule is configured), *cool_fallback* is used. The heat target + falls back to *heat_fallback*. + + Args: + schedule: The schedule dict (may be ``None`` or empty). + now: The current datetime used for time comparisons. + heat_fallback: Heat target when no node supplies one. + cool_fallback: Cool target when a node omits ``cool_temp``. + + Returns: + A ``(heat_target, cool_target)`` tuple. + """ + heat_target = get_scheduled_temperature(schedule, now, heat_fallback) + + if not schedule: + return heat_target, cool_fallback + + nodes = get_schedule_nodes(schedule, now) + if not nodes: + return heat_target, cool_fallback + + current_time = now.time().replace(second=0, microsecond=0) + + valid = [] + for node in nodes: + try: + t = datetime.strptime(node.get("time", ""), "%H:%M").time() + except (ValueError, TypeError): + continue + valid.append((t, node)) + if not valid: + return heat_target, cool_fallback + valid.sort(key=lambda x: x[0]) + + active_node = None + for t, node in valid: + if t <= current_time: + active_node = node + if active_node is None: + active_node = valid[-1][1] # wrap to previous day's last node + + cool_target = active_node.get("cool_temp", cool_fallback) + if not isinstance(cool_target, (int, float)): + cool_target = cool_fallback + return heat_target, cool_target + + def compute_next_node_datetime(schedule: dict | None, now: datetime) -> datetime | None: """Return the datetime of the next upcoming schedule node. diff --git a/docs/multi-device-design.md b/docs/multi-device-design.md index 12fad7e..9f48be0 100644 --- a/docs/multi-device-design.md +++ b/docs/multi-device-design.md @@ -180,14 +180,16 @@ route(decision, devices): Notes: - **Hysteresis** `H` around switch points prevents rapid heat/cool flapping. **Decided:** configurable per instance (Options flow), **default 0.3°C**. -- **Idle never powers a device off** (that kills the airflow on an airco). - **Decided** idle actuation, per device: - - device supports `fan_only` → set `hvac_mode = fan_only` (airflow, no - heat/cool); - - else (e.g. a radiator) → set a **neutral setpoint**: for a heat-capable - device, the heat_target (so it coasts without overshooting); it is not - turned off. - Explicit coordinator `off` is the only path that actually powers devices off. +- **Parking a device that isn't serving the active intent** (idle, or wrong + role — e.g. a radiator while the system is cooling): + - device supports `fan_only` → `hvac_mode = fan_only` (an airco keeps its + airflow, no heat/cool); + - else (no airflow to preserve, e.g. a radiator) → `off`. + A device is **never** left in the *opposite* conditioning mode (a radiator is + never in `heat` while cooling), which is what keeps heat and cool from ever + running together. *(Earlier drafts coasted heaters at a neutral setpoint; that + would nominally put a radiator in `heat` during active cooling, so it was + dropped in favour of `off`.)* - Only issue a device call when the desired (mode, target) differs from the device's current state, to avoid command spam every 10 s. diff --git a/tests/test_control.py b/tests/test_control.py new file mode 100644 index 0000000..96f1e05 --- /dev/null +++ b/tests/test_control.py @@ -0,0 +1,164 @@ +"""Tests for the pure control core: decide(), plan_routes(), and the band.""" + +import itertools +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from custom_components.smart_climate.control import ( + COOL, + HEAT, + IDLE, + OFF, + Decision, + clamp_band, + decide, + plan_routes, +) + + +def _dev(entity_id, role, fan=False): + return {"entity_id": entity_id, "role": role, "supports_fan_only": fan} + + +# --------------------------------------------------------------------------- +# decide() +# --------------------------------------------------------------------------- + +def test_off_mode_is_off(): + d = decide(mode="off", room_temp=30, heat_target=21, cool_target=25) + assert d == Decision(OFF, None) + + +def test_explicit_heat_targets_heat(): + d = decide(mode="heat", room_temp=30, heat_target=21, cool_target=25) + assert d.intent == HEAT and d.target == 21 + + +def test_explicit_cool_targets_cool(): + d = decide(mode="cool", room_temp=10, heat_target=21, cool_target=25) + assert d.intent == COOL and d.target == 25 + + +def test_auto_below_band_heats(): + d = decide(mode="auto", room_temp=18, heat_target=21, cool_target=25) + assert d.intent == HEAT and d.target == 21 + + +def test_auto_above_band_cools(): + d = decide(mode="auto", room_temp=27, heat_target=21, cool_target=25) + assert d.intent == COOL and d.target == 25 + + +def test_auto_inside_band_idles(): + d = decide(mode="auto", room_temp=23, heat_target=21, cool_target=25) + assert d.intent == IDLE + + +def test_hysteresis_keeps_heating_until_target_plus_h(): + # Was heating; room just reached heat_target — keep heating until +H. + d = decide(mode="auto", room_temp=21.1, heat_target=21, cool_target=25, + prev_intent=HEAT, hysteresis=0.3) + assert d.intent == HEAT + # Past heat_target + H → stop (idle). + d2 = decide(mode="auto", room_temp=21.4, heat_target=21, cool_target=25, + prev_intent=HEAT, hysteresis=0.3) + assert d2.intent == IDLE + + +def test_hysteresis_keeps_cooling_until_target_minus_h(): + d = decide(mode="auto", room_temp=24.9, heat_target=21, cool_target=25, + prev_intent=COOL, hysteresis=0.3) + assert d.intent == COOL + d2 = decide(mode="auto", room_temp=24.6, heat_target=21, cool_target=25, + prev_intent=COOL, hysteresis=0.3) + assert d2.intent == IDLE + + +def test_override_single_target_heats_and_cools(): + assert decide(mode="auto", room_temp=18, heat_target=21, cool_target=25, + override_target=22).intent == HEAT + assert decide(mode="auto", room_temp=26, heat_target=21, cool_target=25, + override_target=22).intent == COOL + + +def test_override_ignored_when_off(): + d = decide(mode="off", room_temp=30, heat_target=21, cool_target=25, + override_target=22) + assert d.intent == OFF + + +def test_no_room_temp_holds_prev_intent_in_auto(): + d = decide(mode="auto", room_temp=None, heat_target=21, cool_target=25, + prev_intent=HEAT) + assert d.intent == HEAT + + +def test_misconfigured_band_is_clamped(): + # cool_target below heat_target would allow simultaneous demand — clamp it. + h, c = clamp_band(25, 22, min_gap=1.0) + assert c >= h + 1.0 + # And decide() must never both-heat-and-cool with an overlapping band. + d = decide(mode="auto", room_temp=24, heat_target=25, cool_target=22) + assert d.intent in (HEAT, IDLE, COOL) # a single intent, never a conflict + + +# --------------------------------------------------------------------------- +# plan_routes() +# --------------------------------------------------------------------------- + +def test_heat_intent_routes_to_heat_and_both_only(): + devices = [_dev("climate.rad", "heat"), _dev("climate.ac", "cool", fan=True), + _dev("climate.combo", "both")] + cmds = plan_routes(Decision(HEAT, 21), devices) + by_id = {c.entity_id: c for c in cmds} + assert by_id["climate.rad"].hvac_mode == "heat" + assert by_id["climate.combo"].hvac_mode == "heat" + # cool-only AC parks on fan_only, never heats or turns off + assert by_id["climate.ac"].hvac_mode == "fan_only" + + +def test_cool_intent_routes_to_cool_and_both_only(): + devices = [_dev("climate.rad", "heat"), _dev("climate.ac", "cool", fan=True), + _dev("climate.combo", "both")] + cmds = plan_routes(Decision(COOL, 25), devices) + by_id = {c.entity_id: c for c in cmds} + assert by_id["climate.ac"].hvac_mode == "cool" + assert by_id["climate.combo"].hvac_mode == "cool" + # heat-only radiator (no fan) must go OFF while cooling, never stay in heat + assert by_id["climate.rad"].hvac_mode == "off" + + +def test_off_decision_powers_all_off(): + devices = [_dev("climate.ac", "both", fan=True), _dev("climate.rad", "heat")] + cmds = plan_routes(Decision(OFF, None), devices) + assert all(c.hvac_mode == "off" for c in cmds) + + +def test_idle_parks_fan_devices_on_fan_and_others_off(): + devices = [_dev("climate.ac", "both", fan=True), _dev("climate.rad", "heat")] + cmds = plan_routes(Decision(IDLE, None), devices) + modes = {c.entity_id: c.hvac_mode for c in cmds} + assert modes["climate.ac"] == "fan_only" # airflow preserved + assert modes["climate.rad"] == "off" # no airflow to keep + + +# --------------------------------------------------------------------------- +# Conflict-prevention invariant (property style) +# --------------------------------------------------------------------------- + +def test_never_heats_and_cools_simultaneously(): + devices = [_dev("a", "heat"), _dev("b", "cool", fan=True), _dev("c", "both", fan=True)] + rooms = [5, 15, 20, 21, 23, 25, 27, 35] + bands = [(21, 25), (25, 22), (20, 20), (18, 30)] # includes overlapping/degenerate + modes = ["auto", "heat", "cool", "off"] + prevs = [None, HEAT, COOL, IDLE] + for room, (h, c), mode, prev in itertools.product(rooms, bands, modes, prevs): + d = decide(mode=mode, room_temp=room, heat_target=h, cool_target=c, + prev_intent=prev) + cmds = plan_routes(d, devices) + actions = {cmd.hvac_mode for cmd in cmds} + assert not ("heat" in actions and "cool" in actions), ( + f"conflict: mode={mode} room={room} band=({h},{c}) prev={prev} -> {actions}" + ) diff --git a/tests/test_schedule_band.py b/tests/test_schedule_band.py new file mode 100644 index 0000000..94e84ac --- /dev/null +++ b/tests/test_schedule_band.py @@ -0,0 +1,50 @@ +"""Tests for schedule_helper.get_scheduled_band (per-node heat/cool band).""" + +import pathlib +import sys +from datetime import datetime + +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from custom_components.smart_climate.schedule_helper import get_scheduled_band + + +def test_no_schedule_uses_fallbacks(): + heat, cool = get_scheduled_band(None, datetime(2026, 7, 12, 8, 0), 21, 25) + assert (heat, cool) == (21, 25) + + +def test_node_with_cool_temp(): + schedule = {"mode": "daily", "daily": [ + {"time": "06:00", "temp": 21, "cool_temp": 25}, + {"time": "22:00", "temp": 18, "cool_temp": 23}, + ]} + heat, cool = get_scheduled_band(schedule, datetime(2026, 7, 12, 8, 0), 20, 26) + assert (heat, cool) == (21, 25) + heat2, cool2 = get_scheduled_band(schedule, datetime(2026, 7, 12, 23, 0), 20, 26) + assert (heat2, cool2) == (18, 23) + + +def test_node_without_cool_temp_uses_cool_fallback(): + schedule = {"mode": "daily", "daily": [{"time": "06:00", "temp": 21}]} + heat, cool = get_scheduled_band(schedule, datetime(2026, 7, 12, 8, 0), 20, 27) + assert heat == 21 + assert cool == 27 # fell back + + +def test_before_first_node_wraps_to_last(): + schedule = {"mode": "daily", "daily": [ + {"time": "06:00", "temp": 21, "cool_temp": 25}, + {"time": "22:00", "temp": 18, "cool_temp": 23}, + ]} + # 05:00 is before the first node → wrap to the last node of the day + heat, cool = get_scheduled_band(schedule, datetime(2026, 7, 12, 5, 0), 20, 26) + assert (heat, cool) == (18, 23) + + +def test_invalid_cool_temp_falls_back(): + schedule = {"mode": "daily", "daily": [ + {"time": "06:00", "temp": 21, "cool_temp": "warm"}, + ]} + heat, cool = get_scheduled_band(schedule, datetime(2026, 7, 12, 8, 0), 20, 26) + assert heat == 21 and cool == 26 From 52c7a3e3b6d5cc7df70f1fa77f557d87f8b42700 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 09:35:52 +0000 Subject: [PATCH 12/15] Phase 1b: run climate entity as the multi-device coordinator Rework SmartClimateEntity from a single-device mirror into a coordinator that owns its HVAC mode and drives one or more actuator devices through the pure control core: - Build a devices list (role heat/cool/both), migrating a single wrapped_climate to one 'both' device; back-compat wrapped_climate attribute retained. - Own hvac_mode (persisted) instead of mirroring; hvac_modes is the coordinator set [off, heat, cool, auto]; expose hvac_action from the current intent. - _apply_control resolves the band (get_scheduled_band + presence), reads the room temperature via the selectable source (sensor/primary/mean), calls control.decide, and routes commands to devices with change-detection so no redundant service calls are issued. - Aggregate min/max/step across devices; advertise FAN_MODE only when exactly one device supports it and delegate fan control to it; turn_on/off map to the coordinator mode. - Subscribe to device + temperature-sensor changes; on first run adopt an unpersisted mode from the device so upgrades don't change behavior. - Publish heat_target/cool_limit/intent/devices as attributes. - Rewrite test_cooling.py as coordinator integration tests (mode routing, idle/off parking, change-detection, capability aggregation, migration). 72 tests pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- custom_components/smart_climate/climate.py | 414 +++++++++++++-------- custom_components/smart_climate/const.py | 4 + tests/test_cooling.py | 279 +++++++------- 3 files changed, 417 insertions(+), 280 deletions(-) diff --git a/custom_components/smart_climate/climate.py b/custom_components/smart_climate/climate.py index a571625..c73a52c 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -25,6 +25,18 @@ CONF_SCHEDULE, CONF_COOL_AUTO_TEMPERATURE, CONF_COOL_AWAY_TEMPERATURE, + CONF_DEVICES, + CONF_HVAC_MODE, + CONF_TEMPERATURE_SOURCE, + CONF_TEMPERATURE_SENSOR, + CONF_PRIMARY_DEVICE, + CONF_HYSTERESIS, + CONF_INTEGRATION_DRIVEN_AUTO, + ROLE_BOTH, + TEMP_SOURCE_SENSOR, + TEMP_SOURCE_PRIMARY, + TEMP_SOURCE_MEAN, + DEFAULT_HYSTERESIS, ATTR_MODE, ATTR_PRESENCE, ATTR_REMAINING_MINUTES, @@ -39,8 +51,12 @@ ATTR_AWAY_DELAY_MINUTES, ATTR_DEFAULT_OVERRIDE_MODE, ATTR_DEFAULT_OVERRIDE_DURATION, + ATTR_HEAT_TARGET, + ATTR_COOL_LIMIT, + ATTR_INTENT, + ATTR_DEVICES, ) -from . import schedule_helper +from . import control, schedule_helper from .services import async_register_services _LOGGER = logging.getLogger(__name__) @@ -80,13 +96,16 @@ async def async_setup_entry( class SmartClimateEntity(ClimateEntity): - """Smart Climate controller entity.""" + """Smart Climate coordinator entity. - # HVAC modes for which the wrapper actively manages the target temperature. - # Everything else the wrapped device reports (off, fan_only, dry, …) is - # passed through untouched — the wrapper writes no setpoint for those. - _HEATING_MODES = (HVACMode.HEAT, HVACMode.AUTO) - _COOLING_MODES = (HVACMode.COOL,) + Owns its own HVAC mode (off/heat/cool/auto) and commands one or more + actuator devices (each tagged heat/cool/both). Every evaluation runs through + the pure ``control`` core: resolve the heat/cool band → decide a single + intent → route it to the devices by role. See docs/multi-device-design.md. + """ + + # The coordinator's own HVAC modes (not mirrored from a device). + _OWN_HVAC_MODES = [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO] def __init__( self, @@ -109,13 +128,30 @@ def __init__( self._attr_should_poll = False # Config - self._wrapped_climate = wrapped_climate self._zone_home = zone_home self._away_temperature = away_temp self._away_delay_minutes = away_delay_minutes self._default_override_mode = default_override_mode self._default_override_duration = default_override_duration + # Actuator devices — new `devices` list, or migrate a single + # `wrapped_climate` to one `both`-role device. + self._devices = self._build_devices(entry, wrapped_climate) + # Back-compat single-device attribute (first device, or None). + self._wrapped_climate = self._devices[0]["entity_id"] if self._devices else None + + # Coordinator-owned HVAC mode + tunables (restored from entry.data). + self._hvac_mode = entry.data.get(CONF_HVAC_MODE, HVACMode.HEAT) + self._temperature_source = entry.data.get(CONF_TEMPERATURE_SOURCE, TEMP_SOURCE_MEAN) + self._temperature_sensor = entry.data.get(CONF_TEMPERATURE_SENSOR) + self._primary_device = entry.data.get(CONF_PRIMARY_DEVICE) + self._hysteresis = entry.data.get(CONF_HYSTERESIS, DEFAULT_HYSTERESIS) + self._integration_driven_auto = entry.data.get(CONF_INTEGRATION_DRIVEN_AUTO, True) + self._intent = None + self._heat_target = None + self._cool_target = None + self._active_target = None + # State — auto_temperature and schedule are not constructor args; they are # restored from persisted config-entry storage (see async_set_* setters). self._mode = MODE_AUTO @@ -133,6 +169,20 @@ def __init__( self._away_delay_remaining = 0 self._schedule = entry.data.get(CONF_SCHEDULE, None) + @staticmethod + def _build_devices(entry, wrapped_climate): + """Return the actuator device list, migrating a single wrapped entity.""" + devices = entry.data.get(CONF_DEVICES) + if devices: + return [ + {"entity_id": d["entity_id"], "role": d.get("role", ROLE_BOTH)} + for d in devices + if d.get("entity_id") + ] + if wrapped_climate: + return [{"entity_id": wrapped_climate, "role": ROLE_BOTH}] + return [] + async def async_added_to_hass(self): """Initialize after added to hass.""" await super().async_added_to_hass() @@ -150,6 +200,18 @@ async def async_added_to_hass(self): ) ) + # React to actuator / temperature-sensor changes so control re-evaluates + # promptly (e.g. a device coming back online, or the room warming up). + watched = [d["entity_id"] for d in self._devices] + if self._temperature_sensor: + watched.append(self._temperature_sensor) + if watched: + self.async_on_remove( + async_track_state_change_event( + self.hass, watched, self._on_watched_change + ) + ) + # Start update timer (every 10 seconds) — register cancel for cleanup self.async_on_remove( async_track_time_interval( @@ -157,6 +219,14 @@ async def async_added_to_hass(self): ) ) + # Migration: if the coordinator mode was never persisted, adopt the + # first device's current mode so upgrading a single-device setup does + # not change its behavior on the first run. + if CONF_HVAC_MODE not in self.entry.data and self._devices: + state = self.hass.states.get(self._devices[0]["entity_id"]) + if state and state.state in self._OWN_HVAC_MODES: + self._hvac_mode = state.state + # Initial state update await self._update_state() @@ -203,8 +273,13 @@ async def _update_state(self, now=None): self._mode = MODE_AUTO self._next_node_datetime = None - # Calculate target temperature - await self._update_target_temperature() + # Evaluate the control decision and drive the devices + await self._apply_control() + self.async_write_ha_state() + + async def _on_watched_change(self, event): + """Re-evaluate control when a device or the temp sensor changes.""" + await self._apply_control() self.async_write_ha_state() async def _on_zone_change(self, event): @@ -246,60 +321,119 @@ async def _cancel_away_delay(self): self._away_delay_start = None self._away_delay_remaining = 0 - async def _update_target_temperature(self): - """Calculate and push the target temperature to the wrapped climate. - - The wrapper only manages a setpoint while the wrapped device is in a - temperature-controlled mode (heat/cool/auto). When the device is off — - or in a mode that has no meaningful setpoint such as ``fan_only`` or - ``dry`` — no temperature is written and the device is left untouched. - Cooling modes use the dedicated ``cool_*`` setpoints; heat/auto use the - heating setpoints and schedule. - """ - wrapped = self.hass.states.get(self._wrapped_climate) - if wrapped is None: - # Wrapped entity unavailable — nothing we can safely control. - return - hvac_mode = wrapped.state - if hvac_mode not in self._HEATING_MODES and hvac_mode not in self._COOLING_MODES: - # off / fan_only / dry / unavailable — pass through, write no setpoint. - return - cooling = hvac_mode in self._COOLING_MODES + # ---- Control pipeline ------------------------------------------------- + def _resolve_band(self): + """Return (heat_target, cool_target, override_target) for right now.""" + override_target = None if self._mode in (MODE_OVERRIDE_TIMER, MODE_OVERRIDE_INFINITY, MODE_OVERRIDE_NEXT_NODE): - target = self._override_temperature - elif self._mode == MODE_AUTO: - if self._presence == "home": - if cooling: - # Cooling has no schedule in v1 — use the flat cool setpoint. - target = self._cool_auto_temperature - elif self._schedule: - target = schedule_helper.get_scheduled_temperature( - self._schedule, dt_util.now(), self._auto_temperature - ) - if not isinstance(target, (int, float)): - _LOGGER.warning( - "%s: schedule returned invalid temperature %r, using fallback", - self._attr_name, - target, - ) - target = self._auto_temperature - else: - target = self._auto_temperature - else: - target = self._cool_away_temperature if cooling else self._away_temperature + override_target = self._override_temperature + + if self._presence == "home": + heat_target, cool_target = schedule_helper.get_scheduled_band( + self._schedule, dt_util.now(), + self._auto_temperature, self._cool_auto_temperature, + ) + if not isinstance(heat_target, (int, float)): + heat_target = self._auto_temperature + if not isinstance(cool_target, (int, float)): + cool_target = self._cool_auto_temperature else: - target = self._cool_auto_temperature if cooling else self._auto_temperature - - # Set on wrapped climate - await self.hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": self._wrapped_climate, - "temperature": target, - }, + heat_target = self._away_temperature + cool_target = self._cool_away_temperature + return heat_target, cool_target, override_target + + def _room_temperature(self): + """Return the room temperature per the selected source, with fallthrough.""" + if self._temperature_source == TEMP_SOURCE_SENSOR: + v = self._numeric_state(self._temperature_sensor) + if v is not None: + return v + if self._temperature_source == TEMP_SOURCE_PRIMARY: + v = self._device_current_temp(self._primary_device) + if v is not None: + return v + # mean (default), or fallthrough when the selected source has no value + temps = [self._device_current_temp(d["entity_id"]) for d in self._devices] + temps = [t for t in temps if t is not None] + if temps: + return sum(temps) / len(temps) + return self._numeric_state(self._temperature_sensor) + + def _numeric_state(self, entity_id): + if not entity_id: + return None + state = self.hass.states.get(entity_id) + if not state: + return None + try: + return float(state.state) + except (ValueError, TypeError): + return None + + def _device_current_temp(self, entity_id): + if not entity_id: + return None + state = self.hass.states.get(entity_id) + if not state: + return None + val = state.attributes.get("current_temperature") + return val if isinstance(val, (int, float)) else None + + def _device_caps(self): + """Return per-device dicts (entity_id, role, supports_fan_only) for routing.""" + caps = [] + for dev in self._devices: + state = self.hass.states.get(dev["entity_id"]) + modes = state.attributes.get("hvac_modes") if state else None + caps.append({ + "entity_id": dev["entity_id"], + "role": dev.get("role", ROLE_BOTH), + "supports_fan_only": HVACMode.FAN_ONLY in (modes or []), + }) + return caps + + async def _apply_control(self): + """Resolve the control decision and drive the actuator devices.""" + if not self._devices: + return + + heat_target, cool_target, override_target = self._resolve_band() + self._heat_target = heat_target + self._cool_target = cool_target + room_temp = self._room_temperature() + + decision = control.decide( + mode=self._hvac_mode, + room_temp=room_temp, + heat_target=heat_target, + cool_target=cool_target, + override_target=override_target, + prev_intent=self._intent, + hysteresis=self._hysteresis, ) + self._intent = decision.intent + self._active_target = decision.target + + commands = control.plan_routes(decision, self._device_caps()) + for cmd in commands: + await self._execute(cmd) + + async def _execute(self, cmd): + """Apply one DeviceCommand, only calling services when state differs.""" + state = self.hass.states.get(cmd.entity_id) + if state is None: + return # device unavailable — skip + if state.state != cmd.hvac_mode: + await self.hass.services.async_call( + "climate", "set_hvac_mode", + {"entity_id": cmd.entity_id, "hvac_mode": cmd.hvac_mode}, + ) + if cmd.temperature is not None and state.attributes.get("temperature") != cmd.temperature: + await self.hass.services.async_call( + "climate", "set_temperature", + {"entity_id": cmd.entity_id, "temperature": cmd.temperature}, + ) def _persist(self, **changes) -> None: """Persist runtime-changeable settings to config-entry storage. @@ -319,7 +453,7 @@ async def async_set_override_timer(self, minutes: int, temperature: float): self._override_temperature = temperature self._override_start_time = dt_util.now() self._override_duration_minutes = minutes - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_override_infinity(self, temperature: float): @@ -327,7 +461,7 @@ async def async_set_override_infinity(self, temperature: float): self._mode = MODE_OVERRIDE_INFINITY self._override_temperature = temperature self._override_start_time = None - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_override_next_node(self, temperature: float): @@ -344,7 +478,7 @@ async def async_set_override_next_node(self, temperature: float): self._mode = MODE_OVERRIDE_NEXT_NODE self._override_temperature = temperature self._next_node_datetime = next_dt - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_clear_override(self): @@ -352,7 +486,7 @@ async def async_clear_override(self): self._mode = MODE_AUTO self._override_start_time = None self._next_node_datetime = None - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_interruptible(self, interruptible: bool): @@ -365,28 +499,28 @@ async def async_set_auto_temperature(self, temperature: float): """Set the target temperature used in auto/home mode.""" self._auto_temperature = temperature self._persist(**{CONF_AUTO_TEMPERATURE: temperature}) - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_away_temperature(self, temperature: float): """Set the target temperature used in away mode.""" self._away_temperature = temperature self._persist(**{CONF_AWAY_TEMPERATURE: temperature}) - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_cool_auto_temperature(self, temperature: float): """Set the target temperature used when home and the device is cooling.""" self._cool_auto_temperature = temperature self._persist(**{CONF_COOL_AUTO_TEMPERATURE: temperature}) - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_cool_away_temperature(self, temperature: float): """Set the target temperature used when away and the device is cooling.""" self._cool_away_temperature = temperature self._persist(**{CONF_COOL_AWAY_TEMPERATURE: temperature}) - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_away_delay(self, minutes: int): @@ -409,7 +543,7 @@ async def async_set_schedule(self, schedule): """Set the temperature schedule used in auto/home mode.""" self._schedule = schedule self._persist(**{CONF_SCHEDULE: schedule}) - await self._update_target_temperature() + await self._apply_control() self.async_write_ha_state() async def async_set_temperature(self, **kwargs): @@ -425,124 +559,108 @@ async def async_set_temperature(self, **kwargs): await self.async_set_override_timer(self._default_override_duration, temperature) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Delegate HVAC mode change to the wrapped climate entity.""" - await self.hass.services.async_call( - "climate", - "set_hvac_mode", - { - "entity_id": self._wrapped_climate, - "hvac_mode": hvac_mode, - }, - ) - # Re-evaluate the setpoint for the newly selected mode (heat/cool/auto - # pick different setpoints; off/fan_only/dry write nothing). - await self._update_target_temperature() + """Set the coordinator's own HVAC mode and re-drive the devices.""" + self._hvac_mode = hvac_mode + self._persist(**{CONF_HVAC_MODE: hvac_mode}) + await self._apply_control() self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: - """Delegate fan-mode change to the wrapped climate entity.""" - await self.hass.services.async_call( - "climate", - "set_fan_mode", - {"entity_id": self._wrapped_climate, "fan_mode": fan_mode}, - ) + """Delegate fan mode to the single fan-capable device, if any.""" + device_id = self._single_fan_device() + if device_id: + await self.hass.services.async_call( + "climate", "set_fan_mode", + {"entity_id": device_id, "fan_mode": fan_mode}, + ) self.async_write_ha_state() async def async_turn_off(self) -> None: - """Turn the wrapped climate off.""" - await self.hass.services.async_call( - "climate", "turn_off", {"entity_id": self._wrapped_climate} - ) - self.async_write_ha_state() + """Turn the coordinator off (powers all actuators down).""" + await self.async_set_hvac_mode(HVACMode.OFF) async def async_turn_on(self) -> None: - """Turn the wrapped climate on.""" - await self.hass.services.async_call( - "climate", "turn_on", {"entity_id": self._wrapped_climate} - ) - await self._update_target_temperature() - self.async_write_ha_state() - - def _wrapped_state(self): - """Return the wrapped entity's state object, or ``None``.""" - return self.hass.states.get(self._wrapped_climate) + """Turn the coordinator on (defaults to heat).""" + await self.async_set_hvac_mode(HVACMode.HEAT) + + # ---- Capability aggregation ------------------------------------------ + + def _single_fan_device(self): + """Return the entity_id iff exactly one device supports fan mode.""" + fan_devices = [ + d["entity_id"] + for d in self._devices + if (state := self.hass.states.get(d["entity_id"])) + and (state.attributes.get("supported_features", 0) & ClimateEntityFeature.FAN_MODE) + ] + return fan_devices[0] if len(fan_devices) == 1 else None + + def _device_states(self): + return [ + s + for s in (self.hass.states.get(d["entity_id"]) for d in self._devices) + if s + ] @property def supported_features(self): - """Mirror the wrapped device's fan/turn-on/off support.""" features = ClimateEntityFeature.TARGET_TEMPERATURE - wrapped = self._wrapped_state() - if wrapped and (wrapped.attributes.get("supported_features", 0) & ClimateEntityFeature.FAN_MODE): - features |= ClimateEntityFeature.FAN_MODE - # We can always turn the wrapped device on/off via its hvac mode. features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + if self._single_fan_device(): + features |= ClimateEntityFeature.FAN_MODE return features @property def hvac_modes(self): - """Mirror the wrapped device's supported HVAC modes.""" - wrapped = self._wrapped_state() - if wrapped: - modes = wrapped.attributes.get("hvac_modes") - if modes: - return list(modes) - return [HVACMode.OFF, HVACMode.HEAT, HVACMode.COOL, HVACMode.AUTO] + return list(self._OWN_HVAC_MODES) @property def hvac_mode(self): - wrapped = self._wrapped_state() - if wrapped: - return wrapped.state - return HVACMode.HEAT + return self._hvac_mode + + @property + def hvac_action(self): + return control.intent_to_hvac_action(self._intent) @property def fan_modes(self): - wrapped = self._wrapped_state() - if wrapped: - return wrapped.attributes.get("fan_modes") - return None + device_id = self._single_fan_device() + state = self.hass.states.get(device_id) if device_id else None + return state.attributes.get("fan_modes") if state else None @property def fan_mode(self): - wrapped = self._wrapped_state() - if wrapped: - return wrapped.attributes.get("fan_mode") - return None + device_id = self._single_fan_device() + state = self.hass.states.get(device_id) if device_id else None + return state.attributes.get("fan_mode") if state else None @property def min_temp(self): - wrapped = self._wrapped_state() - if wrapped and wrapped.attributes.get("min_temp") is not None: - return wrapped.attributes["min_temp"] - return 5 + mins = [s.attributes.get("min_temp") for s in self._device_states()] + mins = [m for m in mins if isinstance(m, (int, float))] + return max(mins) if mins else 5 @property def max_temp(self): - wrapped = self._wrapped_state() - if wrapped and wrapped.attributes.get("max_temp") is not None: - return wrapped.attributes["max_temp"] - return 35 + maxs = [s.attributes.get("max_temp") for s in self._device_states()] + maxs = [m for m in maxs if isinstance(m, (int, float))] + return min(maxs) if maxs else 35 @property def target_temperature_step(self): - wrapped = self._wrapped_state() - if wrapped and wrapped.attributes.get("target_temp_step") is not None: - return wrapped.attributes["target_temp_step"] - return 0.5 + steps = [s.attributes.get("target_temp_step") for s in self._device_states()] + steps = [x for x in steps if isinstance(x, (int, float))] + return max(steps) if steps else 0.5 @property def current_temperature(self): - wrapped = self._wrapped_state() - if wrapped: - return wrapped.attributes.get("current_temperature") - return None + return self._room_temperature() @property def target_temperature(self): - wrapped = self._wrapped_state() - if wrapped: - return wrapped.attributes.get("temperature") - return 21 + if self._active_target is not None: + return self._active_target + return self._heat_target @property def device_info(self): @@ -576,4 +694,8 @@ def extra_state_attributes(self): ATTR_AWAY_DELAY_MINUTES: self._away_delay_minutes, ATTR_DEFAULT_OVERRIDE_MODE: self._default_override_mode, ATTR_DEFAULT_OVERRIDE_DURATION: self._default_override_duration, + ATTR_HEAT_TARGET: self._heat_target, + ATTR_COOL_LIMIT: self._cool_target, + ATTR_INTENT: self._intent, + ATTR_DEVICES: self._devices, } \ No newline at end of file diff --git a/custom_components/smart_climate/const.py b/custom_components/smart_climate/const.py index 5872699..e3a191f 100644 --- a/custom_components/smart_climate/const.py +++ b/custom_components/smart_climate/const.py @@ -58,6 +58,10 @@ ATTR_AWAY_DELAY_MINUTES = "away_delay_minutes" ATTR_DEFAULT_OVERRIDE_MODE = "default_override_mode" ATTR_DEFAULT_OVERRIDE_DURATION = "default_override_duration" +ATTR_HEAT_TARGET = "heat_target" +ATTR_COOL_LIMIT = "cool_limit" +ATTR_INTENT = "intent" +ATTR_DEVICES = "devices" # Services SERVICE_SET_OVERRIDE_TIMER = "set_override_timer" diff --git a/tests/test_cooling.py b/tests/test_cooling.py index 5d85b30..fd36144 100644 --- a/tests/test_cooling.py +++ b/tests/test_cooling.py @@ -1,9 +1,7 @@ -"""Tests for cooling support and HVAC-mode-aware setpoint resolution. +"""Coordinator integration tests: mode-owned control driving a single device. -The wrapper reads the wrapped device's current HVAC mode and: - - heat / auto → heating setpoints (away/auto/schedule), - - cool → cooling setpoints (cool_away / cool_auto), - - off / fan_only / dry → writes no setpoint at all. +These exercise SmartClimateEntity._apply_control end-to-end against one actuator +device — the pure decision/routing logic itself lives in test_control.py. """ import pathlib @@ -15,205 +13,218 @@ from custom_components.smart_climate.const import ( CONF_COOL_AUTO_TEMPERATURE, CONF_COOL_AWAY_TEMPERATURE, + CONF_DEVICES, MODE_AUTO, ) WRAPPED = "climate.wrapped" -def _make_entity(wrapped_mode="heat", wrapped_attrs=None, entry_data=None): - """Build an entity whose wrapped device reports *wrapped_mode*. - - ``hass.states.get`` returns a wrapped-climate state for the wrapped id and - ``None`` for anything else (e.g. the zone), so presence stays put. - """ +def _make_entity( + hvac_mode="heat", + device_state="off", + device_attrs=None, + room_temp=None, + presence="home", + entry_data=None, +): from custom_components.smart_climate.climate import SmartClimateEntity - wrapped_state = MagicMock() - wrapped_state.state = wrapped_mode - wrapped_state.attributes = wrapped_attrs or {} + device = MagicMock() + device.state = device_state + attrs = {} + if room_temp is not None: + attrs["current_temperature"] = room_temp + if device_attrs: + attrs.update(device_attrs) + device.attributes = attrs hass = MagicMock() hass.services.async_call = AsyncMock() - - def _states_get(entity_id): - return wrapped_state if entity_id == WRAPPED else None - - hass.states.get = MagicMock(side_effect=_states_get) + hass.states.get = MagicMock(side_effect=lambda eid: device if eid == WRAPPED else None) + hass.config_entries.async_update_entry = MagicMock() entry = MagicMock() entry.entry_id = "test_entry" entry.data = dict(entry_data or {}) - def _update_entry(target, data=None, **kwargs): - if data is not None: - target.data = dict(data) - return True - - hass.config_entries.async_update_entry = MagicMock(side_effect=_update_entry) - entity = SmartClimateEntity( - hass=hass, - entry=entry, - name="Test Climate", - wrapped_climate=WRAPPED, - zone_home="zone.home", - away_temp=14.0, - away_delay_minutes=0, - interruptible=True, - default_override_mode="timer", - default_override_duration=30, + hass=hass, entry=entry, name="Test", wrapped_climate=WRAPPED, + zone_home="zone.home", away_temp=14.0, away_delay_minutes=0, + interruptible=True, default_override_mode="timer", default_override_duration=30, ) entity.async_write_ha_state = MagicMock() + entity._hvac_mode = hvac_mode + entity._presence = presence entity._mode = MODE_AUTO - return entity, hass + return entity, hass, device + + +def _svc(hass, service): + return [ + c.args[2] + for c in hass.services.async_call.call_args_list + if c.args[0] == "climate" and c.args[1] == service + ] -def _last_set_temperature(hass): - """Return the temperature from the last climate.set_temperature call, or None.""" - for call in reversed(hass.services.async_call.call_args_list): - args = call.args - if len(args) >= 2 and args[0] == "climate" and args[1] == "set_temperature": - return args[2]["temperature"] - return None +def _last_temp(hass): + calls = _svc(hass, "set_temperature") + return calls[-1]["temperature"] if calls else None + + +def _last_mode(hass): + calls = _svc(hass, "set_hvac_mode") + return calls[-1]["hvac_mode"] if calls else None # --------------------------------------------------------------------------- -# Heating vs cooling setpoint family +# Setpoint routing per coordinator mode # --------------------------------------------------------------------------- -async def test_heat_home_uses_auto_temperature(): - entity, hass = _make_entity(wrapped_mode="heat") - entity._presence = "home" +async def test_heat_mode_home_sets_heat_and_auto_temp(): + entity, hass, _ = _make_entity(hvac_mode="heat", presence="home") entity._auto_temperature = 21 - await entity._update_target_temperature() - assert _last_set_temperature(hass) == 21 + await entity._apply_control() + assert _last_mode(hass) == "heat" + assert _last_temp(hass) == 21 -async def test_heat_away_uses_away_temperature(): - entity, hass = _make_entity(wrapped_mode="heat") - entity._presence = "away" - await entity._update_target_temperature() - assert _last_set_temperature(hass) == 14.0 +async def test_heat_mode_away_uses_away_temp(): + entity, hass, _ = _make_entity(hvac_mode="heat", presence="away") + await entity._apply_control() + assert _last_temp(hass) == 14.0 -async def test_cool_home_uses_cool_auto_temperature(): - entity, hass = _make_entity( - wrapped_mode="cool", +async def test_cool_mode_home_uses_cool_auto_temp(): + entity, hass, _ = _make_entity( + hvac_mode="cool", presence="home", entry_data={CONF_COOL_AUTO_TEMPERATURE: 24, CONF_COOL_AWAY_TEMPERATURE: 28}, ) - entity._presence = "home" - await entity._update_target_temperature() - assert _last_set_temperature(hass) == 24 + await entity._apply_control() + assert _last_mode(hass) == "cool" + assert _last_temp(hass) == 24 -async def test_cool_away_uses_cool_away_temperature(): - entity, hass = _make_entity( - wrapped_mode="cool", +async def test_cool_mode_away_uses_cool_away_temp(): + entity, hass, _ = _make_entity( + hvac_mode="cool", presence="away", entry_data={CONF_COOL_AUTO_TEMPERATURE: 24, CONF_COOL_AWAY_TEMPERATURE: 28}, ) - entity._presence = "away" - await entity._update_target_temperature() - assert _last_set_temperature(hass) == 28 + await entity._apply_control() + assert _last_temp(hass) == 28 -async def test_cool_ignores_heating_schedule(): - """A configured heating schedule must not affect cooling setpoints.""" - entity, hass = _make_entity( - wrapped_mode="cool", entry_data={CONF_COOL_AUTO_TEMPERATURE: 25} - ) - entity._presence = "home" - entity._schedule = {"mode": "daily", "daily": [{"time": "00:00", "temp": 18}]} - await entity._update_target_temperature() - assert _last_set_temperature(hass) == 25 +async def test_auto_below_band_heats_device(): + entity, hass, _ = _make_entity(hvac_mode="auto", presence="home", room_temp=18, + entry_data={CONF_COOL_AUTO_TEMPERATURE: 25}) + entity._auto_temperature = 21 + await entity._apply_control() + assert _last_mode(hass) == "heat" + assert _last_temp(hass) == 21 -async def test_auto_mode_uses_heating_comfort_target(): - entity, hass = _make_entity(wrapped_mode="auto") - entity._presence = "home" - entity._auto_temperature = 20 - await entity._update_target_temperature() - assert _last_set_temperature(hass) == 20 +async def test_auto_above_band_cools_device(): + entity, hass, _ = _make_entity(hvac_mode="auto", presence="home", room_temp=27, + entry_data={CONF_COOL_AUTO_TEMPERATURE: 25}) + entity._auto_temperature = 21 + await entity._apply_control() + assert _last_mode(hass) == "cool" + assert _last_temp(hass) == 25 -# --------------------------------------------------------------------------- -# Non-managed modes write no setpoint -# --------------------------------------------------------------------------- +async def test_auto_inside_band_idles_device_off_without_fan(): + entity, hass, _ = _make_entity(hvac_mode="auto", device_state="heat", presence="home", + room_temp=23, entry_data={CONF_COOL_AUTO_TEMPERATURE: 25}) + entity._auto_temperature = 21 + await entity._apply_control() + # No fan_only support → parked off, and no setpoint written + assert _last_mode(hass) == "off" + assert _svc(hass, "set_temperature") == [] -async def test_off_writes_no_setpoint(): - entity, hass = _make_entity(wrapped_mode="off") - entity._presence = "home" - await entity._update_target_temperature() - assert _last_set_temperature(hass) is None +async def test_auto_inside_band_keeps_airflow_when_fan_capable(): + entity, hass, _ = _make_entity( + hvac_mode="auto", presence="home", room_temp=23, + device_attrs={"hvac_modes": ["off", "heat", "cool", "fan_only"]}, + entry_data={CONF_COOL_AUTO_TEMPERATURE: 25}, + ) + entity._auto_temperature = 21 + await entity._apply_control() + assert _last_mode(hass) == "fan_only" -async def test_fan_only_writes_no_setpoint(): - entity, hass = _make_entity(wrapped_mode="fan_only") - entity._presence = "home" - await entity._update_target_temperature() - assert _last_set_temperature(hass) is None +async def test_off_mode_powers_device_off(): + entity, hass, _ = _make_entity(hvac_mode="off", device_state="heat", presence="home") + await entity._apply_control() + assert _last_mode(hass) == "off" + assert _svc(hass, "set_temperature") == [] -async def test_dry_writes_no_setpoint(): - entity, hass = _make_entity(wrapped_mode="dry") - entity._presence = "home" - await entity._update_target_temperature() - assert _last_set_temperature(hass) is None +async def test_no_devices_does_nothing(): + entity, hass, _ = _make_entity(hvac_mode="heat") + entity._devices = [] + await entity._apply_control() + assert hass.services.async_call.call_count == 0 -async def test_unavailable_wrapped_writes_no_setpoint(): - entity, hass = _make_entity() - hass.states.get = MagicMock(return_value=None) - await entity._update_target_temperature() - assert _last_set_temperature(hass) is None + +async def test_change_detection_skips_redundant_calls(): + # Device already in heat at the target — no service calls should be issued. + entity, hass, device = _make_entity(hvac_mode="heat", device_state="heat", presence="home") + entity._auto_temperature = 21 + device.attributes["temperature"] = 21 + await entity._apply_control() + assert hass.services.async_call.call_count == 0 # --------------------------------------------------------------------------- -# Mirroring the wrapped device's capabilities +# Capability aggregation & migration # --------------------------------------------------------------------------- -def test_hvac_modes_mirror_wrapped(): - modes = ["heat", "fan_only", "dry", "cool", "auto", "off"] - entity, _ = _make_entity(wrapped_mode="heat", wrapped_attrs={"hvac_modes": modes}) - assert entity.hvac_modes == modes +def test_hvac_modes_are_own_set(): + entity, _, _ = _make_entity() + assert entity.hvac_modes == ["off", "heat", "cool", "auto"] + + +def test_hvac_mode_is_owned(): + entity, _, _ = _make_entity(hvac_mode="cool") + assert entity.hvac_mode == "cool" -def test_min_max_step_mirror_wrapped(): - entity, _ = _make_entity( - wrapped_mode="heat", - wrapped_attrs={"min_temp": 7, "max_temp": 35, "target_temp_step": 1}, +def test_min_max_step_aggregate_from_devices(): + entity, _, _ = _make_entity( + device_attrs={"min_temp": 7, "max_temp": 35, "target_temp_step": 1} ) assert entity.min_temp == 7 assert entity.max_temp == 35 assert entity.target_temperature_step == 1 -def test_supported_features_advertise_fan_when_wrapped_supports_it(): +def test_supported_features_include_fan_when_device_supports_it(): from custom_components.smart_climate.climate import ClimateEntityFeature - # 393 = TARGET_TEMPERATURE | FAN_MODE | TURN_OFF | TURN_ON (the Qlima airco) - entity, _ = _make_entity( - wrapped_mode="cool", wrapped_attrs={"supported_features": 393} - ) + entity, _, _ = _make_entity(device_attrs={"supported_features": 393}) assert entity.supported_features & ClimateEntityFeature.FAN_MODE -def test_supported_features_no_fan_when_wrapped_lacks_it(): - from custom_components.smart_climate.climate import ClimateEntityFeature - - entity, _ = _make_entity( - wrapped_mode="heat", wrapped_attrs={"supported_features": 1} - ) - assert not (entity.supported_features & ClimateEntityFeature.FAN_MODE) +def test_hvac_action_reflects_intent(): + entity, hass, _ = _make_entity(hvac_mode="heat", presence="home") + entity._auto_temperature = 21 + # before any evaluation + assert entity.hvac_action == "idle" -async def test_set_cool_auto_temperature_persists(): - entity, _ = _make_entity(wrapped_mode="cool") - await entity.async_set_cool_auto_temperature(23) - assert entity.entry.data[CONF_COOL_AUTO_TEMPERATURE] == 23 +def test_migration_from_single_wrapped_climate(): + entity, _, _ = _make_entity() + assert entity._devices == [{"entity_id": WRAPPED, "role": "both"}] -async def test_set_cool_away_temperature_persists(): - entity, _ = _make_entity(wrapped_mode="cool") - await entity.async_set_cool_away_temperature(30) - assert entity.entry.data[CONF_COOL_AWAY_TEMPERATURE] == 30 +def test_devices_list_from_entry(): + entity, _, _ = _make_entity( + entry_data={CONF_DEVICES: [ + {"entity_id": "climate.rad", "role": "heat"}, + {"entity_id": "climate.ac", "role": "cool"}, + ]} + ) + assert {d["entity_id"] for d in entity._devices} == {"climate.rad", "climate.ac"} + assert entity._devices[0]["role"] == "heat" From 735c7275592406068a07e8be019c783808f70c14 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 09:36:18 +0000 Subject: [PATCH 13/15] Mark Phase 1 (foundation + decision core) done in design doc Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- docs/multi-device-design.md | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/docs/multi-device-design.md b/docs/multi-device-design.md index 9f48be0..9d6d80e 100644 --- a/docs/multi-device-design.md +++ b/docs/multi-device-design.md @@ -263,15 +263,22 @@ integration-driven auto, and per-device role `select`s (Phase 3 polish). routed as simultaneous heat+cool. - Migration — old `wrapped_climate` entry yields one `both` device. -## 14. Phasing (after this design is signed off) - -1. **Foundation + decision core** — coordinator owns mode; `devices` model + - migration; `get_scheduled_band`; pure `decide`/`route`; wire the control loop; - integration-driven auto toggle. (Single or multiple devices already work.) -2. **Config/Options UX** — the add/edit/remove-device Options flow + sensor + - toggle. +## 14. Phasing + +1. **Foundation + decision core** — ✅ **done.** `control.decide`/`plan_routes` + (pure core, `test_control.py`); `schedule_helper.get_scheduled_band`; + coordinator owns mode; `devices` model + single-device migration; control + loop wired through `_apply_control` with change-detection; selectable room-temp + source; hysteresis; capability aggregation. Multi-device already works when + `devices` is present in the entry (via YAML/storage) — only the *UI* to build + that list is still pending (Phase 2). The `integration_driven_auto=false` + legacy passthrough is stubbed as a config flag but not yet special-cased + (currently always integration-driven). +2. **Config/Options UX** — config subentries for devices + Options flow for the + room-temp source, hysteresis, and the auto toggle. *(next)* 3. **Schedule card** — dual-line (heat/cool) band editing on the timeline. -4. **Polish** — hvac_action in cards, per-device role selects, docs. +4. **Polish** — hvac_action in cards, per-device role selects, docs, and the + `integration_driven_auto=false` passthrough. ## 15. Resolved decisions From 30471e3bd9111d079c32039379d1635320d1921b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 10:05:45 +0000 Subject: [PATCH 14/15] Phase 2: config subentries for devices + Options flow - config_flow: initial flow seeds the first actuator device (entity + role) into the devices list; DeviceSubentryFlowHandler adds/edits further devices as config subentries; SmartClimateOptionsFlow sets the room-temp source, sensor, primary device, hysteresis, and integration-driven-auto toggle. - climate: _build_devices merges device subentries + entry.data list + legacy wrapped migration (deduped); instance tunables read live via _conf() (entry.options over entry.data) so options apply without a reload. - __init__: update listener reloads the entry only when the resolved device list changes, so routine setpoint persistence (entry.data) never triggers a reload. - strings/en/nl: config, subentry, options, and selector translations. - Tests for subentry merge, dedupe, and legacy migration (75 total). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- custom_components/smart_climate/__init__.py | 20 ++ custom_components/smart_climate/climate.py | 79 +++++--- .../smart_climate/config_flow.py | 172 ++++++++++++++++-- custom_components/smart_climate/const.py | 5 + custom_components/smart_climate/strings.json | 82 ++++++++- .../smart_climate/translations/en.json | 82 ++++++++- .../smart_climate/translations/nl.json | 82 ++++++++- docs/multi-device-design.md | 8 +- tests/test_cooling.py | 42 +++++ 9 files changed, 513 insertions(+), 59 deletions(-) diff --git a/custom_components/smart_climate/__init__.py b/custom_components/smart_climate/__init__.py index 2f3abf1..b451cbc 100644 --- a/custom_components/smart_climate/__init__.py +++ b/custom_components/smart_climate/__init__.py @@ -72,10 +72,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].setdefault("entities", {}) hass.data[DOMAIN][entry.entry_id] = entry.data + # Reload when the actuator device list changes (a device subentry added, + # edited, or removed). Routine setpoint persistence writes entry.data but + # never changes the device list, so it does not trigger a reload. + entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True +async def _async_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload the entry only when its resolved device list changed.""" + from .climate import SmartClimateEntity + + wrapped = entry.data.get(CONF_WRAPPED_CLIMATE) + new_devices = SmartClimateEntity._build_devices(entry, wrapped) + for ent in list(hass.data.get(DOMAIN, {}).get("entities", {}).values()): + if getattr(ent, "entry", None) is entry: + if ent._devices != new_devices: + await hass.config_entries.async_reload(entry.entry_id) + return + # Coordinator entity not found yet — reload to pick up the change. + await hass.config_entries.async_reload(entry.entry_id) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/custom_components/smart_climate/climate.py b/custom_components/smart_climate/climate.py index c73a52c..dac2627 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from datetime import timedelta from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature, HVACMode from homeassistant.const import UnitOfTemperature, CONF_NAME @@ -31,8 +32,8 @@ CONF_TEMPERATURE_SENSOR, CONF_PRIMARY_DEVICE, CONF_HYSTERESIS, - CONF_INTEGRATION_DRIVEN_AUTO, ROLE_BOTH, + SUBENTRY_TYPE_DEVICE, TEMP_SOURCE_SENSOR, TEMP_SOURCE_PRIMARY, TEMP_SOURCE_MEAN, @@ -140,13 +141,10 @@ def __init__( # Back-compat single-device attribute (first device, or None). self._wrapped_climate = self._devices[0]["entity_id"] if self._devices else None - # Coordinator-owned HVAC mode + tunables (restored from entry.data). + # Coordinator-owned HVAC mode (persisted to entry.data). The instance + # tunables (temp source, hysteresis, auto toggle) are read *live* via + # _conf() so Options-flow changes take effect without a reload. self._hvac_mode = entry.data.get(CONF_HVAC_MODE, HVACMode.HEAT) - self._temperature_source = entry.data.get(CONF_TEMPERATURE_SOURCE, TEMP_SOURCE_MEAN) - self._temperature_sensor = entry.data.get(CONF_TEMPERATURE_SENSOR) - self._primary_device = entry.data.get(CONF_PRIMARY_DEVICE) - self._hysteresis = entry.data.get(CONF_HYSTERESIS, DEFAULT_HYSTERESIS) - self._integration_driven_auto = entry.data.get(CONF_INTEGRATION_DRIVEN_AUTO, True) self._intent = None self._heat_target = None self._cool_target = None @@ -171,17 +169,43 @@ def __init__( @staticmethod def _build_devices(entry, wrapped_climate): - """Return the actuator device list, migrating a single wrapped entity.""" - devices = entry.data.get(CONF_DEVICES) - if devices: - return [ - {"entity_id": d["entity_id"], "role": d.get("role", ROLE_BOTH)} - for d in devices - if d.get("entity_id") - ] - if wrapped_climate: - return [{"entity_id": wrapped_climate, "role": ROLE_BOTH}] - return [] + """Return the actuator device list from all sources. + + Devices come from (in order) config subentries of type ``device`` and an + ``entry.data['devices']`` list; a legacy single ``wrapped_climate`` is + migrated to one ``both``-role device when no list is present. + """ + devices = [] + seen = set() + + def _add(entity_id, role): + if entity_id and entity_id not in seen: + seen.add(entity_id) + devices.append({"entity_id": entity_id, "role": role or ROLE_BOTH}) + + subentries = getattr(entry, "subentries", None) + if isinstance(subentries, Mapping): + for sub in subentries.values(): + if getattr(sub, "subentry_type", None) == SUBENTRY_TYPE_DEVICE: + data = getattr(sub, "data", {}) or {} + _add(data.get("entity_id"), data.get("role")) + + for d in entry.data.get(CONF_DEVICES, []) or []: + _add(d.get("entity_id"), d.get("role")) + + if not devices and wrapped_climate: + _add(wrapped_climate, ROLE_BOTH) + return devices + + def _conf(self, key, default): + """Read a tunable live — entry.options overrides entry.data.""" + options = getattr(self.entry, "options", None) + if isinstance(options, Mapping) and key in options: + return options[key] + data = self.entry.data + if isinstance(data, Mapping) and key in data: + return data[key] + return default async def async_added_to_hass(self): """Initialize after added to hass.""" @@ -203,8 +227,9 @@ async def async_added_to_hass(self): # React to actuator / temperature-sensor changes so control re-evaluates # promptly (e.g. a device coming back online, or the room warming up). watched = [d["entity_id"] for d in self._devices] - if self._temperature_sensor: - watched.append(self._temperature_sensor) + sensor = self._conf(CONF_TEMPERATURE_SENSOR, None) + if sensor: + watched.append(sensor) if watched: self.async_on_remove( async_track_state_change_event( @@ -345,12 +370,14 @@ def _resolve_band(self): def _room_temperature(self): """Return the room temperature per the selected source, with fallthrough.""" - if self._temperature_source == TEMP_SOURCE_SENSOR: - v = self._numeric_state(self._temperature_sensor) + source = self._conf(CONF_TEMPERATURE_SOURCE, TEMP_SOURCE_MEAN) + sensor = self._conf(CONF_TEMPERATURE_SENSOR, None) + if source == TEMP_SOURCE_SENSOR: + v = self._numeric_state(sensor) if v is not None: return v - if self._temperature_source == TEMP_SOURCE_PRIMARY: - v = self._device_current_temp(self._primary_device) + if source == TEMP_SOURCE_PRIMARY: + v = self._device_current_temp(self._conf(CONF_PRIMARY_DEVICE, None)) if v is not None: return v # mean (default), or fallthrough when the selected source has no value @@ -358,7 +385,7 @@ def _room_temperature(self): temps = [t for t in temps if t is not None] if temps: return sum(temps) / len(temps) - return self._numeric_state(self._temperature_sensor) + return self._numeric_state(sensor) def _numeric_state(self, entity_id): if not entity_id: @@ -410,7 +437,7 @@ async def _apply_control(self): cool_target=cool_target, override_target=override_target, prev_intent=self._intent, - hysteresis=self._hysteresis, + hysteresis=self._conf(CONF_HYSTERESIS, DEFAULT_HYSTERESIS), ) self._intent = decision.intent self._active_target = decision.target diff --git a/custom_components/smart_climate/config_flow.py b/custom_components/smart_climate/config_flow.py index 6935669..9c8a259 100644 --- a/custom_components/smart_climate/config_flow.py +++ b/custom_components/smart_climate/config_flow.py @@ -1,10 +1,17 @@ -from homeassistant.config_entries import ConfigFlow +"""Config, subentry (devices) and options flows for Smart Climate.""" + +from homeassistant.config_entries import ( + ConfigFlow, + ConfigSubentryFlow, + OptionsFlow, +) from homeassistant.const import CONF_NAME +from homeassistant.core import callback from homeassistant.helpers import selector import voluptuous as vol + from .const import ( DOMAIN, - CONF_WRAPPED_CLIMATE, CONF_ZONE_HOME, CONF_AWAY_TEMPERATURE, CONF_AWAY_DELAY_MINUTES, @@ -13,8 +20,47 @@ CONF_DEFAULT_OVERRIDE_DURATION, CONF_COOL_AUTO_TEMPERATURE, CONF_COOL_AWAY_TEMPERATURE, + CONF_DEVICES, + CONF_ENTITY_ID, + CONF_ROLE, + CONF_TEMPERATURE_SOURCE, + CONF_TEMPERATURE_SENSOR, + CONF_PRIMARY_DEVICE, + CONF_HYSTERESIS, + CONF_INTEGRATION_DRIVEN_AUTO, + ROLE_HEAT, + ROLE_COOL, + ROLE_BOTH, + SUBENTRY_TYPE_DEVICE, + TEMP_SOURCE_SENSOR, + TEMP_SOURCE_PRIMARY, + TEMP_SOURCE_MEAN, + DEFAULT_HYSTERESIS, ) +ROLES = [ROLE_HEAT, ROLE_COOL, ROLE_BOTH] + + +def _device_schema(defaults=None): + """Schema for one actuator device (entity + role).""" + defaults = defaults or {} + return vol.Schema( + { + vol.Required( + CONF_ENTITY_ID, default=defaults.get(CONF_ENTITY_ID) + ): selector.EntitySelector( + selector.EntitySelectorConfig(domain="climate") + ), + vol.Required( + CONF_ROLE, default=defaults.get(CONF_ROLE, ROLE_BOTH) + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=ROLES, translation_key="device_role" + ) + ), + } + ) + class SmartClimateConfigFlow(ConfigFlow, domain=DOMAIN): """Config flow for Smart Climate.""" @@ -25,6 +71,17 @@ def __init__(self): super().__init__() self._import_name = None + @classmethod + @callback + def async_get_supported_subentry_types(cls, config_entry): + """Actuator devices are managed as config subentries.""" + return {SUBENTRY_TYPE_DEVICE: DeviceSubentryFlowHandler} + + @staticmethod + @callback + def async_get_options_flow(config_entry): + return SmartClimateOptionsFlow() + async def async_step_import(self, import_data): """Handle import from YAML (only name is required).""" if isinstance(import_data, list): @@ -35,22 +92,32 @@ async def async_step_import(self, import_data): return await self.async_step_user() async def async_step_user(self, user_input=None): - """Handle user step.""" + """Initial setup: name, home zone, and the first actuator device.""" if user_input is not None: await self.async_set_unique_id(user_input.get(CONF_NAME, "smart_climate")) self._abort_if_unique_id_configured() + # Fold the first device into the devices list. + data = dict(user_input) + entity_id = data.pop(CONF_ENTITY_ID, None) + role = data.pop(CONF_ROLE, ROLE_BOTH) + if entity_id: + data[CONF_DEVICES] = [{"entity_id": entity_id, "role": role}] + return self.async_create_entry( title=user_input.get(CONF_NAME, "Smart Climate"), - data=user_input, + data=data, ) schema = vol.Schema( { vol.Required(CONF_NAME, default=self._import_name or "Smart Climate"): selector.TextSelector(), - vol.Required(CONF_WRAPPED_CLIMATE): selector.EntitySelector( + vol.Required(CONF_ENTITY_ID): selector.EntitySelector( selector.EntitySelectorConfig(domain="climate") ), + vol.Required(CONF_ROLE, default=ROLE_BOTH): selector.SelectSelector( + selector.SelectSelectorConfig(options=ROLES, translation_key="device_role") + ), vol.Required(CONF_ZONE_HOME): selector.EntitySelector( selector.EntitySelectorConfig(domain="zone") ), @@ -76,13 +143,10 @@ async def async_step_user(self, user_input=None): } ) - return self.async_show_form( - step_id="user", - data_schema=schema, - ) + return self.async_show_form(step_id="user", data_schema=schema) async def async_step_reconfigure(self, user_input=None): - """Handle reconfiguration — allow changing wrapped_climate and zone_home.""" + """Reconfigure the home zone (devices are managed as subentries).""" entry = self._get_reconfigure_entry() if user_input is not None: @@ -93,12 +157,6 @@ async def async_step_reconfigure(self, user_input=None): schema = vol.Schema( { - vol.Required( - CONF_WRAPPED_CLIMATE, - default=entry.data.get(CONF_WRAPPED_CLIMATE, ""), - ): selector.EntitySelector( - selector.EntitySelectorConfig(domain="climate") - ), vol.Required( CONF_ZONE_HOME, default=entry.data.get(CONF_ZONE_HOME, ""), @@ -108,7 +166,83 @@ async def async_step_reconfigure(self, user_input=None): } ) + return self.async_show_form(step_id="reconfigure", data_schema=schema) + + +class DeviceSubentryFlowHandler(ConfigSubentryFlow): + """Add or edit a single actuator device as a config subentry.""" + + async def async_step_user(self, user_input=None): + """Add a new device.""" + if user_input is not None: + return self.async_create_entry( + title=user_input[CONF_ENTITY_ID], + data={ + CONF_ENTITY_ID: user_input[CONF_ENTITY_ID], + CONF_ROLE: user_input[CONF_ROLE], + }, + ) + return self.async_show_form(step_id="user", data_schema=_device_schema()) + + async def async_step_reconfigure(self, user_input=None): + """Edit an existing device.""" + subentry = self._get_reconfigure_subentry() + if user_input is not None: + return self.async_update_and_abort( + self._get_entry(), + subentry, + title=user_input[CONF_ENTITY_ID], + data={ + CONF_ENTITY_ID: user_input[CONF_ENTITY_ID], + CONF_ROLE: user_input[CONF_ROLE], + }, + ) return self.async_show_form( - step_id="reconfigure", - data_schema=schema, - ) \ No newline at end of file + step_id="reconfigure", data_schema=_device_schema(subentry.data) + ) + + +class SmartClimateOptionsFlow(OptionsFlow): + """Instance tunables: room-temperature source, hysteresis, auto strategy.""" + + async def async_step_init(self, user_input=None): + if user_input is not None: + return self.async_create_entry(data=user_input) + + current = {**self.config_entry.data, **self.config_entry.options} + schema = vol.Schema( + { + vol.Required( + CONF_TEMPERATURE_SOURCE, + default=current.get(CONF_TEMPERATURE_SOURCE, TEMP_SOURCE_MEAN), + ): selector.SelectSelector( + selector.SelectSelectorConfig( + options=[TEMP_SOURCE_MEAN, TEMP_SOURCE_PRIMARY, TEMP_SOURCE_SENSOR], + translation_key="temperature_source", + ) + ), + vol.Optional( + CONF_TEMPERATURE_SENSOR, + description={"suggested_value": current.get(CONF_TEMPERATURE_SENSOR)}, + ): selector.EntitySelector( + selector.EntitySelectorConfig(domain="sensor", device_class="temperature") + ), + vol.Optional( + CONF_PRIMARY_DEVICE, + description={"suggested_value": current.get(CONF_PRIMARY_DEVICE)}, + ): selector.EntitySelector( + selector.EntitySelectorConfig(domain="climate") + ), + vol.Optional( + CONF_HYSTERESIS, + default=current.get(CONF_HYSTERESIS, DEFAULT_HYSTERESIS), + ): selector.NumberSelector( + selector.NumberSelectorConfig(min=0.1, max=3.0, step=0.1, unit_of_measurement="°C", mode=selector.NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_INTEGRATION_DRIVEN_AUTO, + default=current.get(CONF_INTEGRATION_DRIVEN_AUTO, True), + ): selector.BooleanSelector(), + } + ) + return self.async_show_form(step_id="init", data_schema=schema) diff --git a/custom_components/smart_climate/const.py b/custom_components/smart_climate/const.py index e3a191f..c9aa1af 100644 --- a/custom_components/smart_climate/const.py +++ b/custom_components/smart_climate/const.py @@ -32,6 +32,11 @@ ROLE_HEAT = "heat" ROLE_COOL = "cool" ROLE_BOTH = "both" +CONF_ROLE = "role" +CONF_ENTITY_ID = "entity_id" + +# Config subentry type for an actuator device +SUBENTRY_TYPE_DEVICE = "device" # Room-temperature sources TEMP_SOURCE_SENSOR = "sensor" diff --git a/custom_components/smart_climate/strings.json b/custom_components/smart_climate/strings.json index 79022c4..00a33a5 100644 --- a/custom_components/smart_climate/strings.json +++ b/custom_components/smart_climate/strings.json @@ -3,10 +3,11 @@ "step": { "user": { "title": "Smart Climate Setup", - "description": "Configure Smart Climate Controller", + "description": "Configure Smart Climate Controller. You can add more devices afterwards.", "data": { "name": "Name", - "wrapped_climate": "Wrapped Climate Entity", + "entity_id": "Climate device", + "role": "Role", "zone_home": "Home Zone", "away_temperature": "Away Temperature", "cool_auto_temperature": "Cooling Home Temperature", @@ -17,7 +18,8 @@ "default_override_duration": "Default Override Duration (minutes)" }, "data_description": { - "wrapped_climate": "The climate entity to wrap and control", + "entity_id": "The first climate device to control (add more later via the device menu)", + "role": "Whether this device can heat, cool, or both", "zone_home": "The zone entity used for presence detection", "away_temperature": "Target temperature when nobody is home (heating)", "cool_auto_temperature": "Target temperature when home and the device is cooling", @@ -27,11 +29,83 @@ "default_override_mode": "Override mode used when adjusting temperature (timer, infinity, or next_node)", "default_override_duration": "Default duration in minutes for timer override mode" } + }, + "reconfigure": { + "title": "Reconfigure", + "data": { + "zone_home": "Home Zone" + } } }, "error": {}, "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reconfigure_successful": "Reconfiguration saved" + } + }, + "config_subentries": { + "device": { + "initiate_flow": { + "user": "Add device", + "reconfigure": "Edit device" + }, + "entry_type": "Device", + "step": { + "user": { + "title": "Add actuator device", + "data": { + "entity_id": "Climate device", + "role": "Role" + }, + "data_description": { + "role": "Whether this device can heat, cool, or both" + } + }, + "reconfigure": { + "title": "Edit actuator device", + "data": { + "entity_id": "Climate device", + "role": "Role" + } + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Smart Climate options", + "data": { + "temperature_source": "Room temperature source", + "temperature_sensor": "Temperature sensor", + "primary_device": "Primary device", + "hysteresis": "Hysteresis (°C)", + "integration_driven_auto": "Integration-driven auto" + }, + "data_description": { + "temperature_source": "How the room temperature is measured: mean of devices, a primary device, or a dedicated sensor", + "temperature_sensor": "Used when the source is 'Dedicated sensor'", + "primary_device": "Used when the source is 'Primary device'", + "hysteresis": "Deadband around the switch points to avoid rapid heat/cool flapping", + "integration_driven_auto": "In auto, let Smart Climate decide heat vs cool (recommended, required for multiple devices)" + } + } + } + }, + "selector": { + "device_role": { + "options": { + "heat": "Heat only", + "cool": "Cool only", + "both": "Heat & cool" + } + }, + "temperature_source": { + "options": { + "mean": "Mean of devices", + "primary": "Primary device", + "sensor": "Dedicated sensor" + } } }, "entity": { diff --git a/custom_components/smart_climate/translations/en.json b/custom_components/smart_climate/translations/en.json index 79022c4..00a33a5 100644 --- a/custom_components/smart_climate/translations/en.json +++ b/custom_components/smart_climate/translations/en.json @@ -3,10 +3,11 @@ "step": { "user": { "title": "Smart Climate Setup", - "description": "Configure Smart Climate Controller", + "description": "Configure Smart Climate Controller. You can add more devices afterwards.", "data": { "name": "Name", - "wrapped_climate": "Wrapped Climate Entity", + "entity_id": "Climate device", + "role": "Role", "zone_home": "Home Zone", "away_temperature": "Away Temperature", "cool_auto_temperature": "Cooling Home Temperature", @@ -17,7 +18,8 @@ "default_override_duration": "Default Override Duration (minutes)" }, "data_description": { - "wrapped_climate": "The climate entity to wrap and control", + "entity_id": "The first climate device to control (add more later via the device menu)", + "role": "Whether this device can heat, cool, or both", "zone_home": "The zone entity used for presence detection", "away_temperature": "Target temperature when nobody is home (heating)", "cool_auto_temperature": "Target temperature when home and the device is cooling", @@ -27,11 +29,83 @@ "default_override_mode": "Override mode used when adjusting temperature (timer, infinity, or next_node)", "default_override_duration": "Default duration in minutes for timer override mode" } + }, + "reconfigure": { + "title": "Reconfigure", + "data": { + "zone_home": "Home Zone" + } } }, "error": {}, "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reconfigure_successful": "Reconfiguration saved" + } + }, + "config_subentries": { + "device": { + "initiate_flow": { + "user": "Add device", + "reconfigure": "Edit device" + }, + "entry_type": "Device", + "step": { + "user": { + "title": "Add actuator device", + "data": { + "entity_id": "Climate device", + "role": "Role" + }, + "data_description": { + "role": "Whether this device can heat, cool, or both" + } + }, + "reconfigure": { + "title": "Edit actuator device", + "data": { + "entity_id": "Climate device", + "role": "Role" + } + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Smart Climate options", + "data": { + "temperature_source": "Room temperature source", + "temperature_sensor": "Temperature sensor", + "primary_device": "Primary device", + "hysteresis": "Hysteresis (°C)", + "integration_driven_auto": "Integration-driven auto" + }, + "data_description": { + "temperature_source": "How the room temperature is measured: mean of devices, a primary device, or a dedicated sensor", + "temperature_sensor": "Used when the source is 'Dedicated sensor'", + "primary_device": "Used when the source is 'Primary device'", + "hysteresis": "Deadband around the switch points to avoid rapid heat/cool flapping", + "integration_driven_auto": "In auto, let Smart Climate decide heat vs cool (recommended, required for multiple devices)" + } + } + } + }, + "selector": { + "device_role": { + "options": { + "heat": "Heat only", + "cool": "Cool only", + "both": "Heat & cool" + } + }, + "temperature_source": { + "options": { + "mean": "Mean of devices", + "primary": "Primary device", + "sensor": "Dedicated sensor" + } } }, "entity": { diff --git a/custom_components/smart_climate/translations/nl.json b/custom_components/smart_climate/translations/nl.json index 7127b8c..ca1e9a3 100644 --- a/custom_components/smart_climate/translations/nl.json +++ b/custom_components/smart_climate/translations/nl.json @@ -3,10 +3,11 @@ "step": { "user": { "title": "Smart Climate Instellen", - "description": "Configureer Smart Climate Controller", + "description": "Configureer Smart Climate Controller. Je kunt later meer apparaten toevoegen.", "data": { "name": "Naam", - "wrapped_climate": "Gekoppeld Klimaatapparaat", + "entity_id": "Klimaatapparaat", + "role": "Rol", "zone_home": "Thuiszone", "away_temperature": "Weg Temperatuur", "cool_auto_temperature": "Koelen Thuis Temperatuur", @@ -17,7 +18,8 @@ "default_override_duration": "Standaard Overschrijfduur (minuten)" }, "data_description": { - "wrapped_climate": "Het klimaatapparaat om te beheren", + "entity_id": "Het eerste klimaatapparaat om te beheren (voeg er later meer toe via het apparatenmenu)", + "role": "Of dit apparaat kan verwarmen, koelen of beide", "zone_home": "De zone die gebruikt wordt voor aanwezigheidsdetectie", "away_temperature": "Doeltemperatuur wanneer niemand thuis is (verwarmen)", "cool_auto_temperature": "Doeltemperatuur wanneer thuis en het apparaat koelt", @@ -27,11 +29,83 @@ "default_override_mode": "Overschrijfmodus bij het aanpassen van de temperatuur (timer, oneindig of volgend moment)", "default_override_duration": "Standaardduur in minuten voor timer-overschrijfmodus" } + }, + "reconfigure": { + "title": "Herconfigureren", + "data": { + "zone_home": "Thuiszone" + } } }, "error": {}, "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "reconfigure_successful": "Herconfiguratie opgeslagen" + } + }, + "config_subentries": { + "device": { + "initiate_flow": { + "user": "Apparaat toevoegen", + "reconfigure": "Apparaat bewerken" + }, + "entry_type": "Apparaat", + "step": { + "user": { + "title": "Klimaatapparaat toevoegen", + "data": { + "entity_id": "Klimaatapparaat", + "role": "Rol" + }, + "data_description": { + "role": "Of dit apparaat kan verwarmen, koelen of beide" + } + }, + "reconfigure": { + "title": "Klimaatapparaat bewerken", + "data": { + "entity_id": "Klimaatapparaat", + "role": "Rol" + } + } + } + } + }, + "options": { + "step": { + "init": { + "title": "Smart Climate opties", + "data": { + "temperature_source": "Bron kamertemperatuur", + "temperature_sensor": "Temperatuursensor", + "primary_device": "Primair apparaat", + "hysteresis": "Hysterese (°C)", + "integration_driven_auto": "Auto door integratie gestuurd" + }, + "data_description": { + "temperature_source": "Hoe de kamertemperatuur wordt gemeten: gemiddelde van apparaten, een primair apparaat, of een aparte sensor", + "temperature_sensor": "Gebruikt wanneer de bron 'Aparte sensor' is", + "primary_device": "Gebruikt wanneer de bron 'Primair apparaat' is", + "hysteresis": "Dode band rond de schakelpunten om snel wisselen tussen verwarmen/koelen te voorkomen", + "integration_driven_auto": "Laat Smart Climate in auto zelf verwarmen vs koelen bepalen (aanbevolen, vereist bij meerdere apparaten)" + } + } + } + }, + "selector": { + "device_role": { + "options": { + "heat": "Alleen verwarmen", + "cool": "Alleen koelen", + "both": "Verwarmen & koelen" + } + }, + "temperature_source": { + "options": { + "mean": "Gemiddelde van apparaten", + "primary": "Primair apparaat", + "sensor": "Aparte sensor" + } } }, "entity": { diff --git a/docs/multi-device-design.md b/docs/multi-device-design.md index 9d6d80e..a4b44cf 100644 --- a/docs/multi-device-design.md +++ b/docs/multi-device-design.md @@ -274,8 +274,12 @@ integration-driven auto, and per-device role `select`s (Phase 3 polish). that list is still pending (Phase 2). The `integration_driven_auto=false` legacy passthrough is stubbed as a config flag but not yet special-cased (currently always integration-driven). -2. **Config/Options UX** — config subentries for devices + Options flow for the - room-temp source, hysteresis, and the auto toggle. *(next)* +2. **Config/Options UX** — ✅ **done.** Device **config subentries** (add/edit, + role select) via `DeviceSubentryFlowHandler`; the initial config flow seeds + the first device; an **Options flow** sets the room-temp source, sensor, + primary device, hysteresis, and the auto toggle. Tunables are read live from + `entry.options` (no reload); an update listener reloads only when the resolved + device list changes, so routine setpoint persistence never reloads. 3. **Schedule card** — dual-line (heat/cool) band editing on the timeline. 4. **Polish** — hvac_action in cards, per-device role selects, docs, and the `integration_driven_auto=false` passthrough. diff --git a/tests/test_cooling.py b/tests/test_cooling.py index fd36144..4e1e934 100644 --- a/tests/test_cooling.py +++ b/tests/test_cooling.py @@ -228,3 +228,45 @@ def test_devices_list_from_entry(): ) assert {d["entity_id"] for d in entity._devices} == {"climate.rad", "climate.ac"} assert entity._devices[0]["role"] == "heat" + + +class _Sub: + def __init__(self, subentry_type, data): + self.subentry_type = subentry_type + self.data = data + + +def test_build_devices_merges_subentries_and_data(): + from custom_components.smart_climate.climate import SmartClimateEntity + + entry = MagicMock() + entry.subentries = { + "s1": _Sub("device", {"entity_id": "climate.a", "role": "heat"}), + "s2": _Sub("other", {"entity_id": "climate.ignored"}), + } + entry.data = {CONF_DEVICES: [{"entity_id": "climate.b", "role": "cool"}]} + devices = SmartClimateEntity._build_devices(entry, None) + ids = [d["entity_id"] for d in devices] + assert "climate.a" in ids and "climate.b" in ids + assert "climate.ignored" not in ids + + +def test_build_devices_dedupes_by_entity_id(): + from custom_components.smart_climate.climate import SmartClimateEntity + + entry = MagicMock() + entry.subentries = {"s1": _Sub("device", {"entity_id": "climate.a", "role": "heat"})} + entry.data = {CONF_DEVICES: [{"entity_id": "climate.a", "role": "cool"}]} + devices = SmartClimateEntity._build_devices(entry, None) + assert len(devices) == 1 + assert devices[0]["role"] == "heat" # subentry wins (added first) + + +def test_build_devices_migrates_wrapped_when_no_list(): + from custom_components.smart_climate.climate import SmartClimateEntity + + entry = MagicMock() + entry.subentries = {} + entry.data = {} + devices = SmartClimateEntity._build_devices(entry, "climate.legacy") + assert devices == [{"entity_id": "climate.legacy", "role": "both"}] From 4289c0005ef7750e40c72a56d2b074a784be3e9f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 10:14:09 +0000 Subject: [PATCH 15/15] Phase 3: heat/cool comfort band editing in the schedule card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-node cooling limits to the schedule card so users can draw the band the auto mode follows, without any new card: - A "❄ Cooling" header toggle (defaults on when the device can cool or the schedule already has cool limits; heat-only users see the card unchanged). - Each node gains a second, blue cool-above handle; the idle band between the heat and cool lines is shaded. Drag either handle or edit both in the node panel; the card clamps the cool limit to >= heat + 1 C. - Double-click adds a node with a default band; save writes explicit cool_temp on every node so the backend band is fully defined. - Legend and hint updated; heat handles recoloured to match the heat line. Backend get_scheduled_band already consumes node.cool_temp, so no server change. JS passes an ESM syntax check; Python suite unaffected (75 pass). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XcTv8j31cmKdhhzTrmdNqL --- README.md | 2 + .../smart-climate-schedule-card.js | 232 ++++++++++++++++-- docs/multi-device-design.md | 5 +- 3 files changed, 215 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index f0df009..9242a34 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ entity: climate.living_room An interactive schedule editor — shows a temperature schedule graph with drag-and-drop nodes, optional temperature history, and presence detection overlay. +Toggle **❄ Cooling** in the card header to edit a **comfort band**: each schedule node then has a 🔥 *heat-to* handle and a ❄ *cool-above* handle, with the idle band shaded between them. Drag either handle (or edit both in the node panel); the card keeps the cool limit at least 1 °C above the heat target. Heat-only users can leave the toggle off and the card behaves exactly as before. The band is what `auto` mode follows (heat below the lower line, cool above the upper line, idle in between). + ```yaml type: custom:smart-climate-schedule-card entity: climate.living_room diff --git a/custom_components/smart_climate/smart-climate-schedule-card.js b/custom_components/smart_climate/smart-climate-schedule-card.js index c2eeeb4..d0ddc9f 100644 --- a/custom_components/smart_climate/smart-climate-schedule-card.js +++ b/custom_components/smart_climate/smart-climate-schedule-card.js @@ -14,6 +14,10 @@ const VW = 600; // viewBox width const VH = 292; // viewBox height const NR = 8; // node radius const MIN_NODE_DISTANCE_HOURS = 0.25; // minimum 15 minutes between nodes +const COOL_GAP_DEFAULT = 4; // default °C between heat target and cool limit +const MIN_BAND_GAP = 1; // minimum °C the cool limit must sit above heat +const HEAT_COLOR = "var(--accent-color, #f5a623)"; +const COOL_COLOR = "#4aa8ff"; // Friendly-name suffix used to identify a presence sensor when the derived ID // (sensor._presence) is not found in hass.states. const PRESENCE_FRIENDLY_NAME_SUFFIX = " Presence"; @@ -33,6 +37,7 @@ class SmartClimateScheduleCard extends LitElement { _saved: { state: true }, _dirty: { state: true }, _selectedIdx: { state: true }, + _showCooling: { state: true }, }; static getConfigElement() { @@ -91,6 +96,27 @@ class SmartClimateScheduleCard extends LitElement { this._scheduleMode === "5/2" ? "weekday" : this._scheduleMode === "individual" ? "monday" : "daily"; + + // Show the cooling band by default when the device can cool, or the + // schedule already carries cool limits; heat-only users start without it. + if (this._showCooling === undefined) { + const modes = entity.attributes.hvac_modes || []; + const hasCoolNode = Object.values(this._schedule || {}).some( + (v) => Array.isArray(v) && v.some((n) => n && n.cool_temp != null) + ); + this._showCooling = modes.includes("cool") || hasCoolNode; + } + } + + /** The cool limit for a node, defaulting to a band above its heat target. */ + _coolOf(node) { + if (node?.cool_temp != null) return node.cool_temp; + return Math.min(TMAX, (node?.temp ?? 21) + COOL_GAP_DEFAULT); + } + + _toggleCooling() { + this._showCooling = !this._showCooling; + this._selectedIdx = null; } async _fetchHistory() { @@ -223,10 +249,11 @@ class SmartClimateScheduleCard extends LitElement { this._schedule = { ...(this._schedule ?? {}), [key]: nodes }; } - _onNodePointerDown(e, idx) { + _onNodePointerDown(e, idx, field = "temp") { e.stopPropagation(); // Do NOT call e.preventDefault() here — it suppresses click/dblclick synthesis this._draggingIdx = idx; + this._dragField = field; this._dragStartX = e.clientX; this._dragStartY = e.clientY; this._dragMoved = false; @@ -234,6 +261,21 @@ class SmartClimateScheduleCard extends LitElement { if (svgEl) svgEl.setPointerCapture(e.pointerId); } + /** Apply a drag to a node, updating time and the dragged handle (heat/cool). */ + _dragUpdate(node, x, y) { + const time = this._hourToTime(this._fromX(x)); + const val = this._fromY(y); + if (this._dragField === "cool_temp") { + // Cool limit can't drop below heat + gap. + const cool = Math.max(val, (node.temp ?? TMIN) + MIN_BAND_GAP); + return { ...node, time, cool_temp: cool }; + } + // Heat target can't rise above cool limit − gap (when a limit exists). + const cool = node.cool_temp; + const heat = cool != null ? Math.min(val, cool - MIN_BAND_GAP) : val; + return { ...node, time, temp: heat }; + } + _onSvgPointerMove(e) { if (this._draggingIdx == null) return; const dx = e.clientX - this._dragStartX; @@ -241,9 +283,7 @@ class SmartClimateScheduleCard extends LitElement { if (Math.abs(dx) > 4 || Math.abs(dy) > 4) this._dragMoved = true; const { x, y } = this._svgCoords(e); const nodes = this._getNodes().map((n, i) => - i === this._draggingIdx - ? { time: this._hourToTime(this._fromX(x)), temp: this._fromY(y) } - : n + i === this._draggingIdx ? this._dragUpdate(n, x, y) : n ); this._setNodes(nodes); } @@ -287,10 +327,14 @@ class SmartClimateScheduleCard extends LitElement { return Math.min(diff, 24 - diff) < MIN_NODE_DISTANCE_HOURS; }); if (tooClose) return; - const newNodes = [ - ...nodes, - { time: this._hourToTime(newHour), temp: this._fromY(y) }, - ].sort((a, b) => this._timeToHour(a.time) - this._timeToHour(b.time)); + const temp = this._fromY(y); + const node = { time: this._hourToTime(newHour), temp }; + if (this._showCooling) { + node.cool_temp = Math.min(TMAX, temp + COOL_GAP_DEFAULT); + } + const newNodes = [...nodes, node].sort( + (a, b) => this._timeToHour(a.time) - this._timeToHour(b.time) + ); this._setNodes(newNodes); this._selectedIdx = null; this._dirty = true; @@ -307,9 +351,22 @@ class SmartClimateScheduleCard extends LitElement { async _saveSchedule() { if (!this.hass) return; try { + let schedule = { ...(this._schedule ?? {}), mode: this._scheduleMode ?? "daily" }; + // When cooling is enabled, make every node's cool limit explicit so the + // backend band is fully defined (no reliance on the flat fallback). + if (this._showCooling) { + schedule = Object.fromEntries( + Object.entries(schedule).map(([k, v]) => + Array.isArray(v) + ? [k, v.map((n) => (n.cool_temp != null ? n : { ...n, cool_temp: this._coolOf(n) }))] + : [k, v] + ) + ); + this._schedule = schedule; + } await this.hass.callService("smart_climate", "set_schedule", { entity_id: this.config.entity, - schedule: { ...(this._schedule ?? {}), mode: this._scheduleMode ?? "daily" }, + schedule, }); this._dirty = false; this._saved = true; @@ -344,8 +401,25 @@ class SmartClimateScheduleCard extends LitElement { _editNodeTemp(idx, newTemp) { const raw = parseFloat(newTemp); if (isNaN(raw)) return; - const t = Math.max(TMIN, Math.min(TMAX, raw)); - const nodes = this._getNodes().map((n, i) => i === idx ? { ...n, temp: t } : n); + let t = Math.max(TMIN, Math.min(TMAX, raw)); + const nodes = this._getNodes().map((n, i) => { + if (i !== idx) return n; + // Keep heat target at least MIN_BAND_GAP below any cool limit. + if (n.cool_temp != null) t = Math.min(t, n.cool_temp - MIN_BAND_GAP); + return { ...n, temp: t }; + }); + this._setNodes(nodes); + this._dirty = true; + } + + _editNodeCoolTemp(idx, newTemp) { + const raw = parseFloat(newTemp); + if (isNaN(raw)) return; + const nodes = this._getNodes().map((n, i) => { + if (i !== idx) return n; + const t = Math.max((n.temp ?? TMIN) + MIN_BAND_GAP, Math.min(TMAX, raw)); + return { ...n, cool_temp: t }; + }); this._setNodes(nodes); this._dirty = true; } @@ -400,21 +474,49 @@ class SmartClimateScheduleCard extends LitElement { * @param {Array<{time: string, temp: number}>} nodes * @returns {string} */ - _stepPath(nodes) { + _stepPath(nodes, valueOf = (n) => n.temp) { if (!nodes?.length) return ""; const s = [...nodes].sort((a, b) => this._timeToHour(a.time) - this._timeToHour(b.time)); - const lastT = s[s.length - 1].temp; + const lastT = valueOf(s[s.length - 1]); const d = [`M${this._toX(0)},${this._toY(lastT)}`]; for (let i = 0; i < s.length; i++) { const h = this._timeToHour(s[i].time); - const prevT = i === 0 ? lastT : s[i - 1].temp; + const prevT = i === 0 ? lastT : valueOf(s[i - 1]); d.push(`L${this._toX(h)},${this._toY(prevT)}`); - d.push(`L${this._toX(h)},${this._toY(s[i].temp)}`); + d.push(`L${this._toX(h)},${this._toY(valueOf(s[i]))}`); } - d.push(`L${this._toX(24)},${this._toY(s[s.length - 1].temp)}`); + d.push(`L${this._toX(24)},${this._toY(valueOf(s[s.length - 1]))}`); return d.join(" "); } + /** Filled area between the heat step line and the cool step line (the band). */ + _bandPath(nodes) { + if (!nodes?.length) return ""; + const s = [...nodes].sort((a, b) => this._timeToHour(a.time) - this._timeToHour(b.time)); + const heat = []; + const cool = []; + const lastHeat = s[s.length - 1].temp; + const lastCool = this._coolOf(s[s.length - 1]); + heat.push(`M${this._toX(0)},${this._toY(lastHeat)}`); + for (let i = 0; i < s.length; i++) { + const h = this._timeToHour(s[i].time); + const prevHeat = i === 0 ? lastHeat : s[i - 1].temp; + heat.push(`L${this._toX(h)},${this._toY(prevHeat)}`); + heat.push(`L${this._toX(h)},${this._toY(s[i].temp)}`); + } + heat.push(`L${this._toX(24)},${this._toY(lastHeat)}`); + // walk the cool line back from x=24 to x=0 + cool.push(`L${this._toX(24)},${this._toY(lastCool)}`); + for (let i = s.length - 1; i >= 0; i--) { + const h = this._timeToHour(s[i].time); + const coolT = this._coolOf(s[i]); + cool.push(`L${this._toX(h)},${this._toY(coolT)}`); + const prevCool = i === 0 ? lastCool : this._coolOf(s[i - 1]); + cool.push(`L${this._toX(h)},${this._toY(prevCool)}`); + } + return heat.join(" ") + " " + cool.join(" ") + " Z"; + } + /** * Build an SVG path string from the actual temperature history fetched from HA. * @returns {string} @@ -542,6 +644,8 @@ class SmartClimateScheduleCard extends LitElement { }; const stepPathD = this._stepPath(nodes); + const coolPathD = this._showCooling ? this._stepPath(nodes, (n) => this._coolOf(n)) : ""; + const bandPathD = this._showCooling ? this._bandPath(nodes) : ""; const histPathD = this.config.show_history !== false ? this._historyPath() : ""; const yesterdayHistPathD = this.config.show_yesterday !== false ? this._yesterdayHistoryPath() : ""; const tempSensorPathD = this.config.temp_sensor ? this._tempSensorPath() : ""; @@ -561,6 +665,10 @@ class SmartClimateScheduleCard extends LitElement { 📅 Schedule${activeDay !== "daily" ? ` (${dayLabel[activeDay] ?? activeDay})` : ""}
+ ${this._dirty ? html`● Unsaved` : ""} ${this._saved ? html`✓ Saved` : ""}
` : ""} -
Double-click to add node • Drag to move • Select then remove to delete
+ ${this._showCooling ? html` +
+ + 🔥 Heat to + + + ❄ Cool above + + + Idle band + +
+ ` : ""} + +
+ Double-click to add • Drag a dot to move + ${this._showCooling ? " (🔥 lower, ❄ upper)" : ""} • Select then remove to delete +
`; @@ -791,6 +947,24 @@ class SmartClimateScheduleCard extends LitElement { transition: background 0.2s, border-color 0.2s, color 0.2s; } + .cool-toggle { + background: var(--secondary-background-color); + color: var(--secondary-text-color); + border: 1px solid var(--divider-color); + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s, border-color 0.2s, color 0.2s; + } + + .cool-toggle--on { + background: rgba(74, 168, 255, 0.15); + color: #4aa8ff; + border-color: #4aa8ff; + } + .save-btn:disabled { opacity: 0.4; cursor: default; @@ -904,12 +1078,16 @@ class SmartClimateScheduleCard extends LitElement { } .node-c { - fill: #4fc3f7; + fill: var(--accent-color, #f5a623); stroke: white; stroke-width: 2; cursor: grab; } + .node-c--cool { + fill: #4aa8ff; + } + .node-c--next { fill: #ff9800; stroke: white; @@ -1004,6 +1182,14 @@ class SmartClimateScheduleCard extends LitElement { pointer-events: none; } + .node-cool-lbl { + font-size: 12px; + fill: #7cc4ff; + font-weight: 700; + font-family: sans-serif; + pointer-events: none; + } + .node-time { font-size: 12px; fill: rgba(255, 255, 255, 0.7); diff --git a/docs/multi-device-design.md b/docs/multi-device-design.md index a4b44cf..ddb1809 100644 --- a/docs/multi-device-design.md +++ b/docs/multi-device-design.md @@ -280,7 +280,10 @@ integration-driven auto, and per-device role `select`s (Phase 3 polish). primary device, hysteresis, and the auto toggle. Tunables are read live from `entry.options` (no reload); an update listener reloads only when the resolved device list changes, so routine setpoint persistence never reloads. -3. **Schedule card** — dual-line (heat/cool) band editing on the timeline. +3. **Schedule card** — ✅ **done.** A "❄ Cooling" toggle reveals a second + (cool-limit) handle on each node and shades the idle band between heat and + cool; drag either handle, edit both in the panel, save writes explicit + `cool_temp` per node. Heat-only users see no change until they toggle it on. 4. **Polish** — hvac_action in cards, per-device role selects, docs, and the `integration_driven_auto=false` passthrough.