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

Don't remove zwave_js devices automatically #98145

Merged
merged 17 commits into from
Jan 30, 2024
79 changes: 63 additions & 16 deletions homeassistant/components/zwave_js/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,8 @@ async def handle_logging_changed(_: Event | None = None) -> None:
for node in controller.nodes.values()
]

# Devices that are in the device registry that are not known by the controller can be removed
# Devices that are in the device registry that are not known by the controller
# can be removed
for device in stored_devices:
if device not in known_devices:
self.dev_reg.async_remove_device(device.id)
Expand Down Expand Up @@ -509,25 +510,39 @@ def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
driver = self.driver_events.driver
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
device = self.dev_reg.async_get_device(identifiers={device_id})
node_id_device = self.dev_reg.async_get_device(identifiers={device_id})
via_device_id = None
controller = driver.controller
# Get the controller node device ID if this node is not the controller
if controller.own_node and controller.own_node != node:
via_device_id = get_device_id(driver, controller.own_node)

# Replace the device if it can be determined that this node is not the
# same product as it was previously.
if (
device_id_ext
and device
and len(device.identifiers) == 2
and device_id_ext not in device.identifiers
):
self.remove_device(device)
device = None

if device_id_ext:
# If there is a device with this node ID but with a different hardware
raman325 marked this conversation as resolved.
Show resolved Hide resolved
# signature, remove the node ID based identifier from it.
if (
node_id_device
and len(node_id_device.identifiers) == 2
and device_id_ext not in node_id_device.identifiers
):
new_identifiers = node_id_device.identifiers.copy()
new_identifiers.remove(device_id)
self.dev_reg.async_update_device(
node_id_device.id, new_identifiers=new_identifiers
)
# If there is an orphaned device that already exists with this hardware
# based identifier, add the node ID based identifier to the orphaned
# device.
if (
hardware_device := self.dev_reg.async_get_device(
identifiers={device_id_ext}
)
) and len(hardware_device.identifiers) == 1:
new_identifiers = hardware_device.identifiers.copy()
new_identifiers.add(device_id)
self.dev_reg.async_update_device(
hardware_device.id, new_identifiers=new_identifiers
)
ids = {device_id, device_id_ext}
else:
ids = {device_id}
Expand Down Expand Up @@ -769,9 +784,12 @@ def async_on_notification(self, event: dict[str, Any]) -> None:
return

driver = self.controller_events.driver_events.driver
notification: EntryControlNotification | NotificationNotification | PowerLevelNotification | MultilevelSwitchNotification = event[
"notification"
]
notification: (
EntryControlNotification
| NotificationNotification
| PowerLevelNotification
| MultilevelSwitchNotification
) = event["notification"]
device = self.dev_reg.async_get_device(
identifiers={get_device_id(driver, notification.node)}
)
Expand Down Expand Up @@ -984,6 +1002,35 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
LOGGER.error(err)


async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
entry_hass_data = hass.data[DOMAIN][config_entry.entry_id]
client: ZwaveClient = entry_hass_data[DATA_CLIENT]
assert client.driver
raman325 marked this conversation as resolved.
Show resolved Hide resolved
driver: Driver = client.driver
raman325 marked this conversation as resolved.
Show resolved Hide resolved

# If a node is found on the controller that matches the hardware based identifier
# on the device, prevent the device from being removed.
if next(
(
node
for node in driver.controller.nodes.values()
if get_device_id_ext(driver, node) in device_entry.identifiers
),
None,
):
return False

controller_events: ControllerEvents = entry_hass_data[
DATA_DRIVER_EVENTS
].controller_events
controller_events.registered_unique_ids.pop(device_entry.id, None)
controller_events.discovered_value_ids.pop(device_entry.id, None)
return True


