Skip to content
Snippets Groups Projects
Unverified Commit bf83f5a6 authored by Stephan Jauernick's avatar Stephan Jauernick Committed by GitHub
Browse files

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


Co-authored-by: default avatarJ. Nick Koston <nick@koston.org>
parent 463d9617
No related branches found
No related tags found
No related merge requests found
......@@ -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, SIGNAL_DATA_UPDATED
from .const import DOMAIN
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [Platform.SENSOR]
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
......
"""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)
"""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"},
......
"""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
"""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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment