"""Test the OpenDisplay sensor platform.""" from copy import deepcopy 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.config import PowerOption 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 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") async def _setup_entry(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: """Set up the integration and wait for entities to be created.""" mock_config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() async def test_sensors_before_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> None: """Test that sensors are created but unavailable before data arrives.""" await _setup_entry(hass, mock_config_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, ) -> None: """Test sensor entities for a USB-powered Flex device.""" await _setup_entry(hass, mock_config_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, ) -> None: """Test sensor entities for a battery-powered Flex device with LI_ION chemistry.""" device_config = deepcopy(DEVICE_CONFIG) power = device_config.power device_config.power = PowerOption( power_mode=PowerMode.BATTERY, battery_capacity_mah=power.battery_capacity_mah, sleep_timeout_ms=power.sleep_timeout_ms, tx_power=power.tx_power, sleep_flags=power.sleep_flags, battery_sense_pin=power.battery_sense_pin, battery_sense_enable_pin=power.battery_sense_enable_pin, battery_sense_flags=power.battery_sense_flags, capacity_estimator=1, # LI_ION voltage_scaling_factor=power.voltage_scaling_factor, deep_sleep_current_ua=power.deep_sleep_current_ua, deep_sleep_time_seconds=power.deep_sleep_time_seconds, reserved=power.reserved, ) mock_opendisplay_device.config = device_config await _setup_entry(hass, mock_config_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, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, ) -> None: """Test battery sensors are not created for USB-powered devices.""" await _setup_entry(hass, mock_config_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_config_entry: MockConfigEntry, mock_opendisplay_device: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test that no sensor entities are created for non-Flex devices.""" mock_opendisplay_device.is_flex = False await _setup_entry(hass, mock_config_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, mock_config_entry: MockConfigEntry, ) -> None: """Test that advertisements from an unknown manufacturer ID are ignored.""" await _setup_entry(hass, mock_config_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, mock_config_entry: MockConfigEntry, ) -> None: """Test that sensors become unavailable when the device stops advertising.""" start_monotonic = time.monotonic() await _setup_entry(hass, mock_config_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_config_entry: MockConfigEntry, mock_opendisplay_device: MagicMock, ) -> None: """Test battery % uses LI_ION when capacity_estimator is 0.""" device_config = deepcopy(DEVICE_CONFIG) power = device_config.power device_config.power = PowerOption( power_mode=PowerMode.BATTERY, battery_capacity_mah=power.battery_capacity_mah, sleep_timeout_ms=power.sleep_timeout_ms, tx_power=power.tx_power, sleep_flags=power.sleep_flags, battery_sense_pin=power.battery_sense_pin, battery_sense_enable_pin=power.battery_sense_enable_pin, battery_sense_flags=power.battery_sense_flags, capacity_estimator=0, # not configured — defaults to LI_ION in sensor.py voltage_scaling_factor=power.voltage_scaling_factor, deep_sleep_current_ua=power.deep_sleep_current_ua, deep_sleep_time_seconds=power.deep_sleep_time_seconds, reserved=power.reserved, ) mock_opendisplay_device.config = device_config await _setup_entry(hass, mock_config_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)