diff --git a/CLAUDE.md b/CLAUDE.md index bdd974123..87c774b29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,4 +90,4 @@ Procedural/how-to knowledge (running tests, resetting the DB, devcontainer manag - Reuse `test/db_test_helpers.py` for DB mocks/fixtures in tests rather than redefining `DummyDB`/`make_db` locally. - Keep files under ~500 lines; split rather than grow. - Every Python function/method gets a succinct docstring describing its current use and behavior — one or two sentences, not a changelog of what changed or why (that belongs in the commit/PR, not the docstring). Same rule for JS: a JSDoc `/** ... */` block, not a plain `//` line above the function. Whenever you touch a function that only has a plain description comment (Python or JS), convert it to a proper docstring as part of that edit rather than leaving the old style next to new code. -- Before adding a new key to `front/php/templates/language/en_us.json`, search it for an existing key with the same text/purpose and reuse it — prefer generic `Gen_*` keys over page-scoped names for genuinely generic UI text (e.g. `Gen_Prev`/`Gen_Next`, not `Presence_Page_Prev`). Only the English file needs a new key; other locales fall back to it automatically. +- Before adding a new key to `front/php/templates/language/en_us.json`, search it for an existing key with the same text/purpose and reuse it - prefer generic `Gen_*` keys over page-scoped names for genuinely generic UI text (e.g. `Gen_Prev`/`Gen_Next`, not `Presence_Page_Prev`). Only the English file needs a real translation; other locales fall back to it automatically at runtime for a key they don't have. After adding or changing any key in `en_us.json`, run `python3 front/php/templates/language/merge_translations.py` (plain stdlib, no deps) - it re-sorts `en_us.json` alphabetically and propagates the new key into every other locale file with an empty placeholder value, so translators see what needs translating. Skipping this leaves the other 23 locale files out of sync with `en_us.json`'s key set. diff --git a/front/js/device-columns.js b/front/js/device-columns.js index 34022972b..7393b6c0a 100644 --- a/front/js/device-columns.js +++ b/front/js/device-columns.js @@ -51,6 +51,9 @@ const DEVICE_COLUMN_FIELDS = [ "devPrimaryIPv6", // 32 Device_TableHead_IPv6 "devFlapping", // 33 Device_TableHead_Flapping "devComments", // 34 Device_TableHead_Comments + "devAlertEvents", // 35 Device_TableHead_AlertEvents + "devCanSleep", // 36 Device_TableHead_CanSleep + "devStaticIP", // 37 Device_TableHead_StaticIP ]; // Named index constants — eliminates all mapIndx(N) magic numbers. @@ -68,10 +71,8 @@ const NUMERIC_DEFAULTS = new Set([ // These are merged with DEVICE_COLUMN_FIELDS to build the GraphQL query. const GRAPHQL_EXTRA_FIELDS = [ "devComments", - "devStaticIP", "devScan", "devLogEvents", - "devAlertEvents", "devSkipRepeated", "devLastNotification", "devIsNew", @@ -130,6 +131,9 @@ const COLUMN_NAME_MAP = { "Device_TableHead_IPv6": "devPrimaryIPv6", "Device_TableHead_Flapping": "devFlapping", "Device_TableHead_Comments": "devComments", + "Device_TableHead_AlertEvents": "devAlertEvents", + "Device_TableHead_CanSleep": "devCanSleep", + "Device_TableHead_StaticIP": "devStaticIP", }; console.log("init device-columns.js"); diff --git a/front/php/templates/language/ar_ar.json b/front/php/templates/language/ar_ar.json index 81106eb04..080095b50 100644 --- a/front/php/templates/language/ar_ar.json +++ b/front/php/templates/language/ar_ar.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "مخطط الاتصال", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "تنبيه عدم الاتصال", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "الأجهزة المتصلة", "Device_TableHead_CustomProps": "خصائص مخصصة", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "معرف الصف", "Device_TableHead_SSID": "معرف الشبكة اللاسلكية", "Device_TableHead_SourcePlugin": "المكون الإضافي المصدر", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "الحالة", "Device_TableHead_SyncHubNodeName": "اسم عقدة المزامنة", "Device_TableHead_Type": "النوع", diff --git a/front/php/templates/language/ca_ca.json b/front/php/templates/language/ca_ca.json index 1f8f8bcf6..eb7bc0c18 100644 --- a/front/php/templates/language/ca_ca.json +++ b/front/php/templates/language/ca_ca.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Dispositius detectats", "Device_Shortcut_Unstable": "Inestable", "Device_TableHead_AlertDown": "Cancel·lar alerta", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Props / Accions", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID de fila", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Connector(Plugin) font", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Estat", "Device_TableHead_SyncHubNodeName": "Node Sync", "Device_TableHead_Type": "Tipus", diff --git a/front/php/templates/language/cs_cz.json b/front/php/templates/language/cs_cz.json index bae0c5860..c712856f6 100644 --- a/front/php/templates/language/cs_cz.json +++ b/front/php/templates/language/cs_cz.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Výskyt zařízení", "Device_Shortcut_Unstable": "Nestabilní", "Device_TableHead_AlertDown": "Upozornění na nedostupnost", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "Komentáře", "Device_TableHead_Connected_Devices": "Spojení", "Device_TableHead_CustomProps": "Vlastnosti / Akce", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "Identif. řádku", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Zdrojový zásuvný modul", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Stav", "Device_TableHead_SyncHubNodeName": "Synchronizovat uzel", "Device_TableHead_Type": "Typ", diff --git a/front/php/templates/language/de_de.json b/front/php/templates/language/de_de.json index 1a810649d..8e9b5d064 100644 --- a/front/php/templates/language/de_de.json +++ b/front/php/templates/language/de_de.json @@ -237,6 +237,8 @@ "Device_Shortcut_OnlineChart": "Gerätepräsenz im Laufe der Zeit", "Device_Shortcut_Unstable": "Instabil", "Device_TableHead_AlertDown": "Alarm aus", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "Kommentare", "Device_TableHead_Connected_Devices": "Verbindungen", "Device_TableHead_CustomProps": "Eigenschaften / Aktionen", @@ -267,6 +269,7 @@ "Device_TableHead_Rowid": "Zeilennummer", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Quellerweiterung", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Status", "Device_TableHead_SyncHubNodeName": "Synchronisationsknoten", "Device_TableHead_Type": "Typ", diff --git a/front/php/templates/language/en_us.json b/front/php/templates/language/en_us.json index f1afd4381..64928c814 100755 --- a/front/php/templates/language/en_us.json +++ b/front/php/templates/language/en_us.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Device presence", "Device_Shortcut_Unstable": "Unstable", "Device_TableHead_AlertDown": "Alert Down", + "Device_TableHead_AlertEvents": "Alert Events", + "Device_TableHead_CanSleep": "Can Sleep", "Device_TableHead_Comments": "Comments", "Device_TableHead_Connected_Devices": "Connections", "Device_TableHead_CustomProps": "Props / Actions", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "Row ID", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Source Plugin", + "Device_TableHead_StaticIP": "Static IP", "Device_TableHead_Status": "Status", "Device_TableHead_SyncHubNodeName": "Sync Node", "Device_TableHead_Type": "Type", diff --git a/front/php/templates/language/es_es.json b/front/php/templates/language/es_es.json index 621b72c55..288679c17 100644 --- a/front/php/templates/language/es_es.json +++ b/front/php/templates/language/es_es.json @@ -235,6 +235,8 @@ "Device_Shortcut_OnlineChart": "Presencia del dispositivo a lo largo del tiempo", "Device_Shortcut_Unstable": "Inestable", "Device_TableHead_AlertDown": "Alerta desactivada", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Conexiones", "Device_TableHead_CustomProps": "Propiedades / Acciones", @@ -265,6 +267,7 @@ "Device_TableHead_Rowid": "ID de fila", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Fuente del plugin", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Situación", "Device_TableHead_SyncHubNodeName": "Nodo de sincronización", "Device_TableHead_Type": "Tipo", diff --git a/front/php/templates/language/fa_fa.json b/front/php/templates/language/fa_fa.json index 3db381484..d1abab71a 100644 --- a/front/php/templates/language/fa_fa.json +++ b/front/php/templates/language/fa_fa.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "", "Device_TableHead_SyncHubNodeName": "", "Device_TableHead_Type": "", diff --git a/front/php/templates/language/fi_fi.json b/front/php/templates/language/fi_fi.json index 664601f68..1d27b99ae 100644 --- a/front/php/templates/language/fi_fi.json +++ b/front/php/templates/language/fi_fi.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "", "Device_TableHead_SyncHubNodeName": "", "Device_TableHead_Type": "", diff --git a/front/php/templates/language/fr_fr.json b/front/php/templates/language/fr_fr.json index b8d6c2728..d41d5726b 100644 --- a/front/php/templates/language/fr_fr.json +++ b/front/php/templates/language/fr_fr.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Présence de l'appareil", "Device_Shortcut_Unstable": "Instable", "Device_TableHead_AlertDown": "Alerter si En panne", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "Commentaires", "Device_TableHead_Connected_Devices": "Connexions", "Device_TableHead_CustomProps": "Champs / Actions", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID de colonne", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Source du plugin", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "État", "Device_TableHead_SyncHubNodeName": "Noeud de synchro", "Device_TableHead_Type": "Type", @@ -837,4 +840,4 @@ "settings_system_label": "Système", "settings_update_item_warning": "Mettre à jour la valeur ci-dessous. Veillez à bien suivre le même format qu'auparavant. Il n'y a pas de pas de contrôle.", "test_event_tooltip": "Enregistrer d'abord vos modifications avant de tester vôtre paramétrage." -} +} \ No newline at end of file diff --git a/front/php/templates/language/he_il.json b/front/php/templates/language/he_il.json index 664601f68..1d27b99ae 100644 --- a/front/php/templates/language/he_il.json +++ b/front/php/templates/language/he_il.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "", "Device_TableHead_SyncHubNodeName": "", "Device_TableHead_Type": "", diff --git a/front/php/templates/language/hu_hu.json b/front/php/templates/language/hu_hu.json index 664601f68..1d27b99ae 100644 --- a/front/php/templates/language/hu_hu.json +++ b/front/php/templates/language/hu_hu.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "", "Device_TableHead_SyncHubNodeName": "", "Device_TableHead_Type": "", diff --git a/front/php/templates/language/id_id.json b/front/php/templates/language/id_id.json index 664601f68..1d27b99ae 100644 --- a/front/php/templates/language/id_id.json +++ b/front/php/templates/language/id_id.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "", "Device_TableHead_SyncHubNodeName": "", "Device_TableHead_Type": "", diff --git a/front/php/templates/language/it_it.json b/front/php/templates/language/it_it.json index 61e684c75..5eb6c30ed 100644 --- a/front/php/templates/language/it_it.json +++ b/front/php/templates/language/it_it.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Presenza dispositivo", "Device_Shortcut_Unstable": "Instabile", "Device_TableHead_AlertDown": "Avviso disconnessione", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "Commenti", "Device_TableHead_Connected_Devices": "Connessioni", "Device_TableHead_CustomProps": "Proprietà/Azioni", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID riga", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Plugin sorgente", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Stato", "Device_TableHead_SyncHubNodeName": "Sincronizza nodo", "Device_TableHead_Type": "Tipo", @@ -351,12 +354,12 @@ "Gen_LockedDB": "ERRORE: il DB potrebbe essere bloccato, controlla F12 Strumenti di sviluppo -> Console o riprova più tardi.", "Gen_NetworkMask": "Maschera di rete", "Gen_New": "Nuovo", - "Gen_Next": "", + "Gen_Next": "Successivo", "Gen_No_Data": "Nessun dato", "Gen_Offline": "Offline", "Gen_Okay": "Ok", "Gen_Online": "Online", - "Gen_Prev": "", + "Gen_Prev": "Precedente", "Gen_Purge": "Svuota", "Gen_ReadDocs": "Maggiori informazioni nella documentazione.", "Gen_Remove_All": "Rimuovi tutti", @@ -836,7 +839,5 @@ "settings_system_icon": "fa-solid fa-gear", "settings_system_label": "Sistema", "settings_update_item_warning": "Aggiorna il valore qui sotto. Fai attenzione a seguire il formato precedente. La convalida non viene eseguita.", - "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni.", - "Gen_Next": "Successivo", - "Gen_Prev": "Precedente" -} + "test_event_tooltip": "Salva le modifiche prima di provare le nuove impostazioni." +} \ No newline at end of file diff --git a/front/php/templates/language/ja_jp.json b/front/php/templates/language/ja_jp.json index f7dbaa8dc..0f4fd6500 100644 --- a/front/php/templates/language/ja_jp.json +++ b/front/php/templates/language/ja_jp.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "デバイス検出", "Device_Shortcut_Unstable": "不安定", "Device_TableHead_AlertDown": "ダウンアラート", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "接続", "Device_TableHead_CustomProps": "属性 / アクション", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "行ID", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "ソースプラグイン", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "状態", "Device_TableHead_SyncHubNodeName": "同期ノード", "Device_TableHead_Type": "種別", diff --git a/front/php/templates/language/nb_no.json b/front/php/templates/language/nb_no.json index e7a198f35..91ed3953f 100644 --- a/front/php/templates/language/nb_no.json +++ b/front/php/templates/language/nb_no.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Enhetens tilstedeværelse", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Tilkoblinger", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "Rad ID", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Status", "Device_TableHead_SyncHubNodeName": "Synkroniser Node", "Device_TableHead_Type": "Type", diff --git a/front/php/templates/language/pl_pl.json b/front/php/templates/language/pl_pl.json index 21a397a6f..7fc79bcca 100644 --- a/front/php/templates/language/pl_pl.json +++ b/front/php/templates/language/pl_pl.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Obecność urządzenia", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alert niedostępny", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Połączenia", "Device_TableHead_CustomProps": "Właściwości / Akcje", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID wiersza", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Wtyczka źródłowa", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Status", "Device_TableHead_SyncHubNodeName": "Węzeł synchronizacji", "Device_TableHead_Type": "Typ", diff --git a/front/php/templates/language/pt_br.json b/front/php/templates/language/pt_br.json index 3b076178e..78310fad7 100644 --- a/front/php/templates/language/pt_br.json +++ b/front/php/templates/language/pt_br.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Presença do dispositivo", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Alerta em baixo", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Conexões", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID da linha", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Plugin de fonte", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Status", "Device_TableHead_SyncHubNodeName": "Nó de sincronização", "Device_TableHead_Type": "Tipo", diff --git a/front/php/templates/language/pt_pt.json b/front/php/templates/language/pt_pt.json index 557b918aa..3779e9f56 100644 --- a/front/php/templates/language/pt_pt.json +++ b/front/php/templates/language/pt_pt.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Presença do dispositivo", "Device_Shortcut_Unstable": "Instável", "Device_TableHead_AlertDown": "Alerta em baixo", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Conexões", "Device_TableHead_CustomProps": "Propriedades / Ações", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID da linha", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Plugin de fonte", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Estado", "Device_TableHead_SyncHubNodeName": "Nó de sincronização", "Device_TableHead_Type": "Tipo", diff --git a/front/php/templates/language/ru_ru.json b/front/php/templates/language/ru_ru.json index 0a9ce32bd..4d82237d1 100644 --- a/front/php/templates/language/ru_ru.json +++ b/front/php/templates/language/ru_ru.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Присутствие устройств", "Device_Shortcut_Unstable": "Нестабильный", "Device_TableHead_AlertDown": "Оповещение о сост. ВЫКЛ", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "Комментарии", "Device_TableHead_Connected_Devices": "Соединения", "Device_TableHead_CustomProps": "Свойства / Действия", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID строки", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Исходный плагин", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Статус", "Device_TableHead_SyncHubNodeName": "Узел синхронизации", "Device_TableHead_Type": "Тип", @@ -837,4 +840,4 @@ "settings_system_label": "Система", "settings_update_item_warning": "Обновить значение ниже. Будьте осторожны, следуя предыдущему формату. Проверка не выполняется.", "test_event_tooltip": "Сначала сохраните изменения, прежде чем проверять настройки." -} +} \ No newline at end of file diff --git a/front/php/templates/language/sv_sv.json b/front/php/templates/language/sv_sv.json index 664601f68..1d27b99ae 100644 --- a/front/php/templates/language/sv_sv.json +++ b/front/php/templates/language/sv_sv.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "", "Device_TableHead_SyncHubNodeName": "", "Device_TableHead_Type": "", diff --git a/front/php/templates/language/tr_tr.json b/front/php/templates/language/tr_tr.json index 583bd7ce4..6f255d006 100644 --- a/front/php/templates/language/tr_tr.json +++ b/front/php/templates/language/tr_tr.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Cihaz Durumu", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Çalışmama Alarmı", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Bağlantılar", "Device_TableHead_CustomProps": "Özellikler / Eylemler", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "Satır ID", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Kaynak Eklentisi Modülü", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Durum", "Device_TableHead_SyncHubNodeName": "Senkronizasyon Node", "Device_TableHead_Type": "Tür", diff --git a/front/php/templates/language/uk_ua.json b/front/php/templates/language/uk_ua.json index e61bd9736..13c598cd2 100644 --- a/front/php/templates/language/uk_ua.json +++ b/front/php/templates/language/uk_ua.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "Наявність пристрою", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "Агент Вниз", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "Зв'язки", "Device_TableHead_CustomProps": "Реквізит / дії", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "ID рядка", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "Вихідний плагін", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "Статус", "Device_TableHead_SyncHubNodeName": "Вузол синхронізації", "Device_TableHead_Type": "Тип", diff --git a/front/php/templates/language/vi_vn.json b/front/php/templates/language/vi_vn.json index 664601f68..1d27b99ae 100644 --- a/front/php/templates/language/vi_vn.json +++ b/front/php/templates/language/vi_vn.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "", "Device_Shortcut_Unstable": "", "Device_TableHead_AlertDown": "", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "", "Device_TableHead_Connected_Devices": "", "Device_TableHead_CustomProps": "", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "", "Device_TableHead_SSID": "", "Device_TableHead_SourcePlugin": "", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "", "Device_TableHead_SyncHubNodeName": "", "Device_TableHead_Type": "", diff --git a/front/php/templates/language/zh_cn.json b/front/php/templates/language/zh_cn.json index d5f0e33bc..036da1f53 100644 --- a/front/php/templates/language/zh_cn.json +++ b/front/php/templates/language/zh_cn.json @@ -233,6 +233,8 @@ "Device_Shortcut_OnlineChart": "设备统计", "Device_Shortcut_Unstable": "不稳定", "Device_TableHead_AlertDown": "提醒宕机", + "Device_TableHead_AlertEvents": "", + "Device_TableHead_CanSleep": "", "Device_TableHead_Comments": "注释", "Device_TableHead_Connected_Devices": "链接", "Device_TableHead_CustomProps": "属性", @@ -263,6 +265,7 @@ "Device_TableHead_Rowid": "排行", "Device_TableHead_SSID": "SSID", "Device_TableHead_SourcePlugin": "源插件", + "Device_TableHead_StaticIP": "", "Device_TableHead_Status": "状态", "Device_TableHead_SyncHubNodeName": "同步节点", "Device_TableHead_Type": "类型", diff --git a/server/plugins/_publisher_mqtt/mqtt.py b/server/plugins/_publisher_mqtt/mqtt.py index 71eccae3a..ce5e419b1 100755 --- a/server/plugins/_publisher_mqtt/mqtt.py +++ b/server/plugins/_publisher_mqtt/mqtt.py @@ -256,25 +256,36 @@ class sensor_config: # ------------------------------------------------------------------------------- +# A single publish() call is retried a bounded number of times (not +# indefinitely) - see publish_mqtt()'s docstring for why. +_PUBLISH_MAX_ATTEMPTS = 20 +_PUBLISH_RETRY_DELAY_SEC = 0.1 + + def publish_mqtt(mqtt_client, topic, message): """ Publishes a message to an MQTT topic using the provided MQTT client. If the message is not a string, it is converted to a JSON-formatted string. The function retrieves the desired QoS level from settings and logs the publishing process. If the client is not connected to the broker, the function logs an error and aborts. - It attempts to publish the message, retrying until the publish status indicates success. + Retries a bounded number of times (_PUBLISH_MAX_ATTEMPTS) on failure rather + than indefinitely - an unbounded retry here would burn this plugin's whole + RUN_TIMEOUT budget on one stuck publish call whenever the broker is + degraded (accepting connections but rejecting publishes), silently + dropping every other device queued for this run. Args: mqtt_client: The MQTT client instance used to publish the message. topic (str): The MQTT topic to publish to. message (Any): The message payload to send. Non-string messages are converted to JSON. Returns: - bool: True if the message was published successfully, False if not connected to the broker. + bool: True if the message was published successfully, False if not + connected to the broker or the retry budget was exhausted. """ status = 1 # convert anything but a simple string to json if not isinstance(message, str): - message = json.dumps(message).replace("'", '"') + message = json.dumps(message) qos = get_setting_value('MQTT_QOS') @@ -286,7 +297,7 @@ def publish_mqtt(mqtt_client, topic, message): mylog('minimal', [f"[{pluginName}] ⚠ ERROR: Not connected to broker, aborting."]) return False - while status != 0: + for attempt in range(_PUBLISH_MAX_ATTEMPTS): # mylog('verbose', [f"[{pluginName}] mqtt_client.publish "]) # mylog('verbose', [f"[{pluginName}] mqtt_client.is_connected(): {mqtt_client.is_connected()} "]) @@ -303,10 +314,14 @@ def publish_mqtt(mqtt_client, topic, message): # mylog('verbose', [f"[{pluginName}] status: {status}"]) # mylog('verbose', [f"[{pluginName}] result: {result}"]) - if status != 0: - mylog('debug', [f"[{pluginName}] Waiting to reconnect to MQTT broker"]) - time.sleep(0.1) - return True + if status == 0: + return True + + mylog('debug', [f"[{pluginName}] Waiting to reconnect to MQTT broker (attempt {attempt + 1}/{_PUBLISH_MAX_ATTEMPTS})"]) + time.sleep(_PUBLISH_RETRY_DELAY_SEC) + + mylog('minimal', [f"[{pluginName}] ⚠ ERROR: Giving up on topic {topic} after {_PUBLISH_MAX_ATTEMPTS} attempts (status={status})."]) + return False # ------------------------------------------------------------------------------ @@ -433,6 +448,44 @@ def mqtt_create_client(): return mqtt_client +# ----------------------------------------------------------------------------- +def build_device_id(mac): + """Turn a device's MAC into the slug used as its Home Assistant deviceId + (topic/unique_id component) - e.g. 'AA:BB:CC' -> 'mac_aabbcc'.""" + return 'mac_' + mac.replace(" ", "").replace(":", "_").lower() + + +# ----------------------------------------------------------------------------- +def build_display_name(name): + """Strip characters Home Assistant's entity naming doesn't accept from a + device's name, for use as its displayed sensor/device_tracker name.""" + return re.sub('[^a-zA-Z0-9-_\\s]', '', normalize_string(name)) + + +# ----------------------------------------------------------------------------- +def build_device_tracker_attributes(device, devices, devDisplayName): + """Build the shared JSON payload published both to a device's individual + sensor state topic and to its device_tracker's json_attributes_topic - + every key here becomes a Home Assistant entity attribute.""" + return { + "last_ip": device["devLastIP"], + "is_new": str(device["devIsNew"]), + "alert_down": str(device["devAlertDown"]), + "vendor": sanitize_string(device["devVendor"]), + "mac_address": str(device["devMac"]), + "model": devDisplayName, + "last_connection": format_date_iso(str(device["devLastConnection"])), + "first_connection": format_date_iso(str(device["devFirstConnection"])), + "sync_node": device["devSyncHubNode"], + "group": device["devGroup"], + "location": device["devLocation"], + "ssid": device["devSSID"] if "devSSID" in device.keys() else "", + "vlan": device["devVlan"] if "devVlan" in device.keys() else "", + "network_parent_mac": device["devParentMAC"], + "network_parent_name": next((dev["devName"] for dev in devices if dev["devMac"] == device["devParentMAC"]), "") + } + + # ----------------------------------------------------------------------------- def mqtt_start(db): @@ -493,9 +546,8 @@ def mqtt_start(db): # # debug statement END 🔺 # Create devices in Home Assistant - send config messages - deviceId = 'mac_' + device["devMac"].replace(" ", "").replace(":", "_").lower() - # Normalize the string and remove unwanted characters - devDisplayName = re.sub('[^a-zA-Z0-9-_\\s]', '', normalize_string(device["devName"])) + deviceId = build_device_id(device["devMac"]) + devDisplayName = build_display_name(device["devName"]) sensorConfig = create_sensor(mqtt_client, deviceId, devDisplayName, 'sensor', 'last_ip', 'ip-network', device["devMac"]) sensorConfig = create_sensor(mqtt_client, deviceId, devDisplayName, 'sensor', 'mac_address', 'folder-key-network', device["devMac"]) @@ -506,21 +558,7 @@ def mqtt_start(db): # handle device_tracker # IMPORTANT: shared payload - device_tracker attributes and individual sensors - devJson = { - "last_ip": device["devLastIP"], - "is_new": str(device["devIsNew"]), - "alert_down": str(device["devAlertDown"]), - "vendor": sanitize_string(device["devVendor"]), - "mac_address": str(device["devMac"]), - "model": devDisplayName, - "last_connection": format_date_iso(str(device["devLastConnection"])), - "first_connection": format_date_iso(str(device["devFirstConnection"])), - "sync_node": device["devSyncHubNode"], - "group": device["devGroup"], - "location": device["devLocation"], - "network_parent_mac": device["devParentMAC"], - "network_parent_name": next((dev["devName"] for dev in devices if dev["devMac"] == device["devParentMAC"]), "") - } + devJson = build_device_tracker_attributes(device, devices, devDisplayName) # bulk update device sensors in home assistant publish_mqtt(mqtt_client, sensorConfig.state_topic, devJson) # REQUIRED, DON'T DELETE diff --git a/server/plugins/ui_settings/config.json b/server/plugins/ui_settings/config.json index 5455befc9..debfd913f 100755 --- a/server/plugins/ui_settings/config.json +++ b/server/plugins/ui_settings/config.json @@ -477,7 +477,10 @@ "Device_TableHead_IPv4", "Device_TableHead_IPv6", "Device_TableHead_Flapping", - "Device_TableHead_Comments" + "Device_TableHead_Comments", + "Device_TableHead_AlertEvents", + "Device_TableHead_CanSleep", + "Device_TableHead_StaticIP" ], "localized": ["name", "description"], "name": [ diff --git a/server/scan/device_handling.py b/server/scan/device_handling.py index 4e6bec719..14aa4eb3f 100755 --- a/server/scan/device_handling.py +++ b/server/scan/device_handling.py @@ -572,17 +572,22 @@ def save_own_device(db): # ------------------------------------------------------------------------------- def print_scan_stats(db): + """ + Log diagnostic scan-cycle counts (devices detected, new devices, down + alerts, new connections, disconnections, IP changes, per-plugin scan + method counts) at verbose/trace log levels. No return value. + """ sql = db.sql # TO-DO query = f""" SELECT (SELECT COUNT(*) FROM CurrentScan) AS devices_detected, (SELECT COUNT(*) FROM CurrentScan WHERE NOT EXISTS (SELECT 1 FROM Devices WHERE devMac = scanMac)) AS new_devices, - (SELECT COUNT(*) FROM DevicesView WHERE devAlertDown != 0 AND devIsSleeping = 0 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS down_alerts, + (SELECT COUNT(*) FROM DevicesView WHERE devAlertDown != 0 AND devIsSleeping = 0 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE scanMac = devMac)) AS down_alerts, (SELECT COUNT(*) FROM DevicesView WHERE devAlertDown != 0 AND devCanSleep = 0 AND devPresentLastScan = 1 - AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac) + AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE scanMac = devMac) ) AS new_down_alerts, (SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 0) AS new_connections, (SELECT COUNT(*) FROM Devices WHERE devPresentLastScan = 1 AND NOT EXISTS (SELECT 1 FROM CurrentScan WHERE devMac = scanMac)) AS disconnections, diff --git a/test/db_test_helpers.py b/test/db_test_helpers.py index 66763ac2b..d49c18fc0 100644 --- a/test/db_test_helpers.py +++ b/test/db_test_helpers.py @@ -95,8 +95,8 @@ CREATE_EVENTS = """ CREATE_CURRENT_SCAN = """ CREATE TABLE IF NOT EXISTS CurrentScan ( - scanMac TEXT, - scanLastIP TEXT, + scanMac TEXT COLLATE NOCASE, + scanLastIP TEXT COLLATE NOCASE, scanVendor TEXT, scanSourcePlugin TEXT, scanName TEXT, diff --git a/test/plugins/test_publisher_mqtt.py b/test/plugins/test_publisher_mqtt.py new file mode 100644 index 000000000..35b080402 --- /dev/null +++ b/test/plugins/test_publisher_mqtt.py @@ -0,0 +1,277 @@ +"""Tests for the _publisher_mqtt (MQTT) plugin's device_tracker attribute payload. + +mqtt.py is loaded with its NetAlertX-internal dependencies (conf, const, +utils.plugin_utils, plugin_helper, logger, helper, database, +utils.datetime_utils, models.notification_instance) and the external +paho.mqtt.client/pytz packages stubbed out - same approach +test_wificanary.py/test_adguard_import.py use - so this runs without the full +devcontainer environment or a real MQTT broker. `sanitize_string`/ +`format_date_iso` are reimplemented locally (same shape as helper's/ +utils.datetime_utils') to avoid pulling in their own dependency chains. + +Regression coverage for GitHub issue #1816: devSSID and devVlan were missing +from the JSON payload published to both a device's individual sensor state +topic and its device_tracker's json_attributes_topic (mqtt.py's +build_device_tracker_attributes(), extracted from mqtt_start() specifically +so this logic is testable without mocking the whole MQTT publish flow). + +Also covers publish_mqtt()'s bounded retry: a failing publish() call used to +retry indefinitely (an unbounded `while status != 0` loop), which could burn +this plugin's entire RUN_TIMEOUT budget on one stuck call against a degraded +broker. It now gives up after _PUBLISH_MAX_ATTEMPTS. +""" + +import importlib.util +import json +import os +import sys +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +INSTALL_PATH = os.getenv("NETALERTX_APP", "/app") +sys.path.extend([f"{INSTALL_PATH}/server/plugins", f"{INSTALL_PATH}/server"]) + + +def _sanitize_string(value): + """Same shape as helper.sanitize_string, without its import chain.""" + import re + return re.sub(r"[^a-zA-Z0-9-_\s]", "", str(value)) + + +def _format_date_iso(value): + """Same shape as utils.datetime_utils.format_date_iso, without its import chain.""" + return value + + +def _bytes_to_string(value): + """Same shape as helper.bytes_to_string, without its import chain.""" + if isinstance(value, bytes): + value = value.decode("utf-8") + return value + + +def _load_mqtt_module(): + missing_module = object() + previous_modules = {} + + def stub(name, **attributes): + previous_modules[name] = sys.modules.get(name, missing_module) + module = types.ModuleType(name) + for attribute, value in attributes.items(): + setattr(module, attribute, value) + sys.modules[name] = module + + stub("conf", tz=None) + stub("const", confFileName="/tmp/app.conf", logPath="/tmp") + stub("utils.plugin_utils", getPluginObject=MagicMock()) + stub("plugin_helper", Plugin_Objects=MagicMock) + stub("logger", mylog=MagicMock(), Logger=MagicMock()) + stub( + "helper", + get_setting_value=MagicMock(return_value="UTC"), + bytes_to_string=_bytes_to_string, + sanitize_string=_sanitize_string, + normalize_string=lambda s: s, + ) + stub("database", DB=MagicMock, get_device_stats=MagicMock()) + stub("utils.datetime_utils", timeNowUTC=MagicMock(), format_date_iso=_format_date_iso) + stub("models.notification_instance", NotificationInstance=MagicMock) + stub("pytz", timezone=MagicMock(return_value="UTC")) + stub("paho", mqtt=types.ModuleType("paho.mqtt")) + stub("paho.mqtt", client=types.ModuleType("paho.mqtt.client")) + stub("paho.mqtt.client", Client=MagicMock) + + module_path = Path(__file__).resolve().parents[2] / "server" / "plugins" / "_publisher_mqtt" / "mqtt.py" + spec = importlib.util.spec_from_file_location("publisher_mqtt_script", module_path) + module = importlib.util.module_from_spec(spec) + try: + spec.loader.exec_module(module) + finally: + for name, previous_module in previous_modules.items(): + if previous_module is missing_module: + sys.modules.pop(name, None) + else: + sys.modules[name] = previous_module + + return module + + +mqtt = _load_mqtt_module() + + +def _device(**overrides): + base = { + "devLastIP": "192.168.1.33", + "devIsNew": 0, + "devAlertDown": 0, + "devVendor": "Some Vendor", + "devMac": "44:ef:44:ef:44:ef", + "devLastConnection": "2026-01-01 00:00:00", + "devFirstConnection": "2025-01-01 00:00:00", + "devSyncHubNode": "", + "devGroup": "", + "devLocation": "", + "devSSID": "", + "devVlan": "", + "devParentMAC": "", + } + base.update(overrides) + return base + + +class TestBuildDeviceId: + def test_colons_become_underscores(self): + assert mqtt.build_device_id("AA:BB:CC:DD:EE:FF") == "mac_aa_bb_cc_dd_ee_ff" + + def test_spaces_are_stripped_hyphens_are_kept(self): + assert mqtt.build_device_id("AA-BB-CC DD:EE:FF") == "mac_aa-bb-ccdd_ee_ff" + + +class TestBuildDisplayName: + def test_plain_name_unchanged(self): + assert mqtt.build_display_name("Living Room TV") == "Living Room TV" + + def test_disallowed_punctuation_is_stripped(self): + assert mqtt.build_display_name("Bob's Phone!") == "Bobs Phone" + + def test_hyphen_and_underscore_are_kept(self): + assert mqtt.build_display_name("office-printer_2") == "office-printer_2" + + +class TestToBinarySensor: + def test_int_at_least_one_is_on(self): + assert mqtt.to_binary_sensor(1) == "ON" + assert mqtt.to_binary_sensor(2) == "ON" + + def test_int_zero_is_off(self): + assert mqtt.to_binary_sensor(0) == "OFF" + + def test_float_at_least_one_is_on(self): + assert mqtt.to_binary_sensor(1.0) == "ON" + + def test_bool_true_is_on(self): + assert mqtt.to_binary_sensor(True) == "ON" + + def test_bool_false_is_off(self): + assert mqtt.to_binary_sensor(False) == "OFF" + + def test_string_one_is_on(self): + assert mqtt.to_binary_sensor("1") == "ON" + + def test_other_strings_are_off(self): + assert mqtt.to_binary_sensor("0") == "OFF" + assert mqtt.to_binary_sensor("yes") == "OFF" + + def test_bytes_one_is_on(self): + assert mqtt.to_binary_sensor(b"1") == "ON" + + def test_bytes_other_is_off(self): + assert mqtt.to_binary_sensor(b"0") == "OFF" + + def test_none_is_off(self): + assert mqtt.to_binary_sensor(None) == "OFF" + + +class TestBuildDeviceTrackerAttributes: + def test_ssid_and_vlan_are_included(self): + device = _device(devSSID="HomeWiFi", devVlan="10") + attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device") + assert attrs["ssid"] == "HomeWiFi" + assert attrs["vlan"] == "10" + + def test_blank_ssid_and_vlan_pass_through_unchanged(self): + device = _device(devSSID="", devVlan="") + attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device") + assert attrs["ssid"] == "" + assert attrs["vlan"] == "" + + def test_existing_keys_still_present(self): + """Regression guard: the extraction in #1816 must not drop any of + the pre-existing payload keys.""" + device = _device() + attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device") + for key in ( + "last_ip", "is_new", "alert_down", "vendor", "mac_address", "model", + "last_connection", "first_connection", "sync_node", "group", "location", + "network_parent_mac", "network_parent_name", + ): + assert key in attrs, f"missing pre-existing key: {key}" + + def test_network_parent_name_resolved_from_devices_list(self): + parent = _device(devMac="aa:bb:cc:dd:ee:ff") + parent["devName"] = "Router" + child = _device(devParentMAC="aa:bb:cc:dd:ee:ff") + attrs = mqtt.build_device_tracker_attributes(child, [parent, child], "Child Device") + assert attrs["network_parent_name"] == "Router" + + def test_vendor_is_sanitized_but_ssid_is_not(self): + """vendor goes through sanitize_string() (OUI-lookup text); ssid does + not, since real-world SSIDs commonly contain punctuation that + sanitize_string() would strip out, mangling the displayed value.""" + device = _device(devVendor="TP-Link!", devSSID="Bob's WiFi!") + attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device") + assert attrs["vendor"] == "TP-Link" + assert attrs["ssid"] == "Bob's WiFi!" + + def test_missing_ssid_and_vlan_columns_default_to_blank(self): + """MQTT_DEVICES_SQL is a free-text user setting (default SELECT *, + but users can save a narrower custom query). A pre-#1816 custom query + that doesn't select devSSID/devVlan must not crash the whole device + loop - it should fall back to blank for just those two fields.""" + device = _device() + del device["devSSID"] + del device["devVlan"] + attrs = mqtt.build_device_tracker_attributes(device, [device], "My Device") + assert attrs["ssid"] == "" + assert attrs["vlan"] == "" + + +class TestPublishMqttBoundedRetry: + def _client(self, publish_return): + client = MagicMock() + client.publish.return_value = publish_return + return client + + def test_succeeds_immediately_on_first_try(self): + mqtt.mqtt_connected_to_broker = True + client = self._client((0, 1)) + with patch.object(mqtt.time, "sleep") as mock_sleep: + assert mqtt.publish_mqtt(client, "topic", "payload") is True + assert client.publish.call_count == 1 + mock_sleep.assert_not_called() + + def test_retries_then_succeeds(self): + mqtt.mqtt_connected_to_broker = True + client = MagicMock() + client.publish.side_effect = [(1, 1), (1, 1), (0, 1)] + with patch.object(mqtt.time, "sleep"): + assert mqtt.publish_mqtt(client, "topic", "payload") is True + assert client.publish.call_count == 3 + + def test_gives_up_after_max_attempts_instead_of_hanging_forever(self): + """The regression this guards against: a broker that always rejects + the publish must not spin the caller indefinitely.""" + mqtt.mqtt_connected_to_broker = True + client = self._client((1, 1)) # always fails + with patch.object(mqtt.time, "sleep") as mock_sleep: + assert mqtt.publish_mqtt(client, "topic", "payload") is False + assert client.publish.call_count == mqtt._PUBLISH_MAX_ATTEMPTS + assert mock_sleep.call_count == mqtt._PUBLISH_MAX_ATTEMPTS + + def test_aborts_immediately_when_not_connected(self): + mqtt.mqtt_connected_to_broker = False + client = self._client((0, 1)) + assert mqtt.publish_mqtt(client, "topic", "payload") is False + client.publish.assert_not_called() + + def test_dict_payload_with_apostrophe_serializes_to_valid_json(self): + """Regression guard: a prior post-serialization .replace("'", '"') + corrupted any string value containing an apostrophe (e.g. an SSID + like "Bob's WiFi!") into invalid JSON. json.dumps() output must be + published unmodified.""" + mqtt.mqtt_connected_to_broker = True + client = self._client((0, 1)) + mqtt.publish_mqtt(client, "topic", {"ssid": "Bob's WiFi!"}) + published_payload = client.publish.call_args.kwargs["payload"] + assert json.loads(published_payload) == {"ssid": "Bob's WiFi!"} diff --git a/test/scan/test_scan_stats_mac_case.py b/test/scan/test_scan_stats_mac_case.py new file mode 100644 index 000000000..6ca2afb82 --- /dev/null +++ b/test/scan/test_scan_stats_mac_case.py @@ -0,0 +1,105 @@ +""" +Regression coverage for GitHub issue #1818: print_scan_stats() (device_handling.py) +undercounted "down_alerts"/"new_down_alerts" whenever a device's stored MAC and its +CurrentScan MAC differed only by case. + +DevicesView exposes devMac as LOWER(IFNULL(devMac, '')) - a function-derived VIEW +column, not a bare table column. print_scan_stats()'s down-alert subqueries compared +it against CurrentScan.scanMac as `devMac = scanMac` (DevicesView column on the LEFT), +which - unlike a bare COLLATE NOCASE column comparison - loses NOCASE collation and +becomes a case-sensitive byte comparison. Reversing the operand order to +`scanMac = devMac` restores NOCASE collation (SQLite gives precedence to the left +operand's column collation; scanMac is a real COLLATE-free CurrentScan column but the +comparison still resolves via devMac's declared NOCASE affinity once devMac is on the +right, verified empirically against a real SQLite instance built from the actual +DevicesView definition - see server/scan/device_handling.py print_scan_stats()). + +This function is diagnostic/verbose-log-only (mylog("verbose"/"trace", ...), no other +consumer) - it does not drive real "Device Down" notifications, which go through +session_events.insert_events() -> presence.current_scan_presence_condition() instead +and were already safe (scanMac on the left there). This test covers the diagnostic +counter only. +""" + +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from db_test_helpers import ( # noqa: E402 + DummyDB, + make_device_dict, + insert_device_from_dict, + make_current_scan_dict, + insert_current_scan_row_from_dict, +) + +import scan.device_handling as device_handling # noqa: E402 +from scan.device_handling import print_scan_stats # noqa: E402 + + +def _run_stats(conn, monkeypatch): + """Call print_scan_stats() and return the logged down_alerts/new_down_alerts + counts, parsed straight out of its own mylog("verbose", ...) calls - this + exercises the real function/query rather than a re-derived copy of it.""" + logged = {} + + def fake_mylog(level, parts): + """Stand in for logger.mylog: parse the down_alerts/new_down_alerts + counts straight out of print_scan_stats()'s own log lines.""" + text = "".join(parts) if isinstance(parts, list) else str(parts) + if "Down Alerts" in text and "New Down Alerts" not in text: + logged["down_alerts"] = int(text.rsplit(":", 1)[1].strip()) + elif "New Down Alerts" in text: + logged["new_down_alerts"] = int(text.rsplit(":", 1)[1].strip()) + + monkeypatch.setattr(device_handling, "mylog", fake_mylog) + + db = DummyDB(conn) + print_scan_stats(db) + return logged + + +class TestScanStatsMacCase: + def test_matching_mac_different_case_is_not_a_false_down_alert(self, scan_db, monkeypatch): + """Device stored lowercase, present this cycle under an uppercase MAC + (e.g. a plugin like FREEBOX reporting uppercase) must not be counted + as down - this is the exact scenario from issue #1818.""" + device = make_device_dict( + mac="54:ef:44:9a:b6:7f", + devAlertDown=1, + devCanSleep=0, + devPresentLastScan=1, + ) + insert_device_from_dict(scan_db, device) + + current_scan = make_current_scan_dict(mac="54:EF:44:9A:B6:7F") + insert_current_scan_row_from_dict(scan_db, current_scan) + + stats = _run_stats(scan_db, monkeypatch) + + assert stats["down_alerts"] == 0 + assert stats["new_down_alerts"] == 0 + + def test_truly_absent_device_is_still_counted_as_down(self, scan_db, monkeypatch): + """Sanity check: a device genuinely missing from CurrentScan this + cycle must still be counted - the fix must not mask real down alerts. + + An unrelated CurrentScan row is seeded too: print_scan_stats() GROUPs + BY scanSourcePlugin, so an empty CurrentScan produces zero result rows + and print_scan_stats() falls back to its hardcoded all-zero stub - that + would make this assertion pass for the wrong reason.""" + device = make_device_dict( + mac="54:ef:44:9a:b6:7f", + devAlertDown=1, + devCanSleep=0, + devPresentLastScan=1, + ) + insert_device_from_dict(scan_db, device) + + other_scan = make_current_scan_dict(mac="11:22:33:44:55:66") + insert_current_scan_row_from_dict(scan_db, other_scan) + + stats = _run_stats(scan_db, monkeypatch) + + assert stats["down_alerts"] == 1 + assert stats["new_down_alerts"] == 1