From ba62d95715d8bb00a74b9488723e22b8e83eef8f Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:58:09 +0100 Subject: [PATCH] Control time display format on SwitchBot Meter Pro CO2 (#163008) Co-authored-by: Joostlek --- .../components/switchbot/__init__.py | 3 +- homeassistant/components/switchbot/const.py | 2 +- homeassistant/components/switchbot/select.py | 75 +++++++++++ .../components/switchbot/strings.json | 9 ++ tests/components/switchbot/__init__.py | 4 +- tests/components/switchbot/test_select.py | 125 ++++++++++++++++++ tests/components/switchbot/test_sensor.py | 2 +- 7 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/switchbot/select.py create mode 100644 tests/components/switchbot/test_select.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index d77e9e2df4e..e24751c9a40 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -53,7 +53,7 @@ PLATFORMS_BY_TYPE = { Platform.SENSOR, ], SupportedModels.HYGROMETER.value: [Platform.SENSOR], - SupportedModels.HYGROMETER_CO2.value: [Platform.SENSOR], + SupportedModels.HYGROMETER_CO2.value: [Platform.SENSOR, Platform.SELECT], SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.PRESENCE_SENSOR.value: [Platform.BINARY_SENSOR, Platform.SENSOR], @@ -164,6 +164,7 @@ CLASS_BY_DEVICE = { SupportedModels.ART_FRAME.value: switchbot.SwitchbotArtFrame, SupportedModels.KEYPAD_VISION.value: switchbot.SwitchbotKeypadVision, SupportedModels.KEYPAD_VISION_PRO.value: switchbot.SwitchbotKeypadVision, + SupportedModels.HYGROMETER_CO2.value: switchbot.SwitchbotMeterProCO2, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 8617f82d6cf..a94c52dba81 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -106,13 +106,13 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.ART_FRAME: SupportedModels.ART_FRAME, SwitchbotModel.KEYPAD_VISION: SupportedModels.KEYPAD_VISION, SwitchbotModel.KEYPAD_VISION_PRO: SupportedModels.KEYPAD_VISION_PRO, + SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.METER: SupportedModels.HYGROMETER, SwitchbotModel.IO_METER: SupportedModels.HYGROMETER, SwitchbotModel.METER_PRO: SupportedModels.HYGROMETER, - SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.PRESENCE_SENSOR: SupportedModels.PRESENCE_SENSOR, diff --git a/homeassistant/components/switchbot/select.py b/homeassistant/components/switchbot/select.py new file mode 100644 index 00000000000..5322b22f2c3 --- /dev/null +++ b/homeassistant/components/switchbot/select.py @@ -0,0 +1,75 @@ +"""Select platform for SwitchBot.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import switchbot +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity, exception_handler + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + +SCAN_INTERVAL = timedelta(days=7) +TIME_FORMAT_12H = "12h" +TIME_FORMAT_24H = "24h" +TIME_FORMAT_OPTIONS = [TIME_FORMAT_12H, TIME_FORMAT_24H] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot select platform.""" + coordinator = entry.runtime_data + + if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): + async_add_entities([SwitchBotMeterProCO2TimeFormatSelect(coordinator)], True) + + +class SwitchBotMeterProCO2TimeFormatSelect(SwitchbotEntity, SelectEntity): + """Select entity to set time display format on Meter Pro CO2.""" + + _attr_should_poll = True + _attr_entity_registry_enabled_default = False + _device: switchbot.SwitchbotMeterProCO2 + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "time_format" + _attr_options = TIME_FORMAT_OPTIONS + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_time_format" + + @exception_handler + async def async_select_option(self, option: str) -> None: + """Change the time display format.""" + _LOGGER.debug("Setting time format to %s for %s", option, self._address) + is_12h_mode = option == TIME_FORMAT_12H + await self._device.set_time_display_format(is_12h_mode) + self._attr_current_option = option + self.async_write_ha_state() + + async def async_update(self) -> None: + """Fetch the latest time format from the device.""" + try: + device_time = await self._device.get_datetime() + except SwitchbotOperationError: + _LOGGER.debug( + "Failed to update time format for %s", self._address, exc_info=True + ) + return + self._attr_current_option = ( + TIME_FORMAT_12H if device_time["12h_mode"] else TIME_FORMAT_24H + ) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index d08a6279f73..9c9d36fd319 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -265,6 +265,15 @@ } } }, + "select": { + "time_format": { + "name": "Time format", + "state": { + "12h": "12-hour (AM/PM)", + "24h": "24-hour" + } + } + }, "sensor": { "aqi_quality_level": { "name": "Air quality level", diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 6c796b32075..dc9bf76b7b1 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -222,7 +222,7 @@ WOMETERTHPC_SERVICE_INFO = BluetoothServiceInfoBleak( }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"5\x00d"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], - address="AA:BB:CC:DD:EE:AA", + address="AA:BB:CC:DD:EE:FF", rssi=-60, source="local", advertisement=generate_advertisement_data( @@ -233,7 +233,7 @@ WOMETERTHPC_SERVICE_INFO = BluetoothServiceInfoBleak( service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"5\x00d"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=generate_ble_device("AA:BB:CC:DD:EE:AA", "WoTHPc"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoTHPc"), time=0, connectable=True, tx_power=-127, diff --git a/tests/components/switchbot/test_select.py b/tests/components/switchbot/test_select.py new file mode 100644 index 00000000000..97b7c385471 --- /dev/null +++ b/tests/components/switchbot/test_select.py @@ -0,0 +1,125 @@ +"""Tests for the switchbot select platform.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import DOMAIN, WOMETERTHPC_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("mode", "expected_state"), + [ + (False, "24h"), + (True, "12h"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_time_format_select_initial_state( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + mode: bool, + expected_state: str, +) -> None: + """Test the time format select entity initial state.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOMETERTHPC_SERVICE_INFO) + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + with patch( + "switchbot.SwitchbotMeterProCO2.get_datetime", + return_value={ + "12h_mode": mode, + "year": 2025, + "month": 1, + "day": 9, + "hour": 12, + "minute": 0, + "second": 0, + }, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("select.test_name_time_format") + assert state is not None + assert state.state == expected_state + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("origin_mode", "expected_state"), + [ + (False, "24h"), + (True, "12h"), + ], +) +async def test_set_time_format( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + origin_mode: bool, + expected_state: str, +) -> None: + """Test changing time format to 12h.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOMETERTHPC_SERVICE_INFO) + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + mock_get_datetime = AsyncMock( + return_value={ + "12h_mode": origin_mode, + "year": 2025, + "month": 1, + "day": 9, + "hour": 12, + "minute": 0, + "second": 0, + } + ) + mock_set_time_display_format = AsyncMock(return_value=True) + + with ( + patch( + "switchbot.SwitchbotMeterProCO2.get_datetime", + mock_get_datetime, + ), + patch( + "switchbot.SwitchbotMeterProCO2.set_time_display_format", + mock_set_time_display_format, + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_name_time_format", + ATTR_OPTION: expected_state, + }, + blocking=True, + ) + + mock_set_time_display_format.assert_awaited_once_with(origin_mode) + + state = hass.states.get("select.test_name_time_format") + assert state is not None + assert state.state == expected_state diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index b1f9c15ae50..cc2471b2724 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -93,7 +93,7 @@ async def test_co2_sensor(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_ADDRESS: "AA:BB:CC:DD:EE:AA", + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", CONF_NAME: "test-name", CONF_PASSWORD: "test-password", CONF_SENSOR_TYPE: "hygrometer_co2",