From 57589aa3a0a56adb9e043a07c8972127051da3f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 05:51:11 +0000 Subject: [PATCH 1/2] Make release workflow tag-driven only Drop the bootstrap branch trigger now that the workflow lives on develop. Releases are cut by pushing a v* tag (or manual workflow_dispatch). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ro82CB9Y3Sdq8b7Suk9Kys --- .github/workflows/release.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87ffe59..a44d1ae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,6 @@ name: Release on: push: - branches: - - "claude/release-latest-commit-**" tags: - "v*" workflow_dispatch: From d79c725128a8464f00db66d90f1cdddbac11063c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 05:59:44 +0000 Subject: [PATCH 2/2] Add per-device role select helper entities Expose each actuator device's heat/cool/both role as a native select entity, backed by a new smart_climate.set_device_role service and async_set_device_role coordinator method. Roles persist to the device's source of truth (config subentry when present, else the entry.data devices list) and re-drive the control loop without a full reload. Covered by tests/test_device_role.py. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Ro82CB9Y3Sdq8b7Suk9Kys --- CLAUDE.md | 3 +- custom_components/smart_climate/climate.py | 51 +++++++ custom_components/smart_climate/const.py | 1 + custom_components/smart_climate/select.py | 52 +++++++- custom_components/smart_climate/services.py | 12 ++ custom_components/smart_climate/services.yaml | 29 ++++ tests/test_device_role.py | 126 ++++++++++++++++++ 7 files changed, 271 insertions(+), 3 deletions(-) create mode 100644 tests/test_device_role.py diff --git a/CLAUDE.md b/CLAUDE.md index e668725..eda78a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,8 @@ does record. It finds its paired climate entity via the entity registry using th 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 +duration), `switch` (interruptible), and `select` (default override mode, plus +one per-actuator-device role select — heat/cool/both — created per device). 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 diff --git a/custom_components/smart_climate/climate.py b/custom_components/smart_climate/climate.py index dac2627..5e3299d 100644 --- a/custom_components/smart_climate/climate.py +++ b/custom_components/smart_climate/climate.py @@ -32,6 +32,10 @@ CONF_TEMPERATURE_SENSOR, CONF_PRIMARY_DEVICE, CONF_HYSTERESIS, + CONF_ROLE, + CONF_ENTITY_ID, + ROLE_HEAT, + ROLE_COOL, ROLE_BOTH, SUBENTRY_TYPE_DEVICE, TEMP_SOURCE_SENSOR, @@ -573,6 +577,53 @@ async def async_set_schedule(self, schedule): await self._apply_control() self.async_write_ha_state() + def _find_device_subentry(self, entity_id): + """Return the device config subentry for *entity_id*, or ``None``.""" + subentries = getattr(self.entry, "subentries", None) + if isinstance(subentries, Mapping): + for sub in subentries.values(): + if getattr(sub, "subentry_type", None) != SUBENTRY_TYPE_DEVICE: + continue + if (getattr(sub, "data", {}) or {}).get(CONF_ENTITY_ID) == entity_id: + return sub + return None + + async def async_set_device_role(self, device_entity_id: str, role: str) -> None: + """Change one actuator device's role (heat/cool/both) at runtime.""" + if role not in (ROLE_HEAT, ROLE_COOL, ROLE_BOTH): + _LOGGER.warning( + "Ignoring invalid device role %r for %s", role, device_entity_id + ) + return + if not any(d["entity_id"] == device_entity_id for d in self._devices): + _LOGGER.warning( + "Cannot set role: %s is not an actuator device of %s", + device_entity_id, + getattr(self, "entity_id", self._attr_name), + ) + return + + # Update in-memory first so the reload-on-device-change listener treats + # this as a routine setting change (no reload) — the role lives inside + # the device dict, so pre-updating keeps the resolved list equal. + self._devices = [ + {**d, CONF_ROLE: role} if d["entity_id"] == device_entity_id else d + for d in self._devices + ] + + # Persist to the device's source of truth: its config subentry when it + # has one, else the entry.data devices list. + subentry = self._find_device_subentry(device_entity_id) + if subentry is not None: + self.hass.config_entries.async_update_subentry( + self.entry, subentry, data={**subentry.data, CONF_ROLE: role} + ) + else: + self._persist(**{CONF_DEVICES: [dict(d) for d in self._devices]}) + + await self._apply_control() + self.async_write_ha_state() + async def async_set_temperature(self, **kwargs): """Set temperature — activate override based on default override setting.""" temperature = kwargs.get("temperature", 22) diff --git a/custom_components/smart_climate/const.py b/custom_components/smart_climate/const.py index c9aa1af..d78938b 100644 --- a/custom_components/smart_climate/const.py +++ b/custom_components/smart_climate/const.py @@ -81,6 +81,7 @@ SERVICE_SET_AWAY_DELAY = "set_away_delay" SERVICE_SET_DEFAULT_OVERRIDE_MODE = "set_default_override_mode" SERVICE_SET_SCHEDULE = "set_schedule" +SERVICE_SET_DEVICE_ROLE = "set_device_role" # Schedule modes SCHEDULE_MODE_DAILY = "daily" diff --git a/custom_components/smart_climate/select.py b/custom_components/smart_climate/select.py index 418cbae..83f8841 100644 --- a/custom_components/smart_climate/select.py +++ b/custom_components/smart_climate/select.py @@ -8,11 +8,20 @@ from .const import ( ATTR_DEFAULT_OVERRIDE_DURATION, ATTR_DEFAULT_OVERRIDE_MODE, + ATTR_DEVICES, + CONF_ENTITY_ID, + CONF_ROLE, + CONF_WRAPPED_CLIMATE, + ROLE_BOTH, + ROLE_COOL, + ROLE_HEAT, SERVICE_SET_DEFAULT_OVERRIDE_MODE, + SERVICE_SET_DEVICE_ROLE, ) from .entity_base import SmartClimateChildEntity OVERRIDE_MODES = ["timer", "infinity", "next_node"] +ROLES = [ROLE_HEAT, ROLE_COOL, ROLE_BOTH] async def async_setup_entry( @@ -20,8 +29,22 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the Smart Climate select helpers.""" - async_add_entities([SmartClimateOverrideModeSelect(hass, entry)]) + """Set up the Smart Climate select helpers. + + One role select is created per actuator device. The entry reloads whenever + the resolved device list changes, so add/remove of a device re-runs this + setup with the current list. + """ + from .climate import SmartClimateEntity + + devices = SmartClimateEntity._build_devices( + entry, entry.data.get(CONF_WRAPPED_CLIMATE) + ) + entities = [SmartClimateOverrideModeSelect(hass, entry)] + entities += [ + SmartClimateDeviceRoleSelect(hass, entry, d["entity_id"]) for d in devices + ] + async_add_entities(entities) class SmartClimateOverrideModeSelect(SmartClimateChildEntity, SelectEntity): @@ -46,3 +69,28 @@ async def async_select_option(self, option: str) -> None: await self._call_service( SERVICE_SET_DEFAULT_OVERRIDE_MODE, mode=option, duration=int(duration) ) + + +class SmartClimateDeviceRoleSelect(SmartClimateChildEntity, SelectEntity): + """Pick one actuator device's role (heat / cool / both).""" + + _attr_options = ROLES + _attr_icon = "mdi:sun-snowflake" + + def __init__(self, hass, entry, device_entity_id: str) -> None: + super().__init__(hass, entry, f"device_role_{device_entity_id}") + self._device_entity_id = device_entity_id + self._attr_name = f"{device_entity_id} role" + + @property + def current_option(self): + for device in self._climate_attr(ATTR_DEVICES) or []: + if device.get(CONF_ENTITY_ID) == self._device_entity_id: + role = device.get(CONF_ROLE) + return role if role in ROLES else None + return None + + async def async_select_option(self, option: str) -> None: + await self._call_service( + SERVICE_SET_DEVICE_ROLE, device=self._device_entity_id, role=option + ) diff --git a/custom_components/smart_climate/services.py b/custom_components/smart_climate/services.py index 0515c47..360bcab 100644 --- a/custom_components/smart_climate/services.py +++ b/custom_components/smart_climate/services.py @@ -27,6 +27,7 @@ SERVICE_SET_AWAY_DELAY, SERVICE_SET_DEFAULT_OVERRIDE_MODE, SERVICE_SET_SCHEDULE, + SERVICE_SET_DEVICE_ROLE, ) _LOGGER = logging.getLogger(__name__) @@ -131,6 +132,14 @@ async def handle_set_schedule(call: ServiceCall) -> None: if entity: await entity.async_set_schedule(call.data.get("schedule")) + async def handle_set_device_role(call: ServiceCall) -> None: + entity = _get_entity(hass, call) + if entity: + await entity.async_set_device_role( + call.data.get("device"), + call.data.get("role"), + ) + hass.services.async_register( DOMAIN, SERVICE_SET_OVERRIDE_TIMER, handle_set_override_timer ) @@ -165,3 +174,6 @@ async def handle_set_schedule(call: ServiceCall) -> None: hass.services.async_register( DOMAIN, SERVICE_SET_SCHEDULE, handle_set_schedule ) + hass.services.async_register( + DOMAIN, SERVICE_SET_DEVICE_ROLE, handle_set_device_role + ) diff --git a/custom_components/smart_climate/services.yaml b/custom_components/smart_climate/services.yaml index 6c50e18..b859fb0 100644 --- a/custom_components/smart_climate/services.yaml +++ b/custom_components/smart_climate/services.yaml @@ -300,3 +300,32 @@ set_schedule: required: true selector: object: + +set_device_role: + name: Set Device Role + description: Change one actuator device's role (heat, cool, or both) + fields: + entity_id: + name: Entity + description: Smart Climate coordinator entity + required: true + selector: + entity: + domain: climate + device: + name: Device + description: The actuator climate entity to re-role + required: true + selector: + entity: + domain: climate + role: + name: Role + description: What the device may do + required: true + selector: + select: + options: + - heat + - cool + - both diff --git a/tests/test_device_role.py b/tests/test_device_role.py new file mode 100644 index 0000000..ee6a748 --- /dev/null +++ b/tests/test_device_role.py @@ -0,0 +1,126 @@ +"""Tests for the per-device role setter (`async_set_device_role`). + +These verify the runtime role change that backs the per-device role select +helper entities: a valid role updates the resolved device list and persists to +the right source (config subentry when present, else the entry.data devices +list), while invalid roles / unknown devices are ignored. +""" + +import sys +import pathlib +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# Ensure custom_components is importable +sys.path.insert(0, str(pathlib.Path(__file__).parent.parent)) + +from custom_components.smart_climate.const import ( # noqa: E402 + CONF_DEVICES, + CONF_ENTITY_ID, + CONF_ROLE, + ROLE_BOTH, + ROLE_COOL, + ROLE_HEAT, + SUBENTRY_TYPE_DEVICE, +) + + +def _make_entity(devices=None, subentries=None): + """Build a SmartClimateEntity with a given device source, sans HA runtime.""" + 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 = {CONF_DEVICES: devices} if devices is not None else {} + # _build_devices only treats subentries as a source when it's a Mapping. + entry.subentries = subentries if subentries is not None else MagicMock() + + entity = SmartClimateEntity( + hass=hass, + entry=entry, + name="Test Climate", + wrapped_climate=None, + zone_home="zone.home", + away_temp=14.0, + away_delay_minutes=0, + interruptible=True, + default_override_mode="timer", + default_override_duration=30, + ) + # Isolate the role logic from the control/routing pipeline (tested elsewhere). + entity._apply_control = AsyncMock() + entity.async_write_ha_state = MagicMock() + return entity + + +def _role_of(entity, entity_id): + return next(d[CONF_ROLE] for d in entity._devices if d["entity_id"] == entity_id) + + +@pytest.mark.asyncio +async def test_set_role_updates_devices_and_persists_via_entry_data(): + entity = _make_entity( + devices=[ + {"entity_id": "climate.a", "role": ROLE_BOTH}, + {"entity_id": "climate.b", "role": ROLE_HEAT}, + ] + ) + + await entity.async_set_device_role("climate.b", ROLE_COOL) + + assert _role_of(entity, "climate.b") == ROLE_COOL + assert _role_of(entity, "climate.a") == ROLE_BOTH # untouched + # Persisted to entry.data devices (no subentry source present). + entity.hass.config_entries.async_update_entry.assert_called_once() + _, kwargs = entity.hass.config_entries.async_update_entry.call_args + persisted = {d["entity_id"]: d["role"] for d in kwargs["data"][CONF_DEVICES]} + assert persisted == {"climate.a": ROLE_BOTH, "climate.b": ROLE_COOL} + entity._apply_control.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_set_role_uses_subentry_when_present(): + subentries = { + "sub1": SimpleNamespace( + subentry_type=SUBENTRY_TYPE_DEVICE, + data={CONF_ENTITY_ID: "climate.a", CONF_ROLE: ROLE_BOTH}, + ) + } + entity = _make_entity(subentries=subentries) + assert _role_of(entity, "climate.a") == ROLE_BOTH # sourced from subentry + + await entity.async_set_device_role("climate.a", ROLE_HEAT) + + assert _role_of(entity, "climate.a") == ROLE_HEAT + # Subentry path persists via async_update_subentry, not entry.data. + entity.hass.config_entries.async_update_subentry.assert_called_once() + entity.hass.config_entries.async_update_entry.assert_not_called() + + +@pytest.mark.asyncio +async def test_invalid_role_is_ignored(): + entity = _make_entity(devices=[{"entity_id": "climate.a", "role": ROLE_HEAT}]) + + await entity.async_set_device_role("climate.a", "bogus") + + assert _role_of(entity, "climate.a") == ROLE_HEAT + entity.hass.config_entries.async_update_entry.assert_not_called() + entity._apply_control.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unknown_device_is_ignored(): + entity = _make_entity(devices=[{"entity_id": "climate.a", "role": ROLE_HEAT}]) + + await entity.async_set_device_role("climate.zzz", ROLE_COOL) + + assert _role_of(entity, "climate.a") == ROLE_HEAT + assert all(d["entity_id"] != "climate.zzz" for d in entity._devices) + entity.hass.config_entries.async_update_entry.assert_not_called() + entity._apply_control.assert_not_awaited()