mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 13:17:32 +00:00
Add zwave_js controller status sensor (#99252)
* Add zwave_js controller status sensor * Also update network status command * fix tests * Remove WS command since we have a sensor entity * Update sensor.py Co-authored-by: Martin Hjelmare <marhje52@gmail.com> * move driver assertion out of closures * store state in tests --------- Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
027ce55fa6
commit
6e5f4566d5
@ -353,8 +353,13 @@ class ControllerEvents:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# No need for a ping button or node status sensor for controller nodes
|
if node.is_controller_node:
|
||||||
if not node.is_controller_node:
|
# Create a controller status sensor for each device
|
||||||
|
async_dispatcher_send(
|
||||||
|
self.hass,
|
||||||
|
f"{DOMAIN}_{self.config_entry.entry_id}_add_controller_status_sensor",
|
||||||
|
)
|
||||||
|
else:
|
||||||
# Create a node status sensor for each device
|
# Create a node status sensor for each device
|
||||||
async_dispatcher_send(
|
async_dispatcher_send(
|
||||||
self.hass,
|
self.hass,
|
||||||
|
@ -514,6 +514,7 @@ async def websocket_network_status(
|
|||||||
"is_heal_network_active": controller.is_heal_network_active,
|
"is_heal_network_active": controller.is_heal_network_active,
|
||||||
"inclusion_state": controller.inclusion_state,
|
"inclusion_state": controller.inclusion_state,
|
||||||
"rf_region": controller.rf_region,
|
"rf_region": controller.rf_region,
|
||||||
|
"status": controller.status,
|
||||||
"nodes": [node_status(node) for node in driver.controller.nodes.values()],
|
"nodes": [node_status(node) for node in driver.controller.nodes.values()],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ from typing import cast
|
|||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
from zwave_js_server.client import Client as ZwaveClient
|
from zwave_js_server.client import Client as ZwaveClient
|
||||||
from zwave_js_server.const import CommandClass, NodeStatus
|
from zwave_js_server.const import CommandClass, ControllerStatus, NodeStatus
|
||||||
from zwave_js_server.const.command_class.meter import (
|
from zwave_js_server.const.command_class.meter import (
|
||||||
RESET_METER_OPTION_TARGET_VALUE,
|
RESET_METER_OPTION_TARGET_VALUE,
|
||||||
RESET_METER_OPTION_TYPE,
|
RESET_METER_OPTION_TYPE,
|
||||||
@ -91,7 +91,13 @@ from .helpers import get_device_info, get_valueless_base_unique_id
|
|||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
STATUS_ICON: dict[NodeStatus, str] = {
|
CONTROLLER_STATUS_ICON: dict[ControllerStatus, str] = {
|
||||||
|
ControllerStatus.READY: "mdi:check",
|
||||||
|
ControllerStatus.UNRESPONSIVE: "mdi:bell-off",
|
||||||
|
ControllerStatus.JAMMED: "mdi:lock",
|
||||||
|
}
|
||||||
|
|
||||||
|
NODE_STATUS_ICON: dict[NodeStatus, str] = {
|
||||||
NodeStatus.ALIVE: "mdi:heart-pulse",
|
NodeStatus.ALIVE: "mdi:heart-pulse",
|
||||||
NodeStatus.ASLEEP: "mdi:sleep",
|
NodeStatus.ASLEEP: "mdi:sleep",
|
||||||
NodeStatus.AWAKE: "mdi:eye",
|
NodeStatus.AWAKE: "mdi:eye",
|
||||||
@ -485,12 +491,12 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up Z-Wave sensor from config entry."""
|
"""Set up Z-Wave sensor from config entry."""
|
||||||
client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
|
client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
|
||||||
|
driver = client.driver
|
||||||
|
assert driver is not None # Driver is ready before platforms are loaded.
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_add_sensor(info: ZwaveDiscoveryInfo) -> None:
|
def async_add_sensor(info: ZwaveDiscoveryInfo) -> None:
|
||||||
"""Add Z-Wave Sensor."""
|
"""Add Z-Wave Sensor."""
|
||||||
driver = client.driver
|
|
||||||
assert driver is not None # Driver is ready before platforms are loaded.
|
|
||||||
entities: list[ZWaveBaseEntity] = []
|
entities: list[ZWaveBaseEntity] = []
|
||||||
|
|
||||||
if info.platform_data:
|
if info.platform_data:
|
||||||
@ -529,18 +535,19 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_add_controller_status_sensor() -> None:
|
||||||
|
"""Add controller status sensor."""
|
||||||
|
async_add_entities([ZWaveControllerStatusSensor(config_entry, driver)])
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_add_node_status_sensor(node: ZwaveNode) -> None:
|
def async_add_node_status_sensor(node: ZwaveNode) -> None:
|
||||||
"""Add node status sensor."""
|
"""Add node status sensor."""
|
||||||
driver = client.driver
|
|
||||||
assert driver is not None # Driver is ready before platforms are loaded.
|
|
||||||
async_add_entities([ZWaveNodeStatusSensor(config_entry, driver, node)])
|
async_add_entities([ZWaveNodeStatusSensor(config_entry, driver, node)])
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_add_statistics_sensors(node: ZwaveNode) -> None:
|
def async_add_statistics_sensors(node: ZwaveNode) -> None:
|
||||||
"""Add statistics sensors."""
|
"""Add statistics sensors."""
|
||||||
driver = client.driver
|
|
||||||
assert driver is not None # Driver is ready before platforms are loaded.
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
ZWaveStatisticsSensor(
|
ZWaveStatisticsSensor(
|
||||||
@ -565,6 +572,14 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
config_entry.async_on_unload(
|
||||||
|
async_dispatcher_connect(
|
||||||
|
hass,
|
||||||
|
f"{DOMAIN}_{config_entry.entry_id}_add_controller_status_sensor",
|
||||||
|
async_add_controller_status_sensor,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
config_entry.async_on_unload(
|
config_entry.async_on_unload(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
hass,
|
hass,
|
||||||
@ -828,7 +843,7 @@ class ZWaveNodeStatusSensor(SensorEntity):
|
|||||||
@property
|
@property
|
||||||
def icon(self) -> str | None:
|
def icon(self) -> str | None:
|
||||||
"""Icon of the entity."""
|
"""Icon of the entity."""
|
||||||
return STATUS_ICON[self.node.status]
|
return NODE_STATUS_ICON[self.node.status]
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Call when entity is added."""
|
"""Call when entity is added."""
|
||||||
@ -856,6 +871,71 @@ class ZWaveNodeStatusSensor(SensorEntity):
|
|||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
|
||||||
|
class ZWaveControllerStatusSensor(SensorEntity):
|
||||||
|
"""Representation of a controller status sensor."""
|
||||||
|
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, config_entry: ConfigEntry, driver: Driver) -> None:
|
||||||
|
"""Initialize a generic Z-Wave device entity."""
|
||||||
|
self.config_entry = config_entry
|
||||||
|
self.controller = driver.controller
|
||||||
|
node = self.controller.own_node
|
||||||
|
assert node
|
||||||
|
|
||||||
|
# Entity class attributes
|
||||||
|
self._attr_name = "Status"
|
||||||
|
self._base_unique_id = get_valueless_base_unique_id(driver, node)
|
||||||
|
self._attr_unique_id = f"{self._base_unique_id}.controller_status"
|
||||||
|
# device may not be precreated in main handler yet
|
||||||
|
self._attr_device_info = get_device_info(driver, node)
|
||||||
|
|
||||||
|
async def async_poll_value(self, _: bool) -> None:
|
||||||
|
"""Poll a value."""
|
||||||
|
# We log an error instead of raising an exception because this service call occurs
|
||||||
|
# in a separate task since it is called via the dispatcher and we don't want to
|
||||||
|
# raise the exception in that separate task because it is confusing to the user.
|
||||||
|
LOGGER.error(
|
||||||
|
"There is no value to refresh for this entity so the zwave_js.refresh_value"
|
||||||
|
" service won't work for it"
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _status_changed(self, _: dict) -> None:
|
||||||
|
"""Call when status event is received."""
|
||||||
|
self._attr_native_value = self.controller.status.name.lower()
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def icon(self) -> str | None:
|
||||||
|
"""Icon of the entity."""
|
||||||
|
return CONTROLLER_STATUS_ICON[self.controller.status]
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Call when entity is added."""
|
||||||
|
# Add value_changed callbacks.
|
||||||
|
self.async_on_remove(self.controller.on("status changed", self._status_changed))
|
||||||
|
self.async_on_remove(
|
||||||
|
async_dispatcher_connect(
|
||||||
|
self.hass,
|
||||||
|
f"{DOMAIN}_{self.unique_id}_poll_value",
|
||||||
|
self.async_poll_value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# we don't listen for `remove_entity_on_ready_node` signal because this is not
|
||||||
|
# a regular node
|
||||||
|
self.async_on_remove(
|
||||||
|
async_dispatcher_connect(
|
||||||
|
self.hass,
|
||||||
|
f"{DOMAIN}_{self._base_unique_id}_remove_entity",
|
||||||
|
self.async_remove,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._attr_native_value: str = self.controller.status.name.lower()
|
||||||
|
|
||||||
|
|
||||||
class ZWaveStatisticsSensor(SensorEntity):
|
class ZWaveStatisticsSensor(SensorEntity):
|
||||||
"""Representation of a node/controller statistics sensor."""
|
"""Representation of a node/controller statistics sensor."""
|
||||||
|
|
||||||
|
@ -24,7 +24,8 @@
|
|||||||
"sucNodeId": 1,
|
"sucNodeId": 1,
|
||||||
"supportsTimers": false,
|
"supportsTimers": false,
|
||||||
"isHealNetworkActive": false,
|
"isHealNetworkActive": false,
|
||||||
"inclusionState": 0
|
"inclusionState": 0,
|
||||||
|
"status": 0
|
||||||
},
|
},
|
||||||
"nodes": []
|
"nodes": []
|
||||||
}
|
}
|
||||||
|
@ -227,7 +227,9 @@ async def test_indicator_test(
|
|||||||
assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 0
|
assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 0
|
||||||
assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 # only ping
|
assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 # only ping
|
||||||
assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1
|
assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1
|
||||||
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 # include node status
|
assert (
|
||||||
|
len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3
|
||||||
|
) # include node + controller status
|
||||||
assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1
|
assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1
|
||||||
|
|
||||||
entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor"
|
entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor"
|
||||||
|
@ -205,7 +205,7 @@ async def test_on_node_added_not_ready(
|
|||||||
dev_reg = dr.async_get(hass)
|
dev_reg = dr.async_get(hass)
|
||||||
device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}"
|
device_id = f"{client.driver.controller.home_id}-{zp3111_not_ready_state['nodeId']}"
|
||||||
|
|
||||||
assert len(hass.states.async_all()) == 0
|
assert len(hass.states.async_all()) == 1
|
||||||
assert len(dev_reg.devices) == 1
|
assert len(dev_reg.devices) == 1
|
||||||
|
|
||||||
node_state = deepcopy(zp3111_not_ready_state)
|
node_state = deepcopy(zp3111_not_ready_state)
|
||||||
@ -224,7 +224,7 @@ async def test_on_node_added_not_ready(
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
# the only entities are the node status sensor and ping button
|
# the only entities are the node status sensor and ping button
|
||||||
assert len(hass.states.async_all()) == 2
|
assert len(hass.states.async_all()) == 3
|
||||||
|
|
||||||
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
|
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
|
||||||
assert device
|
assert device
|
||||||
@ -326,7 +326,7 @@ async def test_existing_node_not_ready(
|
|||||||
assert not device.sw_version
|
assert not device.sw_version
|
||||||
|
|
||||||
# the only entities are the node status sensor and ping button
|
# the only entities are the node status sensor and ping button
|
||||||
assert len(hass.states.async_all()) == 2
|
assert len(hass.states.async_all()) == 3
|
||||||
|
|
||||||
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
|
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
|
||||||
assert device
|
assert device
|
||||||
@ -964,7 +964,7 @@ async def test_removed_device(
|
|||||||
# Check how many entities there are
|
# Check how many entities there are
|
||||||
ent_reg = er.async_get(hass)
|
ent_reg = er.async_get(hass)
|
||||||
entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id)
|
entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id)
|
||||||
assert len(entity_entries) == 91
|
assert len(entity_entries) == 92
|
||||||
|
|
||||||
# Remove a node and reload the entry
|
# Remove a node and reload the entry
|
||||||
old_node = driver.controller.nodes.pop(13)
|
old_node = driver.controller.nodes.pop(13)
|
||||||
@ -976,7 +976,7 @@ async def test_removed_device(
|
|||||||
device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id)
|
device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id)
|
||||||
assert len(device_entries) == 2
|
assert len(device_entries) == 2
|
||||||
entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id)
|
entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id)
|
||||||
assert len(entity_entries) == 60
|
assert len(entity_entries) == 61
|
||||||
assert (
|
assert (
|
||||||
dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None
|
dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None
|
||||||
)
|
)
|
||||||
|
@ -261,6 +261,47 @@ async def test_config_parameter_sensor(
|
|||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_controller_status_sensor(
|
||||||
|
hass: HomeAssistant, client, integration
|
||||||
|
) -> None:
|
||||||
|
"""Test controller status sensor is created and gets updated on controller state changes."""
|
||||||
|
entity_id = "sensor.z_stick_gen5_usb_controller_status"
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
entity_entry = ent_reg.async_get(entity_id)
|
||||||
|
|
||||||
|
assert not entity_entry.disabled
|
||||||
|
assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state
|
||||||
|
assert state.state == "ready"
|
||||||
|
assert state.attributes[ATTR_ICON] == "mdi:check"
|
||||||
|
|
||||||
|
event = Event(
|
||||||
|
"status changed",
|
||||||
|
data={"source": "controller", "event": "status changed", "status": 1},
|
||||||
|
)
|
||||||
|
client.driver.controller.receive_event(event)
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state
|
||||||
|
assert state.state == "unresponsive"
|
||||||
|
assert state.attributes[ATTR_ICON] == "mdi:bell-off"
|
||||||
|
|
||||||
|
# Test transitions work
|
||||||
|
event = Event(
|
||||||
|
"status changed",
|
||||||
|
data={"source": "controller", "event": "status changed", "status": 2},
|
||||||
|
)
|
||||||
|
client.driver.controller.receive_event(event)
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state
|
||||||
|
assert state.state == "jammed"
|
||||||
|
assert state.attributes[ATTR_ICON] == "mdi:lock"
|
||||||
|
|
||||||
|
# Disconnect the client and make sure the entity is still available
|
||||||
|
await client.disconnect()
|
||||||
|
assert hass.states.get(entity_id).state != STATE_UNAVAILABLE
|
||||||
|
|
||||||
|
|
||||||
async def test_node_status_sensor(
|
async def test_node_status_sensor(
|
||||||
hass: HomeAssistant, client, lock_id_lock_as_id150, integration
|
hass: HomeAssistant, client, lock_id_lock_as_id150, integration
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -325,6 +366,16 @@ async def test_node_status_sensor(
|
|||||||
is None
|
is None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Assert a controller status sensor entity is not created for a node
|
||||||
|
assert (
|
||||||
|
ent_reg.async_get_entity_id(
|
||||||
|
DOMAIN,
|
||||||
|
"sensor",
|
||||||
|
f"{get_valueless_base_unique_id(driver, node)}.controller_status",
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_node_status_sensor_not_ready(
|
async def test_node_status_sensor_not_ready(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user