async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Ensure that Z-Wave JS add-on is installed and running."""
addon_manager = _get_addon_manager(hass)
Expand Down
143 changes: 127 additions & 16 deletions tests/components/zwave_js/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from homeassistant.components.hassio.handler import HassioAPIError
from homeassistant.components.logger import DOMAIN as LOGGER_DOMAIN, SERVICE_SET_LEVEL
from homeassistant.components.persistent_notification import async_dismiss
from homeassistant.components.zwave_js import DOMAIN
from homeassistant.components.zwave_js import DOMAIN, async_remove_config_entry_device
from homeassistant.components.zwave_js.helpers import get_device_id
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
Expand Down Expand Up @@ -962,6 +962,7 @@ async def test_removed_device(
# Make sure there are the same number of devices
dev_reg = dr.async_get(hass)
device_entries = dr.async_entries_for_config_entry(dev_reg, integration.entry_id)
logging.getLogger(__name__).error(device_entries)
assert len(device_entries) == 3

# Check how many entities there are
Expand Down Expand Up @@ -1016,6 +1017,7 @@ async def test_node_removed(
client.driver.controller.receive_event(Event("node added", event))
await hass.async_block_till_done()
old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert old_device
assert old_device.id

event = {"node": node, "reason": 0}
Expand Down Expand Up @@ -1147,11 +1149,11 @@ async def test_replace_different_node(
state["nodeId"] = node_id

device_id = f"{client.driver.controller.home_id}-{node_id}"
multisensor_6_device_id = (
multisensor_6_device_id_ext = (
f"{device_id}-{multisensor_6.manufacturer_id}:"
f"{multisensor_6.product_type}:{multisensor_6.product_id}"
)
hank_device_id = (
hank_device_id_ext = (
f"{device_id}-{state['manufacturerId']}:"
f"{state['productType']}:"
f"{state['productId']}"
Expand All @@ -1160,16 +1162,15 @@ async def test_replace_different_node(
device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device
assert device == dev_reg.async_get_device(
identifiers={(DOMAIN, multisensor_6_device_id)}
identifiers={(DOMAIN, multisensor_6_device_id_ext)}
)
assert device.manufacturer == "AEON Labs"
assert device.model == "ZW100"
dev_id = device.id

assert hass.states.get(AIR_TEMPERATURE_SENSOR)

# A replace node event has the extra field "replaced" set to True
# to distinguish it from an exclusion
# Remove existing node
event = Event(
type="node removed",
data={
Expand All @@ -1183,8 +1184,11 @@ async def test_replace_different_node(
await hass.async_block_till_done()

# Device should still be there after the node was removed
device = dev_reg.async_get(dev_id)
device = dev_reg.async_get_device(
identifiers={(DOMAIN, multisensor_6_device_id_ext)}
)
assert device
assert len(device.identifiers) == 2

# When the node is replaced, a non-ready node added event is emitted
event = Event(
Expand Down Expand Up @@ -1238,18 +1242,125 @@ async def test_replace_different_node(
client.driver.receive_event(event)
await hass.async_block_till_done()

# Old device and entities were removed, but the ID is re-used
device = dev_reg.async_get(dev_id)
assert device
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert device == dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id)})
assert not dev_reg.async_get_device(identifiers={(DOMAIN, multisensor_6_device_id)})
assert device.manufacturer == "HANK Electronics Ltd."
assert device.model == "HKZW-SO01"
# node ID based device identifier should be moved from the old multisensor device
# to the new hank device and both the old and new devices should exist.
new_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert new_device
hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)})
assert hank_device
assert hank_device == new_device
assert hank_device.identifiers == {
(DOMAIN, device_id),
(DOMAIN, hank_device_id_ext),
}
multisensor_6_device = dev_reg.async_get_device(
identifiers={(DOMAIN, multisensor_6_device_id_ext)}
)
assert multisensor_6_device
assert multisensor_6_device != new_device
assert multisensor_6_device.identifiers == {(DOMAIN, multisensor_6_device_id_ext)}

assert new_device.manufacturer == "HANK Electronics Ltd."
assert new_device.model == "HKZW-SO01"

assert not hass.states.get(AIR_TEMPERATURE_SENSOR)
# We keep the old entities in case there are customizations that a user wants to
# keep. They can always delete the device and that will remove the entities as well.
assert hass.states.get(AIR_TEMPERATURE_SENSOR)
assert hass.states.get("switch.smart_plug_with_two_usb_ports")

# Try to add back the first node to see if the device IDs are correct

# Remove existing node
event = Event(
type="node removed",
data={
"source": "controller",
"event": "node removed",
"reason": 3,
"node": state,
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()

# Device should still be there after the node was removed
device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)})
assert device
assert len(device.identifiers) == 2

# When the node is replaced, a non-ready node added event is emitted
event = Event(
type="node added",
data={
"source": "controller",
"event": "node added",
"node": {
"nodeId": multisensor_6.node_id,
"index": 0,
"status": 4,
"ready": False,
"isSecure": False,
"interviewAttempts": 1,
"endpoints": [
{"nodeId": multisensor_6.node_id, "index": 0, "deviceClass": None}
],
"values": [],
"deviceClass": None,
"commandClasses": [],
"interviewStage": "None",
"statistics": {
"commandsTX": 0,
"commandsRX": 0,
"commandsDroppedRX": 0,
"commandsDroppedTX": 0,
"timeoutResponse": 0,
},
"isControllerNode": False,
},
"result": {},
},
)

client.driver.receive_event(event)
await hass.async_block_till_done()

# Mark node as ready
event = Event(
type="ready",
data={
"source": "node",
"event": "ready",
"nodeId": node_id,
"nodeState": multisensor_6_state,
},
)
client.driver.receive_event(event)
await hass.async_block_till_done()

# node ID based device identifier should be moved from the new hank device
# to the old multisensor device and both the old and new devices should exist.
old_device = dev_reg.async_get_device(identifiers={(DOMAIN, device_id)})
assert old_device
hank_device = dev_reg.async_get_device(identifiers={(DOMAIN, hank_device_id_ext)})
assert hank_device
assert hank_device != old_device
assert hank_device.identifiers == {(DOMAIN, hank_device_id_ext)}
multisensor_6_device = dev_reg.async_get_device(
identifiers={(DOMAIN, multisensor_6_device_id_ext)}
)
assert multisensor_6_device
assert multisensor_6_device == old_device
assert multisensor_6_device.identifiers == {
(DOMAIN, device_id),
(DOMAIN, multisensor_6_device_id_ext),
}

# Attempting to remove the hank device should pass, but removing the multisensor should not
assert await async_remove_config_entry_device(hass, integration, hank_device)
assert not await async_remove_config_entry_device(
raman325 marked this conversation as resolved.
Show resolved Hide resolved
hass, integration, multisensor_6_device
)


async def test_node_model_change(
hass: HomeAssistant, zp3111, client, integration
Expand Down
Loading