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
5 changes: 5 additions & 0 deletions custom_components/nikobus/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions custom_components/nikobus/discovery_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions custom_components/nikobus/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions custom_components/nikobus/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions custom_components/nikobus/translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
56 changes: 56 additions & 0 deletions tests/test_coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading