mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-10-02 02:35:05 -04:00
PLG: MQTT add devVLan devSSID #1816
This commit is contained in:
1 parent
2b748bbcd3
commit
81b28cb15e
2 files changed
+250
-18
No files matched your search
@@ -433,6 +433,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"],
|
||||
"vlan": device["devVlan"],
|
||||
"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 +531,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 +543,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
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""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).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
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!"
|
||||
Reference in new issue
Block a user