unifi-network-maps 1.3.0__tar.gz → 1.3.1__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 (102) hide show
  1. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/PKG-INFO +22 -5
  2. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/README.md +20 -1
  3. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/pyproject.toml +3 -3
  4. unifi_network_maps-1.3.1/src/unifi_network_maps/__init__.py +1 -0
  5. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/cli/main.py +43 -7
  6. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/model/topology.py +30 -4
  7. unifi_network_maps-1.3.1/src/unifi_network_maps/render/lldp_md.py +254 -0
  8. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/render/mermaid.py +5 -0
  9. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/render/svg.py +18 -6
  10. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps.egg-info/PKG-INFO +22 -5
  11. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps.egg-info/SOURCES.txt +2 -0
  12. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_cli.py +40 -0
  13. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_clients.py +7 -0
  14. unifi_network_maps-1.3.1/tests/test_lldp_md.py +42 -0
  15. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_mermaid.py +5 -0
  16. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_svg.py +8 -0
  17. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_svg_iso.py +8 -0
  18. unifi_network_maps-1.3.0/src/unifi_network_maps/__init__.py +0 -1
  19. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/CHANGELOG.md +0 -0
  20. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/CONTRIBUTING.md +0 -0
  21. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/LICENSE +0 -0
  22. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/LICENSES.md +0 -0
  23. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/MANIFEST.in +0 -0
  24. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/RELEASING.md +0 -0
  25. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/SECURITY.md +0 -0
  26. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/setup.cfg +0 -0
  27. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/adapters/__init__.py +0 -0
  28. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/adapters/config.py +0 -0
  29. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/adapters/unifi.py +0 -0
  30. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/__init__.py +0 -0
  31. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/__init__.py +0 -0
  32. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/access-point.svg +0 -0
  33. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  34. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/block.svg +0 -0
  35. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/cache.svg +0 -0
  36. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/cardterminal.svg +0 -0
  37. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/cloud.svg +0 -0
  38. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/cronjob.svg +0 -0
  39. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/cube.svg +0 -0
  40. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/desktop.svg +0 -0
  41. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/diamond.svg +0 -0
  42. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/dns.svg +0 -0
  43. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/document.svg +0 -0
  44. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/firewall.svg +0 -0
  45. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/function-module.svg +0 -0
  46. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/image.svg +0 -0
  47. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/laptop.svg +0 -0
  48. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/loadbalancer.svg +0 -0
  49. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/lock.svg +0 -0
  50. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/mail.svg +0 -0
  51. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/mailmultiple.svg +0 -0
  52. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/mobiledevice.svg +0 -0
  53. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/office.svg +0 -0
  54. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/package-module.svg +0 -0
  55. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/paymentcard.svg +0 -0
  56. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/plane.svg +0 -0
  57. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/printer.svg +0 -0
  58. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/pyramid.svg +0 -0
  59. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/queue.svg +0 -0
  60. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/router.svg +0 -0
  61. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/server.svg +0 -0
  62. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/speech.svg +0 -0
  63. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/sphere.svg +0 -0
  64. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/storage.svg +0 -0
  65. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/switch-module.svg +0 -0
  66. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/tower.svg +0 -0
  67. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/truck-2.svg +0 -0
  68. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/truck.svg +0 -0
  69. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/user.svg +0 -0
  70. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/isometric/vm.svg +0 -0
  71. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/laptop.svg +0 -0
  72. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/router-network.svg +0 -0
  73. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/server-network.svg +0 -0
  74. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/icons/server.svg +0 -0
  75. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/themes/dark.yaml +0 -0
  76. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/assets/themes/default.yaml +0 -0
  77. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/cli/__init__.py +0 -0
  78. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/cli/__main__.py +0 -0
  79. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/io/__init__.py +0 -0
  80. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/io/debug.py +0 -0
  81. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/io/export.py +0 -0
  82. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/model/__init__.py +0 -0
  83. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/model/labels.py +0 -0
  84. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/model/lldp.py +0 -0
  85. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/model/ports.py +0 -0
  86. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/render/__init__.py +0 -0
  87. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/render/mermaid_theme.py +0 -0
  88. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/render/svg_theme.py +0 -0
  89. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps/render/theme.py +0 -0
  90. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps.egg-info/dependency_links.txt +0 -0
  91. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps.egg-info/entry_points.txt +0 -0
  92. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps.egg-info/requires.txt +0 -0
  93. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/src/unifi_network_maps.egg-info/top_level.txt +0 -0
  94. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_config.py +0 -0
  95. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_debug.py +0 -0
  96. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_export.py +0 -0
  97. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_groups.py +0 -0
  98. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_labels.py +0 -0
  99. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_lldp.py +0 -0
  100. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_theme.py +0 -0
  101. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_topology.py +0 -0
  102. {unifi_network_maps-1.3.0 → unifi_network_maps-1.3.1}/tests/test_unifi.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.3.0
3
+ Version: 1.3.1
4
4
  Summary: Dynamic UniFi -> network maps in mermaid or svg
5
5
  Author: Merlijn
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/merlijntishauser/unifi-network-maps
8
8
  Project-URL: Repository, https://github.com/merlijntishauser/unifi-network-maps
9
9
  Project-URL: Issues, https://github.com/merlijntishauser/unifi-network-maps/issues
@@ -11,7 +11,6 @@ Project-URL: Changelog, https://github.com/merlijntishauser/unifi-network-maps/b
11
11
  Keywords: unifi,mermaid,network,topology,diagram,svg
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: System Administrators
14
- Classifier: License :: OSI Approved :: MIT License
15
14
  Classifier: Operating System :: OS Independent
16
15
  Classifier: Programming Language :: Python :: 3
17
16
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -23,7 +22,6 @@ Classifier: Topic :: System :: Networking
23
22
  Requires-Python: >=3.10
24
23
  Description-Content-Type: text/markdown
25
24
  License-File: LICENSE
26
- License-File: LICENSES.md
27
25
  Requires-Dist: unifi-controller-api
28
26
  Requires-Dist: python-dotenv
29
27
  Requires-Dist: PyYAML
@@ -109,6 +107,9 @@ SVG size overrides:
109
107
 
110
108
  ```bash
111
109
  unifi-network-maps --format svg --svg-width 1400 --svg-height 900 --output ./network.svg
110
+
111
+ # LLDP tables for troubleshooting
112
+ unifi-network-maps --format lldp-md --output ./lldp.md
112
113
  ```
113
114
 
114
115
  Legend only:
@@ -142,6 +143,20 @@ git push origin vX.Y.Z
142
143
 
143
144
  See `LICENSES.md` for third-party license info.
144
145
 
146
+ ## Installation
147
+
148
+ PyPI: https://pypi.org/project/unifi-network-maps/
149
+
150
+ ```bash
151
+ pip install unifi-network-maps
152
+ ```
153
+
154
+ Then run:
155
+
156
+ ```bash
157
+ unifi-network-maps --help
158
+ ```
159
+
145
160
  ## Options
146
161
 
147
162
  The CLI groups options by category (`Source`, `Functional`, `Mermaid`, `SVG`, `Output`, `Debug`).
@@ -153,6 +168,7 @@ Source:
153
168
  Functional:
154
169
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
155
170
  - `--include-clients`: add active wired clients as leaf nodes.
171
+ - `--client-scope wired|wireless|all`: which client types to include (default wired).
156
172
  - `--only-unifi`: only include neighbors that are UniFi devices.
157
173
 
158
174
  Mermaid:
@@ -165,7 +181,7 @@ SVG:
165
181
  - `--theme-file`: load a YAML theme for Mermaid + SVG colors (see `examples/theme.yaml` and `examples/theme-dark.yaml`).
166
182
 
167
183
  Output:
168
- - `--format mermaid|svg|svg-iso`: output format (default mermaid).
184
+ - `--format mermaid|svg|svg-iso|lldp-md`: output format (default mermaid).
169
185
  - `--stdout`: write output to stdout.
