unifi-network-maps 1.4.12__tar.gz → 1.4.14__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 (135) hide show
  1. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/CHANGELOG.md +19 -1
  2. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/PKG-INFO +9 -3
  3. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/README.md +7 -1
  4. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/pyproject.toml +3 -3
  5. unifi_network_maps-1.4.14/src/unifi_network_maps/__init__.py +1 -0
  6. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/adapters/config.py +6 -2
  7. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/adapters/unifi.py +124 -7
  8. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/cli/args.py +1 -1
  9. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/cli/main.py +85 -4
  10. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/cli/render.py +4 -2
  11. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/io/export.py +11 -2
  12. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/io/mkdocs_assets.py +4 -2
  13. unifi_network_maps-1.4.14/src/unifi_network_maps/io/mock_data.py +42 -0
  14. unifi_network_maps-1.4.14/src/unifi_network_maps/io/paths.py +201 -0
  15. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/model/mock.py +17 -1
  16. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/model/topology.py +3 -3
  17. unifi_network_maps-1.4.14/src/unifi_network_maps/model/vlans.py +119 -0
  18. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/device_ports_md.py +27 -18
  19. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/lldp_md.py +4 -1
  20. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/theme.py +2 -1
  21. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps.egg-info/PKG-INFO +9 -3
  22. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps.egg-info/SOURCES.txt +5 -1
  23. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps.egg-info/requires.txt +1 -1
  24. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_cli.py +86 -0
  25. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_device_ports_md.py +36 -0
  26. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_lldp_md.py +112 -1
  27. unifi_network_maps-1.4.14/tests/test_mkdocs.py +113 -0
  28. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_mock_generate.py +2 -0
  29. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_topology.py +130 -0
  30. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_unifi.py +102 -0
  31. unifi_network_maps-1.4.14/tests/test_vlan_info.py +30 -0
  32. unifi_network_maps-1.4.12/src/unifi_network_maps/__init__.py +0 -1
  33. unifi_network_maps-1.4.12/src/unifi_network_maps/io/mock_data.py +0 -23
  34. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/CONTRIBUTING.md +0 -0
  35. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/LICENSE +0 -0
  36. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/LICENSES.md +0 -0
  37. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/MANIFEST.in +0 -0
  38. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/RELEASING.md +0 -0
  39. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/SECURITY.md +0 -0
  40. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/setup.cfg +0 -0
  41. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/__main__.py +0 -0
  42. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/adapters/__init__.py +0 -0
  43. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/__init__.py +0 -0
  44. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/__init__.py +0 -0
  45. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/access-point.svg +0 -0
  46. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  47. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/block.svg +0 -0
  48. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/cache.svg +0 -0
  49. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/cardterminal.svg +0 -0
  50. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/cloud.svg +0 -0
  51. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/cronjob.svg +0 -0
  52. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/cube.svg +0 -0
  53. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/desktop.svg +0 -0
  54. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/diamond.svg +0 -0
  55. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/dns.svg +0 -0
  56. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/document.svg +0 -0
  57. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/firewall.svg +0 -0
  58. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/function-module.svg +0 -0
  59. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/image.svg +0 -0
  60. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/laptop.svg +0 -0
  61. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/loadbalancer.svg +0 -0
  62. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/lock.svg +0 -0
  63. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/mail.svg +0 -0
  64. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/mailmultiple.svg +0 -0
  65. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/mobiledevice.svg +0 -0
  66. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/office.svg +0 -0
  67. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/package-module.svg +0 -0
  68. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/paymentcard.svg +0 -0
  69. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/plane.svg +0 -0
  70. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/printer.svg +0 -0
  71. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/pyramid.svg +0 -0
  72. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/queue.svg +0 -0
  73. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/router.svg +0 -0
  74. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/server.svg +0 -0
  75. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/speech.svg +0 -0
  76. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/sphere.svg +0 -0
  77. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/storage.svg +0 -0
  78. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/switch-module.svg +0 -0
  79. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/tower.svg +0 -0
  80. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/truck-2.svg +0 -0
  81. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/truck.svg +0 -0
  82. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/user.svg +0 -0
  83. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/isometric/vm.svg +0 -0
  84. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/laptop.svg +0 -0
  85. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/router-network.svg +0 -0
  86. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/server-network.svg +0 -0
  87. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/icons/server.svg +0 -0
  88. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/themes/dark.yaml +0 -0
  89. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/assets/themes/default.yaml +0 -0
  90. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/cli/__init__.py +0 -0
  91. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/cli/__main__.py +0 -0
  92. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/cli/runtime.py +0 -0
  93. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/io/__init__.py +0 -0
  94. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/io/debug.py +0 -0
  95. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/io/mock_generate.py +0 -0
  96. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/model/__init__.py +0 -0
  97. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/model/labels.py +0 -0
  98. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/model/lldp.py +0 -0
  99. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/model/ports.py +0 -0
  100. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/__init__.py +0 -0
  101. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/legend.py +0 -0
  102. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/markdown_tables.py +0 -0
  103. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/mermaid.py +0 -0
  104. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/mermaid_theme.py +0 -0
  105. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/mkdocs.py +0 -0
  106. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/svg.py +0 -0
  107. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/svg_theme.py +0 -0
  108. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/device_port_block.md.j2 +0 -0
  109. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/legend_compact.html.j2 +0 -0
  110. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/lldp_device_section.md.j2 +0 -0
  111. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/markdown_section.md.j2 +0 -0
  112. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +0 -0
  113. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/mkdocs_document.md.j2 +0 -0
  114. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +0 -0
  115. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +0 -0
  116. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/mkdocs_legend.css.j2 +0 -0
  117. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/mkdocs_legend.js.j2 +0 -0
  118. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +0 -0
  119. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps/render/templating.py +0 -0
  120. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps.egg-info/dependency_links.txt +0 -0
  121. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps.egg-info/entry_points.txt +0 -0
  122. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/src/unifi_network_maps.egg-info/top_level.txt +0 -0
  123. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_clients.py +0 -0
  124. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_config.py +0 -0
  125. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_contract_unifi.py +0 -0
  126. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_contract_unifi_live.py +0 -0
  127. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_debug.py +0 -0
  128. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_export.py +0 -0
  129. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_groups.py +0 -0
  130. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_labels.py +0 -0
  131. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_lldp.py +0 -0
  132. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_mermaid.py +0 -0
  133. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_svg.py +0 -0
  134. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_svg_iso.py +0 -0
  135. {unifi_network_maps-1.4.12 → unifi_network_maps-1.4.14}/tests/test_theme.py +0 -0
