From 77718a5239c7ff35221cab5972c683cd44d99baf Mon Sep 17 00:00:00 2001 From: Ryan Tregea Date: Mon, 22 Jun 2026 04:11:37 +0000 Subject: [PATCH 01/22] feat: expand MAC OUI database with router, switch, AP, NAS, and camera vendors Moves the OUI mapping from an inline dict in fingerprint.py into data/oui_database.json grouped by vendor, matching the service_signatures data-file pattern. Adds ~100 curated OUIs covering MikroTik, Ubiquiti, Synology, QNAP, Cisco, Aruba, Juniper, Hikvision, Dahua, Reolink, Axis, Raspberry Pi, Dell, Supermicro, and others. Existing IoT vendors and hypervisor OUIs are preserved. For multi-product vendors the OUI is tagged with the most common homelab category (e.g. Ubiquiti -> ap) and port hints in suggest_node_type continue to upgrade ambiguous matches (Ubiquiti + BGP -> router). --- backend/app/data/oui_database.json | 221 ++++++++++++++++++++++++++++ backend/app/services/fingerprint.py | 69 ++++----- backend/tests/test_fingerprint.py | 78 +++++++++- 3 files changed, 323 insertions(+), 45 deletions(-) create mode 100644 backend/app/data/oui_database.json diff --git a/backend/app/data/oui_database.json b/backend/app/data/oui_database.json new file mode 100644 index 0000000..af99dd8 --- /dev/null +++ b/backend/app/data/oui_database.json @@ -0,0 +1,221 @@ +[ + { + "vendor": "Proxmox / QEMU / KVM", + "type": "vm", + "prefixes": ["52:54:00", "bc:24:11"] + }, + { + "vendor": "VMware", + "type": "vm", + "prefixes": ["00:50:56", "00:0c:29", "00:05:69", "00:1c:14"] + }, + { + "vendor": "VirtualBox", + "type": "vm", + "prefixes": ["08:00:27"] + }, + { + "vendor": "Microsoft Hyper-V", + "type": "vm", + "prefixes": ["00:15:5d"] + }, + { + "vendor": "Xen", + "type": "vm", + "prefixes": ["00:16:3e"] + }, + + { + "vendor": "MikroTik", + "type": "router", + "prefixes": [ + "00:0c:42", + "08:55:31", + "18:fd:74", + "2c:c8:1b", + "48:8f:5a", + "4c:5e:0c", + "64:d1:54", + "6c:3b:6b", + "74:4d:28", + "b8:69:f4", + "c4:ad:34", + "cc:2d:e0", + "d4:ca:6d", + "dc:2c:6e", + "e4:8d:8c" + ] + }, + { + "vendor": "Ubiquiti", + "type": "ap", + "prefixes": [ + "00:15:6d", + "00:27:22", + "04:18:d6", + "24:5a:4c", + "24:a4:3c", + "44:d9:e7", + "68:72:51", + "68:d7:9a", + "74:83:c2", + "78:8a:20", + "78:45:58", + "80:2a:a8", + "94:2a:6f", + "9c:05:d6", + "b4:fb:e4", + "dc:9f:db", + "e0:63:da", + "f0:9f:c2", + "fc:ec:da" + ] + }, + { + "vendor": "Ruckus Wireless", + "type": "ap", + "prefixes": ["00:13:92", "4c:b1:cd", "8c:7a:15", "f0:b0:52", "c0:8a:de"] + }, + { + "vendor": "Aruba Networks (HPE)", + "type": "ap", + "prefixes": ["00:0b:86", "6c:f3:7f", "94:b4:0f", "9c:1c:12", "ac:a3:1e"] + }, + + { + "vendor": "Cisco Systems", + "type": "switch", + "prefixes": [ + "00:00:0c", + "00:1b:0d", + "00:1c:f6", + "00:1e:13", + "00:23:04", + "00:24:13", + "00:25:45", + "00:50:0b", + "b0:00:b4", + "b8:38:61", + "f8:c0:01" + ] + }, + { + "vendor": "Juniper Networks", + "type": "switch", + "prefixes": ["00:14:f6", "2c:6b:f5", "b0:c6:9a", "f0:1c:2d"] + }, + { + "vendor": "Zyxel", + "type": "switch", + "prefixes": ["00:13:49", "60:31:97", "ec:43:f6"] + }, + + { + "vendor": "Netgear", + "type": "router", + "prefixes": ["00:09:5b", "28:c6:8e", "c0:ff:d4", "2c:30:33", "a0:40:a0"] + }, + { + "vendor": "TP-Link", + "type": "router", + "prefixes": ["14:eb:b6", "60:e3:27", "b0:4e:26", "c4:e9:0a", "ec:08:6b"] + }, + + { + "vendor": "Synology", + "type": "nas", + "prefixes": ["00:11:32", "00:f4:6f", "90:09:d0"] + }, + { + "vendor": "QNAP Systems", + "type": "nas", + "prefixes": ["00:08:9b", "00:0e:23", "00:13:42", "04:f0:21", "24:5e:be"] + }, + { + "vendor": "Asustor", + "type": "nas", + "prefixes": ["e8:9c:25"] + }, + + { + "vendor": "Hikvision", + "type": "camera", + "prefixes": ["28:57:be", "44:19:b6", "b4:a3:82", "bc:ad:28", "c0:51:7e", "c0:56:e3", "c4:2f:90"] + }, + { + "vendor": "Dahua / Amcrest", + "type": "camera", + "prefixes": ["3c:ef:8c", "4c:11:bf", "90:02:a9", "bc:32:5f", "e0:50:8b"] + }, + { + "vendor": "Reolink", + "type": "camera", + "prefixes": ["ec:71:db"] + }, + { + "vendor": "Axis Communications", + "type": "camera", + "prefixes": ["00:40:8c", "ac:cc:8e"] + }, + + { + "vendor": "Raspberry Pi Foundation", + "type": "server", + "prefixes": ["28:cd:c1", "2c:cf:67", "b8:27:eb", "d8:3a:dd", "dc:a6:32", "e4:5f:01"] + }, + { + "vendor": "Dell", + "type": "server", + "prefixes": ["00:14:22", "90:b1:1c", "b0:83:fe", "b8:ca:3a", "f8:b1:56"] + }, + { + "vendor": "Supermicro", + "type": "server", + "prefixes": ["00:25:90", "0c:c4:7a", "ac:1f:6b"] + }, + + { + "vendor": "Shelly", + "type": "iot", + "prefixes": ["30:c6:f7", "34:94:54", "84:f3:eb", "ec:fa:bc"] + }, + { + "vendor": "Espressif (ESP8266 / ESP32)", + "type": "iot", + "prefixes": [ + "24:62:ab", + "30:ae:a4", + "3c:71:bf", + "8c:aa:b5", + "a0:20:a6", + "ac:67:b2", + "b4:e6:2d", + "cc:50:e3" + ] + }, + { + "vendor": "Sonoff / ITEAD", + "type": "iot", + "prefixes": ["dc:4f:22", "e8:db:84"] + }, + { + "vendor": "TP-Link Tapo / Kasa", + "type": "iot", + "prefixes": ["10:27:f5", "1c:3b:f3", "50:c7:bf", "b0:a7:b9"] + }, + { + "vendor": "Philips Hue", + "type": "iot", + "prefixes": ["00:17:88", "ec:b5:fa"] + }, + { + "vendor": "IKEA Tradfri", + "type": "iot", + "prefixes": ["00:21:2e", "34:13:e8"] + }, + { + "vendor": "Tuya / Smart Life", + "type": "iot", + "prefixes": ["68:57:2d", "d8:f1:5b"] + } +] diff --git a/backend/app/services/fingerprint.py b/backend/app/services/fingerprint.py index 1a2b4a8..460bdf3 100644 --- a/backend/app/services/fingerprint.py +++ b/backend/app/services/fingerprint.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any _SIGNATURES: list[dict[str, Any]] | None = None +_OUI_MAP: dict[str, str] | None = None _LOCK = threading.Lock() @@ -26,6 +27,29 @@ def _load() -> list[dict[str, Any]]: return _SIGNATURES +def _load_oui() -> dict[str, str]: + """Load OUI database and flatten to {prefix: node_type}.""" + global _OUI_MAP + if _OUI_MAP is None: + with _LOCK: + if _OUI_MAP is None: + path = Path(__file__).parent.parent / "data" / "oui_database.json" + try: + with open(path) as f: + entries = json.load(f) + except FileNotFoundError as err: + raise FileNotFoundError( + f"oui_database.json not found at {path}. " + "This file should be bundled with the application." + ) from err + _OUI_MAP = { + prefix.lower(): entry["type"] + for entry in entries + for prefix in entry["prefixes"] + } + return _OUI_MAP + + def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None: """Return the first signature matching port+protocol, optionally banner.""" for sig in _load(): @@ -65,55 +89,12 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]: return results -# Known OUI prefixes β€” lowercase, colon-separated, first 3 octets -_MAC_OUI_TYPES: dict[str, str] = { - # Hypervisors / VMs - "52:54:00": "vm", # QEMU/KVM (Proxmox VMs) - "bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+) - "00:50:56": "vm", # VMware - "00:0c:29": "vm", # VMware Workstation / Fusion - "08:00:27": "vm", # VirtualBox - "00:15:5d": "vm", # Hyper-V - # Shelly - "34:94:54": "iot", - "84:f3:eb": "iot", - "ec:fa:bc": "iot", - "30:c6:f7": "iot", - # Espressif (ESP8266 / ESP32 β€” used by Sonoff, many DIY IoT) - "a0:20:a6": "iot", - "24:62:ab": "iot", - "30:ae:a4": "iot", - "cc:50:e3": "iot", - "ac:67:b2": "iot", - "b4:e6:2d": "iot", - "3c:71:bf": "iot", - "8c:aa:b5": "iot", - # Sonoff / ITEAD - "dc:4f:22": "iot", - "e8:db:84": "iot", - # Tapo / TP-Link smart home - "b0:a7:b9": "iot", - "50:c7:bf": "iot", - "1c:3b:f3": "iot", - "10:27:f5": "iot", - # Philips Hue - "00:17:88": "iot", - "ec:b5:fa": "iot", - # IKEA Tradfri - "34:13:e8": "iot", - "00:21:2e": "iot", - # Tuya / Smart Life (widely used chip in many brands) - "d8:f1:5b": "iot", - "68:57:2d": "iot", -} - - def suggest_type_from_mac(mac: str | None) -> str | None: """Return a suggested node type from MAC OUI, or None if unknown.""" if not mac: return None prefix = mac.lower()[:8] - return _MAC_OUI_TYPES.get(prefix) + return _load_oui().get(prefix) _PORT_TYPE_HINTS: dict[int, str] = { diff --git a/backend/tests/test_fingerprint.py b/backend/tests/test_fingerprint.py index 713b9bf..168b437 100644 --- a/backend/tests/test_fingerprint.py +++ b/backend/tests/test_fingerprint.py @@ -2,7 +2,12 @@ from unittest.mock import patch import pytest -from app.services.fingerprint import fingerprint_ports, match_port, suggest_node_type +from app.services.fingerprint import ( + fingerprint_ports, + match_port, + suggest_node_type, + suggest_type_from_mac, +) MOCK_SIGNATURES = [ {"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"}, @@ -173,3 +178,74 @@ def test_suggest_node_type_iot_wins_over_server_when_mqtt_present(): {"port": 1883, "protocol": "tcp"}, ]) assert result == "iot" + + +# ── OUI vendor detection ────────────────────────────────────────────────────── + +def test_suggest_type_from_mac_mikrotik_returns_router(): + # The motivating case: MikroTik MAC should be recognized as a router + assert suggest_type_from_mac("4c:5e:0c:11:22:33") == "router" + assert suggest_type_from_mac("b8:69:f4:aa:bb:cc") == "router" + + +def test_suggest_type_from_mac_ubiquiti_returns_ap(): + # Ubiquiti makes routers, switches, APs, cameras β€” most homelab gear is UniFi APs, + # so OUI defaults to "ap". Port hints can still upgrade to "router" if BGP/VPN open. + assert suggest_type_from_mac("24:a4:3c:11:22:33") == "ap" + assert suggest_type_from_mac("fc:ec:da:aa:bb:cc") == "ap" + + +def test_suggest_type_from_mac_synology_returns_nas(): + assert suggest_type_from_mac("00:11:32:11:22:33") == "nas" + + +def test_suggest_type_from_mac_qnap_returns_nas(): + assert suggest_type_from_mac("24:5e:be:aa:bb:cc") == "nas" + + +def test_suggest_type_from_mac_hikvision_returns_camera(): + assert suggest_type_from_mac("28:57:be:11:22:33") == "camera" + + +def test_suggest_type_from_mac_dahua_returns_camera(): + assert suggest_type_from_mac("3c:ef:8c:aa:bb:cc") == "camera" + + +def test_suggest_type_from_mac_cisco_returns_switch(): + assert suggest_type_from_mac("b8:38:61:11:22:33") == "switch" + + +def test_suggest_type_from_mac_raspberry_pi_returns_server(): + assert suggest_type_from_mac("b8:27:eb:11:22:33") == "server" + + +def test_suggest_type_from_mac_handles_uppercase(): + # MACs may arrive in any case; lookup must be case-insensitive + assert suggest_type_from_mac("4C:5E:0C:11:22:33") == "router" + + +def test_suggest_type_from_mac_unknown_oui_returns_none(): + assert suggest_type_from_mac("00:00:01:11:22:33") is None + + +def test_suggest_node_type_mikrotik_mac_returns_router_no_ports(): + # MikroTik device with no scanned ports should still be classified as router via MAC + assert suggest_node_type([], mac="4c:5e:0c:11:22:33") == "router" + + +def test_suggest_node_type_synology_mac_with_http_returns_nas(): + # NAS priority beats server, so a Synology MAC + open HTTP β†’ nas + result = suggest_node_type( + [{"port": 80, "protocol": "tcp"}], + mac="00:11:32:11:22:33", + ) + assert result == "nas" + + +def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router(): + # Ubiquiti OUI suggests "ap", but BGP port hint upgrades to "router" (higher priority) + result = suggest_node_type( + [{"port": 179, "protocol": "tcp"}], + mac="24:a4:3c:11:22:33", + ) + assert result == "router" From 83b296747c5a0084cbccc98e394435015c763da2 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 23 Jun 2026 11:03:53 +0200 Subject: [PATCH 02/22] chore: fix high-severity npm audit advisories (undici, babel, js-yaml) ha-relevant: no --- frontend/package-lock.json | 184 +++++++++++++++++++------------------ 1 file changed, 97 insertions(+), 87 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7a7b97d..20a51d2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -118,12 +118,12 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -132,29 +132,29 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -171,13 +171,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -199,13 +199,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -236,9 +236,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -258,27 +258,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -339,52 +339,52 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -519,31 +519,31 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -551,13 +551,13 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -6638,9 +6638,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9138,9 +9148,9 @@ } }, "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { From 5d62df45f7e3241c17b530817f24e96dfe0b3e0e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 23 Jun 2026 11:28:59 +0200 Subject: [PATCH 03/22] chore: bump zeroconf to 0.149.16 for CVE-2026-48487 ha-relevant: no --- backend/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/requirements.txt b/backend/requirements.txt index 01720fc..0b02927 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,7 +15,7 @@ pyyaml==6.0.2 types-PyYAML==6.0.12.20240917 websockets==13.1 httpx==0.27.2 -zeroconf==0.149.12 +zeroconf==0.149.16 aiomqtt==2.3.0 # Dev From b20c47a0a5f3a4e0f0b13c564eba401abe39a68c Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 23 Jun 2026 12:25:10 +0200 Subject: [PATCH 04/22] feat: add tiered service matching with http_regex and port-agnostic signatures Adds match_service() priority walk for fingerprinting: 1. port + http_regex confirmed 2. port + banner_regex confirmed 3. port:null + http_regex confirmed (custom-port services) 4. port-only fallback http_regex is strict only once a probe has run; with no probe signals it degrades to port-only matching, preserving pre-probe behaviour. match_port kept as a probe-less alias. ha-relevant: yes --- backend/app/services/fingerprint.py | 102 ++++++++++++++++++++++++---- backend/tests/test_fingerprint.py | 84 +++++++++++++++++++++++ 2 files changed, 173 insertions(+), 13 deletions(-) diff --git a/backend/app/services/fingerprint.py b/backend/app/services/fingerprint.py index 460bdf3..4532644 100644 --- a/backend/app/services/fingerprint.py +++ b/backend/app/services/fingerprint.py @@ -50,25 +50,101 @@ def _load_oui() -> dict[str, str]: return _OUI_MAP -def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None: - """Return the first signature matching port+protocol, optionally banner.""" - for sig in _load(): - if sig["port"] != port or sig["protocol"] != protocol: - continue - if sig.get("banner_regex") and (not banner or not re.search(sig["banner_regex"], banner, re.IGNORECASE)): - continue - return sig - return None +def _http_regex_hit(sig: dict[str, Any], http_signals: dict[str, Any] | None) -> bool: + """True when the signature's http_regex matches the probe's title/headers.""" + rx = sig.get("http_regex") + if not rx or not http_signals: + return False + headers = http_signals.get("headers") or {} + haystack = " ".join( + s for s in ( + http_signals.get("title"), + headers.get("Server"), + headers.get("X-Powered-By"), + ) if s + ) + return bool(haystack and re.search(rx, haystack, re.IGNORECASE)) -def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _service_tier( + sig: dict[str, Any], + port: int, + protocol: str, + banner: str | None, + http_signals: dict[str, Any] | None, +) -> int | None: """ - Given a list of {port, protocol, banner?} dicts, return matched services. - Unknown ports are included as unknown_service. + Rank how well a signature matches (lower = stronger). None = not a match. + + Tier 1: port match + http_regex confirmed + Tier 2: port match + banner_regex confirmed + Tier 3: port-agnostic (port: null) + http_regex confirmed + Tier 4: port match only (no regex, or http_regex with probe disabled) + + When http_signals is None (probe not run) an http_regex entry degrades to + a port-only match β€” identical to pre-probe behaviour, no regression. + When http_signals is provided, http_regex is strict: a miss disqualifies. + """ + probe_ran = http_signals is not None + has_http = bool(sig.get("http_regex")) + + # Port-agnostic entries (port: null) match purely on HTTP signals. + if sig.get("port") is None: + if has_http and _http_regex_hit(sig, http_signals): + return 3 + return None + + if sig["port"] != port or sig["protocol"] != protocol: + return None + + # http_regex is authoritative once a probe has run. + if has_http and probe_ran: + return 1 if _http_regex_hit(sig, http_signals) else None + + if sig.get("banner_regex"): + if banner and re.search(sig["banner_regex"], banner, re.IGNORECASE): + return 2 + return None + + # No regex constraint (or http_regex but probe disabled) β†’ port-only guess. + return 4 + + +def match_service( + port: int, + protocol: str, + banner: str | None = None, + http_signals: dict[str, Any] | None = None, +) -> dict[str, Any] | None: + """Return the best signature for a port, walking tiers most-specific first.""" + best: dict[str, Any] | None = None + best_tier = 99 + for sig in _load(): + tier = _service_tier(sig, port, protocol, banner, http_signals) + if tier is not None and tier < best_tier: + best, best_tier = sig, tier + if best_tier == 1: + break # strongest possible β€” stop early + return best + + +def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None: + """Back-compat alias: match without HTTP-probe signals.""" + return match_service(port, protocol, banner) + + +def fingerprint_ports( + open_ports: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """ + Given a list of {port, protocol, banner?, http_signals?} dicts, return + matched services. Unknown ports are included as unknown_service. """ results = [] for p in open_ports: - sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner")) + sig = match_service( + p["port"], p.get("protocol", "tcp"), p.get("banner"), p.get("http_signals") + ) if sig: results.append({ "port": p["port"], diff --git a/backend/tests/test_fingerprint.py b/backend/tests/test_fingerprint.py index 168b437..bdf487e 100644 --- a/backend/tests/test_fingerprint.py +++ b/backend/tests/test_fingerprint.py @@ -5,6 +5,7 @@ import pytest from app.services.fingerprint import ( fingerprint_ports, match_port, + match_service, suggest_node_type, suggest_type_from_mac, ) @@ -249,3 +250,86 @@ def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router(): mac="24:a4:3c:11:22:33", ) assert result == "router" + + +# ── match_service: HTTP probe + port-agnostic ────────────────────────────────── + +HTTP_SIGNATURES = [ + # Generic web fallback on 8096 (port-only guess) + {"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None, + "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"}, + # Same port, but confirmed by HTML title β†’ should win when probe confirms + {"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin", + "service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"}, + # Port-agnostic: matches on HTTP content regardless of port + {"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer", + "service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"}, + # Banner-based entry, no http + {"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None, + "service_name": "Prometheus", "icon": "πŸ”₯", "category": "monitoring", "suggested_node_type": "server"}, +] + + +@pytest.fixture +def http_signatures(): + with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES): + yield + + +def test_http_regex_confirmed_beats_port_only(http_signatures): + # Probe ran and title matches β†’ Jellyfin (tier 1) beats generic HTTP (tier 4) + sig = match_service(8096, "tcp", banner=None, + http_signals={"title": "Jellyfin", "headers": {}}) + assert sig["service_name"] == "Jellyfin" + + +def test_http_regex_matches_on_header(http_signatures): + sig = match_service(8096, "tcp", banner=None, + http_signals={"title": None, "headers": {"Server": "Jellyfin"}}) + assert sig["service_name"] == "Jellyfin" + + +def test_http_regex_miss_falls_back_to_port_only(http_signatures): + # Probe ran but nothing matched the http_regex β†’ generic port-only entry wins + sig = match_service(8096, "tcp", banner=None, + http_signals={"title": "Some Other App", "headers": {}}) + assert sig["service_name"] == "HTTP" + + +def test_probe_disabled_ignores_http_regex(http_signatures): + # http_signals=None (deep scan off) β†’ http_regex entry degrades to port-only, + # generic entry (listed first) wins β€” identical to pre-probe behaviour. + sig = match_service(8096, "tcp", banner=None, http_signals=None) + assert sig["service_name"] == "HTTP" + + +def test_port_agnostic_match_on_custom_port(http_signatures): + # Portainer found on a non-standard port, recognised purely by HTTP content + sig = match_service(54321, "tcp", banner=None, + http_signals={"title": "Portainer", "headers": {}}) + assert sig["service_name"] == "Portainer" + + +def test_port_agnostic_requires_probe(http_signatures): + # Same custom port, probe off β†’ no signal β†’ no match + assert match_service(54321, "tcp", banner=None, http_signals=None) is None + + +def test_banner_match_still_works_with_probe(http_signatures): + sig = match_service(9090, "tcp", banner="prometheus 2.x", + http_signals={"title": "x", "headers": {}}) + assert sig["service_name"] == "Prometheus" + + +def test_match_port_alias_has_no_http(http_signatures): + # match_port() is the probe-less alias β†’ http_regex entry degrades to port-only + sig = match_port(8096, "tcp") + assert sig["service_name"] == "HTTP" + + +def test_fingerprint_ports_uses_http_signals(http_signatures): + results = fingerprint_ports([ + {"port": 8096, "protocol": "tcp", "banner": None, + "http_signals": {"title": "Jellyfin", "headers": {}}}, + ]) + assert results[0]["service_name"] == "Jellyfin" From 44e0029f2b9d9eee7d9b123aead56b2e267aa076 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 23 Jun 2026 13:41:32 +0200 Subject: [PATCH 05/22] feat: add async HTTP prober for deep-scan service identification probe_port() GETs https:// then http:// for an open port and returns the page plus Server/X-Powered-By headers as identifying signals. Non-web ports (SSH, DB, etc.) are skipped; body read is capped at 64KB; 3s timeout. probe_open_ports() fans out over all open ports with a bounded semaphore. ha-relevant: yes --- backend/app/services/http_probe.py | 85 +++++++++++++++++++++ backend/tests/test_http_probe.py | 118 +++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 backend/app/services/http_probe.py create mode 100644 backend/tests/test_http_probe.py diff --git a/backend/app/services/http_probe.py b/backend/app/services/http_probe.py new file mode 100644 index 0000000..8ed9319 --- /dev/null +++ b/backend/app/services/http_probe.py @@ -0,0 +1,85 @@ +"""HTTP probe: GET a discovered port and extract identifying signals. + +Used by the optional deep-scan mode to confirm what service sits behind an +open port, regardless of port number. Returns the page <title> plus a small +set of identifying response headers, which fingerprint.match_service() then +matches against signature http_regex fields. +""" +import asyncio +import logging +import re +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +# Headers that commonly carry the application name. +_SIGNAL_HEADERS = ("Server", "X-Powered-By") +# Cap how much body we read when hunting for <title> β€” avoids large downloads. +_MAX_BODY_BYTES = 64 * 1024 +_TITLE_RE = re.compile(r"<title[^>]*>(.*?)", re.IGNORECASE | re.DOTALL) +_PROBE_TIMEOUT = 3.0 +# Ports we never bother probing over HTTP (not web services). +_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379}) + + +def _extract_title(body: str) -> str | None: + m = _TITLE_RE.search(body) + if not m: + return None + title = re.sub(r"\s+", " ", m.group(1)).strip() + return title or None + + +async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None: + try: + resp = await client.get(url, follow_redirects=True) + except (httpx.HTTPError, OSError): + return None + headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers} + body = resp.text[:_MAX_BODY_BYTES] if resp.text else "" + title = _extract_title(body) + if not title and not headers: + return None + return {"title": title, "headers": headers} + + +async def probe_port( + ip: str, port: int, verify_tls: bool = False +) -> dict[str, Any] | None: + """ + GET https:// then http:// for a port and return {title, headers} or None. + + None means the port did not answer HTTP or yielded no usable signal. + """ + if port in _NON_HTTP_PORTS: + return None + async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client: + for scheme in ("https", "http"): + result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/") + if result is not None: + return result + return None + + +async def probe_open_ports( + ip: str, + open_ports: list[dict[str, Any]], + verify_tls: bool = False, + concurrency: int = 50, +) -> list[dict[str, Any]]: + """ + Probe every open port for HTTP signals (option 2: probe all, match after). + + Returns the same port dicts, each enriched with an http_signals key + (None when the port gave no HTTP signal). + """ + sem = asyncio.Semaphore(concurrency) + + async def _one(p: dict[str, Any]) -> dict[str, Any]: + async with sem: + signals = await probe_port(ip, p["port"], verify_tls) + return {**p, "http_signals": signals} + + return await asyncio.gather(*(_one(p) for p in open_ports)) diff --git a/backend/tests/test_http_probe.py b/backend/tests/test_http_probe.py new file mode 100644 index 0000000..4175d63 --- /dev/null +++ b/backend/tests/test_http_probe.py @@ -0,0 +1,118 @@ +"""Tests for the HTTP probe used by deep-scan service identification.""" +from unittest.mock import AsyncMock, patch + +import httpx +import pytest + +from app.services.http_probe import ( + _extract_title, + probe_open_ports, + probe_port, +) + + +def _response(text: str = "", headers: dict | None = None, status: int = 200) -> httpx.Response: + return httpx.Response(status_code=status, text=text, headers=headers or {}) + + +# ── _extract_title ────────────────────────────────────────────────────────── + +def test_extract_title_basic(): + assert _extract_title("Jellyfin") == "Jellyfin" + + +def test_extract_title_collapses_whitespace(): + assert _extract_title("\n My App\n") == "My App" + + +def test_extract_title_missing(): + assert _extract_title("no title") is None + + +def test_extract_title_case_insensitive(): + assert _extract_title("Portainer") == "Portainer" + + +# ── probe_port ────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_probe_port_reads_title(): + with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("Jellyfin"))): + result = await probe_port("10.0.0.5", 8096) + assert result == {"title": "Jellyfin", "headers": {}} + + +@pytest.mark.asyncio +async def test_probe_port_reads_headers(): + resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"}) + with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)): + result = await probe_port("10.0.0.5", 3000) + assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"} + + +@pytest.mark.asyncio +async def test_probe_port_falls_back_to_http(): + # https raises, http succeeds + calls = {"n": 0} + + async def fake_get(self, url, **kw): + calls["n"] += 1 + if url.startswith("https"): + raise httpx.ConnectError("tls fail") + return _response("HTTP App") + + with patch("httpx.AsyncClient.get", new=fake_get): + result = await probe_port("10.0.0.5", 8080) + assert result["title"] == "HTTP App" + assert calls["n"] == 2 # tried https then http + + +@pytest.mark.asyncio +async def test_probe_port_no_signal_returns_none(): + with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))): + result = await probe_port("10.0.0.5", 8080) + assert result is None + + +@pytest.mark.asyncio +async def test_probe_port_timeout_returns_none(): + with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))): + result = await probe_port("10.0.0.5", 8080) + assert result is None + + +@pytest.mark.asyncio +async def test_probe_port_skips_non_http_ports(): + # SSH should never trigger an HTTP request + get = AsyncMock() + with patch("httpx.AsyncClient.get", new=get): + result = await probe_port("10.0.0.5", 22) + assert result is None + get.assert_not_called() + + +@pytest.mark.asyncio +async def test_probe_port_verify_tls_flag_passed(): + with patch("app.services.http_probe.httpx.AsyncClient") as client_cls: + instance = client_cls.return_value.__aenter__.return_value + instance.get = AsyncMock(return_value=_response("X")) + await probe_port("10.0.0.5", 8443, verify_tls=True) + assert client_cls.call_args.kwargs["verify"] is True + + +# ── probe_open_ports ───────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_probe_open_ports_enriches_each_port(): + async def fake_get(self, url, **kw): + if ":8096" in url: + return _response("Jellyfin") + return _response("") + + ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}] + with patch("httpx.AsyncClient.get", new=fake_get): + result = await probe_open_ports("10.0.0.5", ports) + + by_port = {p["port"]: p for p in result} + assert by_port[8096]["http_signals"]["title"] == "Jellyfin" + assert by_port[9999]["http_signals"] is None From 9d1ebd6c6eb24b643a9887de27d955ea45bb3cb8 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 23 Jun 2026 14:04:15 +0200 Subject: [PATCH 06/22] feat: wire deep-scan port ranges and HTTP probe into scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_scan() accepts DeepScanOptions: user port ranges are validated and appended to the nmap -p list; when http_probe_enabled, open ports are probed for HTTP signals before fingerprinting. Defaults (no options) reproduce the standard scan exactly β€” probe is never called, port list unchanged. ha-relevant: yes --- backend/app/services/scanner.py | 74 +++++++++++++++--- backend/tests/test_scan.py | 2 +- backend/tests/test_scanner.py | 134 +++++++++++++++++++++++++++++++- 3 files changed, 197 insertions(+), 13 deletions(-) diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index 4f0ac43..fccb315 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -7,6 +7,7 @@ import re import socket import subprocess import threading +from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any @@ -15,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import Node, PendingDevice, ScanRun from app.services.fingerprint import fingerprint_ports, suggest_node_type +from app.services.http_probe import probe_open_ports logger = logging.getLogger(__name__) @@ -34,6 +36,37 @@ _EXTRA_PORTS = ( "16686,34567,37777,51413,64738" ) +# nmap -p accepts "N" or "N-M"; user ranges are validated against this. +_PORT_RANGE_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$") + + +@dataclass +class DeepScanOptions: + """Per-scan deep-scan settings (None/empty β†’ standard scan, today's behaviour).""" + + http_ranges: list[str] = field(default_factory=list) + http_probe_enabled: bool = False + verify_tls: bool = False + + +def _valid_port_range(spec: str) -> bool: + if not _PORT_RANGE_RE.match(spec): + return False + parts = [int(p) for p in spec.split("-")] + if any(p < 1 or p > 65535 for p in parts): + return False + return len(parts) == 1 or parts[0] <= parts[1] + + +def _build_port_spec(http_ranges: list[str] | None) -> str: + """Combine the default port list with validated user ranges for nmap -p.""" + if not http_ranges: + return _EXTRA_PORTS + extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())] + if not extra: + return _EXTRA_PORTS + return _EXTRA_PORTS + "," + ",".join(extra) + _MDNS_SERVICE_TYPES = [ "_http._tcp.local.", "_shelly._tcp.local.", @@ -195,7 +228,7 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]: return alive -def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]: +def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]: """ Phase 2 β€” single-IP port scan with service detection. Runs in a thread (blocking). Returns the host dict enriched with open_ports. @@ -210,11 +243,11 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]: is_root = os.geteuid() == 0 if is_root: # SYN scan + version detection (fastest, most accurate) - scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}" + scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}" else: # TCP connect scan (-sT) β€” no raw sockets needed, works without root. # nmap auto-selects -sT without root but being explicit avoids edge cases. - scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}" + scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}" logger.debug("[Phase 2] %s args: %s", ip, scan_args) nm = nmap.PortScanner() @@ -252,7 +285,9 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]: return host_dict -async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: +async def _nmap_port_scan( + alive: dict[str, dict[str, Any]], port_spec: str = _EXTRA_PORTS +) -> list[dict[str, Any]]: """ Phase 2: Per-IP service detection with bounded concurrency. Each host is scanned independently in a thread β€” no inter-host timeout interference. @@ -266,7 +301,7 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]: async with semaphore: - return await asyncio.to_thread(_nmap_scan_single, host_dict) + return await asyncio.to_thread(_nmap_scan_single, host_dict, port_spec) raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True) results = [] @@ -279,7 +314,7 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An return results -async def _nmap_scan(target: str) -> list[dict[str, Any]]: +async def _nmap_scan(target: str, port_spec: str = _EXTRA_PORTS) -> list[dict[str, Any]]: """ Two-phase scan for a CIDR range. Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives). @@ -296,7 +331,7 @@ async def _nmap_scan(target: str) -> list[dict[str, Any]]: except Exception as exc: logger.error("Phase 1 ping sweep failed: %s", exc) raise RuntimeError(str(exc)) from exc - return await _nmap_port_scan(alive) + return await _nmap_port_scan(alive, port_spec) async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]: @@ -375,10 +410,18 @@ def _mock_scan(target: str) -> list[dict[str, Any]]: ] -async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: +async def run_scan( + ranges: list[str], + db: AsyncSession, + run_id: str, + deep_scan: DeepScanOptions | None = None, +) -> None: """Execute scan for given CIDR ranges and populate pending_devices.""" from app.api.routes.status import broadcast_scan_update + deep_scan = deep_scan or DeepScanOptions() + port_spec = _build_port_spec(deep_scan.http_ranges) + devices_found = 0 mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None try: @@ -427,8 +470,17 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: logger.debug("Skipping %s β€” hidden by user", ip) return - services = fingerprint_ports(host["open_ports"]) - suggested_type = suggest_node_type(host["open_ports"], host.get("mac")) + open_ports = host["open_ports"] + # Deep-scan HTTP probe: enrich open ports with title/header signals so + # fingerprint can confirm services on custom ports. No-op when disabled + # or when the host has no open ports (e.g. mDNS-only discovery). + if deep_scan.http_probe_enabled and open_ports: + open_ports = await probe_open_ports( + ip, open_ports, verify_tls=deep_scan.verify_tls + ) + + services = fingerprint_ports(open_ports) + suggested_type = suggest_node_type(open_ports, host.get("mac")) existing_result = await db.execute( select(PendingDevice).where( @@ -463,7 +515,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None: for cidr in ranges: if _is_cancelled(run_id): break - hosts = await _nmap_scan(cidr) + hosts = await _nmap_scan(cidr, port_spec) for host in hosts: if _is_cancelled(run_id): break diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 6feb0b9..56ea3ed 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -518,7 +518,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy call_count = 0 - def nmap_side_effect(target: str): + def nmap_side_effect(target: str, port_spec: str | None = None): nonlocal call_count call_count += 1 # Signal cancellation after the first CIDR scan completes diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py index e29e2fc..b7d66fa 100644 --- a/backend/tests/test_scanner.py +++ b/backend/tests/test_scanner.py @@ -359,7 +359,7 @@ async def test_nmap_port_scan_tolerates_single_host_exception(): call_count = 0 - def _flaky_scan(host_dict): + def _flaky_scan(host_dict, port_spec=None): nonlocal call_count call_count += 1 if host_dict["ip"] == "192.168.1.1": @@ -533,3 +533,135 @@ async def test_run_scan_cancelled_marks_status_cancelled(mem_db): run = await session.get(ScanRun, run_id) assert run is not None assert run.status == "cancelled" + + +# --------------------------------------------------------------------------- +# Deep scan: port-range plumbing + HTTP probe +# --------------------------------------------------------------------------- + +def test_valid_port_range(): + from app.services.scanner import _valid_port_range + + assert _valid_port_range("8080") + assert _valid_port_range("8000-8100") + assert not _valid_port_range("8100-8000") # reversed + assert not _valid_port_range("0") # below 1 + assert not _valid_port_range("70000") # above 65535 + assert not _valid_port_range("abc") + assert not _valid_port_range("80,443") # not a single range + + +def test_build_port_spec_default_when_empty(): + from app.services.scanner import _EXTRA_PORTS, _build_port_spec + + assert _build_port_spec([]) == _EXTRA_PORTS + assert _build_port_spec(None) == _EXTRA_PORTS + + +def test_build_port_spec_appends_valid_ranges(): + from app.services.scanner import _EXTRA_PORTS, _build_port_spec + + spec = _build_port_spec(["8000-8100", "9000"]) + assert spec == _EXTRA_PORTS + ",8000-8100,9000" + + +def test_build_port_spec_drops_invalid_ranges(): + from app.services.scanner import _EXTRA_PORTS, _build_port_spec + + # invalid entries silently dropped; only valid kept + assert _build_port_spec(["bad", "70000"]) == _EXTRA_PORTS + assert _build_port_spec(["bad", "9000"]) == _EXTRA_PORTS + ",9000" + + +@pytest.mark.asyncio +async def test_run_scan_deep_scan_passes_port_spec_to_nmap(mem_db): + from app.services.scanner import DeepScanOptions, run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + await session.commit() + + captured = {} + + async def fake_nmap(target, port_spec): + captured["port_spec"] = port_spec + return [] + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", new=fake_nmap), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan( + ["192.168.1.0/24"], session, run_id, + deep_scan=DeepScanOptions(http_ranges=["8000-8100"]), + ) + + assert "8000-8100" in captured["port_spec"] + + +@pytest.mark.asyncio +async def test_run_scan_probe_enriches_services(mem_db): + """With probe enabled, a custom-port service is identified via HTTP signals.""" + from app.services.scanner import DeepScanOptions, run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + await session.commit() + + nmap_hosts = [{ + "ip": "192.168.1.50", "hostname": None, "mac": None, "os": None, + "open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}], + }] + jellyfin_sig = [{ + "port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin", + "service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server", + }] + + async def fake_probe(ip, ports, verify_tls=False, concurrency=50): + return [{**p, "http_signals": {"title": "Jellyfin", "headers": {}}} for p in ports] + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \ + patch("app.services.scanner.probe_open_ports", new=fake_probe), \ + patch("app.services.fingerprint._load", return_value=jellyfin_sig), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan( + ["192.168.1.0/24"], session, run_id, + deep_scan=DeepScanOptions(http_probe_enabled=True), + ) + + async with mem_db() as session: + result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")) + device = result.scalar_one_or_none() + + assert device is not None + assert any(s["service_name"] == "Jellyfin" for s in device.services) + + +@pytest.mark.asyncio +async def test_run_scan_no_probe_when_disabled(mem_db): + """Probe must not be called on a standard (non-deep) scan.""" + from app.services.scanner import run_scan + + run_id = _make_run_id() + async with mem_db() as session: + session.add(_make_scan_run(run_id)) + await session.commit() + + nmap_hosts = [{ + "ip": "192.168.1.51", "hostname": None, "mac": None, "os": None, + "open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}], + }] + probe = AsyncMock() + + async with mem_db() as session: + with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \ + patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \ + patch("app.services.scanner.probe_open_ports", new=probe), \ + patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock): + await run_scan(["192.168.1.0/24"], session, run_id) + + probe.assert_not_called() From b6423c0115f9501bc0d6f6bbf52e8f101c781511 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 24 Jun 2026 10:04:31 +0200 Subject: [PATCH 07/22] feat: expose deep-scan settings via API and persisted config Adds scanner_http_ranges / scanner_http_probe_enabled / scanner_http_verify_tls to Settings (persisted in scan_config.json, Options page defaults). /scan/trigger accepts an optional body to override these per-scan; /scan/config GET/POST read and persist the defaults. Port ranges validated at the API boundary. ha-relevant: yes --- .env.example | 6 ++ backend/app/api/routes/scan.py | 84 ++++++++++++++++++++++-- backend/app/core/config.py | 15 +++++ backend/tests/test_scan.py | 114 ++++++++++++++++++++++++++++++++- 4 files changed, 211 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 52fa748..1f2b9b9 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,12 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG # Scanner β€” JSON array of CIDR ranges to scan SCANNER_RANGES=["192.168.1.0/24"] +# Deep scan (optional) β€” extra nmap port ranges + HTTP probe for service ID on +# custom ports. Defaults below are overridable per-scan from the scan dialog. +SCANNER_HTTP_RANGES=[] +SCANNER_HTTP_PROBE_ENABLED=false +SCANNER_HTTP_VERIFY_TLS=false + # Status checker interval in seconds STATUS_CHECKER_INTERVAL=60 diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 390c86a..4e1214a 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -14,7 +14,7 @@ from app.db.database import AsyncSessionLocal, get_db from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun from app.schemas.nodes import NodeCreate from app.schemas.scan import PendingDeviceResponse, ScanRunResponse -from app.services.scanner import request_cancel, run_scan +from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan from app.services.zigbee_service import build_zigbee_properties _ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"} @@ -52,8 +52,20 @@ class BulkActionRequest(BaseModel): device_ids: list[str] +def _check_port_ranges(v: list[str]) -> list[str]: + for r in v: + if not _valid_port_range(r.strip()): + raise ValueError(f"Invalid port range: {r!r}") + return v + + class ScanConfig(BaseModel): + """Persisted scan defaults (Options page). Deep-scan fields are optional.""" + ranges: list[str] + http_ranges: list[str] = [] + http_probe_enabled: bool = False + verify_tls: bool = False @field_validator("ranges") @classmethod @@ -65,15 +77,35 @@ class ScanConfig(BaseModel): raise ValueError(f"Invalid CIDR range: {r!r}") from exc return v + @field_validator("http_ranges") + @classmethod + def validate_http_ranges(cls, v: list[str]) -> list[str]: + return _check_port_ranges(v) + + +class TriggerScanRequest(BaseModel): + """Per-scan deep-scan overrides (scan dialog). None β†’ use persisted default.""" + + http_ranges: list[str] | None = None + http_probe_enabled: bool | None = None + verify_tls: bool | None = None + + @field_validator("http_ranges") + @classmethod + def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None: + return None if v is None else _check_port_ranges(v) + logger = logging.getLogger(__name__) router = APIRouter() -async def _background_scan(run_id: str, ranges: list[str]) -> None: +async def _background_scan( + run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None +) -> None: async with AsyncSessionLocal() as db: try: - await run_scan(ranges, db, run_id) + await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions()) except Exception: logger.exception("Scan run %s failed unexpectedly", run_id) await db.rollback() @@ -83,18 +115,38 @@ async def _background_scan(run_id: str, ranges: list[str]) -> None: await db.commit() +def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions: + """Merge per-scan overrides over persisted settings defaults.""" + p = payload or TriggerScanRequest() + return DeepScanOptions( + http_ranges=( + p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges + ), + http_probe_enabled=( + p.http_probe_enabled + if p.http_probe_enabled is not None + else settings.scanner_http_probe_enabled + ), + verify_tls=( + p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls + ), + ) + + @router.post("/trigger", response_model=ScanRunResponse) async def trigger_scan( background_tasks: BackgroundTasks, + payload: TriggerScanRequest | None = None, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user), ) -> ScanRun: ranges = settings.scanner_ranges + deep_scan = _resolve_deep_scan(payload) run = ScanRun(status="running", ranges=ranges) db.add(run) await db.commit() await db.refresh(run) - background_tasks.add_task(_background_scan, run.id, ranges) + background_tasks.add_task(_background_scan, run.id, ranges, deep_scan) return run @@ -427,17 +479,35 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur @router.get("/config", response_model=ScanConfig) async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig: - return ScanConfig(ranges=settings.scanner_ranges) + return ScanConfig( + ranges=settings.scanner_ranges, + http_ranges=settings.scanner_http_ranges, + http_probe_enabled=settings.scanner_http_probe_enabled, + verify_tls=settings.scanner_http_verify_tls, + ) @router.post("/config", response_model=ScanConfig) async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig: - previous = settings.scanner_ranges + previous = ( + settings.scanner_ranges, + settings.scanner_http_ranges, + settings.scanner_http_probe_enabled, + settings.scanner_http_verify_tls, + ) settings.scanner_ranges = payload.ranges + settings.scanner_http_ranges = payload.http_ranges + settings.scanner_http_probe_enabled = payload.http_probe_enabled + settings.scanner_http_verify_tls = payload.verify_tls try: settings.save_overrides() return payload except Exception as exc: - settings.scanner_ranges = previous + ( + settings.scanner_ranges, + settings.scanner_http_ranges, + settings.scanner_http_probe_enabled, + settings.scanner_http_verify_tls, + ) = previous logger.error("Failed to save scan config: %s", exc) raise HTTPException(status_code=500, detail="Failed to save scan config") from exc diff --git a/backend/app/core/config.py b/backend/app/core/config.py index f599f74..b9f4016 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -48,6 +48,12 @@ class Settings(BaseSettings): # Scanner scanner_ranges: list[str] = ["192.168.1.0/24"] + # Deep scan β€” persisted defaults (overridable per-scan from the scan dialog). + # http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default. + scanner_http_ranges: list[str] = [] + scanner_http_probe_enabled: bool = False + scanner_http_verify_tls: bool = False + # Status checker status_checker_interval: int = 60 @@ -85,6 +91,12 @@ class Settings(BaseSettings): self.service_check_enabled = bool(data["service_check_enabled"]) if "service_check_interval" in data: self.service_check_interval = int(data["service_check_interval"]) + if "scanner_http_ranges" in data: + self.scanner_http_ranges = list(data["scanner_http_ranges"]) + if "scanner_http_probe_enabled" in data: + self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"]) + if "scanner_http_verify_tls" in data: + self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"]) except Exception: pass @@ -96,6 +108,9 @@ class Settings(BaseSettings): "status_checker_interval": self.status_checker_interval, "service_check_enabled": self.service_check_enabled, "service_check_interval": self.service_check_interval, + "scanner_http_ranges": self.scanner_http_ranges, + "scanner_http_probe_enabled": self.scanner_http_probe_enabled, + "scanner_http_verify_tls": self.scanner_http_verify_tls, })) diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 56ea3ed..7032772 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -122,7 +122,8 @@ async def test_background_scan_success_path_invokes_run_scan(mem_db): patch("app.api.routes.scan.AsyncSessionLocal", mem_db), patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan, ): - await _background_scan(run_id, ["10.0.0.0/24"]) + from app.services.scanner import DeepScanOptions + await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions()) mock_run_scan.assert_awaited_once() @@ -1122,3 +1123,114 @@ async def test_approve_zigbee_resolves_link_after_second_approval( assert len(edges) == 1 links = (await db_session.execute(select(PendingDeviceLink))).scalars().all() assert links == [] # consumed + + +# --- Deep scan: trigger overrides + config persistence --- + +@pytest.mark.asyncio +async def test_resolve_deep_scan_falls_back_to_settings(): + from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan + + with patch("app.api.routes.scan.settings") as mock_settings: + mock_settings.scanner_http_ranges = ["7000-7100"] + mock_settings.scanner_http_probe_enabled = True + mock_settings.scanner_http_verify_tls = False + # Empty payload β†’ all values come from settings defaults + ds = _resolve_deep_scan(TriggerScanRequest()) + assert ds.http_ranges == ["7000-7100"] + assert ds.http_probe_enabled is True + assert ds.verify_tls is False + + +@pytest.mark.asyncio +async def test_resolve_deep_scan_override_wins(): + from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan + + with patch("app.api.routes.scan.settings") as mock_settings: + mock_settings.scanner_http_ranges = [] + mock_settings.scanner_http_probe_enabled = False + mock_settings.scanner_http_verify_tls = False + ds = _resolve_deep_scan( + TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True) + ) + assert ds.http_ranges == ["9000"] + assert ds.http_probe_enabled is True + assert ds.verify_tls is True + + +@pytest.mark.asyncio +async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers): + captured = {} + + async def fake_bg(run_id, ranges, deep_scan): + captured["deep_scan"] = deep_scan + + with ( + patch("app.api.routes.scan._background_scan", new=fake_bg), + patch("app.api.routes.scan.settings") as mock_settings, + ): + mock_settings.scanner_ranges = ["192.168.1.0/24"] + mock_settings.scanner_http_ranges = [] + mock_settings.scanner_http_probe_enabled = False + mock_settings.scanner_http_verify_tls = False + res = await client.post( + "/api/v1/scan/trigger", + json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]}, + headers=headers, + ) + assert res.status_code == 200 + assert captured["deep_scan"].http_probe_enabled is True + assert captured["deep_scan"].http_ranges == ["8000-8100"] + + +@pytest.mark.asyncio +async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers): + with patch("app.api.routes.scan.settings") as mock_settings: + mock_settings.scanner_ranges = ["192.168.1.0/24"] + res = await client.post( + "/api/v1/scan/trigger", + json={"http_ranges": ["70000-80000"]}, + headers=headers, + ) + assert res.status_code == 422 + + +@pytest.mark.asyncio +async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers): + with patch("app.api.routes.scan.settings") as mock_settings: + mock_settings.scanner_ranges = ["192.168.1.0/24"] + mock_settings.scanner_http_ranges = ["8000-8100"] + mock_settings.scanner_http_probe_enabled = True + mock_settings.scanner_http_verify_tls = False + res = await client.get("/api/v1/scan/config", headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["http_ranges"] == ["8000-8100"] + assert data["http_probe_enabled"] is True + + +@pytest.mark.asyncio +async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers): + saved = {} + + with patch("app.api.routes.scan.settings") as mock_settings: + mock_settings.scanner_ranges = ["192.168.1.0/24"] + mock_settings.scanner_http_ranges = [] + mock_settings.scanner_http_probe_enabled = False + mock_settings.scanner_http_verify_tls = False + mock_settings.save_overrides = lambda: saved.update( + http_ranges=mock_settings.scanner_http_ranges, + probe=mock_settings.scanner_http_probe_enabled, + ) + res = await client.post( + "/api/v1/scan/config", + json={ + "ranges": ["192.168.1.0/24"], + "http_ranges": ["9000-9100"], + "http_probe_enabled": True, + "verify_tls": True, + }, + headers=headers, + ) + assert res.status_code == 200 + assert saved == {"http_ranges": ["9000-9100"], "probe": True} From d01630bf37c06d98daa26096b13238f6f773175a Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 24 Jun 2026 10:31:32 +0200 Subject: [PATCH 08/22] feat: add Deep Scan toggle to scan dialog Collapsible Deep Scan section in ScanConfigModal exposes extra port ranges, HTTP probe and TLS-verify switches. Pre-filled from saved defaults; edits are passed to trigger() as a per-scan override and do not change the persisted defaults (those live in the Options/scan config). scanApi.trigger now accepts an optional deep-scan body. ha-relevant: yes --- frontend/src/api/client.ts | 14 ++- .../src/components/modals/ScanConfigModal.tsx | 93 ++++++++++++++++++- .../modals/__tests__/ScanConfigModal.test.tsx | 62 ++++++++++++- 3 files changed, 160 insertions(+), 9 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 96a8ebe..22b8e8c 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -58,8 +58,16 @@ export const liveviewApi = { getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'), } +export interface DeepScanConfig { + http_ranges: string[] + http_probe_enabled: boolean + verify_tls: boolean +} + +export type ScanConfigData = { ranges: string[] } & DeepScanConfig + export const scanApi = { - trigger: () => api.post('/scan/trigger'), + trigger: (deepScan?: Partial) => api.post('/scan/trigger', deepScan ?? {}), pending: () => api.get('/scan/pending'), hidden: () => api.get('/scan/hidden'), runs: () => api.get('/scan/runs'), @@ -86,8 +94,8 @@ export const scanApi = { restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`), bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }), stop: (runId: string) => api.post(`/scan/${runId}/stop`), - getConfig: () => api.get<{ ranges: string[] }>('/scan/config'), - saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data), + getConfig: () => api.get('/scan/config'), + saveConfig: (data: ScanConfigData) => api.post('/scan/config', data), } export interface AppSettings { diff --git a/frontend/src/components/modals/ScanConfigModal.tsx b/frontend/src/components/modals/ScanConfigModal.tsx index f1d3742..c9b779a 100644 --- a/frontend/src/components/modals/ScanConfigModal.tsx +++ b/frontend/src/components/modals/ScanConfigModal.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from 'react' -import { Plus, Trash2, Settings } from 'lucide-react' +import { Plus, Trash2, Settings, ChevronRight, ChevronDown } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { scanApi } from '@/api/client' +import { scanApi, type DeepScanConfig } from '@/api/client' import { toast } from 'sonner' interface ScanConfigModalProps { @@ -13,24 +13,56 @@ interface ScanConfigModalProps { onScanNow: () => void } +const DEEP_DEFAULTS: DeepScanConfig = { http_ranges: [], http_probe_enabled: false, verify_tls: false } + export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalProps) { const [ranges, setRanges] = useState(['']) const [saving, setSaving] = useState(false) + // Deep-scan section. Pre-filled from persisted defaults; edits here are a + // per-scan override passed to trigger() β€” they do NOT change the saved defaults. + const [deepOpen, setDeepOpen] = useState(false) + const [deepDefaults, setDeepDefaults] = useState(DEEP_DEFAULTS) + const [httpProbe, setHttpProbe] = useState(false) + const [verifyTls, setVerifyTls] = useState(false) + const [httpRangesText, setHttpRangesText] = useState('') + useEffect(() => { if (!open) return scanApi.getConfig() - .then((res) => setRanges(res.data.ranges.length > 0 ? res.data.ranges : [''])) + .then((res) => { + const d = res.data + setRanges(d.ranges.length > 0 ? d.ranges : ['']) + const deep: DeepScanConfig = { + http_ranges: d.http_ranges ?? [], + http_probe_enabled: d.http_probe_enabled ?? false, + verify_tls: d.verify_tls ?? false, + } + setDeepDefaults(deep) + setHttpProbe(deep.http_probe_enabled) + setVerifyTls(deep.verify_tls) + setHttpRangesText(deep.http_ranges.join(', ')) + setDeepOpen(deep.http_probe_enabled || deep.http_ranges.length > 0) + }) .catch(() => {/* use defaults */}) }, [open]) + const parseHttpRanges = () => + httpRangesText.split(',').map((r) => r.trim()).filter(Boolean) + const handleScanNow = async () => { const cleaned = ranges.map((r) => r.trim()).filter(Boolean) if (cleaned.length === 0) { toast.error('Add at least one IP range'); return } setSaving(true) try { - await scanApi.saveConfig({ ranges: cleaned }) - await scanApi.trigger() + // Persist IP ranges; leave deep-scan defaults as configured in Options. + await scanApi.saveConfig({ ranges: cleaned, ...deepDefaults }) + // Per-scan deep-scan override from this dialog. + await scanApi.trigger({ + http_ranges: parseHttpRanges(), + http_probe_enabled: httpProbe, + verify_tls: verifyTls, + }) onScanNow() onClose() } catch { @@ -84,6 +116,57 @@ export function ScanConfigModal({ open, onClose, onScanNow }: ScanConfigModalPro + {/* Deep Scan (opt-in) */} +
+ + + {deepOpen && ( +
+

+ Scan extra ports and probe HTTP services to identify apps on custom ports. + Overrides the saved defaults for this scan only. +

+ +
+ + setHttpRangesText(e.target.value)} + placeholder="8000-8100, 9000-9100" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+ + + + +
+ )} +
+