170
186
  - `--markdown`: wrap Mermaid output in a code fence.
171
187
 
@@ -178,6 +194,7 @@ Debug:
178
194
  - Default output is top-to-bottom (TB) and rendered as a hop-based tree from the gateway(s).
179
195
  - Nodes are color-coded by type (gateway/switch/AP/client) with a sensible default palette.
180
196
  - PoE links are highlighted in blue and annotated with a power icon when detected from `port_table`.
197
+ - Wireless client links render as dashed lines to indicate the last-known upstream.
181
198
  - SVG output uses vendored device glyphs from `src/unifi_network_maps/assets/icons`.
182
199
  - Isometric SVG output uses MIT-licensed icons from `markmanx/isopacks`.
183
200
  - SVG port labels render inside child nodes for readability.
@@ -73,6 +73,9 @@ SVG size overrides:
73
73
 
74
74
  ```bash
75
75
  unifi-network-maps --format svg --svg-width 1400 --svg-height 900 --output ./network.svg
76
+
77
+ # LLDP tables for troubleshooting
78
+ unifi-network-maps --format lldp-md --output ./lldp.md
76
79
  ```
77
80
 
78
81
  Legend only:
@@ -106,6 +109,20 @@ git push origin vX.Y.Z
106
109
 
107
110
  See `LICENSES.md` for third-party license info.
108
111
 
112
+ ## Installation
113
+
114
+ PyPI: https://pypi.org/project/unifi-network-maps/
115
+
116
+ ```bash
117
+ pip install unifi-network-maps
118
+ ```
119
+
120
+ Then run:
121
+
122
+ ```bash
123
+ unifi-network-maps --help
124
+ ```
125
+
109
126
  ## Options
110
127
 
111
128
  The CLI groups options by category (`Source`, `Functional`, `Mermaid`, `SVG`, `Output`, `Debug`).
@@ -117,6 +134,7 @@ Source:
117
134
  Functional:
118
135
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
119
136
  - `--include-clients`: add active wired clients as leaf nodes.
137
+ - `--client-scope wired|wireless|all`: which client types to include (default wired).
120
138
  - `--only-unifi`: only include neighbors that are UniFi devices.
121
139
 
122
140
  Mermaid:
@@ -129,7 +147,7 @@ SVG:
129
147
  - `--theme-file`: load a YAML theme for Mermaid + SVG colors (see `examples/theme.yaml` and `examples/theme-dark.yaml`).
130
148
 
131
149
  Output:
132
- - `--format mermaid|svg|svg-iso`: output format (default mermaid).
150
+ - `--format mermaid|svg|svg-iso|lldp-md`: output format (default mermaid).
133
151
  - `--stdout`: write output to stdout.
134
152
  - `--markdown`: wrap Mermaid output in a code fence.
135
153
 
@@ -142,6 +160,7 @@ Debug:
142
160
  - Default output is top-to-bottom (TB) and rendered as a hop-based tree from the gateway(s).
143
161
  - Nodes are color-coded by type (gateway/switch/AP/client) with a sensible default palette.
144
162
  - PoE links are highlighted in blue and annotated with a power icon when detected from `port_table`.
163
+ - Wireless client links render as dashed lines to indicate the last-known upstream.
145
164
  - SVG output uses vendored device glyphs from `src/unifi_network_maps/assets/icons`.
146
165
  - Isometric SVG output uses MIT-licensed icons from `markmanx/isopacks`.
147
166
  - SVG port labels render inside child nodes for readability.
@@ -4,17 +4,17 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unifi-network-maps"
7
- version = "1.3.0"
7
+ version = "1.3.1"
8
8
  description = "Dynamic UniFi -> network maps in mermaid or svg"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
- license = { text = "MIT" }
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
12
13
  authors = [{ name = "Merlijn" }]
13
14
  keywords = ["unifi", "mermaid", "network", "topology", "diagram", "svg"]
