Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add zwave_js controller status sensor #99252

Merged
merged 9 commits into from
Aug 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
9 changes: 7 additions & 2 deletions homeassistant/components/zwave_js/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,13 @@ async def async_on_node_added(self, node: ZwaveNode) -> None:
)
)

# No need for a ping button or node status sensor for controller nodes
if not node.is_controller_node:
if 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
async_dispatcher_send(
self.hass,
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/zwave_js/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ async def websocket_network_status(
"is_heal_network_active": controller.is_heal_network_active,
"inclusion_state": controller.inclusion_state,
"rf_region": controller.rf_region,
"status": controller.status,
"nodes": [node_status(node) for node in driver.controller.nodes.values()],
},
}
Expand Down
98 changes: 89 additions & 9 deletions homeassistant/components/zwave_js/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import voluptuous as vol
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 (
RESET_METER_OPTION_TARGET_VALUE,
RESET_METER_OPTION_TYPE,
Expand Down Expand Up @@ -91,7 +91,13 @@

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.ASLEEP: "mdi:sleep",
NodeStatus.AWAKE: "mdi:eye",
Expand Down Expand Up @@ -485,12 +491,12 @@ async def async_setup_entry(
) -> None:
"""Set up Z-Wave sensor from config entry."""
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
def async_add_sensor(info: ZwaveDiscoveryInfo) -> None:
"""Add Z-Wave Sensor."""
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
entities: list[ZWaveBaseEntity] = []

if info.platform_data:
Expand Down Expand Up @@ -529,18 +535,19 @@ def async_add_sensor(info: ZwaveDiscoveryInfo) -> None:

async_add_entities(entities)

@callback
def async_add_controller_status_sensor() -> None:
"""Add controller status sensor."""
async_add_entities([ZWaveControllerStatusSensor(config_entry, driver)])

@callback
def async_add_node_status_sensor(node: ZwaveNode) -> None:
"""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)])

@callback
def async_add_statistics_sensors(node: ZwaveNode) -> None:
"""Add statistics sensors."""
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
async_add_entities(
[
ZWaveStatisticsSensor(
Expand All @@ -565,6 +572,14 @@ def async_add_statistics_sensors(node: ZwaveNode) -> None:
)
)

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(
async_dispatcher_connect(
hass,
Expand Down Expand Up @@ -828,7 +843,7 @@ def _status_changed(self, _: dict) -> None:
@property
def icon(self) -> str | None:
"""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:
"""Call when entity is added."""
Expand Down Expand Up @@ -856,6 +871,71 @@ async def async_added_to_hass(self) -> None:
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):
"""Representation of a node/controller statistics sensor."""

Expand Down
3 changes: 2 additions & 1 deletion tests/components/zwave_js/fixtures/controller_state.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"sucNodeId": 1,
"supportsTimers": false,
"isHealNetworkActive": false,
"inclusionState": 0
"inclusionState": 0,
"status": 0
},
"nodes": []
}
4 changes: 3 additions & 1 deletion tests/components/zwave_js/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(BUTTON_DOMAIN)) == 1 # only ping
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

entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor"
Expand Down
10 changes: 5 additions & 5 deletions tests/components/zwave_js/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ async def test_on_node_added_not_ready(
dev_reg = dr.async_get(hass)
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

node_state = deepcopy(zp3111_not_ready_state)
Expand All @@ -223,7 +223,7 @@ async def test_on_node_added_not_ready(
await hass.async_block_till_done()

# 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)})
assert device
Expand Down Expand Up @@ -325,7 +325,7 @@ async def test_existing_node_not_ready(
assert not device.sw_version

# 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)})
assert device
Expand Down Expand Up @@ -963,7 +963,7 @@ async def test_removed_device(
# Check how many entities there are
ent_reg = er.async_get(hass)
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
old_node = driver.controller.nodes.pop(13)
Expand All @@ -975,7 +975,7 @@ async def test_removed_device(
device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id)
assert len(device_entries) == 2
entity_entries = er.async_entries_for_config_entry(ent_reg, integration.entry_id)
assert len(entity_entries) == 60
assert len(entity_entries) == 61
assert (
dev_reg.async_get_device(identifiers={get_device_id(driver, old_node)}) is None
)
Expand Down
51 changes: 51 additions & 0 deletions tests/components/zwave_js/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,47 @@ async def test_config_parameter_sensor(
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(
hass: HomeAssistant, client, lock_id_lock_as_id150, integration
) -> None:
Expand Down Expand Up @@ -325,6 +366,16 @@ async def test_node_status_sensor(
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(
hass: HomeAssistant,
Expand Down