Status check interval can be configured in the sidebar Settings. diff --git a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx index 988be69..8bdb4e8 100644 --- a/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanConfigModal.test.tsx @@ -82,7 +82,12 @@ describe('ScanConfigModal', () => { await screen.findByDisplayValue('192.168.1.0/24') fireEvent.click(screen.getByRole('button', { name: 'Scan Now' })) await waitFor(() => { - expect(scanApi.saveConfig).toHaveBeenCalledWith({ ranges: ['192.168.1.0/24'] }) + expect(scanApi.saveConfig).toHaveBeenCalledWith({ + ranges: ['192.168.1.0/24'], + http_ranges: [], + http_probe_enabled: false, + verify_tls: false, + }) expect(scanApi.trigger).toHaveBeenCalledOnce() expect(onScanNow).toHaveBeenCalledOnce() expect(onClose).toHaveBeenCalledOnce() @@ -108,4 +113,59 @@ describe('ScanConfigModal', () => { ) }) }) + + // --- Deep scan --- + + it('reveals deep-scan fields when the section is toggled', async () => { + render() + await screen.findByDisplayValue('192.168.1.0/24') + expect(screen.queryByText('Enable HTTP probe')).toBeNull() + fireEvent.click(screen.getByText('Deep Scan')) + expect(screen.getByText('Enable HTTP probe')).toBeDefined() + }) + + it('passes deep-scan overrides to trigger() as a per-scan override', async () => { + render() + await screen.findByDisplayValue('192.168.1.0/24') + fireEvent.click(screen.getByText('Deep Scan')) + fireEvent.change(screen.getByPlaceholderText('8000-8100, 9000-9100'), { + target: { value: '8000-8100, 9000' }, + }) + fireEvent.click(screen.getByLabelText('Enable HTTP probe')) + fireEvent.click(screen.getByRole('button', { name: 'Scan Now' })) + await waitFor(() => { + expect(scanApi.trigger).toHaveBeenCalledWith({ + http_ranges: ['8000-8100', '9000'], + http_probe_enabled: true, + verify_tls: false, + }) + }) + }) + + it('auto-opens deep-scan section when a default probe is enabled', async () => { + vi.mocked(scanApi.getConfig).mockResolvedValue({ + data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: false }, + } as never) + render() + await screen.findByDisplayValue('192.168.1.0/24') + expect(screen.getByText('Enable HTTP probe')).toBeDefined() + expect(screen.getByDisplayValue('7000-7100')).toBeDefined() + }) + + it('saving keeps deep-scan defaults untouched (modal only overrides per-scan)', async () => { + vi.mocked(scanApi.getConfig).mockResolvedValue({ + data: { ranges: ['192.168.1.0/24'], http_ranges: ['7000-7100'], http_probe_enabled: true, verify_tls: true }, + } as never) + render() + await screen.findByDisplayValue('192.168.1.0/24') + fireEvent.click(screen.getByRole('button', { name: 'Scan Now' })) + await waitFor(() => { + expect(scanApi.saveConfig).toHaveBeenCalledWith({ + ranges: ['192.168.1.0/24'], + http_ranges: ['7000-7100'], + http_probe_enabled: true, + verify_tls: true, + }) + }) + }) }) From c68084b751b415f584974593ddad41fe11479e2e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Wed, 24 Jun 2026 10:40:03 +0200 Subject: [PATCH 09/22] test: update client trigger assertion for deep-scan body ha-relevant: yes --- frontend/src/api/__tests__/client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts index 46de58d..ea434d4 100644 --- a/frontend/src/api/__tests__/client.test.ts +++ b/frontend/src/api/__tests__/client.test.ts @@ -170,7 +170,7 @@ describe('api/client', () => { it('scanApi endpoints route correctly', () => { mod.scanApi.trigger() - expect(api.post).toHaveBeenCalledWith('/scan/trigger') + expect(api.post).toHaveBeenCalledWith('/scan/trigger', {}) mod.scanApi.pending() expect(api.get).toHaveBeenCalledWith('/scan/pending') mod.scanApi.hidden() From 96107cc65789031339c3ee84a4ff6e364922ea9b Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 25 Jun 2026 14:47:58 +0200 Subject: [PATCH 10/22] feat: seed port-agnostic http_regex signatures for ~50 popular homelab apps Adds port:null signatures matched by HTTP page title for the most-used self-hosted and home-automation apps (Jellyfin, Plex, *Arr, Home Assistant, Node-RED, Zigbee2MQTT, ESPHome, Domoticz, Jeedom, Portainer, Grafana, Uptime Kuma, Pi-hole, AdGuard Home, Nextcloud, Vaultwarden, Authelia, OpenMediaVault, Unraid, etc.). These identify a service on any port once the deep-scan HTTP probe is enabled. Existing port-keyed entries are unchanged. ha-relevant: yes --- backend/app/data/service_signatures.json | 66 +++++++++++++++++++++++- backend/tests/test_signatures_data.py | 63 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_signatures_data.py diff --git a/backend/app/data/service_signatures.json b/backend/app/data/service_signatures.json index 412b14c..54821fb 100644 --- a/backend/app/data/service_signatures.json +++ b/backend/app/data/service_signatures.json @@ -142,5 +142,69 @@ {"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"}, {"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"}, {"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"}, - {"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"} + {"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "film", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Plex", "service_name": "Plex", "icon": "play-circle", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Emby", "service_name": "Emby", "icon": "film", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Overseerr", "service_name": "Overseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyseerr", "service_name": "Jellyseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Tautulli", "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Navidrome", "service_name": "Navidrome", "icon": "music", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]udiobookshelf", "service_name": "Audiobookshelf", "icon": "book-open", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Immich", "service_name": "Immich", "icon": "camera", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "PhotoPrism", "service_name": "PhotoPrism", "icon": "camera", "category": "media", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Calibre[- ]Web", "service_name": "Calibre-Web", "icon": "book", "category": "media", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Sonarr", "service_name": "Sonarr", "icon": "tv", "category": "download", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Radarr", "service_name": "Radarr", "icon": "film", "category": "download", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Lidarr", "service_name": "Lidarr", "icon": "music", "category": "download", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Readarr", "service_name": "Readarr", "icon": "book", "category": "download", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Prowlarr", "service_name": "Prowlarr", "icon": "search", "category": "download", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Bazarr", "service_name": "Bazarr", "icon": "subtitles", "category": "download", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "qBittorrent", "service_name": "qBittorrent", "icon": "download", "category": "download", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "SABnzbd", "service_name": "SABnzbd", "icon": "download", "category": "download", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homarr", "service_name": "Homarr", "icon": "home", "category": "web", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Heimdall", "service_name": "Heimdall", "icon": "home", "category": "web", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dashy", "service_name": "Dashy", "icon": "home", "category": "web", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Organizr", "service_name": "Organizr", "icon": "home", "category": "web", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Portainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dockge", "service_name": "Dockge", "icon": "box", "category": "containers", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Yacht", "service_name": "Yacht", "icon": "box", "category": "containers", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Home Assistant", "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Node-RED", "service_name": "Node-RED", "icon": "share-2", "category": "automation", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Zigbee2MQTT", "service_name": "Zigbee2MQTT", "icon": "radio", "category": "automation", "suggested_node_type": "iot"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "ESPHome", "service_name": "ESPHome", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openHAB", "service_name": "openHAB", "icon": "home", "category": "automation", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homebridge", "service_name": "Homebridge", "icon": "home", "category": "automation", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jeedom", "service_name": "Jeedom", "icon": "home", "category": "automation", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Scrypted", "service_name": "Scrypted", "icon": "video", "category": "automation", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Grafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Uptime Kuma", "service_name": "Uptime Kuma", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Netdata", "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Glances", "service_name": "Glances", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dozzle", "service_name": "Dozzle", "icon": "terminal", "category": "monitoring", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "AdGuard Home", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Pi-hole", "service_name": "Pi-hole", "icon": "shield", "category": "network", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nginx Proxy Manager", "service_name": "Nginx Proxy Manager", "icon": "share-2", "category": "network", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Traefik", "service_name": "Traefik", "icon": "share-2", "category": "network", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Vaultwarden|Bitwarden", "service_name": "Vaultwarden", "icon": "lock", "category": "auth", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Authelia", "service_name": "Authelia", "icon": "lock", "category": "auth", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "lock", "category": "auth", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nextcloud", "service_name": "Nextcloud", "icon": "hard-drive", "category": "storage", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Paperless", "service_name": "Paperless-ngx", "icon": "book", "category": "storage", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"}, + + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"}, + {"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"} ] diff --git a/backend/tests/test_signatures_data.py b/backend/tests/test_signatures_data.py new file mode 100644 index 0000000..4d34221 --- /dev/null +++ b/backend/tests/test_signatures_data.py @@ -0,0 +1,63 @@ +"""Integrity + matching tests against the real service_signatures.json.""" +import re + +import pytest + +from app.services.fingerprint import _load, match_service + +_NODE_TYPES = { + "isp", "router", "switch", "server", "proxmox", "vm", "lxc", + "nas", "iot", "ap", "camera", "generic", +} + + +@pytest.fixture +def signatures(): + return _load() + + +def test_all_entries_well_formed(signatures): + for sig in signatures: + # port is an int or explicitly null (port-agnostic) + assert sig.get("port") is None or isinstance(sig["port"], int) + assert isinstance(sig["service_name"], str) and sig["service_name"] + assert sig["suggested_node_type"] in _NODE_TYPES + if sig.get("banner_regex"): + re.compile(sig["banner_regex"]) + if sig.get("http_regex"): + re.compile(sig["http_regex"]) + + +def test_port_agnostic_entries_require_http_regex(signatures): + for sig in signatures: + if sig.get("port") is None: + assert sig.get("http_regex"), f"port:null entry needs http_regex: {sig}" + + +def test_popular_apps_have_port_agnostic_signatures(signatures): + names = {s["service_name"] for s in signatures if s.get("port") is None} + for expected in { + "Jellyfin", "Plex", "Home Assistant", "Portainer", "Pi-hole", + "AdGuard Home", "Grafana", "Nextcloud", "Vaultwarden", "Sonarr", + }: + assert expected in names, f"missing port-agnostic signature for {expected}" + + +@pytest.mark.parametrize(("title", "expected"), [ + ("Jellyfin", "Jellyfin"), + ("Home Assistant", "Home Assistant"), + ("Portainer", "Portainer"), + ("Vaultwarden Web Vault", "Vaultwarden"), + ("Pi-hole - Dashboard", "Pi-hole"), + ("Audiobookshelf", "Audiobookshelf"), +]) +def test_custom_port_identified_via_http_title(title, expected): + # A service on a non-standard port, recognised purely by its HTML title. + sig = match_service(58000, "tcp", banner=None, http_signals={"title": title, "headers": {}}) + assert sig is not None + assert sig["service_name"] == expected + + +def test_custom_port_without_probe_is_unknown(): + # Same custom port, deep scan off β†’ no signal β†’ no port-agnostic match. + assert match_service(58000, "tcp", banner=None, http_signals=None) is None From d7ab4ba49aeba28a5be52e836cea082a2890e9f9 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 25 Jun 2026 18:05:13 +0200 Subject: [PATCH 11/22] =?UTF-8?q?feat:=20Device=20Inventory=20=E2=80=94=20?= =?UTF-8?q?show=20all=20scanned=20devices=20with=20canvas-presence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the "Pending Devices" panel into a "Device Inventory": scanned devices already placed on a canvas are no longer suppressed β€” they stay listed and badged with how many canvases they appear on. - scanner: stop deleting/skipping on-canvas IPs (hidden still suppressed) - scan API: /pending returns all non-hidden devices; compute canvas_count by correlating ip/ieee_address against nodes grouped by design - frontend: rename to "Device Inventory", top-right canvas-count corner, toggle to show/hide on-canvas devices (default show) ha-relevant: maybe --- backend/app/api/routes/scan.py | 56 ++++++++++- backend/app/schemas/scan.py | 3 + backend/app/services/scanner.py | 26 ++---- backend/tests/test_scan.py | 92 ++++++++++++++++--- backend/tests/test_scanner.py | 9 +- .../components/modals/PendingDeviceModal.tsx | 2 + .../components/modals/PendingDevicesModal.tsx | 39 +++++++- .../__tests__/PendingDevicesModal.test.tsx | 51 ++++++++++ 8 files changed, 236 insertions(+), 42 deletions(-) diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 4e1214a..7f0da05 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -169,10 +169,60 @@ async def stop_scan( return {"stopping": True} +async def _canvas_counts( + db: AsyncSession, devices: list[PendingDevice] +) -> dict[str, int]: + """Map each device id β†’ number of distinct canvases (designs) it appears on. + + Correlates a scanned device to existing nodes by ``ieee_address`` (exact) or + ``ip`` (exact). Runs a single node query and groups in Python β€” node counts are + small for a homelab, so this avoids an N+1 per device. + """ + if not devices: + return {} + rows = ( + await db.execute( + select(Node.ip, Node.ieee_address, Node.design_id).where( + Node.design_id.isnot(None) + ) + ) + ).all() + # ip β†’ set(design_id), ieee β†’ set(design_id) + by_ip: dict[str, set[str]] = {} + by_ieee: dict[str, set[str]] = {} + for ip, ieee, design_id in rows: + if ip: + by_ip.setdefault(ip, set()).add(design_id) + if ieee: + by_ieee.setdefault(ieee, set()).add(design_id) + + counts: dict[str, int] = {} + for d in devices: + designs: set[str] = set() + if d.ieee_address: + designs |= by_ieee.get(d.ieee_address, set()) + if d.ip: + designs |= by_ip.get(d.ip, set()) + counts[d.id] = len(designs) + return counts + + +async def _with_canvas_counts( + db: AsyncSession, devices: list[PendingDevice] +) -> list[PendingDevice]: + """Attach a transient ``canvas_count`` to each device for the response schema.""" + counts = await _canvas_counts(db, devices) + for d in devices: + d.canvas_count = counts.get(d.id, 0) + return devices + + @router.get("/pending", response_model=list[PendingDeviceResponse]) async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: - result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending")) - return list(result.scalars().all()) + # Inventory: every scanned device except the user-hidden ones. Approved devices + # stay listed so they keep showing with a canvas-presence badge. + result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden")) + return await _with_canvas_counts(db, list(result.scalars().all())) @router.delete("/pending", response_model=dict) @@ -189,7 +239,7 @@ async def clear_pending( @router.get("/hidden", response_model=list[PendingDeviceResponse]) async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]: result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden")) - return list(result.scalars().all()) + return await _with_canvas_counts(db, list(result.scalars().all())) @router.post("/pending/bulk-approve", response_model=dict) diff --git a/backend/app/schemas/scan.py b/backend/app/schemas/scan.py index 92151bf..b0f5b78 100644 --- a/backend/app/schemas/scan.py +++ b/backend/app/schemas/scan.py @@ -21,6 +21,9 @@ class PendingDeviceResponse(BaseModel): vendor: str | None = None lqi: int | None = None discovered_at: datetime + # Number of distinct canvases (designs) this device already appears on, + # correlated by ip / ieee_address against existing nodes. Computed per-request. + canvas_count: int = 0 model_config = {"from_attributes": True} diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py index fccb315..909f268 100644 --- a/backend/app/services/scanner.py +++ b/backend/app/services/scanner.py @@ -14,7 +14,7 @@ from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import Node, PendingDevice, ScanRun +from app.db.models import PendingDevice, ScanRun from app.services.fingerprint import fingerprint_ports, suggest_node_type from app.services.http_probe import probe_open_ports @@ -432,26 +432,14 @@ async def run_scan( except ValueError: raise ValueError(f"Invalid CIDR range: {r!r}") from None - # Pre-fetch canvas IPs and hidden IPs once β€” avoids N+1 queries per host - canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None))) - canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()} - + # Pre-fetch hidden IPs once β€” avoids N+1 queries per host. + # Devices already on a canvas are intentionally NOT suppressed: they stay + # in the inventory and are badged "In N canvas" via per-request correlation. hidden_ips_result = await db.execute( select(PendingDevice.ip).where(PendingDevice.status == "hidden") ) hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()} - # Clean up stale pending devices whose IPs are already in the canvas - if canvas_ips: - from sqlalchemy import delete as sa_delete - await db.execute( - sa_delete(PendingDevice).where( - PendingDevice.status == "pending", - PendingDevice.ip.in_(canvas_ips), - ) - ) - await db.commit() - # Start mDNS discovery in the background while nmap scans run mdns_task = asyncio.create_task(_mdns_discover()) @@ -462,10 +450,8 @@ async def run_scan( nonlocal devices_found ip = host["ip"] - # Skip canvas nodes and user-hidden devices (sets pre-fetched before loop) - if ip in canvas_ips: - logger.debug("Skipping %s β€” already in canvas", ip) - return + # Skip only user-hidden devices. On-canvas devices are kept so they + # surface in the inventory with a canvas-presence badge. if ip in hidden_ips: logger.debug("Skipping %s β€” hidden by user", ip) return diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index 7032772..5879390 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -7,7 +7,7 @@ from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import Node, PendingDevice, ScanRun +from app.db.models import Design, Node, PendingDevice, ScanRun from app.services.scanner import _cancelled_runs, request_cancel, run_scan @@ -167,6 +167,66 @@ async def test_list_pending_returns_device(client: AsyncClient, headers, pending assert len(data) == 1 assert data[0]["ip"] == "192.168.1.100" assert data[0]["hostname"] == "my-server" + # No matching node β†’ not on any canvas. + assert data[0]["canvas_count"] == 0 + + +# --- Canvas-presence correlation (canvas_count) --- + +async def _add_design(db_session, name: str) -> str: + design = Design(id=str(uuid.uuid4()), name=name) + db_session.add(design) + await db_session.commit() + return design.id + + +def _node(design_id: str, *, ip=None, ieee=None) -> Node: + return Node( + id=str(uuid.uuid4()), label="n", type="server", status="online", + ip=ip, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0, + design_id=design_id, + ) + + +@pytest.mark.asyncio +async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device): + # Same IP placed on two different canvases β†’ canvas_count == 2. + d1 = await _add_design(db_session, "Home") + d2 = await _add_design(db_session, "Lab") + db_session.add(_node(d1, ip="192.168.1.100")) + db_session.add(_node(d2, ip="192.168.1.100")) + await db_session.commit() + + res = await client.get("/api/v1/scan/pending", headers=headers) + data = res.json() + assert len(data) == 1 + assert data[0]["canvas_count"] == 2 + + +@pytest.mark.asyncio +async def test_canvas_count_correlates_by_ieee(client, headers, db_session): + device = PendingDevice( + id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee", + suggested_type="zigbee_enddevice", services=[], status="pending", + ) + db_session.add(device) + d1 = await _add_design(db_session, "Zigbee") + db_session.add(_node(d1, ieee="0x00124b001")) + await db_session.commit() + + res = await client.get("/api/v1/scan/pending", headers=headers) + by_id = {d["id"]: d for d in res.json()} + assert by_id[device.id]["canvas_count"] == 1 + + +@pytest.mark.asyncio +async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device): + # A node with no design_id is not "on a canvas". + db_session.add(_node(None, ip="192.168.1.100")) + await db_session.commit() + + res = await client.get("/api/v1/scan/pending", headers=headers) + assert res.json()[0]["canvas_count"] == 0 # --- Approve device --- @@ -191,9 +251,13 @@ async def test_approve_device(client: AsyncClient, headers, pending_device): assert data["approved"] is True assert "node_id" in data - # Device should no longer appear in pending list + # Approved devices stay in the inventory (status != "hidden") so they keep + # showing with an "In N canvas" badge β€” they are no longer dropped. pending_res = await client.get("/api/v1/scan/pending", headers=headers) - assert pending_res.json() == [] + inventory = pending_res.json() + assert len(inventory) == 1 + assert inventory[0]["id"] == pending_device.id + assert inventory[0]["status"] == "approved" @pytest.mark.asyncio @@ -332,8 +396,9 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession): @pytest.mark.asyncio -async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession): - """Pending devices that were already in canvas before scan starts must be removed.""" +async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession): + """Pending devices whose IP is already on a canvas are NOT purged β€” they stay + in the inventory and are surfaced with an "In N canvas" badge.""" node = Node( id=str(uuid.uuid4()), label="Existing Server", @@ -372,12 +437,13 @@ async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncS result = await db_session.execute( select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") ) - assert result.scalar_one_or_none() is None + assert result.scalar_one_or_none() is not None @pytest.mark.asyncio -async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession): - """Devices whose IP already exists as a canvas Node must not appear in pending.""" +async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession): + """A scanned IP that already exists as a canvas Node still produces a pending + device (no longer suppressed).""" node = Node( id=str(uuid.uuid4()), label="Existing Server", @@ -405,7 +471,9 @@ async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession): result = await db_session.execute( select(PendingDevice).where(PendingDevice.ip == "192.168.1.50") ) - assert result.scalar_one_or_none() is None + device = result.scalar_one_or_none() + assert device is not None + assert device.status == "pending" @pytest.mark.asyncio @@ -613,9 +681,11 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs" assert len(data["device_ids"]) == 2 assert data["skipped"] == 0 - # Pending list should now be empty + # Approved devices stay in the inventory, now marked "approved". pending_res = await client.get("/api/v1/scan/pending", headers=headers) - assert pending_res.json() == [] + inventory = pending_res.json() + assert len(inventory) == 2 + assert all(d["status"] == "approved" for d in inventory) @pytest.fixture diff --git a/backend/tests/test_scanner.py b/backend/tests/test_scanner.py index b7d66fa..2b02326 100644 --- a/backend/tests/test_scanner.py +++ b/backend/tests/test_scanner.py @@ -457,8 +457,9 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db): @pytest.mark.asyncio -async def test_run_scan_skips_canvas_nodes(mem_db): - """Hosts already approved onto the canvas must be skipped.""" +async def test_run_scan_keeps_canvas_nodes(mem_db): + """Hosts already on a canvas are NOT suppressed β€” they stay in the inventory + (badged "In N canvas" via correlation), so a re-scan still records them.""" from app.services.scanner import run_scan run_id = _make_run_id() @@ -481,7 +482,9 @@ async def test_run_scan_skips_canvas_nodes(mem_db): async with mem_db() as session: result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100")) - assert result.scalar_one_or_none() is None + device = result.scalar_one_or_none() + assert device is not None + assert device.status == "pending" @pytest.mark.asyncio diff --git a/frontend/src/components/modals/PendingDeviceModal.tsx b/frontend/src/components/modals/PendingDeviceModal.tsx index aa76069..542363b 100644 --- a/frontend/src/components/modals/PendingDeviceModal.tsx +++ b/frontend/src/components/modals/PendingDeviceModal.tsx @@ -27,6 +27,8 @@ export interface PendingDevice { vendor?: string | null lqi?: number | null discovered_at: string + // How many canvases (designs) this device already appears on. Computed server-side. + canvas_count?: number } interface PendingDeviceModalProps { diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index de35096..02657cb 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -119,6 +119,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus const [sourceFilter, setSourceFilter] = useState('all') const [typeFilter, setTypeFilter] = useState('all') const [statusFilter, setStatusFilter] = useState(initialStatus) + // Inventory shows on-canvas devices by default; toggle off to hide them. + const [showOnCanvas, setShowOnCanvas] = useState(true) const { addNode, scanEventTs } = useCanvasStore() const highlightRef = useRef(null) @@ -159,6 +161,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus return devices.filter((d) => { if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false + // Inventory-only: optionally hide devices already placed on a canvas. + if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false if (q) { const hay = [ d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model, @@ -168,7 +172,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus } return true }) - }, [devices, search, sourceFilter, typeFilter]) + }, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas]) useEffect(() => { if (!highlightId || loading || !open) return @@ -241,9 +245,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`) else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`) } else { + // Clears only pending rows server-side; approved/on-canvas devices stay, + // so reload rather than blanking the whole inventory. await scanApi.clearPending() - setDevices([]) setSelectedIds(new Set()) + await load() toast.success('Pending devices cleared') } } catch { @@ -398,7 +404,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus

- {statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'} + {statusFilter === 'pending' ? 'Device Inventory' : 'Hidden Devices'} ({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`}) @@ -479,7 +485,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus onClick={() => setStatusFilter('pending')} className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`} > - Pending + Inventory
+ {statusFilter === 'pending' && ( + + )} )} + + updateField('mqtt_host', e.target.value)} + placeholder="192.168.1.x or mqtt.local" + className="font-mono text-sm bg-[#0d1117] border-border" + /> + +
+ + updateField('mqtt_port', e.target.value)} + placeholder="1883" + type="number" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('prefix', e.target.value)} + placeholder="zwave" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('gateway_name', e.target.value)} + placeholder="zwavejs2mqtt" + className="font-mono text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_username', e.target.value)} + placeholder="mqtt_user" + className="text-sm bg-[#0d1117] border-border" + /> +
+
+ + updateField('mqtt_password', e.target.value)} + placeholder="β€’β€’β€’β€’β€’β€’β€’β€’" + type="password" + autoComplete="new-password" + className="text-sm bg-[#0d1117] border-border" + /> +
+
+ + +
+ + + {/* Connection status indicator */} + {connectionStatus !== 'idle' && ( +
+ {connectionStatus === 'testing' && } + {connectionStatus === 'ok' && } + {connectionStatus === 'fail' && } + {connectionStatus === 'testing' ? 'Testing…' : connectionMsg} +
+ )} + +
+ Send devices to: + + +
+
+ + +
+

