Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 60 additions & 29 deletions custom_components/lock_code_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
PER_LOCK_ISSUE_KEYS,
build_pin_deobfuscation_map,
deobfuscate_pins,
lock_display_name,
per_lock_issue_id,
)
from .entity import build_slot_device_info
Expand Down Expand Up @@ -307,6 +308,7 @@ async def async_migrate_entry(
len(renamed),
", ".join(sorted(renamed, key=int)),
)
_async_remove_hub_device(hass, config_entry)
# Last, and part of the same version bump: from here on a slot
# leaving the configuration takes its credential with it, so the
# codes earlier versions left behind are offered up once, measured
Expand Down Expand Up @@ -731,7 +733,6 @@ async def async_setup_entry(
) -> bool:
"""Set up a config entry."""
ent_reg = er.async_get(hass)
entry_id = config_entry.entry_id
try:
entity_id = next(
entity_id
Expand All @@ -753,14 +754,6 @@ async def async_setup_entry(
config=EntryConfig.from_entry(config_entry),
)

dev_reg = dr.async_get(hass)
dev_reg.async_get_or_create(
config_entry_id=entry_id,
identifiers={(DOMAIN, entry_id)},
manufacturer="Lock Code Manager",
name=config_entry.title,
serial_number=entry_id,
)
_async_reclaim_entities_from_foreign_devices(hass, config_entry)
_async_prune_orphaned_slot_devices(hass, config_entry)

Expand Down Expand Up @@ -1081,6 +1074,34 @@ def _is_ours(device: dr.DeviceEntry) -> bool:
dev_reg.async_remove_device(device.id)


@callback
def _async_remove_hub_device(
hass: HomeAssistant, config_entry: LockCodeManagerConfigEntry
) -> None:
"""
Take away the config entry's own device.

It never held an entity. It existed so the per-user devices could name
it in ``via_device`` and be drawn beneath it, which bought a line on a
device page and cost a device on every page that lists them. The users
are the devices worth having.

The ``via_device`` goes with it, and had to: Home Assistant reports a
``via_device`` naming a device that is not there as a use it intends to
break, so leaving it behind would log on every registration.
"""
dev_reg = dr.async_get(hass)
device = dev_reg.async_get_device(identifiers={(DOMAIN, config_entry.entry_id)})
if device is None:
return
_LOGGER.debug(
"%s (%s): Removing the config entry's own device; it holds no entities",
config_entry.entry_id,
config_entry.title,
)
dev_reg.async_remove_device(device.id)


@callback
def _async_rename_event_unique_ids(
hass: HomeAssistant, config_entry: LockCodeManagerConfigEntry
Expand Down Expand Up @@ -1123,16 +1144,6 @@ def _lock_of(entry_id: str, unique_id: str) -> str | None:
return parts[3] if unique_id.startswith(f"{entry_id}|") and len(parts) > 3 else None


def _lock_display_name(ent_reg: er.EntityRegistry, lock_entity_id: str) -> str:
"""Name a lock the way its own integration does."""
entity = ent_reg.async_get(lock_entity_id)
if entity and (display := entity.name or entity.original_name):
return display
# No registry row to ask, so fall back to the object id, which is what
# the lock's own entity id was slugged from in the first place.
return lock_entity_id.split(".", 1)[-1].replace("_", " ")


@callback
def _async_purge_dropped_slots(
hass: HomeAssistant,
Expand Down Expand Up @@ -1199,7 +1210,7 @@ def _async_rename_slot_entity_ids(
# was created, which on an upgraded install predates this shape.
suggested = (
f"{config_entry.title} {name} "
f"{_lock_display_name(ent_reg, lock_entity_id)} "
f"{lock_display_name(hass, lock_entity_id)} "
f"{PER_LOCK_ENTITY_SUFFIX[entity.unique_id.split('|')[2]]}"
)
elif entity.original_name:
Expand All @@ -1210,17 +1221,20 @@ def _async_rename_slot_entity_ids(
# on the entry's title, which may have changed since.
suggested = f"{config_entry.title} {name} {entity.original_name}"
else:
# The event entity has no name of its own, and a registry written
# by an older Home Assistant may not have kept one. Swap the
# device slug instead, which needs the ID to still look like one
# this integration generated.
# A registry row written by an older Home Assistant may have kept
# no name at all, so there is nothing to append the way the branch
# above does. The key inside the unique ID says what the entity is
# -- it is what the translated name is looked up by -- and unlike
# the old object id it is there even when the entity was created
# without a name, which is how the credential-used event was.
#
# Guarded on the ID still looking like one this integration
# generated, so a hand-renamed entity is left alone.
old_prefix = slugify(f"{config_entry.title} Code slot {slot_num}")
if object_id != old_prefix and not object_id.startswith(f"{old_prefix}_"):
continue
suggested = (
f"{slugify(f'{config_entry.title} {name}')}"
f"{object_id.removeprefix(old_prefix)}"
)
key = entity.unique_id.split("|")[2]
suggested = f"{config_entry.title} {name} {key.replace('_', ' ')}"
new_entity_id = ent_reg.async_get_available_entity_id(
domain, suggested, current_entity_id=entity.entity_id
)
Expand Down Expand Up @@ -1428,7 +1442,24 @@ async def _setup_one_lock(lock_entity_id: str) -> BaseLock:
async def async_update_listener(
hass: HomeAssistant, config_entry: LockCodeManagerConfigEntry
) -> None:
"""Update listener."""
"""
Update listener.

Wraps the pass so ``runtime_data.settled`` is set however it ends,
including the early return and a failure. Anything waiting on it is
waiting to learn that the entry has finished reacting, and a pass that
raised has finished reacting as much as it is going to.
"""
try:
await _async_apply_entry_update(hass, config_entry)
finally:
config_entry.runtime_data.settled.set()


async def _async_apply_entry_update(
hass: HomeAssistant, config_entry: LockCodeManagerConfigEntry
) -> None:
"""Bring entities, devices and locks into line with the entry."""
# Refresh the cached EntryConfig on EVERY update — including entity-driven
# writes that go straight to data with empty options (e.g. a slot's name or
# PIN being edited via its text entity). The early-return below skips the
Expand Down
6 changes: 6 additions & 0 deletions custom_components/lock_code_manager/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,12 @@ class LockCodeManagerConfigEntryRuntimeData:
# entry. Guards against stacking when _setup_entry_after_start runs more
# than once (for example, a reload racing with EVENT_HOMEASSISTANT_STARTED).
update_listener_registered: bool = False
# Set whenever the update listener finishes a pass. Home Assistant runs
# update listeners as a task rather than awaiting them, so a caller that
# writes to the entry returns before the entry has reacted -- before the
# entities for a user it just added exist. Anything that must not return
# early clears this, writes, and waits for it.
settled: asyncio.Event = field(default_factory=asyncio.Event)
# (lock, slot) pairs whose credential is to be left on the lock when the
# slot leaves the configuration, set by the delete-user service and drained
# by the update listener. A hand-off cannot be expressed in the new
Expand Down
61 changes: 55 additions & 6 deletions custom_components/lock_code_manager/domain/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

from __future__ import annotations

import asyncio
import logging
from typing import Any

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CONDITION, CONF_ENABLED, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
Expand All @@ -16,6 +19,50 @@
from .names import identity, name_error, normalize_name
from .queries import get_entry_config, get_loaded_config_entry

_LOGGER = logging.getLogger(__name__)

# How long to wait for the entry to react to a write before returning
# anyway. Generous, because the pass can set up a lock it has never
# spoken to; a caller stuck here is waiting on that, not on this.
_SETTLE_TIMEOUT = 30


async def _async_write_and_settle(
hass: HomeAssistant, config_entry: ConfigEntry, options: dict[str, Any]
) -> None:
"""
Write the entry, and wait for it to have finished reacting.

``async_update_entry`` schedules the update listener rather than
awaiting it, so without this a service returns before the entities for
the users it changed exist. A script that adds a user and then sets
their PIN through the new text entity would find nothing to set.

Waiting on the entry's settle event rather than on the specific
entities keeps this honest about what it can promise: another write
landing at the same time can release the wait early. That is no worse
than not waiting, which is the alternative.
"""
runtime_data = config_entry.runtime_data
runtime_data.settled.clear()
if not hass.config_entries.async_update_entry(config_entry, options=options):
# Nothing changed, so no listener will run and nothing will set the
# event. Waiting here would burn the whole timeout for no reason.
return
try:
async with asyncio.timeout(_SETTLE_TIMEOUT):
await runtime_data.settled.wait()
except TimeoutError:
# The write itself is durable, so this is not a failure to report to
# the caller -- the entities will appear when the pass finishes.
_LOGGER.warning(
"%s (%s): entry did not finish updating within %ss; entities for "
"this change may appear late",
config_entry.entry_id,
config_entry.title,
_SETTLE_TIMEOUT,
)


async def async_set_usercode(
hass: HomeAssistant, lock_entity_id: str, code_slot: int, usercode: str
Expand Down Expand Up @@ -71,7 +118,7 @@ async def async_set_slot_condition(
_async_validate_condition(hass, entity_id)

new_config = config.with_slot_field_set(slot, CONF_CONDITION, entity_id)
hass.config_entries.async_update_entry(config_entry, options=new_config.to_dict())
await _async_write_and_settle(hass, config_entry, new_config.to_dict())


async def async_clear_slot_condition(
Expand All @@ -88,7 +135,7 @@ async def async_clear_slot_condition(
raise ServiceValidationError(f"Slot {slot} not found in config entry")

new_config = config.with_slot_field_removed(slot, CONF_CONDITION)
hass.config_entries.async_update_entry(config_entry, options=new_config.to_dict())
await _async_write_and_settle(hass, config_entry, new_config.to_dict())


async def async_add_user(
Expand Down Expand Up @@ -153,9 +200,10 @@ async def async_add_user(
assignment = config.assignment.reconcile(
[*config.users, name], start=1, unavailable=unavailable
)
hass.config_entries.async_update_entry(
await _async_write_and_settle(
hass,
config_entry,
options=EntryConfig(
EntryConfig(
locks=config.locks,
users={**config.users, name: user},
assignment=assignment,
Expand Down Expand Up @@ -201,9 +249,10 @@ async def async_delete_user(
}
# No unavailable set and so no lock read: a departure issues no numbers,
# and everyone remaining keeps theirs by tenure.
hass.config_entries.async_update_entry(
await _async_write_and_settle(
hass,
config_entry,
options=EntryConfig(
EntryConfig(
locks=config.locks,
users=remaining,
assignment=config.assignment.reconcile(remaining, start=1),
Expand Down
46 changes: 43 additions & 3 deletions custom_components/lock_code_manager/domain/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,54 @@

from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_ENABLED, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
CONF_ENABLED,
CONF_PIN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue

from ..const import DOMAIN
from .config import EntryConfig, build_slot_unique_id


@callback
def lock_display_name(hass: HomeAssistant, lock_entity_id: str) -> str:
"""
Name a lock the way Home Assistant does.

The friendly name first, because that is the string on screen and the
one a person would use to say which lock they mean. Everything after
it reconstructs that name for a lock with no state yet, in the order
Home Assistant composes it.

The device step is not a nicety. A lock entity that sets
``has_entity_name`` with no name of its own -- which is how Z-Wave,
Matter and most modern integrations do it -- carries an empty
``original_name`` and takes its whole visible name from its device.
Stopping at the registry row names such a lock ``lock.front_door``.
"""
if (state := hass.states.get(lock_entity_id)) and (
friendly_name := state.attributes.get(ATTR_FRIENDLY_NAME)
):
return str(friendly_name)
if entity := er.async_get(hass).async_get(lock_entity_id):
if name := entity.name or entity.original_name:
return name
if (
entity.device_id
and (device := dr.async_get(hass).async_get(entity.device_id))
and (device_name := device.name_by_user or device.name)
):
return device_name
# Nothing left to ask, so fall back to the object id, which is what the
# lock's own entity id was slugged from in the first place.
return lock_entity_id.split(".", 1)[-1].replace("_", " ")


_LOGGER = logging.getLogger(__name__)

# Every repair issue keyed on a lock entity id. Creation sites live in
Expand Down
1 change: 0 additions & 1 deletion custom_components/lock_code_manager/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ def build_slot_device_info(config_entry: ConfigEntry, slot_num: int) -> DeviceIn
name=f"{config_entry.title} {name}",
manufacturer="Lock Code Manager",
model="User",
via_device=(DOMAIN, config_entry.entry_id),
)


Expand Down
1 change: 0 additions & 1 deletion custom_components/lock_code_manager/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@ def __init__(
BaseLockCodeManagerEntity.__init__(
self, hass, ent_reg, config_entry, slot_num, key
)
self._attr_name = None

def _get_supported_locks(self) -> list[BaseLock]:
"""Get locks that support code slot events."""
Expand Down
4 changes: 2 additions & 2 deletions custom_components/lock_code_manager/providers/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
)
from ..domain.models import SlotCredential
from ..domain.queries import find_entry_for_lock_slot, get_managed_slots
from ..domain.util import mask_pin, per_lock_issue_id
from ..domain.util import lock_display_name, mask_pin, per_lock_issue_id
from ._util import make_tagged_name, parse_tag
from .const import LOGGER

Expand Down Expand Up @@ -401,7 +401,7 @@ def _raise_not_implemented(self, method_name: str, guidance: str = "") -> NoRetu
@property
def display_name(self) -> str:
"""Return a human-readable name for this lock."""
return self.lock.name or self.lock.original_name or self.lock.entity_id
return lock_display_name(self.hass, self.lock.entity_id)

def mask_pin(self, pin: str | None, code_slot: int | str = 0) -> str:
"""Return a masked representation of a PIN for logging."""
Expand Down
1 change: 1 addition & 0 deletions custom_components/lock_code_manager/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
},
"event": {
"credential_used": {
"name": "Credential used",
"state_attributes": {
"event_type": {
"state": {
Expand Down
1 change: 1 addition & 0 deletions custom_components/lock_code_manager/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
},
"event": {
"credential_used": {
"name": "Credential used",
"state_attributes": {
"event_type": {
"state": {
Expand Down

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,14 @@ def slot_entity_id(

SLOT_1_ACTIVE_ENTITY = "binary_sensor.mock_title_test1_active"
SLOT_1_ENABLED_ENTITY = "switch.mock_title_test1_enabled"
SLOT_1_EVENT_ENTITY = "event.mock_title_test1"
SLOT_1_EVENT_ENTITY = "event.mock_title_test1_credential_used"
SLOT_1_NAME_ENTITY = "text.mock_title_test1_name"
SLOT_1_PIN_ENTITY = "text.mock_title_test1_pin"
SLOT_1_IN_SYNC_ENTITY = "binary_sensor.mock_title_test1_test_1_in_sync"

SLOT_2_ENABLED_ENTITY = "switch.mock_title_test2_enabled"
SLOT_2_ACTIVE_ENTITY = "binary_sensor.mock_title_test2_active"
SLOT_2_EVENT_ENTITY = "event.mock_title_test2"
SLOT_2_EVENT_ENTITY = "event.mock_title_test2_credential_used"
SLOT_2_PIN_ENTITY = "text.mock_title_test2_pin"
SLOT_2_NAME_ENTITY = "text.mock_title_test2_name"
SLOT_2_IN_SYNC_ENTITY = "binary_sensor.mock_title_test2_test_1_in_sync"
Expand Down
Loading
Loading