Improve advanced Z-Wave battery discovery (#147127)

This commit is contained in:
Martin Hjelmare 2025-06-19 17:56:47 +02:00 committed by GitHub
parent 7a5c088149
commit da3d8a6332
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 7825 additions and 27 deletions

View File

@ -318,12 +318,37 @@ PROPERTY_SENSOR_MAPPINGS: dict[str, PropertyZWaveJSEntityDescription] = {
# Mappings for boolean sensors
BOOLEAN_SENSOR_MAPPINGS: dict[int, BinarySensorEntityDescription] = {
CommandClass.BATTERY: BinarySensorEntityDescription(
key=str(CommandClass.BATTERY),
BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescription] = {
(CommandClass.BATTERY, "backup"): BinarySensorEntityDescription(
key="battery_backup",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
(CommandClass.BATTERY, "disconnected"): BinarySensorEntityDescription(
key="battery_disconnected",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
(CommandClass.BATTERY, "isLow"): BinarySensorEntityDescription(
key="battery_is_low",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
(CommandClass.BATTERY, "lowFluid"): BinarySensorEntityDescription(
key="battery_low_fluid",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
(CommandClass.BATTERY, "overheating"): BinarySensorEntityDescription(
key="battery_overheating",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
(CommandClass.BATTERY, "rechargeable"): BinarySensorEntityDescription(
key="battery_rechargeable",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
}
@ -432,8 +457,9 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity):
# Entity class attributes
self._attr_name = self.generate_name(include_value_name=True)
primary_value = self.info.primary_value
if description := BOOLEAN_SENSOR_MAPPINGS.get(
self.info.primary_value.command_class
(primary_value.command_class, primary_value.property_)
):
self.entity_description = description

View File

@ -139,7 +139,10 @@ ATTR_TWIST_ASSIST = "twist_assist"
ADDON_SLUG = "core_zwave_js"
# Sensor entity description constants
ENTITY_DESC_KEY_BATTERY = "battery"
ENTITY_DESC_KEY_BATTERY_LEVEL = "battery_level"
ENTITY_DESC_KEY_BATTERY_LIST_STATE = "battery_list_state"
ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY = "battery_maximum_capacity"
ENTITY_DESC_KEY_BATTERY_TEMPERATURE = "battery_temperature"
ENTITY_DESC_KEY_CURRENT = "current"
ENTITY_DESC_KEY_VOLTAGE = "voltage"
ENTITY_DESC_KEY_ENERGY_MEASUREMENT = "energy_measurement"

View File

@ -913,7 +913,6 @@ DISCOVERY_SCHEMAS = [
hint="numeric_sensor",
primary_value=ZWaveValueDiscoverySchema(
command_class={
CommandClass.BATTERY,
CommandClass.ENERGY_PRODUCTION,
CommandClass.SENSOR_ALARM,
CommandClass.SENSOR_MULTILEVEL,
@ -922,6 +921,36 @@ DISCOVERY_SCHEMAS = [
),
data_template=NumericSensorDataTemplate(),
),
ZWaveDiscoverySchema(
platform=Platform.SENSOR,
hint="numeric_sensor",
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.BATTERY},
type={ValueType.NUMBER},
property={"level", "maximumCapacity"},
),
data_template=NumericSensorDataTemplate(),
),
ZWaveDiscoverySchema(
platform=Platform.SENSOR,
hint="numeric_sensor",
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.BATTERY},
type={ValueType.NUMBER},
property={"temperature"},
),
data_template=NumericSensorDataTemplate(),
),
ZWaveDiscoverySchema(
platform=Platform.SENSOR,
hint="list",
primary_value=ZWaveValueDiscoverySchema(
command_class={CommandClass.BATTERY},
type={ValueType.NUMBER},
property={"chargingStatus", "rechargeOrReplace"},
),
data_template=NumericSensorDataTemplate(),
),
ZWaveDiscoverySchema(
platform=Platform.SENSOR,
hint="numeric_sensor",

View File

@ -133,7 +133,10 @@ from homeassistant.const import (
)
from .const import (
ENTITY_DESC_KEY_BATTERY,
ENTITY_DESC_KEY_BATTERY_LEVEL,
ENTITY_DESC_KEY_BATTERY_LIST_STATE,
ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY,
ENTITY_DESC_KEY_BATTERY_TEMPERATURE,
ENTITY_DESC_KEY_CO,
ENTITY_DESC_KEY_CO2,
ENTITY_DESC_KEY_CURRENT,
@ -380,8 +383,31 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate):
def resolve_data(self, value: ZwaveValue) -> NumericSensorDataTemplateData:
"""Resolve helper class data for a discovered value."""
if value.command_class == CommandClass.BATTERY:
return NumericSensorDataTemplateData(ENTITY_DESC_KEY_BATTERY, PERCENTAGE)
if value.command_class == CommandClass.BATTERY and value.property_ == "level":
return NumericSensorDataTemplateData(
ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE
)
if value.command_class == CommandClass.BATTERY and value.property_ in (
"chargingStatus",
"rechargeOrReplace",
):
return NumericSensorDataTemplateData(
ENTITY_DESC_KEY_BATTERY_LIST_STATE, None
)
if (
value.command_class == CommandClass.BATTERY
and value.property_ == "maximumCapacity"
):
return NumericSensorDataTemplateData(
ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE
)
if (
value.command_class == CommandClass.BATTERY
and value.property_ == "temperature"
):
return NumericSensorDataTemplateData(
ENTITY_DESC_KEY_BATTERY_TEMPERATURE, UnitOfTemperature.CELSIUS
)
if value.command_class == CommandClass.METER:
try:

View File

@ -58,7 +58,10 @@ from .const import (
ATTR_VALUE,
DATA_CLIENT,
DOMAIN,
ENTITY_DESC_KEY_BATTERY,
ENTITY_DESC_KEY_BATTERY_LEVEL,
ENTITY_DESC_KEY_BATTERY_LIST_STATE,
ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY,
ENTITY_DESC_KEY_BATTERY_TEMPERATURE,
ENTITY_DESC_KEY_CO,
ENTITY_DESC_KEY_CO2,
ENTITY_DESC_KEY_CURRENT,
@ -95,17 +98,33 @@ from .migrate import async_migrate_statistics_sensors
PARALLEL_UPDATES = 0
# These descriptions should include device class.
ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[
tuple[str, str], SensorEntityDescription
] = {
(ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription(
key=ENTITY_DESC_KEY_BATTERY,
# These descriptions should have a non None unit of measurement.
ENTITY_DESCRIPTION_KEY_UNIT_MAP: dict[tuple[str, str], SensorEntityDescription] = {
(ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE): SensorEntityDescription(
key=ENTITY_DESC_KEY_BATTERY_LEVEL,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
),
(ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE): SensorEntityDescription(
key=ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
),
(
ENTITY_DESC_KEY_BATTERY_TEMPERATURE,
UnitOfTemperature.CELSIUS,
): SensorEntityDescription(
key=ENTITY_DESC_KEY_BATTERY_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
entity_registry_enabled_default=False,
),
(ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription(
key=ENTITY_DESC_KEY_CURRENT,
device_class=SensorDeviceClass.CURRENT,
@ -285,8 +304,14 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[
),
}
# These descriptions are without device class.
# These descriptions are without unit of measurement.
ENTITY_DESCRIPTION_KEY_MAP = {
ENTITY_DESC_KEY_BATTERY_LIST_STATE: SensorEntityDescription(
key=ENTITY_DESC_KEY_BATTERY_LIST_STATE,
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
ENTITY_DESC_KEY_CO: SensorEntityDescription(
key=ENTITY_DESC_KEY_CO,
state_class=SensorStateClass.MEASUREMENT,
@ -538,7 +563,7 @@ def get_entity_description(
"""Return the entity description for the given data."""
data_description_key = data.entity_description_key or ""
data_unit = data.unit_of_measurement or ""
return ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP.get(
return ENTITY_DESCRIPTION_KEY_UNIT_MAP.get(
(data_description_key, data_unit),
ENTITY_DESCRIPTION_KEY_MAP.get(
data_description_key,
@ -588,6 +613,10 @@ async def async_setup_entry(
entities.append(
ZWaveListSensor(config_entry, driver, info, entity_description)
)
elif info.platform_hint == "list":
entities.append(
ZWaveListSensor(config_entry, driver, info, entity_description)
)
elif info.platform_hint == "config_parameter":
entities.append(
ZWaveConfigParameterSensor(

View File

@ -21,7 +21,6 @@ ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2"
VOLTAGE_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_3"
CURRENT_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_4"
SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports"
LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level"
ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any"
DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any"
NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection"

View File

@ -199,6 +199,12 @@ def climate_heatit_z_trm3_no_value_state_fixture() -> dict[str, Any]:
return load_json_object_fixture("climate_heatit_z_trm3_no_value_state.json", DOMAIN)
@pytest.fixture(name="ring_keypad_state", scope="package")
def ring_keypad_state_fixture() -> dict[str, Any]:
"""Load the Ring keypad state fixture data."""
return load_json_object_fixture("ring_keypad_state.json", DOMAIN)
@pytest.fixture(name="nortek_thermostat_state", scope="package")
def nortek_thermostat_state_fixture() -> dict[str, Any]:
"""Load the nortek thermostat node state fixture data."""
@ -876,6 +882,14 @@ def nortek_thermostat_removed_event_fixture(client) -> Node:
return Event("node removed", event_data)
@pytest.fixture(name="ring_keypad")
def ring_keypad_fixture(client: MagicMock, ring_keypad_state: NodeDataType) -> Node:
"""Mock a Ring keypad node."""
node = Node(client, copy.deepcopy(ring_keypad_state))
client.driver.controller.nodes[node.node_id] = node
return node
@pytest.fixture(name="integration")
async def integration_fixture(
hass: HomeAssistant,

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,13 @@
"""Test the Z-Wave JS binary sensor platform."""
from datetime import timedelta
import pytest
from zwave_js_server.event import Event
from zwave_js_server.model.node import Node
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import (
ATTR_DEVICE_CLASS,
STATE_OFF,
@ -15,17 +18,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .common import (
DISABLED_LEGACY_BINARY_SENSOR,
ENABLED_LEGACY_BINARY_SENSOR,
LOW_BATTERY_BINARY_SENSOR,
NOTIFICATION_MOTION_BINARY_SENSOR,
PROPERTY_DOOR_STATUS_BINARY_SENSOR,
TAMPER_SENSOR,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
@ -34,21 +37,56 @@ def platforms() -> list[str]:
return [Platform.BINARY_SENSOR]
async def test_low_battery_sensor(
hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration
async def test_battery_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ring_keypad: Node,
integration: MockConfigEntry,
) -> None:
"""Test boolean binary sensor of type low battery."""
state = hass.states.get(LOW_BATTERY_BINARY_SENSOR)
"""Test boolean battery binary sensors."""
entity_id = "binary_sensor.keypad_v2_low_battery_level"
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY
entity_entry = entity_registry.async_get(LOW_BATTERY_BINARY_SENSOR)
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC
disabled_binary_sensor_battery_entities = (
"binary_sensor.keypad_v2_battery_is_disconnected",
"binary_sensor.keypad_v2_fluid_is_low",
"binary_sensor.keypad_v2_overheating",
"binary_sensor.keypad_v2_rechargeable",
"binary_sensor.keypad_v2_used_as_backup",
)
for entity_id in disabled_binary_sensor_battery_entities:
state = hass.states.get(entity_id)
assert state is None # disabled by default
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC
assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
entity_registry.async_update_entity(entity_id, disabled_by=None)
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
for entity_id in disabled_binary_sensor_battery_entities:
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_OFF
async def test_enabled_legacy_sensor(
hass: HomeAssistant, ecolink_door_sensor, integration

View File

@ -1,6 +1,7 @@
"""Test the Z-Wave JS sensor platform."""
import copy
from datetime import timedelta
import pytest
from zwave_js_server.const.command_class.meter import MeterType
@ -26,6 +27,7 @@ from homeassistant.components.zwave_js.sensor import (
CONTROLLER_STATISTICS_KEY_MAP,
NODE_STATISTICS_KEY_MAP,
)
from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
@ -35,6 +37,7 @@ from homeassistant.const import (
STATE_UNKNOWN,
UV_INDEX,
EntityCategory,
Platform,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
@ -45,6 +48,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.util import dt as dt_util
from .common import (
AIR_TEMPERATURE_SENSOR,
@ -57,7 +61,94 @@ from .common import (
VOLTAGE_SENSOR,
)
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture
def platforms() -> list[str]:
"""Fixture to specify platforms to test."""
return [Platform.SENSOR]
async def test_battery_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
ring_keypad: Node,
integration: MockConfigEntry,
) -> None:
"""Test numeric battery sensors."""
entity_id = "sensor.keypad_v2_battery_level"
state = hass.states.get(entity_id)
assert state
assert state.state == "100.0"
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY
assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC
disabled_sensor_battery_entities = (
"sensor.keypad_v2_chargingstatus",
"sensor.keypad_v2_maximum_capacity",
"sensor.keypad_v2_rechargeorreplace",
"sensor.keypad_v2_temperature",
)
for entity_id in disabled_sensor_battery_entities:
state = hass.states.get(entity_id)
assert state is None # disabled by default
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC
assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
entity_registry.async_update_entity(entity_id, disabled_by=None)
async_fire_time_changed(
hass,
dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1),
)
await hass.async_block_till_done()
entity_id = "sensor.keypad_v2_chargingstatus"
state = hass.states.get(entity_id)
assert state
assert state.state == "Maintaining"
assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM
assert ATTR_STATE_CLASS not in state.attributes
entity_id = "sensor.keypad_v2_maximum_capacity"
state = hass.states.get(entity_id)
assert state
assert (
state.state == "0"
) # This should be None/unknown but will be fixed in a future PR.
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE
assert ATTR_DEVICE_CLASS not in state.attributes
assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT
entity_id = "sensor.keypad_v2_rechargeorreplace"
state = hass.states.get(entity_id)
assert state
assert state.state == "No"
assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM
assert ATTR_STATE_CLASS not in state.attributes
entity_id = "sensor.keypad_v2_temperature"
state = hass.states.get(entity_id)
assert state
assert (
state.state == "0"
) # This should be None/unknown but will be fixed in a future PR.
assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE
assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT
async def test_numeric_sensor(