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
parent
463d9617ac
commit
bf83f5a671
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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"
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue