Add button to set date and time for thermopro TP358/TP393 (#135740)

Co-authored-by: J. Nick Koston <nick@koston.org>
pull/138246/head^2
Stephan Jauernick 2025-02-22 02:40:55 +01:00 committed by GitHub
parent 463d9617ac
commit bf83f5a671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 399 additions and 10 deletions

View File

@ -2,25 +2,47 @@
from __future__ import annotations
from functools import partial
import logging
from thermopro_ble import ThermoProBluetoothDeviceData
from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData
from homeassistant.components.bluetooth import BluetoothScanningMode
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN
from .const import DOMAIN, SIGNAL_DATA_UPDATED
PLATFORMS: list[Platform] = [Platform.SENSOR]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
def process_service_info(
hass: HomeAssistant,
entry: ConfigEntry,
data: ThermoProBluetoothDeviceData,
service_info: BluetoothServiceInfoBleak,
) -> SensorUpdate:
"""Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
update = data.update(service_info)
async_dispatcher_send(
hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", data, service_info, update
)
return update
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up ThermoPro BLE device from a config entry."""
address = entry.unique_id
@ -32,13 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
update_method=partial(process_service_info, hass, entry, data),
)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(
coordinator.async_start()
) # only start after all platforms have had a chance to subscribe
# only start after all platforms have had a chance to subscribe
entry.async_on_unload(coordinator.async_start())
return True

View File

@ -0,0 +1,157 @@
"""Thermopro button platform."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData, ThermoProDevice
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_ble_device_from_address,
async_track_unavailable,
)
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import now
from .const import DOMAIN, SIGNAL_AVAILABILITY_UPDATED, SIGNAL_DATA_UPDATED
PARALLEL_UPDATES = 1 # one connection at a time
@dataclass(kw_only=True, frozen=True)
class ThermoProButtonEntityDescription(ButtonEntityDescription):
"""Describe a ThermoPro button entity."""
press_action_fn: Callable[[HomeAssistant, str], Coroutine[None, Any, Any]]
async def _async_set_datetime(hass: HomeAssistant, address: str) -> None:
"""Set Date&Time for a given device."""
ble_device = async_ble_device_from_address(hass, address, connectable=True)
assert ble_device is not None
await ThermoProDevice(ble_device).set_datetime(now(), am_pm=False)
BUTTON_ENTITIES: tuple[ThermoProButtonEntityDescription, ...] = (
ThermoProButtonEntityDescription(
key="datetime",
translation_key="set_datetime",
icon="mdi:calendar-clock",
entity_category=EntityCategory.CONFIG,
press_action_fn=_async_set_datetime,
),
)
MODELS_THAT_SUPPORT_BUTTONS = {"TP358", "TP393"}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the thermopro button platform."""
address = entry.unique_id
assert address is not None
availability_signal = f"{SIGNAL_AVAILABILITY_UPDATED}_{entry.entry_id}"
entity_added = False
@callback
def _async_on_data_updated(
data: ThermoProBluetoothDeviceData,
service_info: BluetoothServiceInfoBleak,
update: SensorUpdate,
) -> None:
nonlocal entity_added
sensor_device_info = update.devices[data.primary_device_id]
if sensor_device_info.model not in MODELS_THAT_SUPPORT_BUTTONS:
return
if not entity_added:
name = sensor_device_info.name
assert name is not None
entity_added = True
async_add_entities(
ThermoProButtonEntity(
description=description,
data=data,
availability_signal=availability_signal,
address=address,
)
for description in BUTTON_ENTITIES
)
if service_info.connectable:
async_dispatcher_send(hass, availability_signal, True)
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", _async_on_data_updated
)
)
class ThermoProButtonEntity(ButtonEntity):
"""Representation of a ThermoPro button entity."""
_attr_has_entity_name = True
entity_description: ThermoProButtonEntityDescription
def __init__(
self,
description: ThermoProButtonEntityDescription,
data: ThermoProBluetoothDeviceData,
availability_signal: str,
address: str,
) -> None:
"""Initialize the thermopro button entity."""
self.entity_description = description
self._address = address
self._availability_signal = availability_signal
self._attr_unique_id = f"{address}-{description.key}"
self._attr_device_info = dr.DeviceInfo(
name=data.get_device_name(),
identifiers={(DOMAIN, address)},
connections={(dr.CONNECTION_BLUETOOTH, address)},
)
async def async_added_to_hass(self) -> None:
"""Connect availability dispatcher."""
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(
self.hass,
self._availability_signal,
self._async_on_availability_changed,
)
)
self.async_on_remove(
async_track_unavailable(
self.hass, self._async_on_unavailable, self._address, connectable=True
)
)
@callback
def _async_on_unavailable(self, _: BluetoothServiceInfoBleak) -> None:
self._async_on_availability_changed(False)
@callback
def _async_on_availability_changed(self, available: bool) -> None:
self._attr_available = available
self.async_write_ha_state()
async def async_press(self) -> None:
"""Execute the press action for the entity."""
await self.entity_description.press_action_fn(self.hass, self._address)

