1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add battery sensors to Casper Glow (#166801)

This commit is contained in:
Mike O'Driscoll
2026-04-01 12:15:38 -04:00
committed by GitHub
parent d680c72c7c
commit d50d6db1bd
9 changed files with 351 additions and 23 deletions

View File

@@ -16,6 +16,7 @@ PLATFORMS: list[Platform] = [
Platform.BUTTON,
Platform.LIGHT,
Platform.SELECT,
Platform.SENSOR,
]

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.jar_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Charging',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.BATTERY_CHARGING: 'battery_charging'>,
'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': <ANY>,
'entity_id': 'binary_sensor.jar_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[binary_sensor.jar_dimming_paused-entry]
EntityRegistryEntrySnapshot({
'aliases': list([

View File

@@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.jar_battery',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Battery',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.BATTERY: 'battery'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.jar_battery',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@@ -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

View File

@@ -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

View File

@@ -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"