unifi-network-maps 1.4.10__tar.gz → 1.4.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/CHANGELOG.md +17 -1
  2. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/PKG-INFO +2 -2
  3. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/README.md +1 -1
  4. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/pyproject.toml +1 -1
  5. unifi_network_maps-1.4.12/src/unifi_network_maps/__init__.py +1 -0
  6. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/cli/render.py +19 -3
  7. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/cli/runtime.py +7 -1
  8. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/model/topology.py +110 -15
  9. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/lldp_md.py +123 -15
  10. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/svg.py +8 -2
  11. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps.egg-info/PKG-INFO +2 -2
  12. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_clients.py +65 -0
  13. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_lldp_md.py +55 -0
  14. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_svg.py +27 -0
  15. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_topology.py +37 -0
  16. unifi_network_maps-1.4.10/src/unifi_network_maps/__init__.py +0 -1
  17. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/CONTRIBUTING.md +0 -0
  18. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/LICENSE +0 -0
  19. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/LICENSES.md +0 -0
  20. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/MANIFEST.in +0 -0
  21. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/RELEASING.md +0 -0
  22. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/SECURITY.md +0 -0
  23. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/setup.cfg +0 -0
  24. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/__main__.py +0 -0
  25. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/adapters/__init__.py +0 -0
  26. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/adapters/config.py +0 -0
  27. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/adapters/unifi.py +0 -0
  28. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/__init__.py +0 -0
  29. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/__init__.py +0 -0
  30. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/access-point.svg +0 -0
  31. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  32. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/block.svg +0 -0
  33. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/cache.svg +0 -0
  34. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/cardterminal.svg +0 -0
  35. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/cloud.svg +0 -0
  36. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/cronjob.svg +0 -0
  37. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/cube.svg +0 -0
  38. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/desktop.svg +0 -0
  39. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/diamond.svg +0 -0
  40. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/dns.svg +0 -0
  41. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/document.svg +0 -0
  42. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/firewall.svg +0 -0
  43. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/function-module.svg +0 -0
  44. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/image.svg +0 -0
  45. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/laptop.svg +0 -0
  46. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/loadbalancer.svg +0 -0
  47. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/lock.svg +0 -0
  48. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/mail.svg +0 -0
  49. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/mailmultiple.svg +0 -0
  50. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/mobiledevice.svg +0 -0
  51. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/office.svg +0 -0
  52. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/package-module.svg +0 -0
  53. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/paymentcard.svg +0 -0
  54. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/plane.svg +0 -0
  55. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/printer.svg +0 -0
  56. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/pyramid.svg +0 -0
  57. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/queue.svg +0 -0
  58. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/router.svg +0 -0
  59. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/server.svg +0 -0
  60. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/speech.svg +0 -0
  61. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/sphere.svg +0 -0
  62. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/storage.svg +0 -0
  63. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/switch-module.svg +0 -0
  64. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/tower.svg +0 -0
  65. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/truck-2.svg +0 -0
  66. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/truck.svg +0 -0
  67. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/user.svg +0 -0
  68. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/isometric/vm.svg +0 -0
  69. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/laptop.svg +0 -0
  70. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/router-network.svg +0 -0
  71. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/server-network.svg +0 -0
  72. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/icons/server.svg +0 -0
  73. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/themes/dark.yaml +0 -0
  74. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/assets/themes/default.yaml +0 -0
  75. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/cli/__init__.py +0 -0
  76. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/cli/__main__.py +0 -0
  77. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/cli/args.py +0 -0
  78. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/cli/main.py +0 -0
  79. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/io/__init__.py +0 -0
  80. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/io/debug.py +0 -0
  81. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/io/export.py +0 -0
  82. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/io/mkdocs_assets.py +0 -0
  83. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/io/mock_data.py +0 -0
  84. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/io/mock_generate.py +0 -0
  85. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/model/__init__.py +0 -0
  86. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/model/labels.py +0 -0
  87. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/model/lldp.py +0 -0
  88. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/model/mock.py +0 -0
  89. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/model/ports.py +0 -0
  90. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/__init__.py +0 -0
  91. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/device_ports_md.py +0 -0
  92. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/legend.py +0 -0
  93. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/markdown_tables.py +0 -0
  94. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/mermaid.py +0 -0
  95. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/mermaid_theme.py +0 -0
  96. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/mkdocs.py +0 -0
  97. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/svg_theme.py +0 -0
  98. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/device_port_block.md.j2 +0 -0
  99. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/legend_compact.html.j2 +0 -0
  100. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/lldp_device_section.md.j2 +0 -0
  101. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/markdown_section.md.j2 +0 -0
  102. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +0 -0
  103. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/mkdocs_document.md.j2 +0 -0
  104. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +0 -0
  105. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +0 -0
  106. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/mkdocs_legend.css.j2 +0 -0
  107. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/mkdocs_legend.js.j2 +0 -0
  108. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +0 -0
  109. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/templating.py +0 -0
  110. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps/render/theme.py +0 -0
  111. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps.egg-info/SOURCES.txt +0 -0
  112. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps.egg-info/dependency_links.txt +0 -0
  113. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps.egg-info/entry_points.txt +0 -0
  114. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps.egg-info/requires.txt +0 -0
  115. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/src/unifi_network_maps.egg-info/top_level.txt +0 -0
  116. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_cli.py +0 -0
  117. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_config.py +0 -0
  118. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_contract_unifi.py +0 -0
  119. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_contract_unifi_live.py +0 -0
  120. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_debug.py +0 -0
  121. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_device_ports_md.py +0 -0
  122. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_export.py +0 -0
  123. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_groups.py +0 -0
  124. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_labels.py +0 -0
  125. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_lldp.py +0 -0
  126. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_mermaid.py +0 -0
  127. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_mock_generate.py +0 -0
  128. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_svg_iso.py +0 -0
  129. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_theme.py +0 -0
  130. {unifi_network_maps-1.4.10 → unifi_network_maps-1.4.12}/tests/test_unifi.py +0 -0
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.12] - 2026-01-21
9
+ ### Added
10
+ - Filter UniFi clients with --only-unifi, and not only neighbors
11
+
12
+ ### Fixed
13
+ - inconsistencies in --only-unifi
14
+
15
+ ## [1.4.11] - 2026-01-19
16
+ ### Added
17
+ - Add data-edge-left/right attributes to SVG paths
18
+
19
+ ### Fixed
20
+ - Regression in identifying wireless/wired clients
21
+
8
22
  ## [1.4.10] - 2026-01-18
