Skip to content

Commit

Permalink
Add services for programming thermostats
Browse files Browse the repository at this point in the history
  • Loading branch information
ocrease committed Jan 19, 2025
1 parent 21fc70c commit fbf4953
Show file tree
Hide file tree
Showing 11 changed files with 1,606 additions and 38 deletions.
42 changes: 42 additions & 0 deletions custom_components/heatmiserneo/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,55 @@
SERVICE_HOLD_ON = "hold_on"
SERVICE_HOLD_OFF = "hold_off"
SERVICE_TIMER_HOLD_ON = "timer_hold_on"
SERVICE_GET_DEVICE_PROFILE_DEFINITION = "get_device_profile_definition"
SERVICE_GET_PROFILE_DEFINITIONS = "get_profile_definitions"
SERVICE_CREATE_PROFILE_ONE = "create_profile_one"
SERVICE_CREATE_PROFILE_TWO = "create_profile_two"
SERVICE_CREATE_PROFILE_SEVEN = "create_profile_seven"
SERVICE_CREATE_TIMER_PROFILE_ONE = "create_timer_profile_one"
SERVICE_CREATE_TIMER_PROFILE_TWO = "create_timer_profile_two"
SERVICE_CREATE_TIMER_PROFILE_SEVEN = "create_timer_profile_seven"
SERVICE_RENAME_PROFILE = "rename_profile"
SERVICE_DELETE_PROFILE = "delete_profile"
SERVICE_HUB_AWAY = "set_away_mode"
ATTR_HOLD_DURATION = "hold_duration"
ATTR_HOLD_STATE = "hold_state"
ATTR_HOLD_TEMPERATURE = "hold_temperature"
ATTR_AWAY_STATE = "away"
# ATTR_AWAY_START = "start"
ATTR_AWAY_END = "end"
ATTR_NAME_OLD = "old_name"
ATTR_NAME_NEW = "new_name"
ATTR_FRIENDLY_MODE = "friendly_mode"
ATTR_UPDATE = "update"
ATTR_MONDAY_TIMES = "monday_times"
ATTR_MONDAY_TEMPERATURES = "monday_temperatures"
ATTR_TUESDAY_TIMES = "tuesday_times"
ATTR_TUESDAY_TEMPERATURES = "tuesday_temperatures"
ATTR_WEDNESDAY_TIMES = "wednesday_times"
ATTR_WEDNESDAY_TEMPERATURES = "wednesday_temperatures"
ATTR_THURSDAY_TIMES = "thursday_times"
ATTR_THURSDAY_TEMPERATURES = "thursday_temperatures"
ATTR_FRIDAY_TIMES = "friday_times"
ATTR_FRIDAY_TEMPERATURES = "friday_temperatures"
ATTR_SATURDAY_TIMES = "saturday_times"
ATTR_SATURDAY_TEMPERATURES = "saturday_temperatures"
ATTR_SUNDAY_TIMES = "sunday_times"
ATTR_SUNDAY_TEMPERATURES = "sunday_temperatures"
ATTR_MONDAY_ON_TIMES = "monday_on_times"
ATTR_MONDAY_OFF_TIMES = "monday_off_times"
ATTR_TUESDAY_ON_TIMES = "tuesday_on_times"
ATTR_TUESDAY_OFF_TIMES = "tuesday_off_times"
ATTR_WEDNESDAY_ON_TIMES = "wednesday_on_times"
ATTR_WEDNESDAY_OFF_TIMES = "wednesday_off_times"
ATTR_THURSDAY_ON_TIMES = "thursday_on_times"
ATTR_THURSDAY_OFF_TIMES = "thursday_off_times"
ATTR_FRIDAY_ON_TIMES = "friday_on_times"
ATTR_FRIDAY_OFF_TIMES = "friday_off_times"
ATTR_SATURDAY_ON_TIMES = "saturday_on_times"
ATTR_SATURDAY_OFF_TIMES = "saturday_off_times"
ATTR_SUNDAY_ON_TIMES = "sunday_on_times"
ATTR_SUNDAY_OFF_TIMES = "sunday_off_times"

PROFILE_0 = "PROFILE_0"

Expand Down
21 changes: 5 additions & 16 deletions custom_components/heatmiserneo/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from homeassistant.core import HomeAssistant

from . import HeatmiserNeoConfigEntry
from .helpers import to_dict

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -62,10 +63,10 @@ async def async_get_config_entry_diagnostics(
"devices": devices.result if devices else None,
"device_list": device_list,
"zones": zones,
"profiles": _to_dict(profiles),
"profiles_0": _to_dict(profiles_0),
"timer_profiles": _to_dict(timer_profiles),
"timer_profiles_0": _to_dict(timer_profiles_0),
"profiles": to_dict(profiles),
"profiles_0": to_dict(profiles_0),
"timer_profiles": to_dict(timer_profiles),
"timer_profiles_0": to_dict(timer_profiles_0),
"raw_live_data": raw_live_data,
}

