diff --git a/custom_components/nikobus/const.py b/custom_components/nikobus/const.py index e0f1af9..efc8072 100644 --- a/custom_components/nikobus/const.py +++ b/custom_components/nikobus/const.py @@ -151,6 +151,11 @@ def press_signal(address: str) -> str: # previous owner" — both look identical from the bus signal. Push the # decision to the user via a Repairs flow. ISSUE_LEGACY_UNDECODED_BUTTONS: Final[str] = "legacy_undecoded_buttons" +# A module whose link table the library could not align with the scanned +# register window — genuine flash corruption (the Nikobus PC software +# reports the same and asks for reprogramming). Informational only; the +# fix is reprogramming the module, not anything HA can do. +ISSUE_CORRUPT_MODULES: Final[str] = "corrupt_modules" # Physical button types that are INPUT-ONLY by design — they generate # bus press telegrams when their contacts change state but they don't diff --git a/custom_components/nikobus/discovery_mixin.py b/custom_components/nikobus/discovery_mixin.py index cb3c5f1..ec585be 100644 --- a/custom_components/nikobus/discovery_mixin.py +++ b/custom_components/nikobus/discovery_mixin.py @@ -46,6 +46,7 @@ DISCOVERY_WEIGHT_INVENTORY, DISCOVERY_WEIGHT_REGISTER_SCAN, DOMAIN, + ISSUE_CORRUPT_MODULES, ISSUE_LEGACY_UNDECODED_BUTTONS, NKB_IMPORT_CATEGORIES, SIGNAL_DISCOVERY_STATE, @@ -204,6 +205,46 @@ def _surface_legacy_undecoded_buttons( }, ) + def _surface_corrupt_modules(self) -> None: + """Create or clear the ``corrupt_modules`` Repairs issue. + + The library (nikobus-connect 0.27.2+) flags any module whose + link table doesn't align with the scanned register window — + genuine flash corruption, the same thing the Nikobus PC software + reports as "reprogram this module". Their link decode was + skipped (no phantom buttons), so the only action is to tell the + user to reprogram. The issue is informational (not HA-fixable); + it auto-clears on the next module scan once the module reads + cleanly again. + """ + corrupt = sorted( + str(a).upper() + for a in getattr(self.nikobus_discovery, "corrupt_link_tables", None) + or () + ) + issue_id = f"{ISSUE_CORRUPT_MODULES}_{self.config_entry.entry_id}" + if not corrupt: + ir.async_delete_issue(self.hass, DOMAIN, issue_id) + return + _LOGGER.warning( + "Discovery: %d module(s) have a corrupt link table and were " + "skipped — reprogram them in the Nikobus PC software: %s", + len(corrupt), + corrupt, + ) + ir.async_create_issue( + self.hass, + DOMAIN, + issue_id, + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key=ISSUE_CORRUPT_MODULES, + translation_placeholders={ + "count": str(len(corrupt)), + "modules": ", ".join(corrupt), + }, + ) + async def _ingest_cf_broadcasts(self) -> None: """Persist the library's classified CF broadcasts into our store. @@ -416,6 +457,14 @@ async def _reconcile_post_discovery( ): self._surface_legacy_undecoded_buttons(buttons) + # Surface modules the library flagged as having a corrupt link + # table (decode skipped — see nikobus-connect 0.27.2). Only when + # a register scan actually ran (a module scan, not a pure + # PC-Link inventory), so a later inventory-only run doesn't clear + # a still-valid corruption warning. + if inventory_query_type != InventoryQueryType.PC_LINK: + self._surface_corrupt_modules() + # Ingest the library's classified CF activation broadcasts (the # ``38 41 XX`` / ``38 80 XX`` addresses surfaced by # ``_classify_cf_broadcasts_from_unmatched``). Persists into the diff --git a/custom_components/nikobus/translations/en.json b/custom_components/nikobus/translations/en.json index 0fb9906..49f8d05 100644 --- a/custom_components/nikobus/translations/en.json +++ b/custom_components/nikobus/translations/en.json @@ -125,6 +125,10 @@ "no_candidates": "No legacy buttons remain — nothing to do." } } + }, + "corrupt_modules": { + "title": "Nikobus module needs reprogramming", + "description": "{count} Nikobus module(s) reported a corrupt link table and were skipped during discovery (their button links could not be read): **{modules}**.\n\nThis is a module-side issue — the Nikobus PC software reports the same and asks for reprogramming. Open the Nikobus PC software, reprogram the listed module(s), then run **Load Existing Installation** again. This warning clears automatically once the module reads cleanly." } }, "exceptions": { diff --git a/custom_components/nikobus/translations/fr.json b/custom_components/nikobus/translations/fr.json index a030270..70b42aa 100644 --- a/custom_components/nikobus/translations/fr.json +++ b/custom_components/nikobus/translations/fr.json @@ -256,6 +256,10 @@ "no_candidates": "Aucun bouton hérité ne reste — rien à faire." } } + }, + "corrupt_modules": { + "title": "Un module Nikobus doit être reprogrammé", + "description": "{count} module(s) Nikobus ont signalé une table de liens corrompue et ont été ignorés pendant la découverte (leurs liens boutons n'ont pas pu être lus) : **{modules}**.\n\nC'est un problème côté module — le logiciel Nikobus signale la même chose et demande une reprogrammation. Ouvrez le logiciel Nikobus, reprogrammez le(s) module(s) listé(s), puis relancez **Charger l'installation existante**. Cet avertissement disparaît automatiquement dès que le module se lit correctement." } }, "exceptions": { diff --git a/custom_components/nikobus/translations/nl.json b/custom_components/nikobus/translations/nl.json index 0a02e9f..c31aeff 100644 --- a/custom_components/nikobus/translations/nl.json +++ b/custom_components/nikobus/translations/nl.json @@ -256,6 +256,10 @@ "no_candidates": "Geen verouderde knoppen meer — niets te doen." } } + }, + "corrupt_modules": { + "title": "Nikobus-module moet opnieuw geprogrammeerd worden", + "description": "{count} Nikobus-module(s) meldden een beschadigde koppelingstabel en werden overgeslagen tijdens detectie (hun knopkoppelingen konden niet gelezen worden): **{modules}**.\n\nDit is een probleem aan de modulezijde — de Nikobus PC-software meldt hetzelfde en vraagt om herprogrammeren. Open de Nikobus PC-software, herprogrammeer de vermelde module(s) en voer daarna opnieuw **Bestaande installatie laden** uit. Deze waarschuwing verdwijnt automatisch zodra de module weer correct gelezen wordt." } }, "exceptions": { diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index ec6ab68..4417ab3 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -2111,3 +2111,59 @@ def test_unchanged_module_never_dispatches(self): if __name__ == "__main__": unittest.main() + + +class TestSurfaceCorruptModules(unittest.TestCase): + """``_surface_corrupt_modules`` — the user-facing Repairs message for + modules the library flagged as having a corrupt link table.""" + + def _coord(self, corrupt): + coord = MagicMock() + coord.hass = MagicMock() + coord.config_entry = MagicMock() + coord.config_entry.entry_id = "entry_test" + coord.nikobus_discovery = MagicMock() + coord.nikobus_discovery.corrupt_link_tables = set(corrupt) + return coord + + @patch("custom_components.nikobus.discovery_mixin.ir.async_create_issue") + @patch("custom_components.nikobus.discovery_mixin.ir.async_delete_issue") + def test_creates_informational_issue_when_modules_corrupt( + self, mock_delete, mock_create + ): + coord = self._coord({"4707", "0e6c"}) + NikobusDataCoordinator._surface_corrupt_modules(coord) + + mock_delete.assert_not_called() + mock_create.assert_called_once() + kw = mock_create.call_args.kwargs + # Not HA-fixable: the cure is reprogramming in the Nikobus PC software. + self.assertFalse(kw["is_fixable"]) + self.assertEqual(kw["translation_key"], "corrupt_modules") + self.assertEqual(kw["translation_placeholders"]["count"], "2") + # Sorted, upper-cased module list in the message. + self.assertEqual( + kw["translation_placeholders"]["modules"], "0E6C, 4707" + ) + + @patch("custom_components.nikobus.discovery_mixin.ir.async_create_issue") + @patch("custom_components.nikobus.discovery_mixin.ir.async_delete_issue") + def test_clears_issue_when_none_corrupt(self, mock_delete, mock_create): + coord = self._coord(set()) + NikobusDataCoordinator._surface_corrupt_modules(coord) + + mock_create.assert_not_called() + mock_delete.assert_called_once() + + @patch("custom_components.nikobus.discovery_mixin.ir.async_create_issue") + @patch("custom_components.nikobus.discovery_mixin.ir.async_delete_issue") + def test_handles_missing_library_attribute(self, mock_delete, mock_create): + # Older library without corrupt_link_tables → treated as none. + coord = MagicMock() + coord.hass = MagicMock() + coord.config_entry = MagicMock() + coord.config_entry.entry_id = "entry_test" + coord.nikobus_discovery = MagicMock(spec=[]) # no corrupt_link_tables + NikobusDataCoordinator._surface_corrupt_modules(coord) + mock_create.assert_not_called() + mock_delete.assert_called_once()