@@ -5,6 +5,22 @@ 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.14] - 2026-02-01
9
+ ### Added
10
+ - JSON output with VLAN inventory
11
+
12
+ ### Changed
13
+ - Added log message when /tmp can't be resolved
14
+
15
+ ## [1.4.13] - 2026-01-25
16
+ ### Fixed
17
+ - Path Traversal Vulnerability in File Operations
18
+ - Cache Directory Symlink Attack vector
19
+
20
+ ### Changed
21
+ - Improved escaping in Markdown Output
22
+ - Made logging less chatty, moved messages to debug level
23
+
8
24
  ## [1.4.12] - 2026-01-21
9
25
  ### Added
10
26
  - Filter UniFi clients with --only-unifi, and not only neighbors
@@ -183,7 +199,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
183
199
  - Introduced SVG renderer and tree layout fixes.
184
200
  - Increased test coverage and added coverage tooling.
185
201
 
186
- [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.12...HEAD
202
+ [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.14...HEAD
203
+ [1.4.14]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.14
204
+ [1.4.13]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.13
187
205
  [1.4.12]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...v1.4.12
188
206
  [1.4.11]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.10...v1.4.11
189
207
  [1.4.10]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.9...v1.4.10
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.4.12
3
+ Version: 1.4.14
4
4
  Summary: Dynamic UniFi -> network maps in mermaid or svg
5
5
  Author: Merlijn
6
6
  License-Expression: MIT
@@ -32,7 +32,7 @@ Requires-Dist: pre-commit==4.5.1; extra == "dev"
32
32
  Requires-Dist: pytest==9.0.2; extra == "dev"
33
33
  Requires-Dist: pytest-cov==7.0.0; extra == "dev"
34
34
  Requires-Dist: pyright==1.1.408; extra == "dev"
35
- Requires-Dist: ruff==0.14.13; extra == "dev"
35
+ Requires-Dist: ruff==0.14.14; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # unifi-network-maps
@@ -147,6 +147,12 @@ Legend only:
147
147
  unifi-network-maps --legend-only --stdout
148
148
  ```
149
149
 
150
+ JSON payload (devices + clients + VLAN inventory):
151
+
152
+ ```bash
153
+ unifi-network-maps --format json --output ./payload.json
154
+ ```
155
+
150
156
  ## Home Assistant integration
151
157
 
152
158
  The live Home Assistant integration (Config Flow + coordinator + custom card) lives in a separate repo:
@@ -238,7 +244,7 @@ SVG:
238
244
  - `--theme-file`: load a YAML theme for Mermaid + SVG colors (see `examples/theme.yaml` and `examples/theme-dark.yaml`).
239
245
 
240
246
  Output:
241
- - `--format mermaid|svg|svg-iso|lldp-md|mkdocs`: output format (default mermaid).
247
+ - `--format mermaid|svg|svg-iso|lldp-md|mkdocs|json`: output format (default mermaid).
242
248
  - `--stdout`: write output to stdout.
243
249
  - `--markdown`: wrap Mermaid output in a code fence.
244
250
  - `--mkdocs-sidebar-legend`: write assets to place the compact legend in the MkDocs right sidebar.
@@ -110,6 +110,12 @@ Legend only:
110
110
  unifi-network-maps --legend-only --stdout
111
111
  ```
112
112
 
113
+ JSON payload (devices + clients + VLAN inventory):
114
+
115
+ ```bash
116
+ unifi-network-maps --format json --output ./payload.json
117
+ ```
118
+
113
119
  ## Home Assistant integration
114
120
 
115
121
  The live Home Assistant integration (Config Flow + coordinator + custom card) lives in a separate repo:
@@ -201,7 +207,7 @@ SVG:
201
207
  - `--theme-file`: load a YAML theme for Mermaid + SVG colors (see `examples/theme.yaml` and `examples/theme-dark.yaml`).
202
208
 
203
209
  Output:
204
- - `--format mermaid|svg|svg-iso|lldp-md|mkdocs`: output format (default mermaid).
210
+ - `--format mermaid|svg|svg-iso|lldp-md|mkdocs|json`: output format (default mermaid).
205
211
  - `--stdout`: write output to stdout.
206
212
  - `--markdown`: wrap Mermaid output in a code fence.
207
213
  - `--mkdocs-sidebar-legend`: write assets to place the compact legend in the MkDocs right sidebar.
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["setuptools==80.9.0", "wheel==0.45.1"]
2
+ requires = ["setuptools==80.10.2", "wheel==0.46.3"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unifi-network-maps"
7
- version = "1.4.12"
7
+ version = "1.4.14"
8
8
  description = "Dynamic UniFi -> network maps in mermaid or svg"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -44,7 +44,7 @@ dev = [
44
44
  "pytest==9.0.2",
45
45
  "pytest-cov==7.0.0",
46
46
  "pyright==1.1.408",
47
- "ruff==0.14.13"
47
+ "ruff==0.14.14"
48
48
  ]
49
49
 
50
50
  [project.scripts]
@@ -0,0 +1 @@
1
+ __version__ = "1.4.14"
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..io.paths import resolve_env_file
7
10
 
8
11
 
9
12
  def _parse_bool(value: str | None, default: bool = True) -> bool:
@@ -26,13 +29,14 @@ class Config:
26
29
  verify_ssl: bool
27
30
 
28
31
  @classmethod
29
- def from_env(cls, *, env_file: str | None = None) -> Config:
32
+ def from_env(cls, *, env_file: str | Path | None = None) -> Config:
30
33
  if env_file:
31
34
  try:
32
35
  from dotenv import load_dotenv
33
36
  except ImportError:
34
37
  raise ValueError("python-dotenv required for --env-file") from None
35
- load_dotenv(dotenv_path=env_file)
38
+ env_path = resolve_env_file(env_file)
39
+ load_dotenv(dotenv_path=env_path)
36
40
  url = os.environ.get("UNIFI_URL", "").strip()
37
41
  site = os.environ.get("UNIFI_SITE", "default").strip()
38
42
  user = os.environ.get("UNIFI_USER", "").strip()
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import stat
10
+ import tempfile
10
11
  import time
11
12
  from collections.abc import Callable, Iterator, Sequence
12
13
  from concurrent.futures import ThreadPoolExecutor
@@ -15,6 +16,8 @@ from contextlib import contextmanager
15
16
  from pathlib import Path
16
17
  from typing import IO, TYPE_CHECKING
17
18
 
19
+ from ..io.paths import resolve_cache_dir
20
+ from ..model.vlans import build_vlan_info, normalize_networks
18
21
  from .config import Config
19
22
 
20
23
  if TYPE_CHECKING:
@@ -24,7 +27,15 @@ logger = logging.getLogger(__name__)
24
27
 
25
28
 
26
29
  def _cache_dir() -> Path:
27
- return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
30
+ default_dir = ".cache/unifi_network_maps"
31
+ if os.environ.get("PYTEST_CURRENT_TEST"):
32
+ default_dir = str(Path(tempfile.gettempdir()) / f"unifi_network_maps_pytest_{os.getpid()}")
33
+ value = os.environ.get("UNIFI_CACHE_DIR", default_dir)
34
+ try:
35
+ return resolve_cache_dir(value)
36
+ except ValueError as exc:
37
+ logger.warning("Invalid UNIFI_CACHE_DIR (%s); using default: %s", value, exc)
38
+ return resolve_cache_dir(".cache/unifi_network_maps")
28
39
 
29
40
 
30
41
  def _device_attr(device: object, name: str) -> object | None:
@@ -160,6 +171,19 @@ def _serialize_devices_for_cache(devices: Sequence[object]) -> list[dict[str, ob
160
171
  return [_serialize_device_for_cache(device) for device in devices]
161
172
 
162
173
 
174
+ def _serialize_network_for_cache(network: object) -> dict[str, object]:
175
+ return {
176
+ "name": _first_attr(network, "name", "network_name", "networkName"),
177
+ "vlan": _first_attr(network, "vlan", "vlan_id", "vlanId", "vlanid"),
178
+ "vlan_enabled": _first_attr(network, "vlan_enabled", "vlanEnabled"),
179
+ "purpose": _first_attr(network, "purpose"),
180
+ }
181
+
182
+
183
+ def _serialize_networks_for_cache(networks: Sequence[object]) -> list[dict[str, object]]:
184
+ return [_serialize_network_for_cache(network) for network in networks]
185
+
186
+
163
187
  def _cache_lock_path(path: Path) -> Path:
164
188
  return path.with_suffix(path.suffix + ".lock")
165
189
 
@@ -377,13 +401,13 @@ def fetch_devices(
377
401
  cached = None
378
402
  stale_cached, cache_age = None, None
379
403
  if cached is not None:
380
- logger.info("Using cached devices (%d)", len(cached))
404
+ logger.debug("Using cached devices (%d)", len(cached))
381
405
  return cached
382
406
 
383
407
  try:
384
408
  controller = _init_controller(config, is_udm_pro=True)
385
409
  except UnifiAuthenticationError:
386
- logger.info("UDM Pro authentication failed, retrying legacy auth")
410
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
387
411
  controller = _init_controller(config, is_udm_pro=False)
388
412
 
389
413
  def _fetch() -> Sequence[object]:
@@ -402,7 +426,7 @@ def fetch_devices(
402
426
  raise
403
427
  if use_cache:
404
428
  _save_cache(cache_path, _serialize_devices_for_cache(devices))
405
- logger.info("Fetched %d devices", len(devices))
429
+ logger.debug("Fetched %d devices", len(devices))
406
430
  return devices
407
431
 
408
432
 
@@ -428,13 +452,13 @@ def fetch_clients(
428
452
  cached = None
429
453
  stale_cached, cache_age = None, None
430
454
  if cached is not None:
431
- logger.info("Using cached clients (%d)", len(cached))
455
+ logger.debug("Using cached clients (%d)", len(cached))
432
456
  return cached
433
457
 
434
458
  try:
435
459
  controller = _init_controller(config, is_udm_pro=True)
436
460
  except UnifiAuthenticationError:
437
- logger.info("UDM Pro authentication failed, retrying legacy auth")
461
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
438
462
  controller = _init_controller(config, is_udm_pro=False)
439
463
 
440
464
  def _fetch() -> Sequence[object]:
@@ -453,5 +477,98 @@ def fetch_clients(
453
477
  raise
454
478
  if use_cache:
455
479
  _save_cache(cache_path, clients)
456
- logger.info("Fetched %d clients", len(clients))
480
+ logger.debug("Fetched %d clients", len(clients))
457
481
  return clients
482
+
483
+
484
+ def fetch_networks(
485
+ config: Config,
486
+ *,
487
+ site: str | None = None,
488
+ use_cache: bool = True,
489
+ ) -> Sequence[object]:
490
+ """Fetch network inventory from UniFi Controller."""
491
+ try:
492
+ from unifi_controller_api import UnifiAuthenticationError
493
+ except ImportError as exc:
494
+ raise RuntimeError("Missing dependency: unifi-controller-api") from exc
495
+
496
+ site_name = site or config.site
497
+ ttl_seconds = _cache_ttl_seconds()
498
+ cache_path = _cache_dir() / f"networks_{_cache_key(config.url, site_name)}.json"
499
+ if use_cache and _is_cache_dir_safe(cache_path.parent):
500
+ cached = _load_cache(cache_path, ttl_seconds)
501
+ stale_cached, cache_age = _load_cache_with_age(cache_path)
502
+ else:
503
+ cached = None
504
+ stale_cached, cache_age = None, None
505
+ if cached is not None:
506
+ logger.debug("Using cached networks (%d)", len(cached))
507
+ return cached
508
+
509
+ try:
510
+ controller = _init_controller(config, is_udm_pro=True)
511
+ except UnifiAuthenticationError:
512
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
513
+ controller = _init_controller(config, is_udm_pro=False)
514
+
515
+ def _fetch() -> Sequence[object]:
516
+ try:
517
+ return controller.get_unifi_site_networkconf(site_name=site_name, raw=False)
518
+ except Exception as exc: # noqa: BLE001 - fallback to raw network data
519
+ logger.warning("Networkconf model parse failed; retrying raw fetch: %s", exc)
520
+ return controller.get_unifi_site_networkconf(site_name=site_name, raw=True)
521
+
522
+ try:
523
+ networks = _call_with_retries("network fetch", _fetch)
524
+ except Exception as exc: # noqa: BLE001 - fallback to cache
525
+ if stale_cached is not None:
526
+ logger.warning(
527
+ "Network fetch failed; using stale cache (%ds old): %s",
528
+ int(cache_age or 0),
529
+ exc,
530
+ )
531
+ return stale_cached
532
+ raise
533
+ if use_cache:
534
+ _save_cache(cache_path, _serialize_networks_for_cache(networks))
535
+ logger.debug("Fetched %d networks", len(networks))
536
+ return networks
537
+
538
+
539
+ def fetch_payload(
540
+ config: Config,
541
+ *,
542
+ site: str | None = None,
543
+ include_clients: bool = True,
544
+ use_cache: bool = True,
545
+ ) -> dict[str, list[object] | list[dict[str, object]]]:
546
+ """Fetch devices, clients, and VLAN inventory for payload output."""
547
+ devices = list(fetch_devices(config, site=site, detailed=True, use_cache=use_cache))
548
+ clients = _fetch_payload_clients(
549
+ config,
550
+ site=site,
551
+ include_clients=include_clients,
552
+ use_cache=use_cache,
553
+ )
554
+ networks = list(fetch_networks(config, site=site, use_cache=use_cache))
555
+ normalized_networks = normalize_networks(networks)
556
+ vlan_info = build_vlan_info(clients, normalized_networks)
557
+ return {
558
+ "devices": devices,
559
+ "clients": clients,
560
+ "networks": normalized_networks,
561
+ "vlan_info": vlan_info,
562
+ }
563
+
564
+
565
+ def _fetch_payload_clients(
566
+ config: Config,
567
+ *,
568
+ site: str | None,
569
+ include_clients: bool,
570
+ use_cache: bool,
571
+ ) -> list[object]:
572
+ if not include_clients:
573
+ return []
574
+ return list(fetch_clients(config, site=site, use_cache=use_cache))
@@ -125,7 +125,7 @@ def add_general_render_args(parser: argparse._ArgumentGroup) -> None:
125
125
  parser.add_argument(
126
126
  "--format",
127
127
  default="mermaid",
128
- choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
128
+ choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs", "json"],
129
129
  help="Output format",
130
130
  )
131
131
  parser.add_argument(
@@ -3,11 +3,21 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import json
6
7
  import logging
8
+ from pathlib import Path
7
9
 
8
10
  from ..adapters.config import Config
11
+ from ..adapters.unifi import fetch_payload
9
12
  from ..io.export import write_output
10
- from ..io.mock_data import load_mock_data
13
+ from ..io.mock_data import load_mock_data, load_mock_payload
14
+ from ..io.paths import (
15
+ resolve_env_file,
16
+ resolve_mock_data_path,
17
+ resolve_output_path,
18
+ resolve_theme_path,
19
+ )
20
+ from ..model.vlans import build_vlan_info, normalize_networks
11
21
  from ..render.legend import render_legend_only, resolve_legend_style
12
22
  from ..render.theme import resolve_themes
13
23
  from .args import build_parser
@@ -16,7 +26,7 @@ from .render import render_lldp_format, render_standard_format
16
26
  logger = logging.getLogger(__name__)
17
27
 
18
28
 
19
- def _load_dotenv(env_file: str | None = None) -> None:
29
+ def _load_dotenv(env_file: str | Path | None = None) -> None:
20
30
  try:
21
31
  from dotenv import load_dotenv
22
32
  except ImportError:
@@ -30,6 +40,36 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
30
40
  return parser.parse_args(argv)
31
41
 
32
42
 
43
+ class _DowngradeInfoToDebugFilter(logging.Filter):
44
+ def filter(self, record: logging.LogRecord) -> bool:
45
+ if record.name.startswith("unifi_controller_api") and record.levelno == logging.INFO:
46
+ record.levelno = logging.DEBUG
47
+ record.levelname = logging.getLevelName(logging.DEBUG)
48
+ return True
49
+
50
+
51
+ def _downgrade_unifi_controller_logs() -> logging.Filter:
52
+ return _DowngradeInfoToDebugFilter()
53
+
54
+
55
+ def _validate_paths(args: argparse.Namespace) -> bool:
56
+ try:
57
+ if args.env_file:
58
+ resolve_env_file(args.env_file)
59
+ if args.mock_data:
60
+ resolve_mock_data_path(args.mock_data, require_exists=False)
61
+ if args.theme_file:
62
+ resolve_theme_path(args.theme_file, require_exists=False)
63
+ if args.generate_mock:
64
+ resolve_output_path(args.generate_mock, format_name="mock")
65
+ if args.output:
66
+ resolve_output_path(args.output, format_name=args.format)
67
+ except ValueError as exc:
68
+ logging.error(str(exc))
69
+ return False
70
+ return True
71
+
72
+
33
73
  def _load_config(args: argparse.Namespace) -> Config | None:
34
74
  try:
35
75
  _load_dotenv(args.env_file)
@@ -59,7 +99,8 @@ def _handle_generate_mock(args: argparse.Namespace) -> int | None:
59
99
  wireless_client_count=max(0, args.mock_wireless_clients),
60
100
  )
61
101
  content = mock_payload_json(options)
62
- write_output(content, output_path=args.generate_mock, stdout=args.stdout)
102
+ output_kwargs = {"format_name": "mock"} if args.generate_mock else {}
103
+ write_output(content, output_path=args.generate_mock, stdout=args.stdout, **output_kwargs)
63
104
  return 0
64
105
 
65
106
 
@@ -79,9 +120,45 @@ def _load_runtime_context(
79
120
  return config, site, None, None
80
121
 
81
122
 
123
+ def _handle_json_format(
124
+ args: argparse.Namespace,
125
+ *,
126
+ config: Config | None,
127
+ site: str,
128
+ ) -> int | None:
129
+ if args.format != "json":
130
+ return None
131
+ payload: dict[str, list[object] | list[dict[str, object]]]
132
+ if args.mock_data:
133
+ payload = load_mock_payload(args.mock_data)
134
+ if not args.include_clients:
135
+ payload["clients"] = []
136
+ networks = normalize_networks(payload.get("networks", []))
137
+ payload["networks"] = networks
138
+ payload["vlan_info"] = build_vlan_info(payload.get("clients", []), networks)
139
+ else:
140
+ if config is None:
141
+ logging.error("Config required to run")
142
+ return 2
143
+ payload = fetch_payload(
144
+ config,
145
+ site=site,
146
+ include_clients=args.include_clients,
147
+ use_cache=not args.no_cache,
148
+ )
149
+ content = json.dumps(payload, indent=2, sort_keys=True)
150
+ output_kwargs = {"format_name": args.format} if args.output else {}
151
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
152
+ return 0
153
+
154
+
82
155
  def main(argv: list[str] | None = None) -> int:
83
156
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
157
+ for handler in logging.getLogger().handlers:
158
+ handler.addFilter(_downgrade_unifi_controller_logs())
84
159
  args = _parse_args(argv)
160
+ if not _validate_paths(args):
161
+ return 2
85
162
  mock_result = _handle_generate_mock(args)
86
163
  if mock_result is not None:
87
164
  return mock_result
@@ -90,6 +167,9 @@ def main(argv: list[str] | None = None) -> int:
90
167
  except ValueError as exc:
91
168
  logging.error(str(exc))
92
169
  return 2
170
+ payload_result = _handle_json_format(args, config=config, site=site)
171
+ if payload_result is not None:
172
+ return payload_result
93
173
  try:
94
174
  mermaid_theme, svg_theme = resolve_themes(args.theme_file)
95
175
  except Exception as exc: # noqa: BLE001
@@ -107,7 +187,8 @@ def main(argv: list[str] | None = None) -> int:
107
187
  markdown=args.markdown,
108
188
  theme=mermaid_theme,
109
189
  )
110
- write_output(content, output_path=args.output, stdout=args.stdout)
190
+ output_kwargs = {"format_name": args.format} if args.output else {}
191
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
111
192
  return 0
112
193
 
113
194
  if args.format == "lldp-md":
@@ -207,7 +207,8 @@ def render_lldp_format(
207
207
  client_mode=args.client_scope,
208
208
  only_unifi=args.only_unifi,
209
209
  )
210
- write_output(content, output_path=args.output, stdout=args.stdout)
210
+ output_kwargs = {"format_name": args.format} if args.output else {}
211
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
211
212
  return 0
212
213
 
213
214
 
@@ -267,5 +268,6 @@ def render_standard_format(
267
268
  logging.error("Unsupported format: %s", args.format)
268
269
  return 2
269
270
 
270
- write_output(content, output_path=args.output, stdout=args.stdout)
271
+ output_kwargs = {"format_name": args.format} if args.output else {}
272
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
271
273
  return 0
@@ -7,10 +7,19 @@ import sys
7
7
  import tempfile
8
8
  from pathlib import Path
9
9
 
10
+ from .paths import resolve_output_path
10
11
 
11
- def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
12
+
13
+ def write_output(
14
+ content: str,
15
+ *,
16
+ output_path: str | Path | None,
17
+ stdout: bool,
18
+ format_name: str | None = None,
19
+ ) -> None:
12
20
  if output_path:
13
- _write_atomic(Path(output_path), content)
21
+ resolved = resolve_output_path(output_path, format_name=format_name)
22
+ _write_atomic(resolved, content)
14
23
  if stdout or not output_path:
15
24
  sys.stdout.write(content)
16
25
 
@@ -5,10 +5,12 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from ..render.templating import render_template
8
+ from .paths import resolve_output_file
8
9
 
9
10
 
10
- def write_mkdocs_sidebar_assets(output_path: str) -> None:
11
- output_dir = Path(output_path).resolve().parent
11
+ def write_mkdocs_sidebar_assets(output_path: str | Path) -> None:
12
+ resolved = resolve_output_file(output_path, extensions=None, label="MkDocs output file")
13
+ output_dir = resolved.parent
12
14
  assets_dir = output_dir / "assets"
13
15
  assets_dir.mkdir(parents=True, exist_ok=True)
14
16
  (assets_dir / "legend.js").write_text(
@@ -0,0 +1,42 @@
1
+ """Load mock UniFi data from JSON fixtures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .paths import resolve_mock_data_path
8
+
9
+
10
+ def _as_list(value: object, name: str) -> list[object]:
11
+ if value is None:
12
+ return []
13
+ if isinstance(value, list):
14
+ return value
15
+ raise ValueError(f"Mock data field '{name}' must be a list")
16
+
17
+
18
+ def load_mock_data(path: str) -> tuple[list[object], list[object]]:
19
+ resolved = resolve_mock_data_path(path)
20
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
21
+ if not isinstance(payload, dict):
22
+ raise ValueError("Mock data must be a JSON object")
23
+ devices = _as_list(payload.get("devices"), "devices")
24
+ clients = _as_list(payload.get("clients"), "clients")
25
+ return devices, clients
26
+
27
+
28
+ def load_mock_payload(path: str) -> dict[str, list[object] | list[dict[str, object]]]:
29
+ resolved = resolve_mock_data_path(path)
30
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
31
+ if not isinstance(payload, dict):
32
+ raise ValueError("Mock data must be a JSON object")
33
+ devices = _as_list(payload.get("devices"), "devices")
34
+ clients = _as_list(payload.get("clients"), "clients")
35
+ networks = _as_list(payload.get("networks"), "networks")
36
+ vlan_info = _as_list(payload.get("vlan_info"), "vlan_info")
37
+ return {
38
+ "devices": devices,
39
+ "clients": clients,
40
+ "networks": networks,
41
+ "vlan_info": vlan_info,
42
+ }