Expand Down Expand Up @@ -93,15 +94,3 @@ async def retrieve_zone_device_list(zone: str, hub: NeoHub):
response = await hub._send({"GET_DEVICE_LIST": zone}) # noqa: SLF001
_LOGGER.debug("Response for GET_DEVICE_LIST on zone %s: %s", zone, response)
return dict(vars(response)).get(zone, {})


def _to_dict(item):
match item:
case dict():
return {key: _to_dict(value) for key, value in item.items()}
case list() | tuple():
return [_to_dict(x) for x in item]
case object(__dict__=_):
return {key: _to_dict(value) for key, value in vars(item).items()}
case _:
return item
34 changes: 21 additions & 13 deletions custom_components/heatmiserneo/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ class HeatmiserNeoEntityDescription(EntityDescription):
icon_fn: Callable[[NeoStat], str | None] | None = None
# extra_attrs: list[str] | None = None
custom_functions: (
dict[str, Callable[[type[HeatmiserNeoEntity], ServiceCall], Awaitable[None]]]
dict[
str,
Callable[[type[HeatmiserNeoEntity], ServiceCall], Awaitable[Any | None]],
]
| None
) = None

Expand All @@ -57,7 +60,10 @@ class HeatmiserNeoHubEntityDescription(EntityDescription):
icon_fn: Callable[[NeoStat], str | None] | None = None
# extra_attrs: list[str] | None = None
custom_functions: (
dict[str, Callable[[type[HeatmiserNeoEntity], ServiceCall], Awaitable[None]]]
dict[
str,
Callable[[type[HeatmiserNeoEntity], ServiceCall], Awaitable[Any | None]],
]
| None
) = None

Expand Down Expand Up @@ -163,12 +169,13 @@ def entity_registry_enabled_default(self) -> bool:
return self.entity_description.enabled_by_default_fn(self)
return super().entity_registry_enabled_default

async def call_custom_action(self, service_call: ServiceCall) -> None:
async def call_custom_action(self, service_call: ServiceCall) -> Any | None:
"""Call a custom action specified in the entity description."""
await self.entity_description.custom_functions.get(service_call.service)(
self, service_call
)
result = await self.entity_description.custom_functions.get(
service_call.service
)(self, service_call)
self.coordinator.async_update_listeners()
return result

async def async_cancel_away_or_holiday(self) -> None:
"""Cancel away/holiday mode."""
Expand Down Expand Up @@ -270,17 +277,18 @@ def entity_registry_enabled_default(self) -> bool:
return self.entity_description.enabled_by_default_fn(self)
return super().entity_registry_enabled_default

async def call_custom_action(self, service_call: ServiceCall) -> None:
async def call_custom_action(self, service_call: ServiceCall) -> Any | None:
"""Call a custom action specified in the entity description."""
await self.entity_description.custom_functions.get(service_call.service)(
self, service_call
)
result = await self.entity_description.custom_functions.get(
service_call.service
)(self, service_call)
self.coordinator.async_update_listeners()
return result