14
15
  classifiers = [
15
16
  "Development Status :: 3 - Alpha",
16
17
  "Intended Audience :: System Administrators",
17
- "License :: OSI Approved :: MIT License",
18
18
  "Operating System :: OS Independent",
19
19
  "Programming Language :: Python :: 3",
20
20
  "Programming Language :: Python :: 3 :: Only",
@@ -0,0 +1 @@
1
+ __version__ = "1.3.1"
@@ -18,6 +18,7 @@ from ..model.topology import (
18
18
  group_devices_by_type,
19
19
  normalize_devices,
20
20
  )
21
+ from ..render.lldp_md import render_lldp_md
21
22
  from ..render.mermaid import render_legend, render_mermaid
22
23
  from ..render.mermaid_theme import MermaidTheme
23
24
  from ..render.svg import SvgOptions, render_svg
@@ -65,6 +66,12 @@ def _add_functional_args(parser: argparse._ArgumentGroup) -> None:
65
66
  action="store_true",
66
67
  help="Include active clients as leaf nodes",
67
68
  )
69
+ parser.add_argument(
70
+ "--client-scope",
71
+ choices=["wired", "wireless", "all"],
72
+ default="wired",
73
+ help="Client types to include (default: wired)",
74
+ )
68
75
  parser.add_argument(
69
76
  "--only-unifi", action="store_true", help="Only include neighbors that are UniFi devices"
70
77
  )
@@ -94,7 +101,7 @@ def _add_general_render_args(parser: argparse._ArgumentGroup) -> None:
94
101
  parser.add_argument(
95
102
  "--format",
96
103
  default="mermaid",
97
- choices=["mermaid", "svg", "svg-iso"],
104
+ choices=["mermaid", "svg", "svg-iso", "lldp-md"],
98
105
  help="Output format",
99
106
  )