+ Make sure the gateway name matches your Z-Wave JS UI configuration. +

+ + + {/* Device List */} + {devices.length > 0 && ( +
+
+
+ { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }} + onChange={toggleAll} + className="w-3 h-3 cursor-pointer" + style={{ accentColor: ACCENT }} + title="Select all" + /> + + Devices ({checked.size}/{devices.length} selected) + +
+
+ + {(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZwaveNode[]][]) + .filter(([, group]) => group.length > 0) + .map(([type, group]) => { + const Icon = DEVICE_TYPE_ICON[type] + const color = DEVICE_TYPE_COLOR[type] + return ( +
+
+ + + {DEVICE_TYPE_LABEL[type]} ({group.length}) + +
+ {group.map((device) => ( +
toggleCheck(device.id)} + > + toggleCheck(device.id)} + onClick={(e) => e.stopPropagation()} + className="w-3 h-3 mt-0.5 cursor-pointer shrink-0" + style={{ accentColor: ACCENT }} + /> +
+
{device.friendly_name}
+
{device.ieee_address}
+ {(device.model || device.vendor) && ( +
+ {[device.vendor, device.model].filter(Boolean).join(' Β· ')} +
+ )} +
+
+ ))} +
+ ) + })} +
+ )} + + + + + {devices.length > 0 && ( + + )} + + + + ) +} diff --git a/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx b/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx new file mode 100644 index 0000000..7c8f54c --- /dev/null +++ b/frontend/src/components/zwave/__tests__/ZwaveImportModal.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ZwaveImportModal } from '../ZwaveImportModal' + +vi.mock('@/api/client', () => ({ + zwaveApi: { + testConnection: vi.fn(), + importNetwork: vi.fn(), + importToPending: vi.fn(), + }, +})) +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } })) + +import { zwaveApi } from '@/api/client' +import { toast } from 'sonner' + +const defaultProps = { + open: true, + onClose: vi.fn(), + onAddToCanvas: vi.fn(), +} + +const sampleNodes = [ + { + id: 'zwave-0xh-1', + label: 'Controller', + type: 'zwave_coordinator' as const, + ieee_address: 'zwave-0xh-1', + friendly_name: 'Controller', + device_type: 'Controller', + model: null, + vendor: null, + lqi: null, + parent_id: null, + }, + { + id: 'zwave-0xh-2', + label: 'Wall Plug', + type: 'zwave_router' as const, + ieee_address: 'zwave-0xh-2', + friendly_name: 'Wall Plug', + device_type: 'Router', + model: 'ZW100', + vendor: 'Aeotec', + lqi: null, + parent_id: 'zwave-0xh-1', + }, +] + +describe('ZwaveImportModal', () => { + beforeEach(() => { + vi.mocked(zwaveApi.testConnection).mockReset() + vi.mocked(zwaveApi.importNetwork).mockReset() + vi.mocked(zwaveApi.importToPending).mockReset() + vi.mocked(toast.success).mockReset() + vi.mocked(toast.error).mockReset() + vi.mocked(toast.info).mockReset() + defaultProps.onClose.mockReset() + defaultProps.onAddToCanvas.mockReset() + }) + + it('renders nothing when closed', () => { + const { container } = render() + expect(container.querySelector('[role="dialog"]')).toBeNull() + }) + + it('renders the modal with prefix and gateway fields when open', () => { + render() + expect(screen.getByText('Z-Wave Import')).toBeDefined() + expect(screen.getByPlaceholderText('zwave')).toBeDefined() + expect(screen.getByPlaceholderText('zwavejs2mqtt')).toBeDefined() + }) + + it('shows error toast when testing connection without a host', async () => { + render() + fireEvent.click(screen.getByRole('button', { name: /test connection/i })) + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname') + }) + expect(zwaveApi.testConnection).not.toHaveBeenCalled() + }) + + it('shows success status when connection test passes', async () => { + vi.mocked(zwaveApi.testConnection).mockResolvedValue({ + data: { connected: true, message: 'Connection successful' }, + } as never) + + render() + fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), { + target: { value: '192.168.1.100' }, + }) + fireEvent.click(screen.getByRole('button', { name: /test connection/i })) + + await waitFor(() => { + expect(screen.getByText('Connection successful')).toBeDefined() + }) + }) + + const selectCanvasMode = () => { + fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i })) + } + + it('fetches devices and renders them grouped by type', async () => { + vi.mocked(zwaveApi.importNetwork).mockResolvedValue({ + data: { nodes: sampleNodes, edges: [], device_count: 2 }, + } as never) + + render() + selectCanvasMode() + fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), { + target: { value: '192.168.1.100' }, + }) + fireEvent.click(screen.getByRole('button', { name: /fetch devices/i })) + + await waitFor(() => { + expect(screen.getByText('Controller')).toBeDefined() + expect(screen.getByText('Wall Plug')).toBeDefined() + }) + expect(toast.success).toHaveBeenCalledWith('Found 2 devices') + }) + + it('passes prefix and gateway_name to importNetwork', async () => { + vi.mocked(zwaveApi.importNetwork).mockResolvedValue({ + data: { nodes: [], edges: [], device_count: 0 }, + } as never) + + render() + selectCanvasMode() + fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), { + target: { value: '10.0.0.5' }, + }) + fireEvent.change(screen.getByPlaceholderText('zwave'), { target: { value: 'myzw' } }) + fireEvent.change(screen.getByPlaceholderText('zwavejs2mqtt'), { target: { value: 'gw1' } }) + fireEvent.click(screen.getByRole('button', { name: /fetch devices/i })) + + await waitFor(() => expect(zwaveApi.importNetwork).toHaveBeenCalled()) + const payload = vi.mocked(zwaveApi.importNetwork).mock.calls[0][0] + expect(payload.prefix).toBe('myzw') + expect(payload.gateway_name).toBe('gw1') + }) + + it('imports to pending by default and notifies parent', async () => { + vi.mocked(zwaveApi.importToPending).mockResolvedValue({ + data: { + id: 'run-1', + status: 'running', + kind: 'zwave', + ranges: ['192.168.1.100:1883'], + devices_found: 0, + started_at: '2026-01-01T00:00:00Z', + finished_at: null, + error: null, + }, + } as never) + const onPendingImported = vi.fn() + + render() + fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), { + target: { value: '192.168.1.100' }, + }) + fireEvent.click(screen.getByRole('button', { name: /import to pending/i })) + + await waitFor(() => { + expect(zwaveApi.importToPending).toHaveBeenCalled() + expect(onPendingImported).toHaveBeenCalled() + expect(defaultProps.onClose).toHaveBeenCalled() + }) + expect(zwaveApi.importNetwork).not.toHaveBeenCalled() + }) + + it('calls onAddToCanvas with selected devices and closes modal', async () => { + vi.mocked(zwaveApi.importNetwork).mockResolvedValue({ + data: { nodes: sampleNodes, edges: [{ source: 'zwave-0xh-1', target: 'zwave-0xh-2' }], device_count: 2 }, + } as never) + + render() + selectCanvasMode() + fireEvent.change(screen.getByPlaceholderText('192.168.1.x or mqtt.local'), { + target: { value: '192.168.1.100' }, + }) + fireEvent.click(screen.getByRole('button', { name: /fetch devices/i })) + + await waitFor(() => screen.getByText('Controller')) + + fireEvent.click(screen.getByRole('button', { name: /add.*canvas/i })) + + await waitFor(() => { + expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce() + expect(defaultProps.onClose).toHaveBeenCalledOnce() + }) + }) + + it('calls onClose when Cancel is clicked', () => { + render() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(defaultProps.onClose).toHaveBeenCalledOnce() + }) +}) diff --git a/frontend/src/components/zwave/types.ts b/frontend/src/components/zwave/types.ts new file mode 100644 index 0000000..3ee9a87 --- /dev/null +++ b/frontend/src/components/zwave/types.ts @@ -0,0 +1,37 @@ +/** Shared Z-Wave type definitions for the frontend. */ + +export interface ZwaveNode { + id: string + label: string + type: 'zwave_coordinator' | 'zwave_router' | 'zwave_enddevice' + ieee_address: string + friendly_name: string + device_type: string + model?: string | null + vendor?: string | null + lqi?: number | null + parent_id?: string | null +} + +export interface ZwaveEdge { + source: string + target: string +} + +export interface ZwaveImportResponse { + nodes: ZwaveNode[] + edges: ZwaveEdge[] + device_count: number +} + +export interface ZwaveTestConnectionRequest { + mqtt_host: string + mqtt_port: number + mqtt_username?: string + mqtt_password?: string +} + +export interface ZwaveTestConnectionResponse { + connected: boolean + message: string +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 97b6b92..22132b7 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -37,6 +37,9 @@ export type NodeType = | 'zigbee_coordinator' | 'zigbee_router' | 'zigbee_enddevice' + | 'zwave_coordinator' + | 'zwave_router' + | 'zwave_enddevice' | 'grid' | 'ups' | 'battery' @@ -187,6 +190,9 @@ export const NODE_TYPE_LABELS: Record = { zigbee_coordinator: 'Zigbee Coordinator', zigbee_router: 'Zigbee Router', zigbee_enddevice: 'Zigbee End Device', + zwave_coordinator: 'Z-Wave Controller', + zwave_router: 'Z-Wave Router', + zwave_enddevice: 'Z-Wave End Device', grid: 'Grid Connection', ups: 'UPS', battery: 'Battery', diff --git a/frontend/src/utils/__tests__/zwaveProperties.test.ts b/frontend/src/utils/__tests__/zwaveProperties.test.ts new file mode 100644 index 0000000..3e6608d --- /dev/null +++ b/frontend/src/utils/__tests__/zwaveProperties.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest' +import { isZwaveType, buildZwaveProperties } from '../zwaveProperties' + +describe('isZwaveType', () => { + it('returns true for zwave types', () => { + expect(isZwaveType('zwave_coordinator')).toBe(true) + expect(isZwaveType('zwave_router')).toBe(true) + expect(isZwaveType('zwave_enddevice')).toBe(true) + }) + + it('returns false for non-zwave types', () => { + expect(isZwaveType('zigbee_router')).toBe(false) + expect(isZwaveType('server')).toBe(false) + expect(isZwaveType(undefined)).toBe(false) + expect(isZwaveType(null)).toBe(false) + }) +}) + +describe('buildZwaveProperties', () => { + it('builds Z-Wave ID / Vendor / Model rows, all hidden', () => { + const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: 'Aeotec', model: 'ZW100' }) + expect(props).toEqual([ + { key: 'Z-Wave ID', value: 'zwave-0xh-2', icon: null, visible: false }, + { key: 'Vendor', value: 'Aeotec', icon: null, visible: false }, + { key: 'Model', value: 'ZW100', icon: null, visible: false }, + ]) + }) + + it('omits empty fields', () => { + const props = buildZwaveProperties({ ieee_address: 'zwave-0xh-2', vendor: null, model: undefined }) + expect(props.map((p) => p.key)).toEqual(['Z-Wave ID']) + }) + + it('never adds an LQI row', () => { + const props = buildZwaveProperties({ ieee_address: 'x', vendor: 'v', model: 'm' }) + expect(props.some((p) => p.key === 'LQI')).toBe(false) + }) +}) diff --git a/frontend/src/utils/nodeIcons.ts b/frontend/src/utils/nodeIcons.ts index e7ea3a5..182d6a5 100644 --- a/frontend/src/utils/nodeIcons.ts +++ b/frontend/src/utils/nodeIcons.ts @@ -11,7 +11,7 @@ import { // Security & Auth Shield, ShieldCheck, Lock, Key, Users, UserCheck, Flame, // Automation & IoT - Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, BotMessageSquare, Webhook, + Zap, Workflow, Bot, Home, Thermometer, Lightbulb, Radio, RadioTower, Share2, BotMessageSquare, Webhook, // Smart Home / Sensors Plug, Power, BatteryCharging, Sun, DoorOpen, KeyRound, AlarmSmoke, Siren, Radar, PersonStanding, Vibrate, Droplet, Droplets, Wind, AirVent, Fan, @@ -178,6 +178,9 @@ export const NODE_TYPE_DEFAULT_ICONS: Record = { zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb, + zwave_coordinator: RadioTower, + zwave_router: Share2, + zwave_enddevice: Lightbulb, generic: Circle, group: Circle, groupRect: Circle, diff --git a/frontend/src/utils/zwaveProperties.ts b/frontend/src/utils/zwaveProperties.ts new file mode 100644 index 0000000..6a47804 --- /dev/null +++ b/frontend/src/utils/zwaveProperties.ts @@ -0,0 +1,21 @@ +import type { NodeProperty, NodeType } from '@/types' + +const ZWAVE_TYPES: NodeType[] = ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] + +export function isZwaveType(type: NodeType | string | undefined | null): boolean { + return !!type && (ZWAVE_TYPES as string[]).includes(type) +} + +/** Build the Z-Wave ID/Vendor/Model property rows shown in the right panel. + * Matches backend `build_zwave_properties` (no LQI β€” Z-Wave has none). */ +export function buildZwaveProperties(input: { + ieee_address?: string | null + vendor?: string | null + model?: string | null +}): NodeProperty[] { + const props: NodeProperty[] = [] + if (input.ieee_address) props.push({ key: 'Z-Wave ID', value: input.ieee_address, icon: null, visible: false }) + if (input.vendor) props.push({ key: 'Vendor', value: input.vendor, icon: null, visible: false }) + if (input.model) props.push({ key: 'Model', value: input.model, icon: null, visible: false }) + return props +} From 9b8f15bec30fbf058b7970052cdfb53954ed25ce Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 13:10:12 +0200 Subject: [PATCH 17/22] feat: show Z-Wave scan runs in Scan History Scan History now recognises kind=zwave: dedicated Z-Wave filter chip, badge (RadioTower, orange) and import-done toast, instead of falling back to the generic IP type. ha-relevant: yes --- .../components/modals/ScanHistoryModal.tsx | 35 +++++++++++++------ .../__tests__/ScanHistoryModal.test.tsx | 21 +++++++++++ 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/modals/ScanHistoryModal.tsx b/frontend/src/components/modals/ScanHistoryModal.tsx index b76e919..dcbb348 100644 --- a/frontend/src/components/modals/ScanHistoryModal.tsx +++ b/frontend/src/components/modals/ScanHistoryModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react' +import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Inbox } from 'lucide-react' import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { scanApi } from '@/api/client' @@ -22,7 +22,18 @@ interface ScanHistoryModalProps { onClose: () => void } -type KindFilter = 'all' | 'ip' | 'zigbee' +type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' + +/** Normalise a ScanRun.kind into one of the known display kinds. */ +function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' { + return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip' +} + +const KIND_META = { + ip: { label: 'IP', color: '#a855f7' }, + zigbee: { label: 'Zigbee', color: '#00d4ff' }, + zwave: { label: 'Z-Wave', color: '#ff6e00' }, +} as const type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled' const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [ @@ -37,6 +48,7 @@ const KIND_FILTERS: { key: KindFilter; label: string }[] = [ { key: 'all', label: 'All' }, { key: 'ip', label: 'IP' }, { key: 'zigbee', label: 'Zigbee' }, + { key: 'zwave', label: 'Z-Wave' }, ] function statusColor(s: string): string { @@ -90,8 +102,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { toast.error(`Scan failed: ${run.error ?? 'unknown error'}`) } if (prev?.status === 'running' && run.status === 'done') { - if (run.kind === 'zigbee') { - toast.success(`Zigbee import done β€” ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`) + if (run.kind === 'zigbee' || run.kind === 'zwave') { + const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee' + toast.success(`${label} import done β€” ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`) } useCanvasStore.getState().notifyScanDeviceFound() } @@ -143,7 +156,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { } const filtered = runs.filter((r) => { - const k = r.kind === 'zigbee' ? 'zigbee' : 'ip' + const k = runKind(r.kind) if (kindFilter !== 'all' && k !== kindFilter) return false if (statusFilter !== 'all' && r.status !== statusFilter) return false return true @@ -216,7 +229,9 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { )} {filtered.map((r) => { - const isZigbee = r.kind === 'zigbee' + const kind = runKind(r.kind) + const meta = KIND_META[kind] + const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : ScanLine return (
@@ -225,12 +240,10 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) { {r.status === 'running' && } - {isZigbee ? : } - {isZigbee ? 'Zigbee' : 'IP'} + + {meta.label} {r.devices_found} found diff --git a/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx index 764c2d9..229eea7 100644 --- a/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx +++ b/frontend/src/components/modals/__tests__/ScanHistoryModal.test.tsx @@ -61,6 +61,17 @@ const ZIGBEE_RUN = { error: null, } +const ZWAVE_RUN = { + id: 'run-5', + status: 'done', + kind: 'zwave', + ranges: [], + devices_found: 5, + started_at: new Date().toISOString(), + finished_at: new Date().toISOString(), + error: null, +} + function renderModal() { return render( @@ -155,4 +166,14 @@ describe('ScanHistoryModal', () => { expect(screen.getByText('7 found')).toBeDefined() expect(screen.queryByText('3 found')).toBeNull() }) + + it('filters by zwave kind', async () => { + vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZWAVE_RUN] } as never) + renderModal() + await waitFor(() => expect(screen.getAllByText('done').length).toBe(2)) + fireEvent.click(screen.getByRole('button', { name: 'Z-Wave' })) + // Only the zwave run (5 found) remains + expect(screen.getByText('5 found')).toBeDefined() + expect(screen.queryByText('3 found')).toBeNull() + }) }) From 5b08d5712464ff84d009b8aba35fb62679ce00de Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 13:58:30 +0200 Subject: [PATCH 18/22] fix: add zwave node types to canvas theme maps The 6 per-theme NodeType color maps in themes.ts were missing the three zwave_* keys, breaking the production build (tsc -b) even though the dev typecheck passed. ha-relevant: yes --- frontend/src/utils/themes.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/frontend/src/utils/themes.ts b/frontend/src/utils/themes.ts index f2a1d23..892ca46 100644 --- a/frontend/src/utils/themes.ts +++ b/frontend/src/utils/themes.ts @@ -64,6 +64,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' }, zigbee_router: { border: '#e3b341', icon: '#e3b341' }, zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' }, + zwave_coordinator: { border: '#ff6e00', icon: '#ff6e00' }, + zwave_router: { border: '#e3b341', icon: '#e3b341' }, + zwave_enddevice: { border: '#a855f7', icon: '#a855f7' }, generic: { border: '#8b949e', icon: '#8b949e' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' }, @@ -143,6 +146,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#fb923c', icon: '#fb923c' }, zigbee_router: { border: '#fbbf24', icon: '#fbbf24' }, zigbee_enddevice: { border: '#c084fc', icon: '#c084fc' }, + zwave_coordinator: { border: '#fb923c', icon: '#fb923c' }, + zwave_router: { border: '#fbbf24', icon: '#fbbf24' }, + zwave_enddevice: { border: '#c084fc', icon: '#c084fc' }, generic: { border: '#94a3b8', icon: '#94a3b8' }, groupRect: { border: '#22d3ee', icon: '#22d3ee' }, group: { border: '#22d3ee', icon: '#22d3ee' }, @@ -222,6 +228,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ea580c', icon: '#ea580c' }, zigbee_router: { border: '#b45309', icon: '#b45309' }, zigbee_enddevice: { border: '#7c3aed', icon: '#7c3aed' }, + zwave_coordinator: { border: '#ea580c', icon: '#ea580c' }, + zwave_router: { border: '#b45309', icon: '#b45309' }, + zwave_enddevice: { border: '#7c3aed', icon: '#7c3aed' }, generic: { border: '#6b7280', icon: '#6b7280' }, groupRect: { border: '#0284c7', icon: '#0284c7' }, group: { border: '#0284c7', icon: '#0284c7' }, @@ -301,6 +310,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ff8800', icon: '#ff8800' }, zigbee_router: { border: '#ffff00', icon: '#ffff00' }, zigbee_enddevice: { border: '#ff00ff', icon: '#ff00ff' }, + zwave_coordinator: { border: '#ff8800', icon: '#ff8800' }, + zwave_router: { border: '#ffff00', icon: '#ffff00' }, + zwave_enddevice: { border: '#ff00ff', icon: '#ff00ff' }, generic: { border: '#8888ff', icon: '#8888ff' }, groupRect: { border: '#00ffff', icon: '#00ffff' }, group: { border: '#00ffff', icon: '#00ffff' }, @@ -380,6 +392,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#33ff66', icon: '#33ff66' }, zigbee_router: { border: '#66ff33', icon: '#66ff33' }, zigbee_enddevice: { border: '#008822', icon: '#008822' }, + zwave_coordinator: { border: '#33ff66', icon: '#33ff66' }, + zwave_router: { border: '#66ff33', icon: '#66ff33' }, + zwave_enddevice: { border: '#008822', icon: '#008822' }, generic: { border: '#006600', icon: '#006600' }, groupRect: { border: '#00ff41', icon: '#00ff41' }, group: { border: '#00ff41', icon: '#00ff41' }, @@ -459,6 +474,9 @@ export const THEMES: Record = { zigbee_coordinator:{ border: '#ff6e00', icon: '#ff6e00' }, zigbee_router: { border: '#e3b341', icon: '#e3b341' }, zigbee_enddevice: { border: '#a855f7', icon: '#a855f7' }, + zwave_coordinator:{ border: '#ff6e00', icon: '#ff6e00' }, + zwave_router: { border: '#e3b341', icon: '#e3b341' }, + zwave_enddevice: { border: '#a855f7', icon: '#a855f7' }, generic: { border: '#8b949e', icon: '#8b949e' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' }, From ecf3cbdfe4116a122a1050cfef0fd5c28a14b9f9 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 16:02:23 +0200 Subject: [PATCH 19/22] feat: group node types by category in Custom Style editor The Custom Style modal listed node types as a flat list. It now groups them under category headers (Hardware, Virtualization, IoT, Zigbee, Z-Wave, Personal, Generic) like the Add/Edit Node modal, and exposes the Z-Wave node types for styling. ha-relevant: yes --- .../components/modals/CustomStyleModal.tsx | 81 +++++++++++-------- .../__tests__/CustomStyleModal.test.tsx | 10 +++ 2 files changed, 56 insertions(+), 35 deletions(-) diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index b1f6918..8504d81 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -1,9 +1,9 @@ -import { useState, useCallback } from 'react' +import { Fragment, useState, useCallback } from 'react' import { toast } from 'sonner' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame, - Radio, Zap, Lightbulb, + Radio, Zap, Lightbulb, RadioTower, Share2, type LucideIcon, } from 'lucide-react' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -17,13 +17,16 @@ import type { } from '@/types' import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types' -// ── Node types exposed for custom style (skip groupRect/group) ─────────────── +// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ── -const EDITABLE_NODE_TYPES: NodeType[] = [ - 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', - 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', - 'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice', - 'generic', +const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ + { label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] }, + { label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] }, + { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, + { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, + { label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] }, + { label: 'Personal', types: ['computer', 'laptop', 'mobile'] }, + { label: 'Generic', types: ['generic'] }, ] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical'] @@ -34,6 +37,7 @@ const NODE_ICONS: Record = { camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap, docker_host: Anchor, docker_container: Package, zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb, + zwave_coordinator: RadioTower, zwave_router: Share2, zwave_enddevice: Lightbulb, generic: Circle, } @@ -363,34 +367,41 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { {/* Type list */}
- {tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => { - const Icon = NODE_ICONS[t] ?? Circle - const style = draft.nodes[t] - const isSelected = selection?.kind === 'node' && selection.type === t - const swatchColor = style - ? applyOpacity(style.borderColor, style.borderOpacity) - : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' + {tab === 'nodes' && NODE_TYPE_GROUPS.map((group) => ( + +
+ {group.label} +
+ {group.types.map((t) => { + const Icon = NODE_ICONS[t] ?? Circle + const style = draft.nodes[t] + const isSelected = selection?.kind === 'node' && selection.type === t + const swatchColor = style + ? applyOpacity(style.borderColor, style.borderOpacity) + : THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e' - return ( - - ) - })} + return ( + + ) + })} +
+ ))} {tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => { const style = draft.edges[t] diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index 0ea98a7..d368a87 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -37,6 +37,16 @@ describe('CustomStyleModal', () => { expect(screen.getByText(/edge type from the list/i)).toBeDefined() }) + it('groups node types under category headers (incl. Zigbee and Z-Wave)', () => { + render() + expect(screen.getByText('Hardware')).toBeDefined() + expect(screen.getByText('Zigbee')).toBeDefined() + expect(screen.getByText('Z-Wave')).toBeDefined() + // A Z-Wave node type is selectable from its category. + fireEvent.click(screen.getByRole('button', { name: /Z-Wave Controller/ })) + expect(screen.getByText(/Apply to existing Z-Wave Controller/)).toBeDefined() + }) + it('selecting a node type opens the node editor', () => { render() fireEvent.click(screen.getByRole('button', { name: 'Router' })) From f749b38edcc9b7c8771923a5889cc7ff763c597e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 16:20:30 +0200 Subject: [PATCH 20/22] fix: reset Custom Style draft on reopen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal is kept mounted by its parent (only `open` toggles), so Radix onOpenChange never fires for a parent-driven open and the draft-reset branch was dead code β€” abandoned edits leaked into the next open. Reset on the `open` prop edge via effect instead. Also pass radix 10 to parseInt for the size inputs. Adds a reopen-after-cancel regression test. ha-relevant: yes --- .../components/modals/CustomStyleModal.tsx | 24 ++++++++++++------- .../__tests__/CustomStyleModal.test.tsx | 19 +++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index 8504d81..c4d962f 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -1,4 +1,4 @@ -import { Fragment, useState, useCallback } from 'react' +import { Fragment, useState, useEffect, useCallback } from 'react' import { toast } from 'sonner' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, @@ -160,7 +160,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor min={0} step={10} value={style.width} - onChange={(e) => set('width', parseInt(e.target.value) || 0)} + onChange={(e) => set('width', parseInt(e.target.value, 10) || 0)} className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" />
@@ -171,7 +171,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor min={0} step={10} value={style.height} - onChange={(e) => set('height', parseInt(e.target.value) || 0)} + onChange={(e) => set('height', parseInt(e.target.value, 10) || 0)} className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]" />
@@ -285,14 +285,22 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) { edges: { ...customStyle.edges }, })) - const handleOpen = (isOpen: boolean) => { - if (isOpen) { - // Reset draft to current saved customStyle on open + // Reset the draft to the saved customStyle whenever the modal is (re)opened. + // The parent keeps this component mounted and only toggles `open`, so Radix's + // onOpenChange never fires for a parent-driven open β€” we key off the prop edge + // instead. Without this, abandoned edits (Cancel) would leak into the next open. + useEffect(() => { + if (open) { setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } }) setSelection(null) - } else { - onClose() } + // Intentional snapshot-on-open: we don't want live customStyle changes to + // clobber an in-progress edit, only a fresh open should reset. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleOpen = (isOpen: boolean) => { + if (!isOpen) onClose() } const getNodeStyle = (t: NodeType): NodeTypeStyle => diff --git a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx index d368a87..13a4d47 100644 --- a/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx +++ b/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx @@ -134,4 +134,23 @@ describe('CustomStyleModal', () => { fireEvent.change(widthInputs[0], { target: { value: '250' } }) expect((widthInputs[0] as HTMLInputElement).value).toBe('250') }) + + it('resets abandoned edits when reopened after cancel (mounted parent)', () => { + // Parent keeps the modal mounted and only toggles `open`, so the reset must + // happen on the open-prop edge, not via Radix onOpenChange. + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + const widthInput = screen.getAllByRole('spinbutton')[0] + fireEvent.change(widthInput, { target: { value: '250' } }) + expect((widthInput as HTMLInputElement).value).toBe('250') + + // Cancel = parent flips open β†’ false, then later β†’ true again. + rerender() + rerender() + + fireEvent.click(screen.getByRole('button', { name: 'Router' })) + const reopenedWidth = screen.getAllByRole('spinbutton')[0] + // Draft was reset to saved style (default width 0) β€” edit did not leak. + expect((reopenedWidth as HTMLInputElement).value).toBe('0') + }) }) From d41896fadf55a7e580649998dabe7a0e5fca8cd7 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 20:42:52 +0200 Subject: [PATCH 21/22] fix: render styled canvas nodes for zwave types The zwave_* node types were not registered in the React Flow nodeTypes map, so imported Z-Wave devices fell back to the default unstyled node (no icon, no accent). Add ZwaveCoordinator/Router/EndDevice node components and register them. Adds a registry guard test. ha-relevant: yes --- .../canvas/nodes/__tests__/nodeTypes.test.ts | 15 +++++++++++++++ frontend/src/components/canvas/nodes/index.tsx | 7 ++++++- frontend/src/components/canvas/nodes/nodeTypes.ts | 4 ++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts diff --git a/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts b/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts new file mode 100644 index 0000000..1e0b237 --- /dev/null +++ b/frontend/src/components/canvas/nodes/__tests__/nodeTypes.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { nodeTypes } from '../nodeTypes' + +describe('nodeTypes registry', () => { + it('registers a component for every wireless mesh node type', () => { + // Regression: zwave_* types were missing, so React Flow fell back to the + // default (unstyled) node β€” no icon, no accent. (Zigbee covered too.) + for (const t of [ + 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice', + 'zwave_coordinator', 'zwave_router', 'zwave_enddevice', + ]) { + expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined() + } + }) +}) diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index 1af4afd..d7b758a 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -1,7 +1,7 @@ import { type NodeProps, type Node } from '@xyflow/react' import { Globe, Router, Network, Server, Layers, Box, Container, - HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, + HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2, Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap, } from 'lucide-react' import { BaseNode } from './BaseNode' @@ -34,6 +34,11 @@ export const ZigbeeCoordinatorNode = (props: N) => export const ZigbeeEndDeviceNode = (props: N) => +// Z-Wave node types +export const ZwaveCoordinatorNode = (props: N) => +export const ZwaveRouterNode = (props: N) => +export const ZwaveEndDeviceNode = (props: N) => + // Electrical node types export const GridNode = (props: N) => export const UpsNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index e278307..98f8b8e 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -3,6 +3,7 @@ import { NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode, + ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode, GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode, CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode, LightNode, MeterNode, TransformerNode, LoadNode, @@ -39,6 +40,9 @@ export const nodeTypes = { zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_router: ZigbeeRouterNode, zigbee_enddevice: ZigbeeEndDeviceNode, + zwave_coordinator: ZwaveCoordinatorNode, + zwave_router: ZwaveRouterNode, + zwave_enddevice: ZwaveEndDeviceNode, grid: GridNode, ups: UpsNode, battery: BatteryNode, From 13420bead8ec9e1282d7c9084647d9512b9ccf13 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 26 Jun 2026 21:58:31 +0200 Subject: [PATCH 22/22] fix: approve devices onto the active design + Z-Wave node fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approve (single + bulk) created the canvas Node under the first design instead of the design the user is viewing, so approved devices were invisible on the active canvas and got wiped on the next save β€” and a re-approve returned "0 approved" because the rows were already approved. - bulk-approve now accepts design_id; the UI sends the active design. - single approve already honoured design_id; the UI now sends it too. - generalize the wireless branch (status=online, mesh props, no ICMP check) to Z-Wave as well as Zigbee, using build_zwave_properties. ha-relevant: yes --- backend/app/api/routes/scan.py | 58 +++++++++++---- backend/tests/test_scan.py | 73 +++++++++++++++++++ frontend/src/api/__tests__/client.test.ts | 4 +- frontend/src/api/client.ts | 4 +- .../components/modals/PendingDevicesModal.tsx | 6 +- .../__tests__/PendingDevicesModal.test.tsx | 4 +- 6 files changed, 127 insertions(+), 22 deletions(-) diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 7f0da05..5377cb6 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -16,8 +16,28 @@ from app.schemas.nodes import NodeCreate from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan from app.services.zigbee_service import build_zigbee_properties +from app.services.zwave_service import build_zwave_properties _ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"} +_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"} + + +def _is_wireless(node_type: str | None) -> bool: + """Zigbee + Z-Wave mesh devices share online status / no ICMP check.""" + return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES + + +def _wireless_properties( + node_type: str | None, + ieee: str | None, + vendor: str | None, + model: str | None, + lqi: int | None, +) -> list[dict[str, Any]]: + """Build the right property rows for a mesh device (Z-Wave has no LQI).""" + if node_type in _ZWAVE_TYPES: + return build_zwave_properties(ieee, vendor, model) + return build_zigbee_properties(ieee, vendor, model, lqi) def build_mac_property(mac: str | None) -> list[dict[str, Any]]: @@ -50,6 +70,10 @@ def merge_mac_property( class BulkActionRequest(BaseModel): device_ids: list[str] + # Target design for approved nodes. Falls back to the first design when + # omitted (keeps older clients working), but the UI should send the active + # design so approved devices land on the canvas the user is looking at. + design_id: str | None = None def _check_port_ranges(v: list[str]) -> list[str]: @@ -248,9 +272,11 @@ async def bulk_approve_devices( db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user), ) -> dict[str, Any]: - # Determine target design (use first design as fallback) - first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() - default_design_id = first_design.id if first_design else None + # Target the design the user is on; fall back to the first design. + default_design_id = payload.design_id + if default_design_id is None: + first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + default_design_id = first_design.id if first_design else None result = await db.execute( select(PendingDevice).where( @@ -263,22 +289,22 @@ async def bulk_approve_devices( for device in devices: device.status = "approved" node_type = device.suggested_type or "generic" - is_zigbee = node_type in _ZIGBEE_TYPES + is_wireless = _is_wireless(node_type) node = Node( label=device.hostname or device.friendly_name or device.ip or "device", type=node_type, ip=device.ip, mac=device.mac, hostname=device.hostname, - status="online" if is_zigbee else "unknown", + status="online" if is_wireless else "unknown", services=device.services or [], ieee_address=device.ieee_address, - properties=build_zigbee_properties( - device.ieee_address, device.vendor, device.model, device.lqi - ) if is_zigbee else build_mac_property(device.mac), + properties=_wireless_properties( + node_type, device.ieee_address, device.vendor, device.model, device.lqi + ) if is_wireless else build_mac_property(device.mac), # Default to ping so the status checker actually polls the new node. # Without this the scheduler skips it (check_method NULL β†’ no check). - check_method="none" if is_zigbee else ("ping" if device.ip else None), + check_method="none" if is_wireless else ("ping" if device.ip else None), design_id=default_design_id, ) db.add(node) @@ -375,7 +401,7 @@ async def approve_device( if device.status != "pending": raise HTTPException(status_code=409, detail="Device already processed") device.status = "approved" - _is_zigbee = node_data.type in _ZIGBEE_TYPES + wireless = _is_wireless(node_data.type) # Prefer the MAC discovered during the scan (stored on the pending device); # fall back to whatever the approve payload carried. _mac = device.mac or node_data.mac @@ -385,14 +411,14 @@ async def approve_device( ip=node_data.ip, mac=_mac, hostname=node_data.hostname, - status="online" if _is_zigbee else node_data.status, + status="online" if wireless else node_data.status, services=node_data.services or [], ieee_address=device.ieee_address, - properties=build_zigbee_properties( - device.ieee_address, device.vendor, device.model, device.lqi - ) if _is_zigbee else merge_mac_property(node_data.properties, _mac), - check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)), - check_target=None if _is_zigbee else node_data.check_target, + properties=_wireless_properties( + node_data.type, device.ieee_address, device.vendor, device.model, device.lqi + ) if wireless else merge_mac_property(node_data.properties, _mac), + check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)), + check_target=None if wireless else node_data.check_target, design_id=node_design_id, ) db.add(node) diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py index dc6b518..b9213c8 100644 --- a/backend/tests/test_scan.py +++ b/backend/tests/test_scan.py @@ -1357,3 +1357,76 @@ async def test_update_scan_config_persists_deep_scan(client: AsyncClient, header ) assert res.status_code == 200 assert saved == {"http_ranges": ["9000-9100"], "probe": True} + + +# --- Z-Wave approve: active design targeting + wireless props (regression) --- + +@pytest.mark.asyncio +async def test_bulk_approve_targets_requested_design(client, headers, db_session): + """bulk-approve must place nodes on the design_id sent by the UI, not the + first design β€” otherwise approved devices land on the wrong canvas.""" + first = await _add_design(db_session, "Default") # first design (fallback) + active = await _add_design(db_session, "zwave") # the design the user is on + dev = PendingDevice( + id=str(uuid.uuid4()), + ieee_address="zwave-H-2", + friendly_name="Living Room Plug", + suggested_type="zwave_router", + device_subtype="Router", + vendor="Aeotec", + model="ZW096", + status="pending", + discovery_source="zwave", + ) + db_session.add(dev) + await db_session.commit() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": [dev.id], "design_id": active}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json()["approved"] == 1 + + node = ( + await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2")) + ).scalar_one() + assert node.design_id == active + assert node.design_id != first + # Z-Wave device β†’ online + Z-Wave property rows, no ICMP check. + assert node.status == "online" + assert node.check_method == "none" + assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"} + + +@pytest.mark.asyncio +async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session): + active = await _add_design(db_session, "zwave") + dev = PendingDevice( + id=str(uuid.uuid4()), + ieee_address="zwave-H-9", + friendly_name="Door Sensor", + suggested_type="zwave_enddevice", + vendor="Aeotec", + model="ZW120", + status="pending", + discovery_source="zwave", + ) + db_session.add(dev) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{dev.id}/approve", + json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active}, + headers=headers, + ) + assert res.status_code == 200 + + node = ( + await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9")) + ).scalar_one() + assert node.design_id == active + assert node.status == "online" + assert node.check_method == "none" + assert any(p["key"] == "Z-Wave ID" for p in node.properties) diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts index f5a129d..557c56d 100644 --- a/frontend/src/api/__tests__/client.test.ts +++ b/frontend/src/api/__tests__/client.test.ts @@ -186,7 +186,9 @@ describe('api/client', () => { mod.scanApi.ignore('d1') expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore') mod.scanApi.bulkApprove(['a', 'b']) - expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] }) + expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'], design_id: undefined }) + mod.scanApi.bulkApprove(['a'], 'design-9') + expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' }) mod.scanApi.bulkHide(['a']) expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] }) mod.scanApi.restore('d1') diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 45a5958..a21755e 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -81,7 +81,7 @@ export const scanApi = { }>(`/scan/pending/${id}/approve`, nodeData), hide: (id: string) => api.post(`/scan/pending/${id}/hide`), ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`), - bulkApprove: (ids: string[]) => + bulkApprove: (ids: string[], designId?: string | null) => api.post<{ approved: number node_ids: string[] @@ -89,7 +89,7 @@ export const scanApi = { edges_created: number edges: { id: string; source: string; target: string }[] skipped: number - }>('/scan/pending/bulk-approve', { device_ids: ids }), + }>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }), bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }), restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`), bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }), diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index 933fa60..b458fea 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -6,6 +6,7 @@ import { import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { scanApi } from '@/api/client' import { useCanvasStore } from '@/stores/canvasStore' +import { useDesignStore } from '@/stores/designStore' import { toast } from 'sonner' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import type { NodeType, ServiceInfo } from '@/types' @@ -127,6 +128,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus // Optionally restrict to devices that have at least one detected service. const [withServicesOnly, setWithServicesOnly] = useState(false) const { addNode, scanEventTs } = useCanvasStore() + const activeDesignId = useDesignStore((s) => s.activeDesignId) const highlightRef = useRef(null) const load = useCallback(async () => { @@ -283,6 +285,8 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus status: wireless ? 'online' : 'unknown', services: (device.services ?? []) as ServiceInfo[], properties, + // Approve onto the design the user is viewing, not the first design. + design_id: activeDesignId ?? undefined, } const res = await scanApi.approve(device.id, nodeData) const nodeId = res.data.node_id @@ -327,7 +331,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus const ids = [...selectedIds] if (ids.length === 0) return try { - const res = await scanApi.bulkApprove(ids) + const res = await scanApi.bulkApprove(ids, activeDesignId) const deviceToNode: Record = {} res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] }) const approvedDevices = devices.filter((d) => ids.includes(d.id)) diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx index f988d1d..92baef6 100644 --- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx +++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx @@ -213,7 +213,7 @@ describe('PendingDevicesModal', () => { fireEvent.click(screen.getByTestId('pending-card-dev-a')) fireEvent.click(screen.getByTestId('pending-card-dev-b')) fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ })) - await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) + await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null)) }) it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => { @@ -290,7 +290,7 @@ describe('PendingDevicesModal', () => { fireEvent.click(screen.getByRole('button', { name: 'Select mode' })) fireEvent.click(screen.getByTestId('pending-card-dev-a')) fireEvent.keyDown(window, { key: 'Enter' }) - await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'])) + await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a'], null)) expect(mockBulkRestore).not.toHaveBeenCalled() })