Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: Release

on:
push:
branches:
- "claude/release-latest-commit-**"
tags:
- "v*"
workflow_dispatch:
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions custom_components/smart_climate/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions custom_components/smart_climate/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
52 changes: 50 additions & 2 deletions custom_components/smart_climate/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,43 @@
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(
hass: HomeAssistant,
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):
Expand All @@ -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
)
12 changes: 12 additions & 0 deletions custom_components/smart_climate/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
SERVICE_SET_AWAY_DELAY,
SERVICE_SET_DEFAULT_OVERRIDE_MODE,
SERVICE_SET_SCHEDULE,
SERVICE_SET_DEVICE_ROLE,
)

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
29 changes: 29 additions & 0 deletions custom_components/smart_climate/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
126 changes: 126 additions & 0 deletions tests/test_device_role.py
Original file line number Diff line number Diff line change
@@ -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()
Loading