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:
@@ -16,6 +16,7 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
61
homeassistant/components/casper_glow/sensor.py
Normal file
61
homeassistant/components/casper_glow/sensor.py
Normal 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()
|
||||
@@ -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([
|
||||
|
||||
56
tests/components/casper_glow/snapshots/test_sensor.ambr
Normal file
56
tests/components/casper_glow/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
72
tests/components/casper_glow/test_sensor.py
Normal file
72
tests/components/casper_glow/test_sensor.py
Normal 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"
|
||||
Reference in New Issue
Block a user