unifi-network-maps 1.4.9__tar.gz → 1.4.11__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.9 → unifi_network_maps-1.4.11}/CHANGELOG.md +14 -1
  2. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/PKG-INFO +2 -2
  3. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/pyproject.toml +2 -2
  4. unifi_network_maps-1.4.11/src/unifi_network_maps/__init__.py +1 -0
  5. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/model/topology.py +70 -16
  6. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/lldp_md.py +23 -11
  7. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/svg.py +8 -2
  8. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps.egg-info/PKG-INFO +2 -2
  9. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps.egg-info/requires.txt +1 -1
  10. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_clients.py +7 -0
  11. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_svg.py +27 -0
  12. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_topology.py +21 -0
  13. unifi_network_maps-1.4.9/src/unifi_network_maps/__init__.py +0 -1
  14. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/CONTRIBUTING.md +0 -0
  15. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/LICENSE +0 -0
  16. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/LICENSES.md +0 -0
  17. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/MANIFEST.in +0 -0
  18. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/README.md +0 -0
  19. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/RELEASING.md +0 -0
  20. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/SECURITY.md +0 -0
  21. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/setup.cfg +0 -0
  22. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/__main__.py +0 -0
  23. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/adapters/__init__.py +0 -0
  24. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/adapters/config.py +0 -0
  25. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/adapters/unifi.py +0 -0
  26. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/__init__.py +0 -0
  27. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/__init__.py +0 -0
  28. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/access-point.svg +0 -0
  29. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  30. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/block.svg +0 -0
  31. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/cache.svg +0 -0
  32. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/cardterminal.svg +0 -0
  33. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/cloud.svg +0 -0
  34. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/cronjob.svg +0 -0
  35. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/cube.svg +0 -0
  36. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/desktop.svg +0 -0
  37. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/diamond.svg +0 -0
  38. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/dns.svg +0 -0
  39. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/document.svg +0 -0
  40. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/firewall.svg +0 -0
  41. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/function-module.svg +0 -0
  42. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/image.svg +0 -0
  43. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/laptop.svg +0 -0
  44. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/loadbalancer.svg +0 -0
  45. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/lock.svg +0 -0
  46. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/mail.svg +0 -0
  47. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/mailmultiple.svg +0 -0
  48. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/mobiledevice.svg +0 -0
  49. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/office.svg +0 -0
  50. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/package-module.svg +0 -0
  51. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/paymentcard.svg +0 -0
  52. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/plane.svg +0 -0
  53. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/printer.svg +0 -0
  54. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/pyramid.svg +0 -0
  55. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/queue.svg +0 -0
  56. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/router.svg +0 -0
  57. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/server.svg +0 -0
  58. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/speech.svg +0 -0
  59. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/sphere.svg +0 -0
  60. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/storage.svg +0 -0
  61. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/switch-module.svg +0 -0
  62. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/tower.svg +0 -0
  63. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/truck-2.svg +0 -0
  64. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/truck.svg +0 -0
  65. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/user.svg +0 -0
  66. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/isometric/vm.svg +0 -0
  67. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/laptop.svg +0 -0
  68. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/router-network.svg +0 -0
  69. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/server-network.svg +0 -0
  70. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/icons/server.svg +0 -0
  71. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/themes/dark.yaml +0 -0
  72. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/assets/themes/default.yaml +0 -0
  73. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/cli/__init__.py +0 -0
  74. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/cli/__main__.py +0 -0
  75. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/cli/args.py +0 -0
  76. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/cli/main.py +0 -0
  77. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/cli/render.py +0 -0
  78. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/cli/runtime.py +0 -0
  79. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/io/__init__.py +0 -0
  80. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/io/debug.py +0 -0
  81. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/io/export.py +0 -0
  82. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/io/mkdocs_assets.py +0 -0
  83. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/io/mock_data.py +0 -0
  84. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/io/mock_generate.py +0 -0
  85. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/model/__init__.py +0 -0
  86. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/model/labels.py +0 -0
  87. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/model/lldp.py +0 -0
  88. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/model/mock.py +0 -0
  89. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/model/ports.py +0 -0
  90. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/__init__.py +0 -0
  91. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/device_ports_md.py +0 -0
  92. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/legend.py +0 -0
  93. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/markdown_tables.py +0 -0
  94. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/mermaid.py +0 -0
  95. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/mermaid_theme.py +0 -0
  96. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/mkdocs.py +0 -0
  97. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/svg_theme.py +0 -0
  98. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/device_port_block.md.j2 +0 -0
  99. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/legend_compact.html.j2 +0 -0
  100. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/lldp_device_section.md.j2 +0 -0
  101. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/markdown_section.md.j2 +0 -0
  102. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +0 -0
  103. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/mkdocs_document.md.j2 +0 -0
  104. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +0 -0
  105. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +0 -0
  106. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/mkdocs_legend.css.j2 +0 -0
  107. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/mkdocs_legend.js.j2 +0 -0
  108. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +0 -0
  109. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/templating.py +0 -0
  110. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps/render/theme.py +0 -0
  111. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps.egg-info/SOURCES.txt +0 -0
  112. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps.egg-info/dependency_links.txt +0 -0
  113. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps.egg-info/entry_points.txt +0 -0
  114. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/src/unifi_network_maps.egg-info/top_level.txt +0 -0
  115. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_cli.py +0 -0
  116. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_config.py +0 -0
  117. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_contract_unifi.py +0 -0
  118. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_contract_unifi_live.py +0 -0
  119. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_debug.py +0 -0
  120. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_device_ports_md.py +0 -0
  121. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_export.py +0 -0
  122. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_groups.py +0 -0
  123. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_labels.py +0 -0
  124. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_lldp.py +0 -0
  125. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_lldp_md.py +0 -0
  126. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_mermaid.py +0 -0
  127. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_mock_generate.py +0 -0
  128. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_svg_iso.py +0 -0
  129. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_theme.py +0 -0
  130. {unifi_network_maps-1.4.9 → unifi_network_maps-1.4.11}/tests/test_unifi.py +0 -0
@@ -5,6 +5,17 @@ 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.11] - 2026-01-19
9
+ ### Added
10
+ - Add data-edge-left/right attributes to SVG paths
11
+
12
+ ### Fixed
13
+ - Regression in identifying wireless/wired clients
14
+
15
+ ## [1.4.10] - 2026-01-18
16
+ ### Added
17
+ - Add speed and channel fields to Edge dataclass
18
+
8
19
  ## [1.4.9] - 2026-01-15
9
20
  ### Changed
10
21
  - Declared support for Python 3.12+ (3.13 preferred) and added CI coverage for 3.12.
@@ -165,7 +176,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
165
176
  - Introduced SVG renderer and tree layout fixes.
166
177
  - Increased test coverage and added coverage tooling.
167
178
 
168
- [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.9...HEAD
179
+ [Unreleased]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.11...HEAD
180
+ [1.4.11]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.10...v1.4.111
181
+ [1.4.10]:https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.9...v1.4.10
169
182
  [1.4.9]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.8...v1.4.9
170
183
  [1.4.8]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.7...v1.4.8
171
184
  [1.4.7]: https://github.com/merlijntishauser/unifi-network-maps/compare/v1.4.6...v1.4.7
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.4.9
3
+ Version: 1.4.11
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.11; extra == "dev"
35
+ Requires-Dist: ruff==0.14.13; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # unifi-network-maps
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unifi-network-maps"
7
- version = "1.4.9"
7
+ version = "1.4.11"
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.11"
47
+ "ruff==0.14.13"
48
48
  ]
49
49
 
50
50
  [project.scripts]
@@ -0,0 +1 @@
1
+ __version__ = "1.4.11"
@@ -37,6 +37,8 @@ class Edge:
37
37
  label: str | None = None
38
38
  poe: bool = False
39
39
  wireless: bool = False
40
+ speed: int | None = None
41
+ channel: int | None = None
40
42
 
41
43
 
42
44
  type DeviceSource = object
@@ -64,6 +66,7 @@ class PortInfo:
64
66
 
65
67
  type PortMap = dict[tuple[str, str], str]
66
68
  type PoeMap = dict[tuple[str, str], bool]
69
+ type SpeedMap = dict[tuple[str, str], int]
67
70
  type ClientPortMap = dict[str, list[tuple[int, str]]]
68
71
 
69
72
 