View File

@ -1,3 +1,6 @@
"""Constants for the ThermoPro Bluetooth integration."""
DOMAIN = "thermopro"
SIGNAL_DATA_UPDATED = f"{DOMAIN}_service_info_updated"
SIGNAL_AVAILABILITY_UPDATED = f"{DOMAIN}_availability_updated"

View File

@ -9,7 +9,6 @@ from thermopro_ble import (
Units,
)
from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
@ -23,6 +22,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@ -110,7 +110,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ThermoPro BLE sensors."""

View File

@ -17,5 +17,12 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"entity": {
"button": {
"set_datetime": {
"name": "Set Date&Time"
}
}
}
}

View File

@ -23,6 +23,16 @@ TP357_SERVICE_INFO = BluetoothServiceInfo(
source="local",
)
TP358_SERVICE_INFO = BluetoothServiceInfo(
name="TP358 (4221)",
manufacturer_data={61890: b"\x00\x1d\x02,"},
service_uuids=[],
address="aa:bb:cc:dd:ee:ff",
rssi=-65,
service_data={},
source="local",
)
TP962R_SERVICE_INFO = BluetoothServiceInfo(
name="TP962R (0000)",
manufacturer_data={14081: b"\x00;\x0b7\x00"},

View File

@ -1,8 +1,64 @@
"""ThermoPro session fixtures."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
from thermopro_ble import ThermoProDevice
from homeassistant.components.thermopro.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.util.dt import now
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth: None) -> None:
"""Auto mock bluetooth."""
@pytest.fixture
def dummy_thermoprodevice(monkeypatch: pytest.MonkeyPatch) -> ThermoProDevice:
"""Mock for downstream library."""
client = ThermoProDevice("")
monkeypatch.setattr(client, "set_datetime", AsyncMock())
return client
@pytest.fixture
def mock_thermoprodevice(
monkeypatch: pytest.MonkeyPatch, dummy_thermoprodevice: ThermoProDevice
) -> ThermoProDevice:
"""Return downstream library mock."""
monkeypatch.setattr(
"homeassistant.components.thermopro.button.ThermoProDevice",
MagicMock(return_value=dummy_thermoprodevice),
)
return dummy_thermoprodevice
@pytest.fixture
def mock_now(monkeypatch: pytest.MonkeyPatch) -> datetime:
"""Return fixed datetime for comparison."""
fixed_now = now()
monkeypatch.setattr(
"homeassistant.components.thermopro.button.now",
MagicMock(return_value=fixed_now),
)
return fixed_now
@pytest.fixture
async def setup_thermopro(
hass: HomeAssistant, mock_thermoprodevice: ThermoProDevice
) -> None:
"""Set up the Thermopro integration."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
return entry

View File

@ -0,0 +1,135 @@
"""Test the ThermoPro button platform."""
from datetime import datetime, timedelta
import time
import pytest
from thermopro_ble import ThermoProDevice
from homeassistant.components.bluetooth import (
FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import TP357_SERVICE_INFO, TP358_SERVICE_INFO
from tests.common import async_fire_time_changed
from tests.components.bluetooth import (
inject_bluetooth_service_info,
patch_all_discovered_devices,
patch_bluetooth_time,
)
@pytest.mark.usefixtures("setup_thermopro")
async def test_buttons_tp357(hass: HomeAssistant) -> None:
"""Test setting up creates the sensors."""
assert not hass.states.async_all()
assert not hass.states.get("button.tp358_4221_set_date_time")
inject_bluetooth_service_info(hass, TP357_SERVICE_INFO)
await hass.async_block_till_done()
assert not hass.states.get("button.tp358_4221_set_date_time")
@pytest.mark.usefixtures("setup_thermopro")
async def test_buttons_tp358_discovery(hass: HomeAssistant) -> None:
"""Test discovery of device with button."""
assert not hass.states.async_all()
assert not hass.states.get("button.tp358_4221_set_date_time")
inject_bluetooth_service_info(hass, TP358_SERVICE_INFO)
await hass.async_block_till_done()
button = hass.states.get("button.tp358_4221_set_date_time")
assert button is not None
assert button.state == STATE_UNKNOWN
@pytest.mark.usefixtures("setup_thermopro")
async def test_buttons_tp358_unavailable(hass: HomeAssistant) -> None:
"""Test tp358 set date&time button goes to unavailability."""
start_monotonic = time.monotonic()
assert not hass.states.async_all()
assert not hass.states.get("button.tp358_4221_set_date_time")
inject_bluetooth_service_info(hass, TP358_SERVICE_INFO)
await hass.async_block_till_done()
button = hass.states.get("button.tp358_4221_set_date_time")
assert button is not None
assert button.state == STATE_UNKNOWN
# Fast-forward time without BLE advertisements
monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15
with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]):
async_fire_time_changed(
hass,
dt_util.utcnow()
+ timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15),
)
await hass.async_block_till_done()
button = hass.states.get("button.tp358_4221_set_date_time")
assert button.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("setup_thermopro")
async def test_buttons_tp358_reavailable(hass: HomeAssistant) -> None:
"""Test TP358/TP393 set date&time button goes to unavailablity and recovers."""
start_monotonic = time.monotonic()
assert not hass.states.async_all()
assert not hass.states.get("button.tp358_4221_set_date_time")
inject_bluetooth_service_info(hass, TP358_SERVICE_INFO)
await hass.async_block_till_done()
button = hass.states.get("button.tp358_4221_set_date_time")
assert button is not None
assert button.state == STATE_UNKNOWN
# Fast-forward time without BLE advertisements
monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15
with patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]):
async_fire_time_changed(
hass,
dt_util.utcnow()
+ timedelta(seconds=FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 15),
)
await hass.async_block_till_done()
button = hass.states.get("button.tp358_4221_set_date_time")
assert button.state == STATE_UNAVAILABLE
inject_bluetooth_service_info(hass, TP358_SERVICE_INFO)
await hass.async_block_till_done()
button = hass.states.get("button.tp358_4221_set_date_time")
assert button.state == STATE_UNKNOWN
@pytest.mark.usefixtures("setup_thermopro")
async def test_buttons_tp358_press(
hass: HomeAssistant, mock_now: datetime, mock_thermoprodevice: ThermoProDevice
) -> None:
"""Test TP358/TP393 set date&time button press."""
assert not hass.states.async_all()
assert not hass.states.get("button.tp358_4221_set_date_time")
inject_bluetooth_service_info(hass, TP358_SERVICE_INFO)
await hass.async_block_till_done()
assert hass.states.get("button.tp358_4221_set_date_time")
await hass.services.async_call(
"button",
"press",
{ATTR_ENTITY_ID: "button.tp358_4221_set_date_time"},
blocking=True,
)
mock_thermoprodevice.set_datetime.assert_awaited_once_with(mock_now, am_pm=False)
button_state = hass.states.get("button.tp358_4221_set_date_time")
assert button_state.state != STATE_UNKNOWN