Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions custom_components/keymaster/providers/zigbee2mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,22 +74,23 @@ def supports_connection_status(self) -> bool:

@property
def base_topic(self) -> str | None:
"""Get the base topic dynamically from the device identifiers or name."""
"""Get the base topic dynamically from the device name or identifiers."""
device_entry = self.get_device_entry()
if not device_entry:
return None

# Extract the original Z2M friendly name from device identifiers to support device renaming
# Prefer device_entry.name if set to a string
if isinstance(device_entry.name, str) and device_entry.name:
return f"zigbee2mqtt/{device_entry.name}"

# Fallback to extracting friendly name from device identifiers if available
for domain, identifier in device_entry.identifiers:
if domain == MQTT_DOMAIN and identifier.startswith("zigbee2mqtt_"):
friendly_name = identifier[len("zigbee2mqtt_") :]
if friendly_name:
return f"zigbee2mqtt/{friendly_name}"

name = device_entry.name
if not name:
return None
return f"zigbee2mqtt/{name}"
return None

@property
def set_topic(self) -> str | None:
Expand Down Expand Up @@ -179,7 +180,9 @@ def handle_state_message(msg: mqtt.ReceiveMessage) -> None:
action = payload.get("action")
action_slot_num = payload.get("action_user")
if action or action_slot_num:
self.hass.async_create_task(self._async_handle_action(action, action_slot_num))
self.hass.async_create_task(
self._async_handle_action(action, action_slot_num, payload)
)

# Parse bulk users list if available.
if "users" in payload and isinstance(payload["users"], dict):
Expand Down Expand Up @@ -411,15 +414,27 @@ async def async_clear_usercode(self, slot_num: int) -> bool:
)
return True

async def _async_handle_action(self, action: Any, slot_num: Any) -> None:
async def _async_handle_action(
self, action: Any, slot_num: Any, payload: dict[str, Any] | None = None
) -> None:
"""Handle keypad action events."""
if not isinstance(slot_num, int):
return

if not isinstance(payload, dict):
payload = {}
action_source_name = payload.get("action_source_name")
action_source = payload.get("action_source")
is_keypad = (
action_source_name == "keypad"
or action_source == 0
or (isinstance(action, str) and action.startswith("keypad_"))
)
Comment on lines +426 to +432

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SUGGESTION] The discriminator is not defensive against payload variation across converters/firmware:

  • action_source == 0 is True for action_source: false (Python False == 0), and False for the string "0", which some converters emit.
  • action_source_name casing is converter-dependent; compare case-insensitively.
Suggested change
action_source_name = payload.get("action_source_name")
action_source = payload.get("action_source")
is_keypad = (
action_source_name == "keypad"
or action_source == 0
or (isinstance(action, str) and action.startswith("keypad_"))
)
action_source_name = payload.get("action_source_name")
action_source = payload.get("action_source")
is_keypad = (
(isinstance(action_source_name, str) and action_source_name.lower() == "keypad")
or (
isinstance(action_source, int)
and not isinstance(action_source, bool)
and action_source == 0
)
or (isinstance(action, str) and action.startswith("keypad_"))
)


if self._lock_event_callback:
if action == "keypad_unlock":
if is_keypad and action in ("keypad_unlock", "unlock"):
await self._lock_event_callback(slot_num, "Unlocked via Keypad", 1)
elif action == "keypad_lock":
elif is_keypad and action in ("keypad_lock", "lock"):
await self._lock_event_callback(slot_num, "Keypad Lock", 5)

if action in ("pin_code_added", "pin_code_deleted"):
Expand Down
103 changes: 101 additions & 2 deletions tests/providers/test_zigbee2mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,14 +164,14 @@ async def test_connect_success(self, provider, mock_hass):

async def test_connect_success_rename_device(self, provider, mock_hass):
"""Test that device rename behavior handles identifiers and name fallbacks."""
# 1. With zigbee2mqtt identifier: renaming name does NOT change topics
# 1. With device_entry.name set, base_topic reflects the device's friendly name
await connect_provider(provider, mock_hass)
assert provider.base_topic == "zigbee2mqtt/my_lock"