@@ -421,7 +424,15 @@ def _tree_edges_from_parent(
421
424
  tree_edges.append(Edge(left=parent_name, right=child))
422
425
  else:
423
426
  tree_edges.append(
424
- Edge(left=parent_name, right=child, label=original.label, poe=original.poe)
427
+ Edge(
428
+ left=parent_name,
429
+ right=child,
430
+ label=original.label,
431
+ poe=original.poe,
432
+ wireless=original.wireless,
433
+ speed=original.speed,
434
+ channel=original.channel,
435
+ )
425
436
  )
426
437
  return tree_edges
427
438
 
@@ -471,20 +482,31 @@ def _client_uplink_mac(client: object) -> str | None:
471
482
 
472
483
 
473
484
  def _client_uplink_port(client: object) -> int | None:
474
- for key in ("uplink_remote_port", "sw_port", "ap_port"):
475
- value = _client_field(client, key)
476
- if isinstance(value, int):
477
- return value
478
- if isinstance(value, str) and value.isdigit():
479
- return int(value)
485
+ for value in _client_port_values(client):
486
+ parsed = _parse_port_value(value)
487
+ if parsed is not None:
488
+ return parsed
489
+ return None
490
+
491
+
492
+ def _client_port_values(client: object) -> Iterable[object | None]:
493
+ for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
494
+ yield _client_field(client, key)
480
495
  for key in ("uplink", "last_uplink"):
481
496
  nested = _client_field(client, key)
482
497
  if isinstance(nested, dict):
483
- value = nested.get("uplink_remote_port")
484
- if isinstance(value, int):
485
- return value
486
- if isinstance(value, str) and value.isdigit():
487
- return int(value)
498
+ for nested_key in ("uplink_remote_port", "port_idx"):
499
+ yield nested.get(nested_key)
500
+
501
+
502
+ def _parse_port_value(value: object | None) -> int | None:
503
+ if isinstance(value, int):
504
+ return value
505
+ if isinstance(value, str):
506
+ stripped = value.strip()
507
+ if stripped.isdigit():
508
+ return int(stripped)
509
+ return extract_port_number(stripped)
488
510
  return None
489
511
 
490
512
 
@@ -492,6 +514,16 @@ def _client_is_wired(client: object) -> bool:
492
514
  return bool(_client_field(client, "is_wired"))
493
515
 
494
516
 
517
+ def _client_channel(client: object) -> int | None:
518
+ for key in ("channel", "radio_channel", "wifi_channel"):
519
+ value = _client_field(client, key)
520
+ if isinstance(value, int):
521
+ return value
522
+ if isinstance(value, str) and value.isdigit():
523
+ return int(value)
524
+ return None
525
+
526
+
495
527
  def _client_matches_mode(client: object, mode: str) -> bool:
496
528
  wired = _client_is_wired(client)
497
529
  if mode == "all":
@@ -528,12 +560,15 @@ def build_client_edges(
528
560
  key = (device_name, name)
529
561
  if key in seen:
530
562
  continue
563
+ is_wireless = not _client_is_wired(client)
564
+ channel = _client_channel(client) if is_wireless else None
531
565
  edges.append(
532
566
  Edge(
533
567
  left=device_name,
534
568
  right=name,
535
569
  label=label,
536
- wireless=not _client_is_wired(client),
570
+ wireless=is_wireless,
571
+ channel=channel,
537
572
  )
538
573
  )
539
574
  seen.add(key)
@@ -572,12 +607,14 @@ def build_edges(
572
607
  seen: set[frozenset[str]] = set()
573
608
  port_map: PortMap = {}
574
609
  poe_map: PoeMap = {}
610
+ speed_map: SpeedMap = {}
575
611
 
576
612
  devices_with_lldp_edges = _collect_lldp_links(
577
613
  ordered_devices,
578
614
  index,
579
615
  port_map,
580
616
  poe_map,
617
+ speed_map,
581
618
  raw_links,
582
619
  seen,
583
620
  only_unifi=only_unifi,
@@ -597,6 +634,7 @@ def build_edges(
597
634
  raw_links,
598
635
  port_map,
599
636
  poe_map,
637
+ speed_map,
600
638
  device_by_name,
601
639
  include_ports=include_ports,
602
640
  )
@@ -614,12 +652,14 @@ def build_port_map(devices: Iterable[Device], *, only_unifi: bool = True) -> Por
614
652
  seen: set[frozenset[str]] = set()
615
653
  port_map: PortMap = {}
616
654
  poe_map: PoeMap = {}
655
+ speed_map: SpeedMap = {}
617
656
 
618
657
  devices_with_lldp_edges = _collect_lldp_links(
619
658
  ordered_devices,
620
659
  index,
621
660
  port_map,
622
661
  poe_map,
662
+ speed_map,
623
663
  raw_links,
624
664
  seen,
625
665
  only_unifi=only_unifi,
@@ -661,11 +701,19 @@ def build_client_port_map(
661
701
  return port_map
662
702
 
663
703
 
704
+ def _port_speed_by_idx(port_table: list[PortInfo], port_idx: int) -> int | None:
705
+ for port in port_table:
706
+ if port.port_idx == port_idx:
707
+ return port.speed
708
+ return None
709
+
710
+
664
711
  def _collect_lldp_links(
665
712
  devices: list[Device],
666
713
  index: dict[str, str],
667
714
  port_map: PortMap,
668
715
  poe_map: PoeMap,
716
+ speed_map: SpeedMap,
669
717
  raw_links: list[tuple[str, str]],
670
718
  seen: set[frozenset[str]],
671
719
  *,
@@ -704,8 +752,12 @@ def _collect_lldp_links(
704
752
  label = local_port_label(entry_for_label)
705
753
  if label:
706
754
  port_map[(device.name, peer_name)] = label
707
- if resolved_port_idx is not None and resolved_port_idx in poe_ports:
708
- poe_map[(device.name, peer_name)] = poe_ports[resolved_port_idx]
755
+ if resolved_port_idx is not None:
756
+ if resolved_port_idx in poe_ports:
757
+ poe_map[(device.name, peer_name)] = poe_ports[resolved_port_idx]
758
+ port_speed = _port_speed_by_idx(device.port_table, resolved_port_idx)
759
+ if port_speed is not None:
760
+ speed_map[(device.name, peer_name)] = port_speed
709
761
 
710
762
  key = frozenset({device.name, peer_name})
711
763
  if key in seen:
@@ -794,6 +846,7 @@ def _build_ordered_edges(
794
846
  raw_links: list[tuple[str, str]],
795
847
  port_map: PortMap,
796
848
  poe_map: PoeMap,
849
+ speed_map: SpeedMap,
797
850
  device_by_name: dict[str, Device],
798
851
  *,
799
852
  include_ports: bool,
@@ -820,8 +873,9 @@ def _build_ordered_edges(
820
873
  poe = poe_map.get((left_name, right_name), False) or poe_map.get(
821
874
  (right_name, left_name), False
822
875
  )
876
+ speed = speed_map.get((left_name, right_name)) or speed_map.get((right_name, left_name))
823
877
  label = compose_port_label(left_name, right_name, port_map) if include_ports else None
824
- edges.append(Edge(left=left_name, right=right_name, label=label, poe=poe))
878
+ edges.append(Edge(left=left_name, right=right_name, label=label, poe=poe, speed=speed))
825
879
  return edges
826
880
 
827
881
 
@@ -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
@@ -44,20 +45,31 @@ def _client_uplink_mac(client: object) -> str | None:
44
45
 
45
46
 
46
47
  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)
48
+ for value in _client_port_values(client):
49
+ parsed = _parse_port_value(value)
50
+ if parsed is not None:
51
+ return parsed
52
+ return None
53
+
54
+
55
+ def _client_port_values(client: object) -> Iterable[object | None]:
56
+ for key in ("uplink_remote_port", "sw_port", "ap_port", "port_idx"):
57
+ yield _client_field(client, key)
53
58
  for key in ("uplink", "last_uplink"):
54
59
  nested = _client_field(client, key)
55
60
  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)
61
+ for nested_key in ("uplink_remote_port", "port_idx"):
62
+ yield nested.get(nested_key)
63
+
64
+
65
+ def _parse_port_value(value: object | None) -> int | None:
66
+ if isinstance(value, int):
67
+ return value
68
+ if isinstance(value, str):
69
+ stripped = value.strip()
70
+ if stripped.isdigit():
71
+ return int(stripped)
72
+ return extract_port_number(stripped)
61
73
  return None
62
74
 
63
75
 
@@ -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.9
3
+ Version: 1.4.11
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.11; extra == "dev"
35
+ Requires-Dist: ruff==0.14.13; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # unifi-network-maps
@@ -10,4 +10,4 @@ pre-commit==4.5.1
10
10
  pytest==9.0.2
11
11
  pytest-cov==7.0.0
12
12
  pyright==1.1.408
13
- ruff==0.14.11
13
+ ruff==0.14.13
@@ -36,6 +36,13 @@ def test_build_client_edges_includes_wireless_when_requested():
36
36
  assert edges[0].wireless is True
37
37
 
38
38
 
39
+ def test_build_client_edges_includes_channel_for_wireless():
40
+ device_index = {"aa:bb:cc:dd:ee:ff": "AP One"}
41
+ clients = [{"name": "Phone", "ap_mac": "aa:bb:cc:dd:ee:ff", "is_wired": False, "channel": 36}]
42
+ edges = build_client_edges(clients, device_index, client_mode="wireless")
43
+ assert edges[0].channel == 36
44
+
45
+
39
46
  def test_build_client_edges_includes_uplink_port_label():
40
47
  device_index = {"aa:bb:cc:dd:ee:ff": "Switch A"}
41
48
  clients = [
@@ -263,3 +263,30 @@ def test_render_svg_isometric_nodes_reference_iso_node_prefix():
263
263
  [Edge("A", "B")], node_types={"A": "switch", "B": "switch"}
264
264
  )
265
265
  assert 'fill="url(#iso-node-switch)"' in output
266
+
267
+
268
+ def test_render_svg_adds_edge_data_attributes():
269
+ output = svg_module.render_svg(
270
+ [Edge("Gateway", "Switch")],
271
+ node_types={"Gateway": "gateway", "Switch": "switch"},
272
+ )
273
+ assert 'data-edge-left="Gateway"' in output
274
+ assert 'data-edge-right="Switch"' in output
275
+
276
+
277
+ def test_render_svg_escapes_edge_data_attributes():
278
+ output = svg_module.render_svg(
279
+ [Edge('Node "A"', "Node <B>")],
280
+ node_types={'Node "A"': "gateway", "Node <B>": "switch"},
281
+ )
282
+ assert 'data-edge-left="Node &quot;A&quot;"' in output
283
+ assert 'data-edge-right="Node &lt;B&gt;"' in output
284
+
285
+
286
+ def test_render_svg_isometric_adds_edge_data_attributes():
287
+ output = svg_module.render_svg_isometric(
288
+ [Edge("Gateway", "Switch")],
289
+ node_types={"Gateway": "gateway", "Switch": "switch"},
290
+ )
291
+ assert 'data-edge-left="Gateway"' in output
292
+ assert 'data-edge-right="Switch"' in output
@@ -181,6 +181,22 @@ def test_build_edges_sets_poe_with_port_poe():
181
181
  assert edges[0].poe is True
182
182
 
183
183
 
184
+ def test_build_edges_sets_speed_from_port():
185
+ dev_switch = DummyDevice(
186
+ "Switch A",
187
+ "aa:bb:cc:dd:ee:01",
188
+ [LLDPEntry("aa:bb:cc:dd:ee:02", "eth1", local_port_idx=1)],
189
+ port_table=[{"port_idx": 1, "speed": 1000}],
190
+ )
191
+ dev_ap = DummyDevice(
192
+ "AP One",
193
+ "aa:bb:cc:dd:ee:02",
194
+ [LLDPEntry("aa:bb:cc:dd:ee:01", "eth0")],
195
+ )
196
+ edges = build_edges(normalize_devices([dev_switch, dev_ap]))
197
+ assert edges[0].speed == 1000
198
+
199
+
184
200
  def test_coerce_device_uses_lldp_fallback():
185
201
  class DeviceWithLldp:
186
202
  name = "Device"
@@ -486,6 +502,11 @@ def test_client_uplink_port_direct_str_digit():
486
502
  assert _client_uplink_port(client) == 7
487
503
 
488
504
 
505
+ def test_client_uplink_port_parses_port_label():
506
+ client = {"uplink_remote_port": "Port 9"}
507
+ assert _client_uplink_port(client) == 9
508
+
509
+
489
510
  def test_client_uplink_port_nested_int():
490
511
  client = {"uplink": {"uplink_remote_port": 8}}
491
512
  assert _client_uplink_port(client) == 8
@@ -1 +0,0 @@
1
- __version__ = "1.4.9"