"""Test the OpenDisplay sensor platform.""" from collections.abc import Awaitable, Callable from copy import deepcopy from dataclasses import replace from datetime import timedelta import time from unittest.mock import MagicMock from habluetooth import CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS from opendisplay import voltage_to_percent from opendisplay.models.enums import CapacityEstimator, PowerMode import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.bluetooth.const import UNAVAILABLE_TRACK_SECONDS from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import DEVICE_CONFIG, TEST_ADDRESS, VALID_SERVICE_INFO, make_service_info from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform from tests.components.bluetooth import ( inject_bluetooth_service_info, patch_all_discovered_devices, patch_bluetooth_time, ) pytestmark = pytest.mark.usefixtures("entity_registry_enabled_by_default") @pytest.fixture def platforms() -> list[Platform]: """Only set up the sensor platform.""" return [Platform.SENSOR] async def test_sensors_before_data( hass: HomeAssistant, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test that sensors are created but unavailable before data arrives.""" await setup_entry() # All sensors exist but coordinator has no data yet assert hass.states.get("sensor.opendisplay_1234_temperature") is not None assert ( hass.states.get("sensor.opendisplay_1234_temperature").state == STATE_UNAVAILABLE ) async def test_sensor_entities_usb_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test sensor entities for a USB-powered Flex device.""" await setup_entry() inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_sensor_entities_battery_device( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_opendisplay_device: MagicMock, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test sensor entities for a battery-powered Flex device with LI_ION chemistry.""" device_config = deepcopy(DEVICE_CONFIG) device_config.power = replace( device_config.power, power_mode=PowerMode.BATTERY, capacity_estimator=1, # LI_ION ) mock_opendisplay_device.config = device_config await setup_entry() inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) async def test_battery_sensors_not_created_for_usb_devices( hass: HomeAssistant, entity_registry: er.EntityRegistry, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test battery sensors are not created for USB-powered devices.""" await setup_entry() inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) await hass.async_block_till_done() assert entity_registry.async_get("sensor.opendisplay_1234_battery") is None assert entity_registry.async_get("sensor.opendisplay_1234_battery_voltage") is None async def test_no_sensors_for_non_flex_devices( hass: HomeAssistant, mock_opendisplay_device: MagicMock, entity_registry: er.EntityRegistry, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test that no sensor entities are created for non-Flex devices.""" mock_opendisplay_device.is_flex = False await setup_entry() assert entity_registry.async_get("sensor.opendisplay_1234_temperature") is None assert entity_registry.async_get("sensor.opendisplay_1234_battery") is None assert entity_registry.async_get("sensor.opendisplay_1234_battery_voltage") is None async def test_coordinator_ignores_unknown_manufacturer( hass: HomeAssistant, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test that advertisements from an unknown manufacturer ID are ignored.""" await setup_entry() unknown_service_info = make_service_info( address=TEST_ADDRESS, manufacturer_data={0x9999: b"\x00" * 14}, ) inject_bluetooth_service_info(hass, unknown_service_info) await hass.async_block_till_done() # Coordinator has no data; device is visible but no OpenDisplay data parsed assert hass.states.get("sensor.opendisplay_1234_temperature").state == STATE_UNKNOWN async def test_sensor_goes_unavailable_when_device_disappears( hass: HomeAssistant, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test that sensors become unavailable when the device stops advertising.""" start_monotonic = time.monotonic() await setup_entry() inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) await hass.async_block_till_done() assert ( hass.states.get("sensor.opendisplay_1234_temperature").state != STATE_UNAVAILABLE ) # Must exceed both the connectable stale threshold (195s) and the # unavailability polling interval (300s) to trigger the callback. advance = ( CONNECTABLE_FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + UNAVAILABLE_TRACK_SECONDS + 1 ) monotonic_now = start_monotonic + advance with ( patch_bluetooth_time(monotonic_now), patch_all_discovered_devices([]), ): async_fire_time_changed( hass, dt_util.utcnow() + timedelta(seconds=advance), ) await hass.async_block_till_done() assert ( hass.states.get("sensor.opendisplay_1234_temperature").state == STATE_UNAVAILABLE ) async def test_battery_sensor_defaults_to_liion_when_capacity_estimator_unset( hass: HomeAssistant, mock_opendisplay_device: MagicMock, setup_entry: Callable[[], Awaitable[None]], ) -> None: """Test battery % uses LI_ION when capacity_estimator is 0.""" device_config = deepcopy(DEVICE_CONFIG) device_config.power = replace( device_config.power, power_mode=PowerMode.BATTERY, capacity_estimator=0, # not configured — defaults to LI_ION in sensor.py ) mock_opendisplay_device.config = device_config await setup_entry() inject_bluetooth_service_info(hass, VALID_SERVICE_INFO) await hass.async_block_till_done() battery_state = hass.states.get("sensor.opendisplay_1234_battery") assert battery_state is not None # capacity_estimator=0 should fall back to LI_ION, producing # the same value as explicit LI_ION expected = voltage_to_percent(3700, CapacityEstimator.LI_ION) assert battery_state.state == str(expected)