From d50d6db1bd9dae79072c0914aa07b3f402386cc2 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Wed, 1 Apr 2026 12:15:38 -0400 Subject: [PATCH] Add battery sensors to Casper Glow (#166801) --- .../components/casper_glow/__init__.py | 1 + .../components/casper_glow/binary_sensor.py | 45 ++++++++++- .../components/casper_glow/quality_scale.yaml | 8 +- .../components/casper_glow/sensor.py | 61 +++++++++++++++ .../snapshots/test_binary_sensor.ambr | 51 ++++++++++++ .../casper_glow/snapshots/test_sensor.ambr | 56 +++++++++++++ .../casper_glow/test_binary_sensor.py | 78 +++++++++++++++---- tests/components/casper_glow/test_select.py | 2 +- tests/components/casper_glow/test_sensor.py | 72 +++++++++++++++++ 9 files changed, 351 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/casper_glow/sensor.py create mode 100644 tests/components/casper_glow/snapshots/test_sensor.ambr create mode 100644 tests/components/casper_glow/test_sensor.py diff --git a/homeassistant/components/casper_glow/__init__.py b/homeassistant/components/casper_glow/__init__.py index 216379cb4a0..e4e114fd245 100644 --- a/homeassistant/components/casper_glow/__init__.py +++ b/homeassistant/components/casper_glow/__init__.py @@ -16,6 +16,7 @@ PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.LIGHT, Platform.SELECT, + Platform.SENSOR, ] diff --git a/homeassistant/components/casper_glow/binary_sensor.py b/homeassistant/components/casper_glow/binary_sensor.py index 9da8bcfe984..0180ccbcc6e 100644 --- a/homeassistant/components/casper_glow/binary_sensor.py +++ b/homeassistant/components/casper_glow/binary_sensor.py @@ -4,7 +4,11 @@ from __future__ import annotations from pycasperglow import GlowState -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,7 +25,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform for Casper Glow.""" - async_add_entities([CasperGlowPausedBinarySensor(entry.runtime_data)]) + async_add_entities( + [ + CasperGlowPausedBinarySensor(entry.runtime_data), + CasperGlowChargingBinarySensor(entry.runtime_data), + ] + ) class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity): @@ -46,6 +55,34 @@ class CasperGlowPausedBinarySensor(CasperGlowEntity, BinarySensorEntity): @callback def _async_handle_state_update(self, state: GlowState) -> None: """Handle a state update from the device.""" - if state.is_paused is not None: + if state.is_paused is not None and state.is_paused != self._attr_is_on: self._attr_is_on = state.is_paused - self.async_write_ha_state() + self.async_write_ha_state() + + +class CasperGlowChargingBinarySensor(CasperGlowEntity, BinarySensorEntity): + """Binary sensor indicating whether the Casper Glow is charging.""" + + _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the charging binary sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_charging" + if coordinator.device.state.is_charging is not None: + self._attr_is_on = coordinator.device.state.is_charging + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.is_charging is not None and state.is_charging != self._attr_is_on: + self._attr_is_on = state.is_charging + self.async_write_ha_state() diff --git a/homeassistant/components/casper_glow/quality_scale.yaml b/homeassistant/components/casper_glow/quality_scale.yaml index 3d2cfceaf7c..45dec1b1cc3 100644 --- a/homeassistant/components/casper_glow/quality_scale.yaml +++ b/homeassistant/components/casper_glow/quality_scale.yaml @@ -53,15 +53,15 @@ rules: docs-use-cases: todo dynamic-devices: todo entity-category: done - entity-device-class: - status: exempt - comment: No applicable device classes for binary_sensor, button, light, or select entities. + entity-device-class: done entity-disabled-by-default: todo entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: todo - repair-issues: todo + repair-issues: + status: exempt + comment: Integration does not register repair issues. stale-devices: todo # Platinum diff --git a/homeassistant/components/casper_glow/sensor.py b/homeassistant/components/casper_glow/sensor.py new file mode 100644 index 00000000000..8ecc26dad84 --- /dev/null +++ b/homeassistant/components/casper_glow/sensor.py @@ -0,0 +1,61 @@ +"""Casper Glow integration sensor platform.""" + +from __future__ import annotations + +from pycasperglow import GlowState + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator +from .entity import CasperGlowEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: CasperGlowConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform for Casper Glow.""" + async_add_entities([CasperGlowBatterySensor(entry.runtime_data)]) + + +class CasperGlowBatterySensor(CasperGlowEntity, SensorEntity): + """Sensor entity for Casper Glow battery level.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: CasperGlowCoordinator) -> None: + """Initialize the battery sensor.""" + super().__init__(coordinator) + self._attr_unique_id = f"{format_mac(coordinator.device.address)}_battery" + if coordinator.device.state.battery_level is not None: + self._attr_native_value = coordinator.device.state.battery_level.percentage + + async def async_added_to_hass(self) -> None: + """Register state update callback when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self._device.register_callback(self._async_handle_state_update) + ) + + @callback + def _async_handle_state_update(self, state: GlowState) -> None: + """Handle a state update from the device.""" + if state.battery_level is not None: + new_value = state.battery_level.percentage + if new_value != self._attr_native_value: + self._attr_native_value = new_value + self.async_write_ha_state() diff --git a/tests/components/casper_glow/snapshots/test_binary_sensor.ambr b/tests/components/casper_glow/snapshots/test_binary_sensor.ambr index 0c336383d41..705d8efd676 100644 --- a/tests/components/casper_glow/snapshots/test_binary_sensor.ambr +++ b/tests/components/casper_glow/snapshots/test_binary_sensor.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_entities[binary_sensor.jar_charging-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.jar_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Charging', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_charging', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[binary_sensor.jar_charging-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'Jar Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.jar_charging', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_entities[binary_sensor.jar_dimming_paused-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/casper_glow/snapshots/test_sensor.ambr b/tests/components/casper_glow/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..63da94d743c --- /dev/null +++ b/tests/components/casper_glow/snapshots/test_sensor.ambr @@ -0,0 +1,56 @@ +# serializer version: 1 +# name: test_entities[sensor.jar_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.jar_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'casper_glow', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.jar_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Jar Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.jar_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/casper_glow/test_binary_sensor.py b/tests/components/casper_glow/test_binary_sensor.py index 010e11b7839..cca51cb9659 100644 --- a/tests/components/casper_glow/test_binary_sensor.py +++ b/tests/components/casper_glow/test_binary_sensor.py @@ -1,5 +1,6 @@ """Test the Casper Glow binary sensor platform.""" +from collections.abc import Callable from unittest.mock import MagicMock, patch from pycasperglow import GlowState @@ -14,7 +15,8 @@ from . import setup_integration from tests.common import MockConfigEntry, snapshot_platform -ENTITY_ID = "binary_sensor.jar_dimming_paused" +PAUSED_ENTITY_ID = "binary_sensor.jar_dimming_paused" +CHARGING_ENTITY_ID = "binary_sensor.jar_charging" async def test_entities( @@ -37,31 +39,31 @@ async def test_entities( [(True, STATE_ON), (False, STATE_OFF)], ids=["paused", "not-paused"], ) -async def test_binary_sensor_state_update( +async def test_paused_state_update( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], is_paused: bool, expected_state: str, ) -> None: - """Test that the binary sensor reflects is_paused state changes.""" + """Test that the paused binary sensor reflects is_paused state changes.""" with patch( "homeassistant.components.casper_glow.PLATFORMS", [Platform.BINARY_SENSOR] ): await setup_integration(hass, mock_config_entry) - cb = mock_casper_glow.register_callback.call_args[0][0] - - cb(GlowState(is_paused=is_paused)) - state = hass.states.get(ENTITY_ID) + fire_callbacks(GlowState(is_paused=is_paused)) + state = hass.states.get(PAUSED_ENTITY_ID) assert state is not None assert state.state == expected_state -async def test_binary_sensor_ignores_none_paused_state( +async def test_paused_ignores_none_state( hass: HomeAssistant, mock_casper_glow: MagicMock, mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that a callback with is_paused=None does not overwrite the state.""" with patch( @@ -69,16 +71,64 @@ async def test_binary_sensor_ignores_none_paused_state( ): await setup_integration(hass, mock_config_entry) - cb = mock_casper_glow.register_callback.call_args[0][0] - # Set a known value first - cb(GlowState(is_paused=True)) - state = hass.states.get(ENTITY_ID) + fire_callbacks(GlowState(is_paused=True)) + state = hass.states.get(PAUSED_ENTITY_ID) assert state is not None assert state.state == STATE_ON # Callback with no is_paused data — state should remain unchanged - cb(GlowState(is_on=True)) - state = hass.states.get(ENTITY_ID) + fire_callbacks(GlowState(is_on=True)) + state = hass.states.get(PAUSED_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + +@pytest.mark.parametrize( + ("is_charging", "expected_state"), + [(True, STATE_ON), (False, STATE_OFF)], + ids=["charging", "not-charging"], +) +async def test_charging_state_update( + hass: HomeAssistant, + mock_casper_glow: MagicMock, + mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], + is_charging: bool, + expected_state: str, +) -> None: + """Test that the charging binary sensor reflects is_charging state changes.""" + with patch( + "homeassistant.components.casper_glow.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + fire_callbacks(GlowState(is_charging=is_charging)) + state = hass.states.get(CHARGING_ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +async def test_charging_ignores_none_state( + hass: HomeAssistant, + mock_casper_glow: MagicMock, + mock_config_entry: MockConfigEntry, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test that a callback with is_charging=None does not overwrite the state.""" + with patch( + "homeassistant.components.casper_glow.PLATFORMS", [Platform.BINARY_SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + # Set a known value first + fire_callbacks(GlowState(is_charging=True)) + state = hass.states.get(CHARGING_ENTITY_ID) + assert state is not None + assert state.state == STATE_ON + + # Callback with no is_charging data — state should remain unchanged + fire_callbacks(GlowState(is_on=True)) + state = hass.states.get(CHARGING_ENTITY_ID) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/casper_glow/test_select.py b/tests/components/casper_glow/test_select.py index ef72c092bb3..5ca1071a49c 100644 --- a/tests/components/casper_glow/test_select.py +++ b/tests/components/casper_glow/test_select.py @@ -153,7 +153,7 @@ async def test_select_ignores_remaining_time_updates( fire_callbacks: Callable[[GlowState], None], ) -> None: """Test that callbacks with only remaining time do not change the select state.""" - fire_callbacks(GlowState(dimming_time_remaining_ms=44)) + fire_callbacks(GlowState(dimming_time_remaining_ms=2_640_000)) state = hass.states.get(ENTITY_ID) assert state is not None diff --git a/tests/components/casper_glow/test_sensor.py b/tests/components/casper_glow/test_sensor.py new file mode 100644 index 00000000000..329e2440e36 --- /dev/null +++ b/tests/components/casper_glow/test_sensor.py @@ -0,0 +1,72 @@ +"""Test the Casper Glow sensor platform.""" + +from collections.abc import Callable +from unittest.mock import MagicMock, patch + +from pycasperglow import BatteryLevel, GlowState +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +BATTERY_ENTITY_ID = "sensor.jar_battery" + + +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test all sensor entities match the snapshot.""" + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("battery_level", "expected_state"), + [ + (BatteryLevel.PCT_75, "75"), + (BatteryLevel.PCT_50, "50"), + ], +) +async def test_battery_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + battery_level: BatteryLevel, + expected_state: str, +) -> None: + """Test that the battery sensor reflects device state at setup.""" + mock_casper_glow.state = GlowState(battery_level=battery_level) + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + state = hass.states.get(BATTERY_ENTITY_ID) + assert state is not None + assert state.state == expected_state + + +async def test_battery_state_updated_via_callback( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_casper_glow: MagicMock, + fire_callbacks: Callable[[GlowState], None], +) -> None: + """Test battery sensor updates when a device callback fires.""" + with patch("homeassistant.components.casper_glow.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + fire_callbacks(GlowState(battery_level=BatteryLevel.PCT_50)) + + state = hass.states.get(BATTERY_ENTITY_ID) + assert state is not None + assert state.state == "50"