Skip to content

Commit fc478ed

Browse files
raman325claude
andcommitted
Switch drift alerting from persistent notifications to repair issues
Replace all persistent_notification usage with Home Assistant repair issues for better UX. Add coordinator poll failure alerting that creates a repair issue after 12 consecutive failures and auto-dismisses on recovery. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 50ea6a9 commit fc478ed

12 files changed

Lines changed: 278 additions & 35 deletions

File tree

custom_components/lock_code_manager/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@
108108
BACKOFF_INITIAL_SECONDS: int = 60
109109
BACKOFF_MAX_SECONDS: int = 1800 # 30 minutes
110110

111+
# Poll failure alerting
112+
POLL_FAILURE_ALERT_THRESHOLD: int = 12
113+
111114
# Sync timing
112115
TICK_INTERVAL = timedelta(seconds=5)
113116
MAX_SYNC_ATTEMPTS = 3

custom_components/lock_code_manager/coordinator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
from homeassistant.const import CONF_ENABLED, CONF_PIN
1414
from homeassistant.core import HomeAssistant, callback
1515
from homeassistant.helpers.event import async_track_time_interval
16+
from homeassistant.helpers.issue_registry import (
17+
IssueSeverity,
18+
async_create_issue,
19+
async_delete_issue,
20+
)
1621
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
1722

1823
from .const import (
@@ -21,6 +26,7 @@
2126
BACKOFF_MAX_SECONDS,
2227
CONF_SLOTS,
2328
DOMAIN,
29+
POLL_FAILURE_ALERT_THRESHOLD,
2430
)
2531
from .data import get_entry_data
2632
from .exceptions import LockCodeManagerError
@@ -136,9 +142,25 @@ def _apply_backoff(self) -> None:
136142
self._lock.lock.entity_id,
137143
)
138144

