PLG: MQTT add devVLan devSSID #1816

This commit is contained in:
jokob-sk committed 2026-09-27 09:25:58 +10:00
1 parent 2b748bbcd3
commit 81b28cb15e
2 files changed
+250 -18

No files matched your search

+41 -18
View File
@@ -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
+209
View File
@@ -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!"