Skip to content

Commit

Permalink
Add zwave_js controller status sensor (#99252)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
raman325 and MartinHjelmare committed Aug 30, 2023
1 parent 027ce55 commit 6e5f456
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 18 deletions.
9 changes: 7 additions & 2 deletions homeassistant/components/zwave_js/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,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 @@ -205,7 +205,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 @@ -224,7 +224,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 @@ -326,7 +326,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 @@ -964,7 +964,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 @@ -976,7 +976,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

0 comments on commit 6e5f456

Please sign in to comment.