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 new file mode 100644 index 0000000..e668725 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,266 @@ +# 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 + 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 + 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`) 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`) + +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.** + +`_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 + (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. + +### 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** +(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/ +``` + +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 + +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`. +- **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. + +### 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), `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 | +|---|---| +| 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 d5507f3..9242a34 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`). | @@ -58,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 @@ -67,6 +124,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`). | @@ -75,14 +135,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 @@ -182,6 +266,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. @@ -218,9 +324,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: diff --git a/custom_components/smart_climate/__init__.py b/custom_components/smart_climate/__init__.py index 51e0433..b451cbc 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,9 +29,11 @@ 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"]), + 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), } ) @@ -68,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 1c4208b..dac2627 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -1,7 +1,8 @@ +from collections.abc import Mapping 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 @@ -21,6 +22,22 @@ CONF_INTERRUPTIBLE, CONF_DEFAULT_OVERRIDE_MODE, CONF_DEFAULT_OVERRIDE_DURATION, + CONF_AUTO_TEMPERATURE, + 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, + ROLE_BOTH, + SUBENTRY_TYPE_DEVICE, + TEMP_SOURCE_SENSOR, + TEMP_SOURCE_PRIMARY, + TEMP_SOURCE_MEAN, + DEFAULT_HYSTERESIS, ATTR_MODE, ATTR_PRESENCE, ATTR_REMAINING_MINUTES, @@ -28,8 +45,19 @@ ATTR_AWAY_DELAY_SECONDS_REMAINING, ATTR_OVERRIDE_TEMPERATURE, 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, + 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__) @@ -69,9 +97,16 @@ async def async_setup_entry( class SmartClimateEntity(ClimateEntity): - """Smart Climate controller entity.""" + """Smart Climate coordinator entity. - _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + 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, @@ -91,23 +126,38 @@ 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 - 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 - # State + # 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 (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._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 self._presence = "away" # Start as away self._interruptible = interruptible - self._auto_temperature = 21 + 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 @@ -115,7 +165,47 @@ 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) + + @staticmethod + def _build_devices(entry, wrapped_climate): + """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.""" @@ -134,6 +224,19 @@ 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] + sensor = self._conf(CONF_TEMPERATURE_SENSOR, None) + if sensor: + watched.append(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( @@ -141,6 +244,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() @@ -187,8 +298,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): @@ -230,38 +346,132 @@ async def _cancel_away_delay(self): self._away_delay_start = None self._away_delay_remaining = 0 - async def _update_target_temperature(self): - """Calculate and update target temperature to wrapped climate.""" + # ---- 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 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._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._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.""" + 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 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 + 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(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._conf(CONF_HYSTERESIS, DEFAULT_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. + + 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): @@ -270,7 +480,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): @@ -278,7 +488,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): @@ -295,7 +505,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): @@ -303,41 +513,64 @@ 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): """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 - await self._update_target_temperature() + self._persist(**{CONF_AUTO_TEMPERATURE: 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 - await self._update_target_temperature() + self._persist(**{CONF_AWAY_TEMPERATURE: 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._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._apply_control() 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 - await self._update_target_temperature() + self._persist(**{CONF_SCHEDULE: schedule}) + await self._apply_control() self.async_write_ha_state() async def async_set_temperature(self, **kwargs): @@ -353,41 +586,118 @@ 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, - }, - ) + """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 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 coordinator off (powers all actuators down).""" + await self.async_set_hvac_mode(HVACMode.OFF) + + async def async_turn_on(self) -> None: + """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): + features = ClimateEntityFeature.TARGET_TEMPERATURE + features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF + if self._single_fan_device(): + features |= ClimateEntityFeature.FAN_MODE + return features + @property def hvac_modes(self): - return [HVACMode.HEAT, HVACMode.OFF] + return list(self._OWN_HVAC_MODES) @property def hvac_mode(self): - wrapped = self.hass.states.get(self._wrapped_climate) - 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): + 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): + 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): + 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): + 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): + 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.hass.states.get(self._wrapped_climate) - if wrapped: - return wrapped.attributes.get("current_temperature") - return None + return self._room_temperature() @property def target_temperature(self): - wrapped = self.hass.states.get(self._wrapped_climate) - 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): + """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): @@ -404,4 +714,15 @@ 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, + 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, + 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/config_flow.py b/custom_components/smart_climate/config_flow.py index 7df5586..9c8a259 100644 --- a/custom_components/smart_climate/config_flow.py +++ b/custom_components/smart_climate/config_flow.py @@ -1,18 +1,66 @@ -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, CONF_INTERRUPTIBLE, CONF_DEFAULT_OVERRIDE_MODE, 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.""" @@ -23,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): @@ -33,28 +92,44 @@ 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") ), 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) ), @@ -68,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: @@ -85,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, ""), @@ -100,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 2edca32..c9aa1af 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" @@ -15,6 +15,37 @@ 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" +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" +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" +TEMP_SOURCE_PRIMARY = "primary" +TEMP_SOURCE_MEAN = "mean" + +# Defaults +DEFAULT_HYSTERESIS = 0.3 +MIN_BAND_GAP = 1.0 # Attributes ATTR_MODE = "mode" @@ -25,6 +56,17 @@ 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" +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" +ATTR_HEAT_TARGET = "heat_target" +ATTR_COOL_LIMIT = "cool_limit" +ATTR_INTENT = "intent" +ATTR_DEVICES = "devices" # Services SERVICE_SET_OVERRIDE_TIMER = "set_override_timer" @@ -34,6 +76,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/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/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/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/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": [], 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/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/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/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/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/custom_components/smart_climate/strings.json b/custom_components/smart_climate/strings.json index 4edc71b..00a33a5 100644 --- a/custom_components/smart_climate/strings.json +++ b/custom_components/smart_climate/strings.json @@ -3,31 +3,109 @@ "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", + "cool_away_temperature": "Cooling Away Temperature", "away_delay_minutes": "Away Delay (minutes)", "interruptible": "Override is interruptible", "default_override_mode": "Default Override Mode", "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", + "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" } + }, + "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/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/custom_components/smart_climate/translations/en.json b/custom_components/smart_climate/translations/en.json index 4edc71b..00a33a5 100644 --- a/custom_components/smart_climate/translations/en.json +++ b/custom_components/smart_climate/translations/en.json @@ -3,31 +3,109 @@ "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", + "cool_away_temperature": "Cooling Away Temperature", "away_delay_minutes": "Away Delay (minutes)", "interruptible": "Override is interruptible", "default_override_mode": "Default Override Mode", "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", + "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" } + }, + "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 baced02..ca1e9a3 100644 --- a/custom_components/smart_climate/translations/nl.json +++ b/custom_components/smart_climate/translations/nl.json @@ -3,31 +3,109 @@ "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", + "cool_away_temperature": "Koelen Weg Temperatuur", "away_delay_minutes": "Vertrekvertraging (minuten)", "interruptible": "Overschrijving is onderbrekbaar", "default_override_mode": "Standaard Overschrijfmodus", "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", + "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" } + }, + "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 new file mode 100644 index 0000000..ddb1809 --- /dev/null +++ b/docs/multi-device-design.md @@ -0,0 +1,299 @@ +# Design: multi-device coordination, per-node band, and integration-driven auto + +Status: **decisions locked** — ready to implement (see §14 phasing). + +This document designs three interlocking features and, importantly, the +foundational change they share. All five open questions are resolved (§15). + +## 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_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) + "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 — **decided: raise HA floor, use config subentries** + +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**: 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**. + +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 + +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} +``` + +### 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): + +``` +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: +- **Hysteresis** `H` around switch points prevents rapid heat/cool flapping. + **Decided:** configurable per instance (Options flow), **default 0.3°C**. +- **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. + +## 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` = **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 — **decided: selectable** + +A per-instance **temperature-source** setting (config/Options flow) chooses how +the room temperature is measured: + +- **`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). + +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 + +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. +- **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 + +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** — ✅ **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** — ✅ **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. + +## 15. Resolved decisions + +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 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..fadbeb8 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") @@ -54,16 +62,50 @@ 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 _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_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_cooling.py b/tests/test_cooling.py new file mode 100644 index 0000000..4e1e934 --- /dev/null +++ b/tests/test_cooling.py @@ -0,0 +1,272 @@ +"""Coordinator integration tests: mode-owned control driving a single device. + +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 +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, + CONF_DEVICES, + MODE_AUTO, +) + +WRAPPED = "climate.wrapped" + + +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 + + 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() + 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 {}) + + entity = SmartClimateEntity( + 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, 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_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 + + +# --------------------------------------------------------------------------- +# Setpoint routing per coordinator mode +# --------------------------------------------------------------------------- + +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._apply_control() + assert _last_mode(hass) == "heat" + assert _last_temp(hass) == 21 + + +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_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}, + ) + await entity._apply_control() + assert _last_mode(hass) == "cool" + assert _last_temp(hass) == 24 + + +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}, + ) + await entity._apply_control() + assert _last_temp(hass) == 28 + + +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_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 + + +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_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_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_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_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 + + +# --------------------------------------------------------------------------- +# Capability aggregation & migration +# --------------------------------------------------------------------------- + +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_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_include_fan_when_device_supports_it(): + from custom_components.smart_climate.climate import ClimateEntityFeature + + entity, _, _ = _make_entity(device_attrs={"supported_features": 393}) + assert 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" + + +def test_migration_from_single_wrapped_climate(): + entity, _, _ = _make_entity() + assert entity._devices == [{"entity_id": WRAPPED, "role": "both"}] + + +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" + + +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"}] 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"] 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 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() 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