async def call_custom_action(
entity: HeatmiserNeoEntity, service_call: ServiceCall
) -> None:
) -> Any | None:
"""Call a custom action specified in the entity description."""
if (
not entity.entity_description.custom_functions
Expand All @@ -291,8 +299,8 @@ async def call_custom_action(
raise HomeAssistantError(
f"Entity {entity.entity_id} does not support service"
)
return
await entity.call_custom_action(service_call)
return None
return await entity.call_custom_action(service_call)


def _device_supports_away(dev: NeoStat) -> bool:
Expand Down
120 changes: 120 additions & 0 deletions custom_components/heatmiserneo/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,3 +220,123 @@ def profile_level(
## so that is the next level
current_level = previous_level
return current_level


def to_dict(item):
"""Convert an arbitrary object to a dict."""
match item:
case dict():
return {key: to_dict(value) for key, value in item.items()}
case list() | tuple():
return [to_dict(x) for x in item]
case object(__dict__=_):
return {key: to_dict(value) for key, value in vars(item).items()}
case _:
return item


def get_profile_definition(
profile_id: int,
coordinator: HeatmiserNeoCoordinator,
friendly_mode: bool = False,
device_id: int = 0,
):
"""Set override with custom duration."""
profile_format = coordinator.system_data.FORMAT
profile = None
p0 = False
timer = False
if profile_id == 0:
profile = coordinator.profiles_0.get(device_id)
p0 = True
else:
profile = coordinator.profiles.get(profile_id)
if not profile:
if profile_format == ScheduleFormat.ZERO:
profile_format = coordinator.system_data.ALT_TIMER_FORMAT

if profile_id == 0:
profile = coordinator.timer_profiles_0.get(device_id)
else:
profile = coordinator.timer_profiles.get(profile_id)
if profile:
timer = True
if not profile:
return None
profile_dict = to_dict(profile)

levels = None
info = None
if p0:
info = profile_dict.get("profiles")[0]
del info["device"]
else:
info = profile_dict.get("info")

result = {"id": profile_id, "name": "PROFILE_0" if p0 else profile_dict["name"]}

if friendly_mode:
result["format"] = profile_format
result["type"] = "timer" if timer else "heating"

if timer:
if friendly_mode:
levels = {
wd: [
{"time_on": e[0], "time_off": e[1]}
for e in sorted(lv.values(), key=lambda x: x[0])
if e[0] != "24:00"
]
for wd, lv in info.items()
}

result["info"] = levels
else:
on_times = {
wd + "_on_times": [
e[0]
for e in sorted(lv.values(), key=lambda x: x[0])
if e[0] != "24:00"
]
for wd, lv in info.items()
}
off_times = {
wd + "_off_times": [
e[1]
for e in sorted(lv.values(), key=lambda x: x[0])
if e[0] != "24:00"
]
for wd, lv in info.items()
}
times = on_times | off_times
times = dict(sorted(times.items(), reverse=True))

result = result | times
elif friendly_mode:
levels = {
wd: [
{"time": e[0], "temperature": e[1]}
for e in sorted(lv.values(), key=lambda x: x[0])
if e[0] != "24:00"
]
for wd, lv in info.items()
}
result["info"] = levels
else:
times = {
wd + "_times": [
e[0] for e in sorted(lv.values(), key=lambda x: x[0]) if e[0] != "24:00"
]
for wd, lv in info.items()
}
temperatures = {
wd + "_temperatures": [
e[1] for e in sorted(lv.values(), key=lambda x: x[0]) if e[0] != "24:00"
]
for wd, lv in info.items()
}
levels = times | temperatures
levels = dict(sorted(levels.items(), reverse=True))
result = result | levels

return result
37 changes: 32 additions & 5 deletions custom_components/heatmiserneo/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@

from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator

from . import HeatmiserNeoConfigEntry, hold_duration_validation
from .const import (
ATTR_FRIENDLY_MODE,
ATTR_HOLD_DURATION,
ATTR_HOLD_STATE,
DEFAULT_PLUG_HOLD_DURATION,
Expand All @@ -32,6 +33,7 @@
HEATMISER_TYPE_IDS_THERMOSTAT_NOT_HC,
HEATMISER_TYPE_IDS_TIMER,
PROFILE_0,
SERVICE_GET_DEVICE_PROFILE_DEFINITION,
SERVICE_TIMER_HOLD_ON,
ModeSelectOption,
)
Expand All @@ -44,6 +46,7 @@
call_custom_action,
profile_sensor_enabled_by_default,
)
from .helpers import get_profile_definition

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -319,6 +322,20 @@ async def async_base_set_profile(
entity.data.active_profile = profile_id


async def _async_get_profile_definition(
entity: HeatmiserNeoSelectEntity, service_call: ServiceCall
):
"""Set override with custom duration."""
coordinator = entity.coordinator
data = entity.data
profile_id = data.active_profile

friendly_mode = service_call.data.get(ATTR_FRIENDLY_MODE, False)
return get_profile_definition(
int(profile_id), coordinator, friendly_mode, data.device_id
)


TIMER_SET_MODE = {
ModeSelectOption.AUTO: set_timer_auto,
ModeSelectOption.OVERRIDE_ON: lambda entity: set_timer_override(entity, True),
Expand Down Expand Up @@ -398,8 +415,6 @@ async def async_base_set_profile(
HeatmiserNeoSelectEntityDescription(
key="heatmiser_neo_active_profile",
options_fn=lambda entity: _profile_names(entity.coordinator),
# entity_category=EntityCategory.CONFIG,
# entity_registry_enabled_default=False,
setup_filter_fn=lambda device, _: (
device.device_type in HEATMISER_TYPE_IDS_THERMOSTAT_NOT_HC
and not device.time_clock_mode
Expand All @@ -411,12 +426,13 @@ async def async_base_set_profile(
name="Active Profile",
# translation_key="preheat_time",
enabled_by_default_fn=profile_sensor_enabled_by_default,
custom_functions={
SERVICE_GET_DEVICE_PROFILE_DEFINITION: _async_get_profile_definition
},
),
HeatmiserNeoSelectEntityDescription(
key="heatmiser_neo_active_timer_profile",
options_fn=lambda entity: _timer_profile_names(entity.coordinator),
# entity_category=EntityCategory.CONFIG,
# entity_registry_enabled_default=False,
setup_filter_fn=lambda device, _: (
device.device_type in HEATMISER_TYPE_IDS_THERMOSTAT_NOT_HC
and device.time_clock_mode
Expand All @@ -428,6 +444,9 @@ async def async_base_set_profile(
name="Active Profile",
# translation_key="preheat_time",
enabled_by_default_fn=profile_sensor_enabled_by_default,
custom_functions={
SERVICE_GET_DEVICE_PROFILE_DEFINITION: _async_get_profile_definition
},
),
)

Expand Down Expand Up @@ -531,6 +550,14 @@ async def async_setup_entry(
},
call_custom_action,
)
platform.async_register_entity_service(
SERVICE_GET_DEVICE_PROFILE_DEFINITION,
{
vol.Optional(ATTR_FRIENDLY_MODE, default=False): cv.boolean,
},
call_custom_action,
supports_response=SupportsResponse.ONLY,
)


class HeatmiserNeoSelectEntity(HeatmiserNeoEntity, SelectEntity):
Expand Down
Loading

0 comments on commit fbf4953

Please sign in to comment.