Skip to content

Commit

Permalink
Merge pull request #253 from ocrease/Issue252
Browse files Browse the repository at this point in the history
Add support for Locking thermostat keypad
  • Loading branch information
ocrease authored Jan 31, 2025
2 parents 4fbc1b7 + f0715d5 commit 21226a7
Show file tree
Hide file tree
Showing 9 changed files with 196 additions and 0 deletions.
1 change: 1 addition & 0 deletions custom_components/heatmiserneo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CLIMATE,
Platform.LOCK,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Expand Down
1 change: 1 addition & 0 deletions custom_components/heatmiserneo/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions custom_components/heatmiserneo/lock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# 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
import re
from typing import Any

from neohubapi.neohub import NeoHub, NeoStat

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
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)
)


# 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."""
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):
"""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)
28 changes: 28 additions & 0 deletions custom_components/heatmiserneo/number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
),
)


Expand Down
5 changes: 5 additions & 0 deletions custom_components/heatmiserneo/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
"error": {}
},
"entity": {
"lock": {
"lock": {
"name": "Lock"
}
},
"select": {
"timer_mode": {
"state": {
Expand Down
5 changes: 5 additions & 0 deletions custom_components/heatmiserneo/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
"error": {}
},
"entity": {
"lock": {
"lock": {
"name": "Lock"
}
},
"select": {
"timer_mode": {
"state": {
Expand Down
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/stat.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/timeclock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
>
Expand Down

0 comments on commit 21226a7

Please sign in to comment.