From bf1a15eb6c5992766eeef2cb9f986fc8e0b40cd9 Mon Sep 17 00:00:00 2001 From: Oliver Crease <18347739+ocrease@users.noreply.github.com> Date: Thu, 30 Jan 2025 23:30:53 +0000 Subject: [PATCH 1/5] Issue #252 - Add lock entity to thermostats --- custom_components/heatmiserneo/__init__.py | 1 + custom_components/heatmiserneo/const.py | 1 + custom_components/heatmiserneo/lock.py | 130 ++++++++++++++++++ custom_components/heatmiserneo/strings.json | 5 + .../heatmiserneo/translations/en.json | 5 + 5 files changed, 142 insertions(+) create mode 100644 custom_components/heatmiserneo/lock.py diff --git a/custom_components/heatmiserneo/__init__.py b/custom_components/heatmiserneo/__init__.py index 08ab0fa..248842f 100644 --- a/custom_components/heatmiserneo/__init__.py +++ b/custom_components/heatmiserneo/__init__.py @@ -21,6 +21,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.LOCK, Platform.NUMBER, Platform.SELECT, Platform.SENSOR, diff --git a/custom_components/heatmiserneo/const.py b/custom_components/heatmiserneo/const.py index 49e6be3..a579fda 100644 --- a/custom_components/heatmiserneo/const.py +++ b/custom_components/heatmiserneo/const.py @@ -132,6 +132,7 @@ HEATMISER_TYPE_IDS_IDENTIFY = HEATMISER_TYPE_IDS_THERMOSTAT.union( HEATMISER_TYPE_IDS_TIMER ).difference(HEATMISER_TYPE_IDS_PLUG) +HEATMISER_TYPE_IDS_LOCK = HEATMISER_TYPE_IDS_IDENTIFY # This should be in the neohubapi.neohub enums code diff --git a/custom_components/heatmiserneo/lock.py b/custom_components/heatmiserneo/lock.py new file mode 100644 index 0000000..1d844e5 --- /dev/null +++ b/custom_components/heatmiserneo/lock.py @@ -0,0 +1,130 @@ +# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-only + +"""Heatmiser Neo Binary Sensors via Heatmiser Neo-hub.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +import logging +from typing import Any + +from neohubapi.neohub import NeoHub, NeoStat + +from homeassistant.components.lock import LockEntity, LockEntityDescription +from homeassistant.const import ATTR_CODE +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import HeatmiserNeoConfigEntry +from .const import HEATMISER_TYPE_IDS_LOCK +from .coordinator import HeatmiserNeoCoordinator +from .entity import HeatmiserNeoEntity, HeatmiserNeoEntityDescription + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HeatmiserNeoConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Heatmiser Neo Switch entities.""" + hub = entry.runtime_data.hub + coordinator = entry.runtime_data.coordinator + + if coordinator.data is None: + _LOGGER.error("Coordinator data is None. Cannot set up lock entities") + return + + neo_devices, _ = coordinator.data + system_data = coordinator.system_data + + _LOGGER.info("Adding Neo Locks") + + async_add_entities( + HeatmiserNeoLockEntity(neodevice, coordinator, hub, description) + for description in LOCKS + for neodevice in neo_devices.values() + if description.setup_filter_fn(neodevice, system_data) + ) + + +async def async_lock_device(entity: HeatmiserNeoEntity, **kwargs): + """Lock a thermostat.""" + await entity.data.set_lock(int(kwargs.get(ATTR_CODE, 0))) + + +async def async_unlock_device(entity: HeatmiserNeoEntity): + """Unlock a thermostat.""" + await entity.data.unlock() + + +@dataclass(frozen=True, kw_only=True) +class HeatmiserNeoLockEntityDescription( + HeatmiserNeoEntityDescription, LockEntityDescription +): + """Describes a button entity.""" + + value_fn: Callable[[HeatmiserNeoEntity], bool] + default_pin_fn: Callable[[HeatmiserNeoEntity], int] + lock_fn: Callable[[HeatmiserNeoEntity, Any], Awaitable[None]] + unlock_fn: Callable[[HeatmiserNeoEntity], Awaitable[None]] + + +LOCKS: tuple[HeatmiserNeoLockEntityDescription, ...] = ( + HeatmiserNeoLockEntityDescription( + key="heatmiser_neo_stat_lock", + translation_key="lock", + value_fn=lambda entity: entity.data.lock, + default_pin_fn=lambda entity: entity.data.pin_number, + lock_fn=async_lock_device, + unlock_fn=async_unlock_device, + setup_filter_fn=lambda device, _: ( + device.device_type in HEATMISER_TYPE_IDS_LOCK + ), + ), +) + + +class HeatmiserNeoLockEntity(HeatmiserNeoEntity, LockEntity): + """Heatmiser Neo switch entity.""" + + def __init__( + self, + neostat: NeoStat, + coordinator: HeatmiserNeoCoordinator, + hub: NeoHub, + entity_description: HeatmiserNeoLockEntityDescription, + ) -> None: + """Initialize Heatmiser Neo lock entity.""" + super().__init__( + neostat, + coordinator, + hub, + entity_description, + ) + self._update() + + async def async_lock(self, **kwargs): + """Turn the entity on.""" + await self.entity_description.lock_fn(self, **kwargs) + self.data.lock = True + self.coordinator.async_update_listeners() + + async def async_unlock(self, **kwargs): + """Turn the entity off.""" + await self.entity_description.unlock_fn(self) + self.data.lock = False + self.coordinator.async_update_listeners() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update() + super()._handle_coordinator_update() + + def _update(self): + if self.entity_description.default_pin_fn: + self._lock_option_default_code = self.entity_description.default_pin_fn( + self + ) + self._attr_is_locked = self.entity_description.value_fn(self) diff --git a/custom_components/heatmiserneo/strings.json b/custom_components/heatmiserneo/strings.json index f05b27d..0146b89 100644 --- a/custom_components/heatmiserneo/strings.json +++ b/custom_components/heatmiserneo/strings.json @@ -41,6 +41,11 @@ "error": {} }, "entity": { + "lock": { + "lock": { + "name": "Lock" + } + }, "select": { "timer_mode": { "state": { diff --git a/custom_components/heatmiserneo/translations/en.json b/custom_components/heatmiserneo/translations/en.json index 786a3f9..4ff38fa 100644 --- a/custom_components/heatmiserneo/translations/en.json +++ b/custom_components/heatmiserneo/translations/en.json @@ -45,6 +45,11 @@ "error": {} }, "entity": { + "lock": { + "lock": { + "name": "Lock" + } + }, "select": { "timer_mode": { "state": { From 386d221c133758b64092d7229dde320d2f0cf1c9 Mon Sep 17 00:00:00 2001 From: Oliver Crease <18347739+ocrease@users.noreply.github.com> Date: Thu, 30 Jan 2025 23:45:55 +0000 Subject: [PATCH 2/5] Issue #252 - Add user limit --- custom_components/heatmiserneo/number.py | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/custom_components/heatmiserneo/number.py b/custom_components/heatmiserneo/number.py index 43548b4..e93443b 100644 --- a/custom_components/heatmiserneo/number.py +++ b/custom_components/heatmiserneo/number.py @@ -81,6 +81,15 @@ async def async_set_floor_limit(entity: HeatmiserNeoEntity, val: float) -> None: setattr(entity.data._data_, "ENG_FLOOR_LIMIT", int(val)) +async def async_set_user_limit(entity: HeatmiserNeoEntity, val: int) -> None: + """Set the floor limit temperature on a device.""" + message = {"USER_LIMIT": [val, [entity.data.name]]} + # TODO this should be in the API + await entity.coordinator.hub._send(message) # noqa: SLF001 + entity.coordinator.hub._update_timestamps["TIMESTAMP_ENGINEERS"] = 0 # noqa: SLF001 + setattr(entity.data._data_, "USER_LIMIT", int(val)) + + NUMBERS: tuple[HeatmiserNeoNumberEntityDescription, ...] = ( HeatmiserNeoNumberEntityDescription( key="heatmiser_neo_frost_temp", @@ -138,6 +147,25 @@ async def async_set_floor_limit(entity: HeatmiserNeoEntity, val: float) -> None: ), mode=NumberMode.BOX, ), + HeatmiserNeoNumberEntityDescription( + key="heatmiser_neo_user_limit", + name="User Limit", + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + setup_filter_fn=lambda device, _: ( + device.device_type in HEATMISER_TYPE_IDS_THERMOSTAT + and not device.time_clock_mode + ), + value_fn=lambda dev: dev._data_.USER_LIMIT, + set_value_fn=async_set_user_limit, + native_step=1, + native_min_value=0, + native_max_value=10, + unit_of_measurement_fn=lambda _, sys_data: ( + HEATMISER_TEMPERATURE_UNIT_HA_UNIT.get(sys_data.CORF, None) + ), + mode=NumberMode.BOX, + ), ) From b420760e899c823b026210c84fda4c213fbc16a4 Mon Sep 17 00:00:00 2001 From: Oliver Crease <18347739+ocrease@users.noreply.github.com> Date: Thu, 30 Jan 2025 23:48:08 +0000 Subject: [PATCH 3/5] Issue #252 - updated documentation --- docs/stat.md | 2 ++ docs/timeclock.md | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/stat.md b/docs/stat.md index f05dd32..22f1e4b 100644 --- a/docs/stat.md +++ b/docs/stat.md @@ -30,6 +30,7 @@ There are three preset modes: - Profile Next Time - The next time there is a state change managed by the profile - Profile Current Temeperature - The profile's current temperature - Profile Next Temeperature - The profile's temperature at the next state change +- Lock - Lock or unlock the keypad on a thermostat. Use the standard `lock.lock` service if you want to set a new pin number > ##### INFO > @@ -42,6 +43,7 @@ There are three preset modes: - Optimum Start - maxiumu preheat period - Switching Differential - How far the temperature has to drop before heat is called for - Floor Limit Temperature - Thermostat will stop calling for heat if the floor reaches this temperature (only if a floor sensor is in use) +- User Limit - if thermostat is locked, this the temperature change allowed without unlocking the thermostat. ## Diagnostic Entities diff --git a/docs/timeclock.md b/docs/timeclock.md index 8edea33..7bfdb1b 100644 --- a/docs/timeclock.md +++ b/docs/timeclock.md @@ -17,6 +17,7 @@ TimeClocks and NeoPlugs are modeled as a select entity in Home Assistant. - PROFILE_0 is a special profile when the profile is managed directly on the thermostat rather than a shared profile managed in the hub - Profile Next Time - The next time there is a state change managed by the profile - Profile State - The profile's current state. There is no next state sensor since it is just the opposite of the current state +- Lock - Lock or unlock the keypad on a thermostat. Use the standard `lock.lock` service if you want to set a new pin number > ##### INFO > From 3aa4348d01bc98a98e2d20ba4a6bce95e9df9107 Mon Sep 17 00:00:00 2001 From: Oliver Crease <18347739+ocrease@users.noreply.github.com> Date: Fri, 31 Jan 2025 00:35:42 +0000 Subject: [PATCH 4/5] Issue #252 - added validation to the pin number --- custom_components/heatmiserneo/lock.py | 31 +++++++++++++++++++++----- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/custom_components/heatmiserneo/lock.py b/custom_components/heatmiserneo/lock.py index 1d844e5..7ca6b72 100644 --- a/custom_components/heatmiserneo/lock.py +++ b/custom_components/heatmiserneo/lock.py @@ -5,13 +5,15 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging +import re from typing import Any from neohubapi.neohub import NeoHub, NeoStat -from homeassistant.components.lock import LockEntity, LockEntityDescription +from homeassistant.components.lock import DOMAIN, LockEntity, LockEntityDescription from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HeatmiserNeoConfigEntry @@ -48,12 +50,29 @@ async def async_setup_entry( ) -async def async_lock_device(entity: HeatmiserNeoEntity, **kwargs): +# Ideally we would set this on the lock entity itself +# but doing so adds a code entry dialog to the UI +# which we don't want +pin_format = r"^\d{1,4}$" +pin_format_cmp = re.compile(pin_format) + + +async def _async_lock_device(entity: HeatmiserNeoEntity, **kwargs): """Lock a thermostat.""" - await entity.data.set_lock(int(kwargs.get(ATTR_CODE, 0))) + code = str(kwargs.get(ATTR_CODE, 0)) + if not pin_format_cmp.match(code): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="add_default_code", + translation_placeholders={ + "entity_id": entity.entity_id, + "code_format": pin_format, + }, + ) + await entity.data.set_lock(int(code)) -async def async_unlock_device(entity: HeatmiserNeoEntity): +async def _async_unlock_device(entity: HeatmiserNeoEntity): """Unlock a thermostat.""" await entity.data.unlock() @@ -76,8 +95,8 @@ class HeatmiserNeoLockEntityDescription( translation_key="lock", value_fn=lambda entity: entity.data.lock, default_pin_fn=lambda entity: entity.data.pin_number, - lock_fn=async_lock_device, - unlock_fn=async_unlock_device, + lock_fn=_async_lock_device, + unlock_fn=_async_unlock_device, setup_filter_fn=lambda device, _: ( device.device_type in HEATMISER_TYPE_IDS_LOCK ), From f0715d582d5f128914a06a79b44cf8f46612ae56 Mon Sep 17 00:00:00 2001 From: Oliver Crease <18347739+ocrease@users.noreply.github.com> Date: Fri, 31 Jan 2025 00:37:11 +0000 Subject: [PATCH 5/5] Issue #252 - updated changelog --- docs/changelog.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changelog.md b/docs/changelog.md index 8c2ec67..ec3a2dc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,10 @@ ## 20250129 +- Merged https://github.com/MindrustUK/Heatmiser-for-home-assistant/pull/253 by @ocrease, added lock and user limit + +## 20250129 + - Merged https://github.com/MindrustUK/Heatmiser-for-home-assistant/pull/251 by @ocrease, fixes issues with HVAC Mode configuration for NeoStat HC ## 20250126