-
Notifications
You must be signed in to change notification settings - Fork 54
fix: parent/child lock sync resolution #604
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -110,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b | |
| updated_config[CONF_PARENT_ENTRY_ID] = None | ||
| elif updated_config.get(CONF_PARENT_ENTRY_ID) is None: | ||
| for entry in hass.config_entries.async_entries(DOMAIN): | ||
| if updated_config.get(CONF_PARENT) == entry.data.get(CONF_LOCK_NAME): | ||
| if updated_config.get(CONF_PARENT) in (entry.title, entry.data.get(CONF_LOCK_NAME)): | ||
| updated_config[CONF_PARENT_ENTRY_ID] = entry.entry_id | ||
| break | ||
|
|
||
|
|
@@ -128,6 +128,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b | |
| if updated_config != config_entry.data: | ||
| hass.config_entries.async_update_entry(config_entry, data=updated_config) | ||
|
|
||
| # Use normalized values for the rest of setup so runtime objects are created | ||
| # with resolved parent relationships in the same setup pass. | ||
| setup_data = updated_config | ||
|
|
||
| # _LOGGER.debug(f"[init async_setup_entry] updated config_entry.data: {config_entry.data}") | ||
|
|
||
| await async_setup_services(hass) | ||
|
|
@@ -145,7 +149,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b | |
| device_registry = dr.async_get(hass) | ||
|
|
||
| via_device: tuple[str, str] | None = None | ||
| if parent_entry_id := config_entry.data.get(CONF_PARENT_ENTRY_ID): | ||
| if parent_entry_id := setup_data.get(CONF_PARENT_ENTRY_ID): | ||
| via_device = (DOMAIN, parent_entry_id) | ||
|
|
||
| # _LOGGER.debug( | ||
|
|
@@ -158,7 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b | |
| device_registry.async_get_or_create( | ||
| config_entry_id=config_entry.entry_id, | ||
| identifiers={(DOMAIN, config_entry.entry_id)}, | ||
| name=config_entry.data.get(CONF_LOCK_NAME), | ||
| name=setup_data.get(CONF_LOCK_NAME), | ||
| configuration_url="https://github.com/FutureTense/keymaster", | ||
| via_device=via_device, | ||
| ) | ||
|
|
@@ -167,51 +171,49 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b | |
|
|
||
| code_slots: MutableMapping[int, KeymasterCodeSlot] = {} | ||
| for x in range( | ||
| config_entry.data[CONF_START], | ||
| config_entry.data[CONF_START] + config_entry.data[CONF_SLOTS], | ||
| setup_data[CONF_START], | ||
| setup_data[CONF_START] + setup_data[CONF_SLOTS], | ||
| ): | ||
| dow_slots: MutableMapping[int, KeymasterCodeSlotDayOfWeek] = {} | ||
| for i, dow in enumerate(DAY_NAMES): | ||
| dow_slots[i] = KeymasterCodeSlotDayOfWeek(day_of_week_num=i, day_of_week_name=dow) | ||
| code_slots[x] = KeymasterCodeSlot(number=x, accesslimit_day_of_week=dow_slots) | ||
|
|
||
| kmlock = KeymasterLock( | ||
| lock_name=config_entry.data[CONF_LOCK_NAME], | ||
| lock_entity_id=config_entry.data[CONF_LOCK_ENTITY_ID], | ||
| lock_name=setup_data[CONF_LOCK_NAME], | ||
| lock_entity_id=setup_data[CONF_LOCK_ENTITY_ID], | ||
| keymaster_config_entry_id=config_entry.entry_id, | ||
| alarm_level_or_user_code_entity_id=config_entry.data.get( | ||
| CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID | ||
| ), | ||
| alarm_type_or_access_control_entity_id=config_entry.data.get( | ||
| alarm_level_or_user_code_entity_id=setup_data.get(CONF_ALARM_LEVEL_OR_USER_CODE_ENTITY_ID), | ||
| alarm_type_or_access_control_entity_id=setup_data.get( | ||
| CONF_ALARM_TYPE_OR_ACCESS_CONTROL_ENTITY_ID | ||
| ), | ||
| door_sensor_entity_id=config_entry.data.get(CONF_DOOR_SENSOR_ENTITY_ID), | ||
| number_of_code_slots=config_entry.data[CONF_SLOTS], | ||
| starting_code_slot=config_entry.data[CONF_START], | ||
| door_sensor_entity_id=setup_data.get(CONF_DOOR_SENSOR_ENTITY_ID), | ||
| number_of_code_slots=setup_data[CONF_SLOTS], | ||
| starting_code_slot=setup_data[CONF_START], | ||
| code_slots=code_slots, | ||
| parent_name=config_entry.data.get(CONF_PARENT), | ||
| parent_config_entry_id=config_entry.data.get(CONF_PARENT_ENTRY_ID), | ||
| notify_script_name=config_entry.data.get(CONF_NOTIFY_SCRIPT_NAME), | ||
| parent_name=setup_data.get(CONF_PARENT), | ||
| parent_config_entry_id=setup_data.get(CONF_PARENT_ENTRY_ID), | ||
| notify_script_name=setup_data.get(CONF_NOTIFY_SCRIPT_NAME), | ||
| ) | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This change from This is the likely cause of the 12 "Lingering timer" test failures in CI — during setup, if a lock already exists in the coordinator (e.g. reload scenario), Previously, Suggestion: Consider scoping this to only use
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up: I see you've added However, my architectural concern remains unanswered: why does every lock need Could you explain the rationale? Is this needed to handle a specific scenario, or would it be possible to scope this to only locks whose parent relationship has actually changed? Understanding your reasoning here would help move this forward. |
||
| try: | ||
| await coordinator.add_lock(kmlock=kmlock) | ||
| await coordinator.add_lock(kmlock=kmlock, update=True) | ||
| except asyncio.exceptions.CancelledError as e: | ||
| _LOGGER.error("Timeout on add_lock. %s: %s", e.__class__.__qualname__, e) | ||
|
|
||
| await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) | ||
| await async_generate_lovelace( | ||
| hass=hass, | ||
| kmlock_name=config_entry.data[CONF_LOCK_NAME], | ||
| kmlock_name=setup_data[CONF_LOCK_NAME], | ||
| keymaster_config_entry_id=config_entry.entry_id, | ||
| parent_config_entry_id=config_entry.data.get(CONF_PARENT_ENTRY_ID), | ||
| code_slot_start=config_entry.data[CONF_START], | ||
| code_slots=config_entry.data[CONF_SLOTS], | ||
| lock_entity=config_entry.data[CONF_LOCK_ENTITY_ID], | ||
| advanced_date_range=config_entry.data[CONF_ADVANCED_DATE_RANGE], | ||
| advanced_day_of_week=config_entry.data[CONF_ADVANCED_DAY_OF_WEEK], | ||
| door_sensor=config_entry.data.get(CONF_DOOR_SENSOR_ENTITY_ID), | ||
| hide_pins=config_entry.data.get(CONF_HIDE_PINS, False), | ||
| parent_config_entry_id=setup_data.get(CONF_PARENT_ENTRY_ID), | ||
| code_slot_start=setup_data[CONF_START], | ||
| code_slots=setup_data[CONF_SLOTS], | ||
| lock_entity=setup_data[CONF_LOCK_ENTITY_ID], | ||
| advanced_date_range=setup_data[CONF_ADVANCED_DATE_RANGE], | ||
| advanced_day_of_week=setup_data[CONF_ADVANCED_DAY_OF_WEEK], | ||
| door_sensor=setup_data.get(CONF_DOOR_SENSOR_ENTITY_ID), | ||
| hide_pins=setup_data.get(CONF_HIDE_PINS, False), | ||
| ) | ||
|
|
||
| return True | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -480,7 +480,14 @@ def _kmlocks_to_dict(self, instance: object) -> object: | |
|
|
||
| async def _rebuild_lock_relationships(self) -> None: | ||
| for keymaster_config_entry_id, kmlock in self.kmlocks.items(): | ||
| if kmlock.parent_name is not None: | ||
| if ( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good improvement — prioritizing an already-resolved One edge case to consider: if
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Follow-up: Thanks for adding The edge case I mentioned is still untested though: what happens when Could you add that test case, or explain why you think it's unnecessary? |
||
| kmlock.parent_config_entry_id is not None | ||
| and kmlock.parent_config_entry_id in self.kmlocks | ||
| ): | ||
| parent_lock = self.kmlocks[kmlock.parent_config_entry_id] | ||
| if keymaster_config_entry_id not in parent_lock.child_config_entry_ids: | ||
| parent_lock.child_config_entry_ids.append(keymaster_config_entry_id) | ||
| elif kmlock.parent_name is not None: | ||
| for parent_config_entry_id, parent_lock in self.kmlocks.items(): | ||
| if kmlock.parent_name == parent_lock.lock_name: | ||
| if kmlock.parent_config_entry_id is None: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit:
setup_data = updated_configcreates an alias to the same dict object, not a copy. Afterasync_update_entryis called above,config_entry.dataalready contains the updated values, soconfig_entry.dataandupdated_configpoint to the same data.This means all the
config_entry.data→setup_datasubstitutions below are functionally no-ops — they read the same values either way. The variable namesetup_dataimplies it might be a separate snapshot, but it isn't.This isn't a bug, but it adds 20+ line changes that don't change behavior, which makes the diff harder to review and inflates patch coverage requirements. Consider either:
config_entry.data(fewer changes, same behavior), orasync_update_entrynot updating in-place in the future), add a comment explaining whyThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow-up: This is still unaddressed. The
setup_data = updated_configalias adds ~20 line changes with no behavioral change sinceasync_update_entryupdatesconfig_entry.datain-place.I'd like to understand the intent here — is this a defensive pattern against future HA API changes, a readability preference, or something else? If there's a good reason, a brief comment in the code explaining why would be helpful. Otherwise, removing it would simplify the diff significantly.
Please respond so we can resolve this thread one way or the other.