From bf83f5a671da2ad008f11868f97a4fb27a30b525 Mon Sep 17 00:00:00 2001
From: Stephan Jauernick <stephan@stejau.de>
Date: Sat, 22 Feb 2025 02:40:55 +0100
Subject: [PATCH] Add button to set date and time for thermopro TP358/TP393 
 (#135740)

Co-authored-by: J. Nick Koston <nick@koston.org>
---
 .../components/thermopro/__init__.py          |  37 ++++-
 homeassistant/components/thermopro/button.py  | 157 ++++++++++++++++++
 homeassistant/components/thermopro/const.py   |   3 +
 homeassistant/components/thermopro/sensor.py  |   4 +-
 .../components/thermopro/strings.json         |   7 +
 tests/components/thermopro/__init__.py        |  10 ++
 tests/components/thermopro/conftest.py        |  56 +++++++
 tests/components/thermopro/test_button.py     | 135 +++++++++++++++
 8 files changed, 399 insertions(+), 10 deletions(-)
 create mode 100644 homeassistant/components/thermopro/button.py
 create mode 100644 tests/components/thermopro/test_button.py

diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py
index 2cd207818c5..742449cffbe 100644
--- a/homeassistant/components/thermopro/__init__.py
+++ b/homeassistant/components/thermopro/__init__.py
@@ -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
 
 
diff --git a/homeassistant/components/thermopro/button.py b/homeassistant/components/thermopro/button.py
new file mode 100644
index 00000000000..9faa9f22c4c
--- /dev/null
+++ b/homeassistant/components/thermopro/button.py
@@ -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)
diff --git a/homeassistant/components/thermopro/const.py b/homeassistant/components/thermopro/const.py
index 343729442cf..7d2170f8cf9 100644
--- a/homeassistant/components/thermopro/const.py
+++ b/homeassistant/components/thermopro/const.py
@@ -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"
diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py
index 4c9c6a4e42a..853f00f2dd5 100644
--- a/homeassistant/components/thermopro/sensor.py
+++ b/homeassistant/components/thermopro/sensor.py
@@ -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."""
diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json
index 4e12a84b653..5789de410b2 100644
--- a/homeassistant/components/thermopro/strings.json
+++ b/homeassistant/components/thermopro/strings.json
@@ -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"
+      }
+    }
   }
 }
diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py
index 264e556756c..d3cba26858f 100644
--- a/tests/components/thermopro/__init__.py
+++ b/tests/components/thermopro/__init__.py
@@ -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"},
diff --git a/tests/components/thermopro/conftest.py b/tests/components/thermopro/conftest.py
index 445f52b7844..0dcc03ae7f4 100644
--- a/tests/components/thermopro/conftest.py
+++ b/tests/components/thermopro/conftest.py
@@ -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
diff --git a/tests/components/thermopro/test_button.py b/tests/components/thermopro/test_button.py
new file mode 100644
index 00000000000..e4c73af11be
--- /dev/null
+++ b/tests/components/thermopro/test_button.py
@@ -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
-- 
GitLab