device_entry = provider.device_registry.async_get.return_value
device_entry.name = "new_lock_name"

assert provider.base_topic == "zigbee2mqtt/my_lock"
assert provider.base_topic == "zigbee2mqtt/new_lock_name"

# 2. Without zigbee2mqtt identifier: renaming name DOES change topics
device_entry.identifiers = {("mqtt", "some_other_id")}
Expand Down Expand Up @@ -990,3 +990,102 @@ class CustomBaseException(BaseException):
pytest.raises(CustomBaseException),
):
await provider.async_get_usercodes()

async def test_base_topic_prefers_device_name_over_ieee_identifier(self, provider, mock_hass):
"""Test that base_topic prefers device_entry.name over identifier suffix."""
setup_successful_connect(
provider,
mock_hass,
device_name="Front Door Lock",
identifiers={("mqtt", "zigbee2mqtt_0x000d6f001933df17")},
)
assert provider.base_topic == "zigbee2mqtt/Front Door Lock"

async def test_async_handle_action_with_action_source_name(self, provider):
"""Test _async_handle_action handling action: unlock with action_source_name: keypad."""
callback = AsyncMock()
provider._lock_event_callback = callback

payload = {
"action": "unlock",
"action_source": 0,
"action_source_name": "keypad",
"action_user": 1,
}
await provider._async_handle_action("unlock", 1, payload)
callback.assert_called_once_with(1, "Unlocked via Keypad", 1)

async def test_async_handle_action_with_action_source_lock(self, provider):
"""Test _async_handle_action handling action: lock with action_source: 0."""
callback = AsyncMock()
provider._lock_event_callback = callback

payload = {
"action": "lock",
"action_source": 0,
"action_user": 2,
}
await provider._async_handle_action("lock", 2, payload)
callback.assert_called_once_with(2, "Keypad Lock", 5)
Comment thread
firstof9 marked this conversation as resolved.

async def test_async_handle_action_with_non_dict_payload(self, provider):
"""Test _async_handle_action handles non-dict payloads without crashing."""
callback = AsyncMock()
provider._lock_event_callback = callback

# Pass invalid payload types (None, list, int)
await provider._async_handle_action("keypad_unlock", 1, None)
callback.assert_called_once_with(1, "Unlocked via Keypad", 1)
callback.reset_mock()

await provider._async_handle_action("keypad_lock", 2, "not a dict")
callback.assert_called_once_with(2, "Keypad Lock", 5)

async def test_async_handle_action_ignores_non_keypad_source(self, provider):
"""Test that RF/app-sourced unlock does not raise a keypad event."""
callback = AsyncMock()
provider._lock_event_callback = callback

payload = {
"action": "unlock",
"action_source": 1,
"action_source_name": "rf",
"action_user": 1,
}
await provider._async_handle_action("unlock", 1, payload)
callback.assert_not_called()

async def test_async_handle_action_ignores_bare_action_without_source(self, provider):
"""Test that a bare unlock with no source information is not attributed."""
callback = AsyncMock()
provider._lock_event_callback = callback

await provider._async_handle_action("unlock", 1, {"action": "unlock", "action_user": 1})
callback.assert_not_called()

async def test_base_topic_ignores_name_by_user(self, provider, mock_hass):
"""Test that an HA-side rename (name_by_user) does not change the topic."""
setup_successful_connect(
provider,
mock_hass,
device_name="Front Door Lock",
identifiers={("mqtt", "zigbee2mqtt_0x000d6f001933df17")},
)
device_entry = provider.device_registry.async_get.return_value
device_entry.name_by_user = "Custom User Name"
assert provider.base_topic == "zigbee2mqtt/Front Door Lock"

async def test_base_topic_fallback_to_identifier_when_device_name_empty(
self, provider, mock_hass
):
"""Test fallback to identifier when device name and name_by_user are empty."""
setup_successful_connect(
provider,
mock_hass,
device_name=None,
identifiers={("mqtt", "zigbee2mqtt_0x000d6f001933df17")},
)
device_entry = provider.device_registry.async_get.return_value
device_entry.name_by_user = None
device_entry.name = None
assert provider.base_topic == "zigbee2mqtt/0x000d6f001933df17"
Loading