100
107
  parser.add_argument(
@@ -147,13 +154,20 @@ def _render_legend_only(args: argparse.Namespace, mermaid_theme: MermaidTheme) -
147
154
  return content
148
155
 
149
156
 
150
- def _build_topology_data(
157
+ def _load_devices_data(
151
158
  args: argparse.Namespace, config: Config, site: str
152
- ) -> tuple[list[Device], list[str], object]:
159
+ ) -> tuple[list[object], list[Device]]:
153
160
  raw_devices = list(fetch_devices(config, site=site, detailed=True))
154
161
  devices = normalize_devices(raw_devices)
155
162
  if args.debug_dump:
156
163
  debug_dump_devices(raw_devices, devices, sample_count=max(0, args.debug_sample))
164
+ return raw_devices, devices
165
+
166
+
167
+ def _build_topology_data(
168
+ args: argparse.Namespace, config: Config, site: str
169
+ ) -> tuple[list[Device], list[str], object]:
170
+ _raw_devices, devices = _load_devices_data(args, config, site)
157
171
  groups_for_rank = group_devices_by_type(devices)
158
172
  gateways = groups_for_rank.get("gateway", [])
159
173
  topology = build_topology(
@@ -176,7 +190,12 @@ def _build_edges_with_clients(
176
190
  if args.include_clients:
177
191
  clients = list(fetch_clients(config, site=site))
178
192
  device_index = build_device_index(devices)
179
- edges = edges + build_client_edges(clients, device_index, include_ports=args.include_ports)
193
+ edges = edges + build_client_edges(
194
+ clients,
195
+ device_index,
196
+ include_ports=args.include_ports,
197
+ client_mode=args.client_scope,
198
+ )
180
199
  return edges, clients
181
200
 
182
201
 
@@ -207,7 +226,7 @@ def _render_mermaid_output(
207
226
  direction=args.direction,
208
227
  groups=groups,
209
228
  group_order=group_order,
210
- node_types=build_node_type_map(devices, clients),
229
+ node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
211
230
  theme=mermaid_theme,
212
231
  )
213
232
  if args.markdown:
@@ -233,13 +252,13 @@ def _render_svg_output(
233
252
 
234
253
  return render_svg_isometric(
235
254
  edges,
236
- node_types=build_node_type_map(devices, clients),
255
+ node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
237
256
  options=options,
238
257
  theme=svg_theme,
239
258
  )
240
259
  return render_svg(
241
260
  edges,
242
- node_types=build_node_type_map(devices, clients),
261
+ node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
243
262
  options=options,
244
263
  theme=svg_theme,
245
264
  )
@@ -259,6 +278,23 @@ def main(argv: list[str] | None = None) -> int:
259
278
  write_output(content, output_path=args.output, stdout=args.stdout)
260
279
  return 0
261
280
 
281
+ if args.format == "lldp-md":
282
+ try:
283
+ _raw_devices, devices = _load_devices_data(args, config, site)
284
+ except Exception as exc:
285
+ logging.error("Failed to load devices: %s", exc)
286
+ return 1
287
+ clients = list(fetch_clients(config, site=site))
288
+ content = render_lldp_md(
289
+ devices,
290
+ clients=clients,
291
+ include_ports=args.include_ports,
292
+ show_clients=args.include_clients,
293
+ client_mode=args.client_scope,
294
+ )
295
+ write_output(content, output_path=args.output, stdout=args.stdout)
296
+ return 0
297
+
262
298
  try:
263
299
  devices, _gateways, topology = _build_topology_data(args, config, site)
264
300
  except Exception as exc:
@@ -27,6 +27,7 @@ class Device:
27
27
  poe_ports: dict[int, bool] = field(default_factory=dict)
28
28
  uplink: UplinkInfo | None = None
29
29
  last_uplink: UplinkInfo | None = None
30
+ version: str = ""
30
31
 
31
32
 
32
33
  @dataclass(frozen=True)
@@ -35,6 +36,7 @@ class Edge:
35
36
  right: str
36
37
  label: str | None = None
37
38
  poe: bool = False
39
+ wireless: bool = False
38
40
 
39
41
 
40
42
  class DeviceLike(Protocol):
@@ -56,6 +58,8 @@ class DeviceLike(Protocol):
56
58
  last_uplink_mac: object | None
57
59
  uplink_device_name: object | None
58
60
  uplink_remote_port: object | None
61
+ version: object | None
62
+ displayable_version: object | None
59
63
 
60
64
 
61
65
  @dataclass(frozen=True)
@@ -242,6 +246,7 @@ def coerce_device(device: DeviceLike) -> Device:
242
246
  mac = _get_attr(device, "mac")
243
247
  ip = _get_attr(device, "ip") or _get_attr(device, "ip_address")
244
248
  dev_type = _get_attr(device, "type") or _get_attr(device, "device_type")
249
+ version = _get_attr(device, "displayable_version") or _get_attr(device, "version")
245
250
  lldp_info = _get_attr(device, "lldp_info")
246
251
  if lldp_info is None:
247
252
  lldp_info = _get_attr(device, "lldp")
@@ -271,6 +276,7 @@ def coerce_device(device: DeviceLike) -> Device:
271
276
  poe_ports=poe_ports,
272
277
  uplink=uplink,
273
278
  last_uplink=last_uplink,
279
+ version=str(version or ""),
274
280
  )
275
281
 
276
282
 
@@ -407,16 +413,26 @@ def _client_is_wired(client: object) -> bool:
407
413
  return bool(_client_field(client, "is_wired"))
408
414
 
409
415
 
416
+ def _client_matches_mode(client: object, mode: str) -> bool:
417
+ wired = _client_is_wired(client)
418
+ if mode == "all":
419
+ return True
420
+ if mode == "wireless":
421
+ return not wired
422
+ return wired
423
+
424
+
410
425
  def build_client_edges(
411
426
  clients: Iterable[object],
412
427
  device_index: dict[str, str],
413
428
  *,
414
429
  include_ports: bool = False,
430
+ client_mode: str = "wired",
415
431
  ) -> list[Edge]:
416
432
  edges: list[Edge] = []
417
433
  seen: set[tuple[str, str]] = set()
418
434
  for client in clients:
419
- if not _client_is_wired(client):
435
+ if not _client_matches_mode(client, client_mode):
420
436
  continue
421
437
  name = _client_display_name(client)
422
438
  uplink_mac = _client_uplink_mac(client)
@@ -433,20 +449,30 @@ def build_client_edges(
433
449
  key = (device_name, name)
434
450
  if key in seen:
435
451
  continue
436
- edges.append(Edge(left=device_name, right=name, label=label))
452
+ edges.append(
453
+ Edge(
454
+ left=device_name,
455
+ right=name,
456
+ label=label,
457
+ wireless=not _client_is_wired(client),
458
+ )
459
+ )
437
460
  seen.add(key)
438
461
  return edges
439
462
 
440
463
 
441
464
  def build_node_type_map(
442
- devices: Iterable[Device], clients: Iterable[object] | None = None
465
+ devices: Iterable[Device],
466
+ clients: Iterable[object] | None = None,
467
+ *,
468
+ client_mode: str = "wired",
443
469
  ) -> dict[str, str]:
444
470
  node_types: dict[str, str] = {}
445
471
  for device in devices:
446
472
  node_types[device.name] = classify_device_type(device)
447
473
  if clients:
448
474
  for client in clients:
449
- if not _client_is_wired(client):
475
+ if not _client_matches_mode(client, client_mode):
450
476
  continue
451
477
  name = _client_display_name(client)
452
478
  if name:
@@ -0,0 +1,254 @@
1
+ """Render LLDP data as Markdown tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+ from ..model.lldp import LLDPEntry, local_port_label
8
+ from ..model.topology import Device, build_device_index
9
+
10
+
11
+ def _normalize_mac(value: str) -> str:
12
+ return value.strip().lower()
13
+
14
+
15
+ def _client_field(client: object, name: str) -> object | None:
16
+ if isinstance(client, dict):
17
+ return client.get(name)
18
+ return getattr(client, name, None)
19
+
20
+
21
+ def _client_display_name(client: object) -> str | None:
22
+ for key in ("name", "hostname", "mac"):
23
+ value = _client_field(client, key)
24
+ if isinstance(value, str) and value.strip():
25
+ return value.strip()
26
+ return None
27
+
28
+
29
+ def _client_uplink_mac(client: object) -> str | None:
30
+ for key in ("ap_mac", "sw_mac", "uplink_mac", "uplink_device_mac", "last_uplink_mac"):
31
+ value = _client_field(client, key)
32
+ if isinstance(value, str) and value.strip():
33
+ return value.strip()
34
+ for key in ("uplink", "last_uplink"):
35
+ nested = _client_field(client, key)
36
+ if isinstance(nested, dict):
37
+ value = nested.get("uplink_mac") or nested.get("uplink_device_mac")
38
+ if isinstance(value, str) and value.strip():
39
+ return value.strip()
40
+ return None
41
+
42
+
43
+ def _client_uplink_port(client: object) -> int | None:
44
+ for key in ("uplink_remote_port", "sw_port", "ap_port"):
45
+ value = _client_field(client, key)
46
+ if isinstance(value, int):
47
+ return value
48
+ if isinstance(value, str) and value.isdigit():
49
+ return int(value)
50
+ for key in ("uplink", "last_uplink"):
51
+ nested = _client_field(client, key)
52
+ if isinstance(nested, dict):
53
+ value = nested.get("uplink_remote_port")
54
+ if isinstance(value, int):
55
+ return value
56
+ if isinstance(value, str) and value.isdigit():
57
+ return int(value)
58
+ return None
59
+
60
+
61
+ def _client_is_wired(client: object) -> bool:
62
+ return bool(_client_field(client, "is_wired"))
63
+
64
+
65
+ def _client_matches_mode(client: object, mode: str) -> bool:
66
+ wired = _client_is_wired(client)
67
+ if mode == "all":
68
+ return True
69
+ if mode == "wireless":
70
+ return not wired
71
+ return wired
72
+
73
+
74
+ def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
75
+ port_label = local_port_label(entry) or ""
76
+ port_number = "".join(ch for ch in port_label if ch.isdigit())
77
+ return (int(port_number or 0), port_label, entry.port_id)
78
+
79
+
80
+ def _device_header_lines(device: Device) -> list[str]:
81
+ lines = [f"## {device.name}"]
82
+ meta = []
83
+ if device.model_name:
84
+ meta.append(f"Model: {device.model_name}")
85
+ if device.ip:
86
+ meta.append(f"IP: {device.ip}")
87
+ if device.mac:
88
+ meta.append(f"MAC: {device.mac}")
89
+ if meta:
90
+ lines.append(f"*{' | '.join(meta)}*")
91
+ return lines
92
+
93
+
94
+ def _port_summary(device: Device) -> str:
95
+ ports = [port for port in device.port_table if port.port_idx is not None]
96
+ if not ports:
97
+ return "-"
98
+ total_ports = len(ports)
99
+ poe_capable = sum(1 for port in ports if port.port_poe or port.poe_enable)
100
+ poe_active = sum(1 for port in ports if device.poe_ports.get(port.port_idx or -1))
101
+ total_power = sum(port.poe_power or 0.0 for port in ports)
102
+ summary = f"Total {total_ports}, PoE {poe_capable} (active {poe_active})"
103
+ if total_power > 0:
104
+ summary = f"{summary}, {total_power:.2f}W"
105
+ return summary
106
+
107
+
108
+ def _uplink_summary(device: Device) -> str:
109
+ uplink = device.uplink or device.last_uplink
110
+ if not uplink:
111
+ return "-"
112
+ name = uplink.name or uplink.mac or "Unknown"
113
+ if uplink.port is not None:
114
+ return f"{name} (Port {uplink.port})"
115
+ return name
116
+
117
+
118
+ def _client_summary(
119
+ device: Device, client_rows: dict[str, list[tuple[str, str | None]]]
120
+ ) -> tuple[str, str]:
121
+ rows = client_rows.get(device.name)
122
+ if rows is None:
123
+ return "-", "-"
124
+ count = len(rows)
125
+ names = [name for name, _port in rows]
126
+ sample = ", ".join(names[:3])
127
+ if len(names) > 3:
128
+ sample = f"{sample}, ..."
129
+ return str(count), sample or "-"
130
+
131
+
132
+ def _details_table_lines(
133
+ device: Device,
134
+ client_rows: dict[str, list[tuple[str, str | None]]],
135
+ client_mode: str,
136
+ ) -> list[str]:
137
+ wired_count, client_sample = _client_summary(device, client_rows)
138
+ client_label = f"Clients ({client_mode})"
139
+ lines = [
140
+ "### Details",
141
+ "",
142
+ "| Field | Value |",
143
+ "| --- | --- |",
144
+ f"| Firmware | {_escape_cell(device.version or '-')} |",
145
+ f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
146
+ f"| Ports | {_escape_cell(_port_summary(device))} |",
147
+ f"| {client_label} | {_escape_cell(wired_count)} |",
148
+ f"| Client examples | {_escape_cell(client_sample)} |",
149
+ "",
150
+ ]
151
+ return lines
152
+
153
+
154
+ def _lldp_rows(
155
+ entries: Iterable[LLDPEntry],
156
+ device_index: dict[str, str],
157
+ ) -> list[list[str]]:
158
+ rows: list[list[str]] = []
159
+ for entry in sorted(entries, key=_lldp_sort_key):
160
+ local_label = local_port_label(entry) or "?"
161
+ peer_name = device_index.get(_normalize_mac(entry.chassis_id), "")
162
+ peer_port = entry.port_id or "?"
163
+ port_desc = entry.port_desc or ""
164
+ rows.append(
165
+ [
166
+ local_label,
167
+ peer_name or "-",
168
+ peer_port,
169
+ entry.chassis_id,
170
+ port_desc or "-",
171
+ ]
172
+ )
173
+ return rows
174
+
175
+
176
+ def _escape_cell(value: str) -> str:
177
+ return value.replace("|", "\\|")
178
+
179
+
180
+ def _client_rows(
181
+ clients: Iterable[object],
182
+ device_index: dict[str, str],
183
+ *,
184
+ include_ports: bool,
185
+ client_mode: str,
186
+ ) -> dict[str, list[tuple[str, str | None]]]:
187
+ rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
188
+ for client in clients:
189
+ if not _client_matches_mode(client, client_mode):
190
+ continue
191
+ name = _client_display_name(client)
192
+ uplink_mac = _client_uplink_mac(client)
193
+ if not name or not uplink_mac:
194
+ continue
195
+ device_name = device_index.get(_normalize_mac(uplink_mac))
196
+ if not device_name:
197
+ continue
198
+ port_label = None
199
+ if include_ports:
200
+ uplink_port = _client_uplink_port(client)
201
+ if uplink_port is not None:
202
+ port_label = f"Port {uplink_port}"
203
+ rows_by_device.setdefault(device_name, []).append((name, port_label))
204
+ return rows_by_device
205
+
206
+
207
+ def render_lldp_md(
208
+ devices: list[Device],
209
+ *,
210
+ clients: Iterable[object] | None = None,
211
+ include_ports: bool = False,
212
+ show_clients: bool = False,
213
+ client_mode: str = "wired",
214
+ ) -> str:
215
+ device_index = build_device_index(devices)
216
+ client_rows = (
217
+ _client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
218
+ if clients
219
+ else {}
220
+ )
221
+ lines: list[str] = ["# LLDP Neighbors", ""]
222
+ for device in sorted(devices, key=lambda item: item.name.lower()):
223
+ lines.extend(_device_header_lines(device))
224
+ lines.append("")
225
+ lines.extend(_details_table_lines(device, client_rows, client_mode))
226
+ if device.lldp_info:
227
+ lines.append("")
228
+ lines.append(
229
+ "| Local Port | Neighbor | Neighbor Port | Chassis ID | Port Description |"
230
+ )
231
+ lines.append("| --- | --- | --- | --- | --- |")
232
+ for row in _lldp_rows(device.lldp_info, device_index):
233
+ lines.append("| " + " | ".join(_escape_cell(cell) for cell in row) + " |")
234
+ lines.append("")
235
+ else:
236
+ lines.append("_No LLDP neighbors._")
237
+ lines.append("")
238
+ rows = client_rows.get(device.name)
239
+ if rows and show_clients:
240
+ lines.append("")
241
+ lines.append("### Clients")
242
+ if include_ports:
243
+ lines.append("")
244
+ lines.append("| Client | Port |")
245
+ lines.append("| --- | --- |")
246
+ for client_name, port_label in rows:
247
+ lines.append(
248
+ f"| {_escape_cell(client_name)} | {_escape_cell(port_label or '-')} |"
249
+ )
250
+ else:
251
+ for client_name, _port_label in rows:
252
+ lines.append(f"- {_escape_cell(client_name)}")
253
+ lines.append("")
254
+ return "\n".join(lines).rstrip() + "\n"
@@ -71,6 +71,7 @@ def render_mermaid(
71
71
  id_map = _build_id_map(edge_list, group_nodes)
72
72
  lines = [f"graph {direction}"]
73
73
  poe_links: list[int] = []
74
+ wireless_links: list[int] = []
74
75
  link_index = 0
75
76
  if groups:
76
77
  ordered = group_order or list(groups.keys())
@@ -99,6 +100,8 @@ def render_mermaid(
99
100
  lines.append(f" {left} --- {right};")
100
101
  if edge.poe:
101
102
  poe_links.append(link_index)
103
+ if edge.wireless:
104
+ wireless_links.append(link_index)
102
105
  link_index += 1
103
106
  if node_types:
104
107
  class_map = {
@@ -121,6 +124,8 @@ def render_mermaid(
121
124
  f"{index} stroke:{theme.poe_link},stroke-width:{theme.poe_link_width}px,"
122
125
  f"arrowhead:{theme.poe_link_arrow};"
123
126
  )
127
+ for index in wireless_links:
128
+ lines.append(f" linkStyle {index} stroke-dasharray: 5 4;")
124
129
  return "\n".join(lines) + "\n"
125
130
 
126
131