9
23
  ### Added
10
24
  - Add speed and channel fields to Edge dataclass
@@ -169,7 +183,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
169
183
  - Introduced SVG renderer and tree layout fixes.
170
184
  - Increased test coverage and added coverage tooling.
171
185
 
172
- [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.10...HEAD
186
+ [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.12...HEAD
187
+ [1.4.12]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.12
188
+ [1.4.11]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.10...v1.4.11
173
189
  [1.4.10]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.9...v1.4.10
174
190
  [1.4.9]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.8...v1.4.9
175
191
  [1.4.8]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.7...v1.4.8
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.4.10
3
+ Version: 1.4.12
4
4
  Summary: Dynamic UniFi -> network maps in mermaid or svg
5
5
  Author: Merlijn
6
6
  License-Expression: MIT
@@ -223,7 +223,7 @@ Functional:
223
223
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
224
224
  - `--include-clients`: add active wired clients as leaf nodes.
225
225
  - `--client-scope wired|wireless|all`: which client types to include (default wired).
226
- - `--only-unifi`: only include neighbors that are UniFi devices.
226
+ - `--only-unifi`: only include neighbors that are UniFi devices; when clients are included, filters to UniFi-managed clients (by explicit UniFi flags or vendor/OUI).
227
227
  - `--no-cache`: disable UniFi API cache reads and writes.
228
228
 
229
229
  Mermaid:
@@ -186,7 +186,7 @@ Functional:
186
186
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
187
187
  - `--include-clients`: add active wired clients as leaf nodes.
188
188
  - `--client-scope wired|wireless|all`: which client types to include (default wired).
189
- - `--only-unifi`: only include neighbors that are UniFi devices.
189
+ - `--only-unifi`: only include neighbors that are UniFi devices; when clients are included, filters to UniFi-managed clients (by explicit UniFi flags or vendor/OUI).
190
190
  - `--no-cache`: disable UniFi API cache reads and writes.
191
191
 
192
192
  Mermaid:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unifi-network-maps"
7
- version = "1.4.10"
7
+ version = "1.4.12"
8
8
  description = "Dynamic UniFi -> network maps in mermaid or svg"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1 @@
1
+ __version__ = "1.4.12"
@@ -62,7 +62,12 @@ def render_mermaid_output(
62
62
  direction=args.direction,
63
63
  groups=groups,
64
64
  group_order=group_order,
65
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
65
+ node_types=build_node_type_map(
66
+ devices,
67
+ clients,
68
+ client_mode=args.client_scope,
69
+ only_unifi=args.only_unifi,
70
+ ),
66
71
  theme=mermaid_theme,
67
72
  )
68
73
  if args.markdown:
@@ -97,13 +102,23 @@ def render_svg_output(
97
102
 
98
103
  return render_svg_isometric(
99
104
  edges,
100
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
105
+ node_types=build_node_type_map(
106
+ devices,
107
+ clients,
108
+ client_mode=args.client_scope,
109
+ only_unifi=args.only_unifi,
110
+ ),
101
111
  options=options,
102
112
  theme=svg_theme,
103
113
  )
104
114
  return render_svg(
105
115
  edges,
106
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
116
+ node_types=build_node_type_map(
117
+ devices,
118
+ clients,
119
+ client_mode=args.client_scope,
120
+ only_unifi=args.only_unifi,
121
+ ),
107
122
  options=options,
108
123
  theme=svg_theme,
109
124
  )
@@ -190,6 +205,7 @@ def render_lldp_format(
190
205
  include_ports=args.include_ports,
191
206
  show_clients=args.include_clients,
192
207
  client_mode=args.client_scope,
208
+ only_unifi=args.only_unifi,
193
209
  )
194
210
  write_output(content, output_path=args.output, stdout=args.stdout)
195
211
  return 0
@@ -95,6 +95,7 @@ def build_edges_with_clients(
95
95
  device_index,
96
96
  include_ports=args.include_ports,
97
97
  client_mode=args.client_scope,
98
+ only_unifi=args.only_unifi,
98
99
  )
99
100
  return edges, clients
100
101
 
@@ -153,5 +154,10 @@ def resolve_mkdocs_client_ports(
153
154
  clients = list(fetch_clients(config, site=site))
154
155
  else:
155
156
  clients = mock_clients
156
- client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
157
+ client_ports = build_client_port_map(
158
+ devices,
159
+ clients,
160
+ client_mode=args.client_scope,
161
+ only_unifi=args.only_unifi,
162
+ )
157
163
  return client_ports, None
@@ -460,7 +460,13 @@ def _client_field(client: object, name: str) -> object | None:
460
460
 
461
461
 
462
462
  def _client_display_name(client: object) -> str | None:
463
- for key in ("name", "hostname", "mac"):
463
+ raw_name = _client_field(client, "name")
464
+ if isinstance(raw_name, str) and raw_name.strip():
465
+ return raw_name.strip()
466
+ preferred = _client_ucore_display_name(client)
467
+ if preferred:
468
+ return preferred
469
+ for key in ("hostname", "mac"):
464
470
  value = _client_field(client, key)
465
471
  if isinstance(value, str) and value.strip():
466
472
  return value.strip()
@@ -482,20 +488,31 @@ def _client_uplink_mac(client: object) -> str | None:
482
488
 
483
489
 
484
490
  def _client_uplink_port(client: object) -> int | None:
485
- for key in ("uplink_remote_port", "sw_port", "ap_port"):
486
- value = _client_field(client, key)
487
- if isinstance(value, int):
488
- return value
489
- if isinstance(value, str) and value.isdigit():
490
- return int(value)
491
+ for value in _client_port_values(client):
492
+ parsed = _parse_port_value(value)
493
+ if parsed is not None:
494
+ return parsed
495
+ return None
496
+
497
+
498
+ def _client_port_values(client: object) -> Iterable[object | None]:
499
+ for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
500
+ yield _client_field(client, key)
491
501
  for key in ("uplink", "last_uplink"):
492
502
  nested = _client_field(client, key)
493
503
  if isinstance(nested, dict):
494
- value = nested.get("uplink_remote_port")
495
- if isinstance(value, int):
496
- return value
497
- if isinstance(value, str) and value.isdigit():
498
- return int(value)
504
+ for nested_key in ("uplink_remote_port", "port_idx"):
505
+ yield nested.get(nested_key)
506
+
507
+
508
+ def _parse_port_value(value: object | None) -> int | None:
509
+ if isinstance(value, int):
510
+ return value
511
+ if isinstance(value, str):
512
+ stripped = value.strip()
513
+ if stripped.isdigit():
514
+ return int(stripped)
515
+ return extract_port_number(stripped)
499
516
  return None
500
517
 
501
518
 
@@ -503,6 +520,73 @@ def _client_is_wired(client: object) -> bool:
503
520
  return bool(_client_field(client, "is_wired"))
504
521
 
505
522
 
523
+ def _client_unifi_flag(client: object) -> bool | None:
524
+ for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
525
+ value = _client_field(client, key)
526
+ if isinstance(value, bool):
527
+ return value
528
+ if isinstance(value, int):
529
+ return value != 0
530
+ return None
531
+
532
+
533
+ def _client_vendor(client: object) -> str | None:
534
+ for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
535
+ value = _client_field(client, key)
536
+ if isinstance(value, str) and value.strip():
537
+ return value.strip()
538
+ return None
539
+
540
+
541
+ def _client_ucore_info(client: object) -> dict[str, object] | None:
542
+ info = _client_field(client, "unifi_device_info_from_ucore")
543
+ if isinstance(info, dict):
544
+ return info
545
+ return None
546
+
547
+
548
+ def _client_ucore_display_name(client: object) -> str | None:
549
+ ucore = _client_ucore_info(client)
550
+ if not ucore:
551
+ return None
552
+ for key in ("name", "computed_model", "product_model", "product_shortname"):
553
+ value = ucore.get(key)
554
+ if isinstance(value, str) and value.strip():
555
+ return value.strip()
556
+ return None
557
+
558
+
559
+ def _client_hostname_source(client: object) -> str | None:
560
+ value = _client_field(client, "hostname_source")
561
+ if isinstance(value, str) and value.strip():
562
+ return value.strip()
563
+ return None
564
+
565
+
566
+ def _client_is_unifi(client: object) -> bool:
567
+ flag = _client_unifi_flag(client)
568
+ if flag is not None:
569
+ return flag
570
+ ucore = _client_ucore_info(client)
571
+ if ucore:
572
+ managed = ucore.get("managed")
573
+ if isinstance(managed, bool) and managed:
574
+ return True
575
+ if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
576
+ return True
577
+ if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
578
+ return True
579
+ for key in ("name", "computed_model", "product_model"):
580
+ value = ucore.get(key)
581
+ if isinstance(value, str) and value.strip():
582
+ return True
583
+ vendor = _client_vendor(client)
584
+ if not vendor:
585
+ return False
586
+ normalized = vendor.lower()
587
+ return "ubiquiti" in normalized or "unifi" in normalized
588
+
589
+
506
590
  def _client_channel(client: object) -> int | None:
507
591
  for key in ("channel", "radio_channel", "wifi_channel"):
508
592
  value = _client_field(client, key)
@@ -522,17 +606,26 @@ def _client_matches_mode(client: object, mode: str) -> bool:
522
606
  return wired
523
607
 
524
608
 
609
+ def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
610
+ if not _client_matches_mode(client, client_mode):
611
+ return False
612
+ if only_unifi and not _client_is_unifi(client):
613
+ return False
614
+ return True
615
+
616
+
525
617
  def build_client_edges(
526
618
  clients: Iterable[object],
527
619
  device_index: dict[str, str],
528
620
  *,
529
621
  include_ports: bool = False,
530
622
  client_mode: str = "wired",
623
+ only_unifi: bool = False,
531
624
  ) -> list[Edge]:
532
625
  edges: list[Edge] = []
533
626
  seen: set[tuple[str, str]] = set()
534
627
  for client in clients:
535
- if not _client_matches_mode(client, client_mode):
628
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
536
629
  continue
537
630
  name = _client_display_name(client)
538
631
  uplink_mac = _client_uplink_mac(client)
@@ -569,13 +662,14 @@ def build_node_type_map(
569
662
  clients: Iterable[object] | None = None,
570
663
  *,
571
664
  client_mode: str = "wired",
665
+ only_unifi: bool = False,
572
666
  ) -> dict[str, str]:
573
667
  node_types: dict[str, str] = {}
574
668
  for device in devices:
575
669
  node_types[device.name] = classify_device_type(device)
576
670
  if clients:
577
671
  for client in clients:
578
- if not _client_matches_mode(client, client_mode):
672
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
579
673
  continue
580
674
  name = _client_display_name(client)
581
675
  if name:
@@ -672,11 +766,12 @@ def build_client_port_map(
672
766
  clients: Iterable[object],
673
767
  *,
674
768
  client_mode: str,
769
+ only_unifi: bool = False,
675
770
  ) -> ClientPortMap:
676
771
  device_index = build_device_index(devices)
677
772
  port_map: ClientPortMap = {}
678
773
  for client in clients:
679
- if not _client_matches_mode(client, client_mode):
774
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
680
775
  continue
681
776
  name = _client_display_name(client)
682
777
  uplink_mac = _client_uplink_mac(client)
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from collections.abc import Iterable
6
6
 
7
7
  from ..model.lldp import LLDPEntry, local_port_label
8
+ from ..model.ports import extract_port_number
8
9
  from ..model.topology import Device, build_client_port_map, build_device_index, build_port_map
9
10
  from .device_ports_md import render_device_port_details
10
11
  from .markdown_tables import markdown_table_lines
@@ -22,7 +23,13 @@ def _client_field(client: object, name: str) -> object | None:
22
23
 
23
24
 
24
25
  def _client_display_name(client: object) -> str | None:
25
- for key in ("name", "hostname", "mac"):
26
+ raw_name = _client_field(client, "name")
27
+ if isinstance(raw_name, str) and raw_name.strip():
28
+ return raw_name.strip()
29
+ preferred = _client_ucore_display_name(client)
30
+ if preferred:
31
+ return preferred
32
+ for key in ("hostname", "mac"):
26
33
  value = _client_field(client, key)
27
34
  if isinstance(value, str) and value.strip():
28
35
  return value.strip()
@@ -44,20 +51,31 @@ def _client_uplink_mac(client: object) -> str | None:
44
51
 
45
52
 
46
53
  def _client_uplink_port(client: object) -> int | None:
47
- for key in ("uplink_remote_port", "sw_port", "ap_port"):
48
- value = _client_field(client, key)
49
- if isinstance(value, int):
50
- return value
51
- if isinstance(value, str) and value.isdigit():
52
- return int(value)
54
+ for value in _client_port_values(client):
55
+ parsed = _parse_port_value(value)
56
+ if parsed is not None:
57
+ return parsed
58
+ return None
59
+
60
+
61
+ def _client_port_values(client: object) -> Iterable[object | None]:
62
+ for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
63
+ yield _client_field(client, key)
53
64
  for key in ("uplink", "last_uplink"):
54
65
  nested = _client_field(client, key)
55
66
  if isinstance(nested, dict):
56
- value = nested.get("uplink_remote_port")
57
- if isinstance(value, int):
58
- return value
59
- if isinstance(value, str) and value.isdigit():
60
- return int(value)
67
+ for nested_key in ("uplink_remote_port", "port_idx"):
68
+ yield nested.get(nested_key)
69
+
70
+
71
+ def _parse_port_value(value: object | None) -> int | None:
72
+ if isinstance(value, int):
73
+ return value
74
+ if isinstance(value, str):
75
+ stripped = value.strip()
76
+ if stripped.isdigit():
77
+ return int(stripped)
78
+ return extract_port_number(stripped)
61
79
  return None
62
80
 
63
81
 
@@ -65,6 +83,73 @@ def _client_is_wired(client: object) -> bool:
65
83
  return bool(_client_field(client, "is_wired"))
66
84
 
67
85
 
86
+ def _client_unifi_flag(client: object) -> bool | None:
87
+ for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
88
+ value = _client_field(client, key)
89
+ if isinstance(value, bool):
90
+ return value
91
+ if isinstance(value, int):
92
+ return value != 0
93
+ return None
94
+
95
+
96
+ def _client_vendor(client: object) -> str | None:
97
+ for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
98
+ value = _client_field(client, key)
99
+ if isinstance(value, str) and value.strip():
100
+ return value.strip()
101
+ return None
102
+
103
+
104
+ def _client_ucore_info(client: object) -> dict[str, object] | None:
105
+ info = _client_field(client, "unifi_device_info_from_ucore")
106
+ if isinstance(info, dict):
107
+ return info
108
+ return None
109
+
110
+
111
+ def _client_ucore_display_name(client: object) -> str | None:
112
+ ucore = _client_ucore_info(client)
113
+ if not ucore:
114
+ return None
115
+ for key in ("name", "computed_model", "product_model", "product_shortname"):
116
+ value = ucore.get(key)
117
+ if isinstance(value, str) and value.strip():
118
+ return value.strip()
119
+ return None
120
+
121
+
122
+ def _client_hostname_source(client: object) -> str | None:
123
+ value = _client_field(client, "hostname_source")
124
+ if isinstance(value, str) and value.strip():
125
+ return value.strip()
126
+ return None
127
+
128
+
129
+ def _client_is_unifi(client: object) -> bool:
130
+ flag = _client_unifi_flag(client)
131
+ if flag is not None:
132
+ return flag
133
+ ucore = _client_ucore_info(client)
134
+ if ucore:
135
+ managed = ucore.get("managed")
136
+ if isinstance(managed, bool) and managed:
137
+ return True
138
+ if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
139
+ return True
140
+ if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
141
+ return True
142
+ for key in ("name", "computed_model", "product_model"):
143
+ value = ucore.get(key)
144
+ if isinstance(value, str) and value.strip():
145
+ return True
146
+ vendor = _client_vendor(client)
147
+ if not vendor:
148
+ return False
149
+ normalized = vendor.lower()
150
+ return "ubiquiti" in normalized or "unifi" in normalized
151
+
152
+
68
153
  def _client_matches_mode(client: object, mode: str) -> bool:
69
154
  wired = _client_is_wired(client)
70
155
  if mode == "all":
@@ -74,6 +159,14 @@ def _client_matches_mode(client: object, mode: str) -> bool:
74
159
  return wired
75
160
 
76
161
 
162
+ def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
163
+ if not _client_matches_mode(client, client_mode):
164
+ return False
165
+ if only_unifi and not _client_is_unifi(client):
166
+ return False
167
+ return True
168
+
169
+
77
170
  def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
78
171
  port_label = local_port_label(entry) or ""
79
172
  port_number = "".join(ch for ch in port_label if ch.isdigit())
@@ -187,10 +280,11 @@ def _client_rows(
187
280
  *,
188
281
  include_ports: bool,
189
282
  client_mode: str,
283
+ only_unifi: bool,
190
284
  ) -> dict[str, list[tuple[str, str | None]]]:
191
285
  rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
192
286
  for client in clients:
193
- if not _client_matches_mode(client, client_mode):
287
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
194
288
  continue
195
289
  name = _client_display_name(client)
196
290
  uplink_mac = _client_uplink_mac(client)
@@ -215,6 +309,7 @@ def _prepare_lldp_maps(
215
309
  include_ports: bool,
216
310
  show_clients: bool,
217
311
  client_mode: str,
312
+ only_unifi: bool,
218
313
  ) -> tuple[
219
314
  dict[tuple[str, str], str],
220
315
  dict[str, list[tuple[int, str]]] | None,
@@ -222,7 +317,13 @@ def _prepare_lldp_maps(
222
317
  ]:
223
318
  device_index = build_device_index(devices)
224
319
  client_rows = (
225
- _client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
320
+ _client_rows(
321
+ clients,
322
+ device_index,
323
+ include_ports=include_ports,
324
+ client_mode=client_mode,
325
+ only_unifi=only_unifi,
326
+ )
226
327
  if clients
227
328
  else {}
228
329
  )
@@ -231,7 +332,12 @@ def _prepare_lldp_maps(
231
332
  if include_ports:
232
333
  port_map = build_port_map(devices, only_unifi=False)
233
334
  if clients and show_clients:
234
- client_port_map = build_client_port_map(devices, clients, client_mode=client_mode)
335
+ client_port_map = build_client_port_map(
336
+ devices,
337
+ clients,
338
+ client_mode=client_mode,
339
+ only_unifi=only_unifi,
340
+ )
235
341
  return port_map, client_port_map, client_rows
236
342
 
237
343
 
@@ -306,6 +412,7 @@ def render_lldp_md(
306
412
  include_ports: bool = False,
307
413
  show_clients: bool = False,
308
414
  client_mode: str = "wired",
415
+ only_unifi: bool = False,
309
416
  ) -> str:
310
417
  device_index = build_device_index(devices)
311
418
  port_map, client_port_map, client_rows = _prepare_lldp_maps(
@@ -314,6 +421,7 @@ def render_lldp_md(
314
421
  include_ports=include_ports,
315
422
  show_clients=show_clients,
316
423
  client_mode=client_mode,
424
+ only_unifi=only_unifi,
317
425
  )
318
426
  sections: list[str] = []
319
427
  for device in sorted(devices, key=lambda item: item.name.lower()):
@@ -547,8 +547,11 @@ def _render_svg_edges(
547
547
  f"L {dst_cx} {mid_y} L {dst_cx} {dst_top}"
548
548
  )
549
549
  dash = ' stroke-dasharray="6 4"' if edge.wireless else ""
550
+ left_attr = _escape_attr(edge.left, quote=True)
551
+ right_attr = _escape_attr(edge.right, quote=True)
550
552
  lines.append(
551
- f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" fill="none"{dash}/>'
553
+ f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" fill="none"{dash} '
554
+ f'data-edge-left="{left_attr}" data-edge-right="{right_attr}"/>'
552
555
  )
553
556
  if edge.poe:
554
557
  icon_x = dst_cx
@@ -834,9 +837,12 @@ def _render_iso_edges(
834
837
  dst_cy,
835
838
  )
836
839
  dash = ' stroke-dasharray="8 6"' if edge.wireless else ""
840
+ left_attr = _escape_attr(edge.left, quote=True)
841
+ right_attr = _escape_attr(edge.right, quote=True)
837
842
  lines.append(
838
843
  f'<path d="{" ".join(path_cmds)}" stroke="{color}" stroke-width="{width_px}" '
839
- f'fill="none" stroke-linecap="round" stroke-linejoin="round"{dash}/>'
844
+ f'fill="none" stroke-linecap="round" stroke-linejoin="round"{dash} '
845
+ f'data-edge-left="{left_attr}" data-edge-right="{right_attr}"/>'
840
846
  )
841
847
  if edge.poe:
842
848
  icon_x = dst_cx
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.4.10
3
+ Version: 1.4.12
4
4
  Summary: Dynamic UniFi -> network maps in mermaid or svg
5
5
  Author: Merlijn
6
6
  License-Expression: MIT
@@ -223,7 +223,7 @@ Functional:
223
223
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
224
224
  - `--include-clients`: add active wired clients as leaf nodes.
225
225
  - `--client-scope wired|wireless|all`: which client types to include (default wired).
226
- - `--only-unifi`: only include neighbors that are UniFi devices.
226
+ - `--only-unifi`: only include neighbors that are UniFi devices; when clients are included, filters to UniFi-managed clients (by explicit UniFi flags or vendor/OUI).
227
227
  - `--no-cache`: disable UniFi API cache reads and writes.
228
228
 
229
229
  Mermaid:
@@ -57,6 +57,58 @@ def test_build_client_edges_includes_uplink_port_label():
57
57
  assert edges[0].label == "Switch A: Port 3 <-> Laptop"
58
58
 
59
59
 
60
+ def test_build_client_edges_only_unifi_filters_non_unifi():
61
+ device_index = {"aa:bb:cc:dd:ee:ff": "Switch A"}
62
+ clients = [
63
+ {"name": "Desk PC", "is_wired": True, "sw_mac": "aa:bb:cc:dd:ee:ff", "is_unifi": False},
64
+ {"name": "Protect Cam", "is_wired": True, "sw_mac": "aa:bb:cc:dd:ee:ff", "is_unifi": True},
65
+ ]
66
+ edges = build_client_edges(clients, device_index, only_unifi=True)
67
+ assert [edge.right for edge in edges] == ["Protect Cam"]
68
+
69
+
70
+ def test_build_client_edges_only_unifi_vendor_fallback():
71
+ device_index = {"aa:bb:cc:dd:ee:ff": "Switch A"}
72
+ clients = [
73
+ {
74
+ "name": "UniFi Sensor",
75
+ "is_wired": True,
76
+ "sw_mac": "aa:bb:cc:dd:ee:ff",
77
+ "oui": "Ubiquiti Inc.",
78
+ }
79
+ ]
80
+ edges = build_client_edges(clients, device_index, only_unifi=True)
81
+ assert edges[0].right == "UniFi Sensor"
82
+
83
+
84
+ def test_build_client_edges_only_unifi_ucore_managed():
85
+ device_index = {"aa:bb:cc:dd:ee:ff": "Switch A"}
86
+ clients = [
87
+ {
88
+ "name": "Doorbell Lite",
89
+ "is_wired": True,
90
+ "sw_mac": "aa:bb:cc:dd:ee:ff",
91
+ "unifi_device_info_from_ucore": {"managed": True},
92
+ }
93
+ ]
94
+ edges = build_client_edges(clients, device_index, only_unifi=True)
95
+ assert edges[0].right == "Doorbell Lite"
96
+
97
+
98
+ def test_build_client_edges_prefers_ucore_name_over_hostname():
99
+ device_index = {"aa:bb:cc:dd:ee:ff": "Switch A"}
100
+ clients = [
101
+ {
102
+ "hostname": "espressif",
103
+ "is_wired": True,
104
+ "sw_mac": "aa:bb:cc:dd:ee:ff",
105
+ "unifi_device_info_from_ucore": {"name": "Smart PoE Chime"},
106
+ }
107
+ ]
108
+ edges = build_client_edges(clients, device_index, only_unifi=True)
109
+ assert edges[0].right == "Smart PoE Chime"
110
+
111
+
60
112
  def test_build_node_type_map_skips_wireless_clients():
61
113
  devices = [
62
114
  Device(name="Gateway", model_name="", model="", mac="aa", ip="", type="udm", lldp_info=[])
@@ -64,3 +116,16 @@ def test_build_node_type_map_skips_wireless_clients():
64
116
  clients = [{"name": "Phone", "is_wired": False}]
65
117
  node_types = build_node_type_map(devices, clients)
66
118
  assert "Phone" not in node_types
119
+
120
+
121
+ def test_build_node_type_map_only_unifi_filters_clients():
122
+ devices = [
123
+ Device(name="Gateway", model_name="", model="", mac="aa", ip="", type="udm", lldp_info=[])
124
+ ]
125
+ clients = [
126
+ {"name": "Desk PC", "is_wired": True, "is_unifi": False},
127
+ {"name": "Protect Cam", "is_wired": True, "is_unifi": True},
128
+ ]
129
+ node_types = build_node_type_map(devices, clients, only_unifi=True)
130
+ assert "Protect Cam" in node_types
131
+ assert "Desk PC" not in node_types