145+
if self._consecutive_failures == POLL_FAILURE_ALERT_THRESHOLD:
146+
async_create_issue(
147+
self.hass,
148+
DOMAIN,
149+
f"lock_offline_{self._lock.lock.entity_id}",
150+
is_fixable=False,
151+
is_persistent=False,
152+
severity=IssueSeverity.WARNING,
153+
translation_key="lock_offline",
154+
translation_placeholders={
155+
"lock_entity_id": self._lock.lock.entity_id,
156+
"failure_count": str(self._consecutive_failures),
157+
},
158+
)
159+
139160
def _reset_backoff(self) -> None:
140161
"""Reset failure counter and restore original update interval."""
141162
if self._consecutive_failures > 0:
163+
had_alert = self._consecutive_failures >= POLL_FAILURE_ALERT_THRESHOLD
142164
_LOGGER.info(
143165
"Lock %s recovered after %d consecutive failures",
144166
self._lock.lock.entity_id,
@@ -147,6 +169,12 @@ def _reset_backoff(self) -> None:
147169
self._consecutive_failures = 0
148170
if self._original_update_interval is not None:
149171
self.update_interval = self._original_update_interval
172+
if had_alert:
173+
async_delete_issue(
174+
self.hass,
175+
DOMAIN,
176+
f"lock_offline_{self._lock.lock.entity_id}",
177+
)
150178

151179
async def async_get_usercodes(self) -> dict[int, str | SlotCode]:
152180
"""Update usercodes."""

custom_components/lock_code_manager/repairs.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@
99
from .const import CONF_NUMBER_OF_USES, CONF_SLOTS, DOMAIN
1010

1111

12+
class AcknowledgeRepairFlow(RepairsFlow):
13+
"""Simple repair flow that just acknowledges the issue."""
14+
15+
async def async_step_init(
16+
self, user_input: dict[str, str] | None = None
17+
) -> data_entry_flow.FlowResult:
18+
"""Handle the confirm step."""
19+
if user_input is not None:
20+
return self.async_create_entry(title="", data={})
21+
return self.async_show_form(step_id="init")
22+
23+
1224
class NumberOfUsesDeprecatedFlow(RepairsFlow):
1325
"""Handler for the number_of_uses deprecation repair.
1426
@@ -54,4 +66,6 @@ async def async_create_fix_flow(
5466
"""Create a fix flow for a repair issue."""
5567
if issue_id == "number_of_uses_deprecated":
5668
return NumberOfUsesDeprecatedFlow()
69+
if issue_id.startswith("slot_disabled_") or issue_id.startswith("pin_required_"):
70+
return AcknowledgeRepairFlow()
5771
raise ValueError(f"Unknown issue: {issue_id}")

custom_components/lock_code_manager/strings.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,32 @@
140140
}
141141
}
142142
}
143+
},
144+
"slot_disabled": {
145+
"title": "Lock Code Manager: Slot {slot_num} disabled",
146+
"fix_flow": {
147+
"step": {
148+
"init": {
149+
"title": "Slot {slot_num} was disabled",
150+
"description": "Slot {slot_num} has been automatically disabled.\n\n**Reason:** {reason}\n\nClick **Submit** to acknowledge this issue."
151+
}
152+
}
153+
}
154+
},
155+
"pin_required": {
156+
"title": "Lock Code Manager: PIN required for slot {slot_num}",
157+
"fix_flow": {
158+
"step": {
159+
"init": {
160+
"title": "PIN required for slot {slot_num}",
161+
"description": "A PIN is required to enable slot {slot_num} on the lock configuration **{config_entry_title}**. Please set a PIN before enabling this slot.\n\nClick **Submit** to acknowledge this issue."
162+
}
163+
}
164+
}
165+
},
166+
"lock_offline": {
167+
"title": "Lock unreachable: {lock_entity_id}",
168+
"description": "Lock Code Manager has been unable to communicate with `{lock_entity_id}` for {failure_count} consecutive attempts. Code synchronization is paused until the lock is reachable again. This issue will be automatically dismissed when the lock recovers."
143169
}
144170
}
145171
}

custom_components/lock_code_manager/switch.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44

55
import logging
66

7-
from homeassistant.components.persistent_notification import async_create
87
from homeassistant.components.switch import SwitchEntity
98
from homeassistant.const import CONF_ENABLED, CONF_PIN, STATE_UNKNOWN, Platform
109
from homeassistant.core import HomeAssistant, callback
1110
from homeassistant.helpers import entity_registry as er
1211
from homeassistant.helpers.entity_platform import AddEntitiesCallback
12+
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
1313

1414
from .const import DOMAIN
1515
from .entity import BaseLockCodeManagerEntity
@@ -64,14 +64,18 @@ async def async_turn_on(self, **kwargs) -> None:
6464
and (state := self.hass.states.get(self._pin_entity_id))
6565
and state.state in (None, "", STATE_UNKNOWN)
6666
):
67-
async_create(
67+
async_create_issue(
6868
self.hass,
69-
(
70-
f"PIN is required to enable slot {self.slot_num} on the lock "
71-
f"configuration {self.config_entry.title}."
72-
),
73-
"Problem with Lock Code Manager",
74-
f"{DOMAIN}_{self.config_entry.entry_id}_{self.slot_num}_pin_required",
69+
DOMAIN,
70+
f"pin_required_{self.config_entry.entry_id}_{self.slot_num}",
71+
is_fixable=True,
72+
is_persistent=True,
73+
severity=IssueSeverity.WARNING,
74+
translation_key="pin_required",
75+
translation_placeholders={
76+
"slot_num": str(self.slot_num),
77+
"config_entry_title": self.config_entry.title,
78+
},
7579
)
7680
return
7781
self._update_config_entry(True)

custom_components/lock_code_manager/sync.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,15 +299,14 @@ async def _perform_sync(self, slot_state: SlotState) -> None:
299299
self._last_set_pin = None
300300
_LOGGER.debug("%s: Cleared usercode", self._log_prefix)
301301

302-
async def _disable_slot(self, reason: str, title: str) -> None:
303-
"""Disable the slot and create a persistent notification."""
302+
async def _disable_slot(self, reason: str) -> None:
303+
"""Disable the slot and create a repair issue."""
304304
await async_disable_slot(
305305
self._hass,
306306
self._ent_reg,
307307
self._config_entry.entry_id,
308308
self._slot_num,
309309
reason=reason,
310-
title=title,
311310
lock_name=self._lock.display_name,
312311
lock_entity_id=self._lock.lock.entity_id,
313312
)
@@ -484,7 +483,6 @@ async def _async_tick_impl(self) -> None:
484483
f"The lock may be rejecting the code silently. "
485484
f"Slot {self._slot_num} has been disabled. Check the "
486485
f"code and re-enable the slot.",
487-
title="Lock Code Sync Failed",
488486
)
489487
return
490488

@@ -497,7 +495,6 @@ async def _async_tick_impl(self) -> None:
497495
f"Lock **{err.lock_entity_id}**: slot **{err.code_slot}** "
498496
f"has been disabled. {err}\n\n"
499497
f"Fix the issue and re-enable the slot.",
500-
title="Lock Code Rejected",
501498
)
502499
return
503500
except LockDisconnected as err:
@@ -525,7 +522,6 @@ async def _async_tick_impl(self) -> None:
525522
f"encountered an unexpected error during sync. This may indicate a bug "
526523
f"in the lock code manager integration. Check logs for details and "
527524
f"report this issue.\n\nError: {type(err).__name__}: {err}",
528-
title="Lock Code Sync Error",
529525
)
530526
return
531527
else:

custom_components/lock_code_manager/translations/en.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,32 @@
140140
}
141141
}
142142
}
143+
},
144+
"slot_disabled": {
145+
"title": "Lock Code Manager: Slot {slot_num} disabled",
146+
"fix_flow": {
147+
"step": {
148+
"init": {
149+
"title": "Slot {slot_num} was disabled",
150+
"description": "Slot {slot_num} has been automatically disabled.\n\n**Reason:** {reason}\n\nClick **Submit** to acknowledge this issue."
151+
}
152+
}
153+
}
154+
},
155+
"pin_required": {
156+
"title": "Lock Code Manager: PIN required for slot {slot_num}",
157+
"fix_flow": {
158+
"step": {
159+
"init": {
160+
"title": "PIN required for slot {slot_num}",
161+
"description": "A PIN is required to enable slot {slot_num} on the lock configuration **{config_entry_title}**. Please set a PIN before enabling this slot.\n\nClick **Submit** to acknowledge this issue."
162+
}
163+
}
164+
}
165+
},
166+
"lock_offline": {
167+
"title": "Lock unreachable: {lock_entity_id}",
168+
"description": "Lock Code Manager has been unable to communicate with `{lock_entity_id}` for {failure_count} consecutive attempts. Code synchronization is paused until the lock is reachable again. This issue will be automatically dismissed when the lock recovers."
143169
}
144170
}
145171
}

custom_components/lock_code_manager/util.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@
99
from typing import Any
1010
import zlib
1111

12-
from homeassistant.components.persistent_notification import async_create
1312
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF
1413
from homeassistant.const import ATTR_ENTITY_ID, CONF_ENABLED
1514
from homeassistant.core import HomeAssistant, callback
1615
from homeassistant.helpers import entity_registry as er
1716
from homeassistant.helpers.event import async_call_later
17+
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
1818

1919
from .const import DOMAIN
2020
from .data import build_slot_unique_id
@@ -43,15 +43,14 @@ async def async_disable_slot(
4343
slot_num: int,
4444
*,
4545
reason: str | None = None,
46-
title: str = "Lock Code Manager: Slot Disabled",
4746
lock_name: str | None = None,
4847
lock_entity_id: str | None = None,
4948
) -> bool:
50-
"""Disable a slot via the enabled switch and optionally create a notification.
49+
"""Disable a slot via the enabled switch and optionally create a repair issue.
5150
5251
Returns True if the switch was found and turned off, False otherwise.
53-
When reason is provided, a persistent notification is created with the
54-
given title.
52+
When reason is provided, a repair issue is created so the user can
53+
acknowledge it through the Home Assistant repairs dashboard.
5554
"""
5655
enabled_entity_id = ent_reg.async_get_entity_id(
5756
SWITCH_DOMAIN,
@@ -75,11 +74,21 @@ async def async_disable_slot(
7574
)
7675

7776
if reason:
78-
async_create(
77+
issue_id = (
78+
f"slot_disabled_{config_entry_id}_{slot_num}_{lock_entity_id or 'unknown'}"
79+
)
80+
async_create_issue(
7981
hass,
80-
reason,
81-
title=title,
82-
notification_id=f"{DOMAIN}_{config_entry_id}_{slot_num}_slot_disabled",
82+
DOMAIN,
83+
issue_id,
84+
is_fixable=True,
85+
is_persistent=True,
86+
severity=IssueSeverity.WARNING,
87+
translation_key="slot_disabled",
88+
translation_placeholders={
89+
"slot_num": str(slot_num),
90+
"reason": reason,
91+
},
8392
)
8493

8594
return True

tests/providers/test_zwave_js.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,12 @@
1616
from zwave_js_server.event import Event as ZwaveEvent
1717
from zwave_js_server.model.node import Node
1818

19-
from homeassistant.components.persistent_notification import (
20-
_async_get_or_create_notifications,
21-
)
2219
from homeassistant.components.zwave_js.const import DOMAIN as ZWAVE_JS_DOMAIN
2320
from homeassistant.config_entries import ConfigEntryState
2421
from homeassistant.const import CONF_ENABLED, CONF_NAME, CONF_PIN, STATE_ON
2522
from homeassistant.core import Event, HomeAssistant, callback
2623
from homeassistant.helpers import device_registry as dr, entity_registry as er
24+
from homeassistant.helpers.issue_registry import async_get as async_get_issue_registry
2725

2826
from custom_components.lock_code_manager.const import (
2927
ATTR_ACTION_TEXT,
@@ -1910,10 +1908,14 @@ async def test_duplicate_code_notification_ignored_when_not_in_progress(
19101908
# Switch should still be on
19111909
assert hass.states.get(switch_entity_id).state == STATE_ON
19121910

1913-
# No persistent notification
1914-
notifications = _async_get_or_create_notifications(hass)
1915-
notification_id = f"{DOMAIN}_{lcm_entry.entry_id}_2_slot_disabled"
1916-
assert notification_id not in notifications
1911+
# No repair issue created for slot_disabled
1912+
issue_registry = async_get_issue_registry(hass)
1913+
matching_issues = [
1914+
issue
1915+
for issue in issue_registry.issues.values()
1916+
if issue.domain == DOMAIN and issue.issue_id.startswith("slot_disabled_")
1917+
]
1918+
assert len(matching_issues) == 0
19171919

19181920
await hass.config_entries.async_unload(lcm_entry.entry_id)
19191921

0 commit comments

Comments
 (0)