unifi-network-maps 1.4.14__py3-none-any.whl → 1.5.0__py3-none-any.whl

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 (74) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/adapters/unifi.py +83 -101
  3. unifi_network_maps/assets/icons/modern/ap.svg +9 -0
  4. unifi_network_maps/assets/icons/modern/camera.svg +9 -0
  5. unifi_network_maps/assets/icons/modern/client.svg +9 -0
  6. unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
  7. unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
  8. unifi_network_maps/assets/icons/modern/iot.svg +9 -0
  9. unifi_network_maps/assets/icons/modern/nas.svg +9 -0
  10. unifi_network_maps/assets/icons/modern/other.svg +10 -0
  11. unifi_network_maps/assets/icons/modern/phone.svg +10 -0
  12. unifi_network_maps/assets/icons/modern/printer.svg +9 -0
  13. unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
  14. unifi_network_maps/assets/icons/modern/switch.svg +10 -0
  15. unifi_network_maps/assets/icons/modern/tv.svg +10 -0
  16. unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
  17. unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
  18. unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
  19. unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
  20. unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
  21. unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
  22. unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
  23. unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
  24. unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
  25. unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
  26. unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
  27. unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
  28. unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
  29. unifi_network_maps/assets/themes/dark.yaml +53 -10
  30. unifi_network_maps/assets/themes/default.yaml +34 -0
  31. unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
  32. unifi_network_maps/assets/themes/minimal.yaml +92 -0
  33. unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
  34. unifi_network_maps/assets/themes/unifi.yaml +92 -0
  35. unifi_network_maps/cli/args.py +54 -0
  36. unifi_network_maps/cli/main.py +18 -7
  37. unifi_network_maps/cli/render.py +79 -27
  38. unifi_network_maps/cli/runtime.py +29 -15
  39. unifi_network_maps/io/debug.py +2 -1
  40. unifi_network_maps/io/export.py +19 -13
  41. unifi_network_maps/io/mock_data.py +5 -3
  42. unifi_network_maps/io/paths.py +5 -3
  43. unifi_network_maps/model/classify.py +199 -0
  44. unifi_network_maps/model/clients.py +271 -0
  45. unifi_network_maps/model/connection.py +37 -0
  46. unifi_network_maps/model/diff.py +544 -0
  47. unifi_network_maps/model/edges.py +558 -0
  48. unifi_network_maps/model/helpers.py +64 -0
  49. unifi_network_maps/model/lldp.py +20 -25
  50. unifi_network_maps/model/mock.py +110 -23
  51. unifi_network_maps/model/snapshot.py +294 -0
  52. unifi_network_maps/model/topology.py +143 -931
  53. unifi_network_maps/model/topology_coerce.py +339 -0
  54. unifi_network_maps/model/vlans.py +32 -46
  55. unifi_network_maps/model/wan.py +132 -0
  56. unifi_network_maps/render/device_ports_md.py +39 -97
  57. unifi_network_maps/render/device_summary.py +53 -0
  58. unifi_network_maps/render/lldp_md.py +29 -219
  59. unifi_network_maps/render/markdown_tables.py +8 -0
  60. unifi_network_maps/render/mermaid.py +11 -2
  61. unifi_network_maps/render/mkdocs.py +2 -1
  62. unifi_network_maps/render/svg.py +566 -908
  63. unifi_network_maps/render/svg_icons.py +231 -0
  64. unifi_network_maps/render/svg_isometric.py +1196 -0
  65. unifi_network_maps/render/svg_labels.py +184 -0
  66. unifi_network_maps/render/svg_theme.py +166 -32
  67. unifi_network_maps/render/theme.py +86 -1
  68. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
  70. unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
  71. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,184 @@
1
+ """Text formatting and label utilities for SVG rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..model.topology import WanInfo, WanInterface
6
+
7
+
8
+ def _escape_text(value: str) -> str:
9
+ return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
10
+
11
+
12
+ def _extract_port_text(side: str) -> str | None:
13
+ candidate = side.split(":", 1)[1].strip() if ":" in side else side.strip()
14
+ if candidate.lower().startswith("port "):
15
+ return candidate
16
+ return None
17
+
18
+
19
+ def _extract_device_name(side: str) -> str | None:
20
+ if ":" not in side:
21
+ return None
22
+ name = side.split(":", 1)[0].strip()
23
+ return name or None
24
+
25
+
26
+ def _compact_edge_label(
27
+ label: str, *, left_node: str | None = None, right_node: str | None = None
28
+ ) -> str:
29
+ if "<->" not in label:
30
+ return label
31
+ left_segment, right_segment = (part.strip() for part in label.split("<->", 1))
32
+ left_name = _extract_device_name(left_segment)
33
+ right_name = _extract_device_name(right_segment)
34
+ left_port = _extract_port_text(left_segment)
35
+ right_port = _extract_port_text(right_segment)
36
+ if left_node and right_node:
37
+ if right_name and right_name == left_node and left_name == right_node:
38
+ left_name, right_name = right_name, left_name
39
+ left_port, right_port = right_port, left_port
40
+ if left_port and right_port:
41
+ if left_name:
42
+ return f"{left_name} {left_port} <-> {right_port}"
43
+ return f"{left_port} <-> {right_port}"
44
+ if left_port:
45
+ return left_port
46
+ if right_port:
47
+ return right_port
48
+ return label
49
+
50
+
51
+ def _format_port_label_lines(
52
+ port_label: str,
53
+ *,
54
+ node_type: str,
55
+ prefix: str,
56
+ max_chars: int,
57
+ ) -> list[str]:
58
+ def _port_only(segment: str) -> str:
59
+ port = _extract_port_text(segment)
60
+ if port:
61
+ return port
62
+ lower = segment.lower()
63
+ idx = lower.rfind("port ")
64
+ if idx != -1:
65
+ return segment[idx:].strip()
66
+ return segment.split(":", 1)[-1].strip()
67
+
68
+ def _truncate(text: str, max_len: int = max_chars) -> str:
69
+ return text[: max_len - 3].rstrip() + "..." if len(text) > max_len else text
70
+
71
+ if "<->" in port_label:
72
+ left_part, right_part = (part.strip() for part in port_label.split("<->", 1))
73
+ front_text = _truncate(f"{prefix}: {_port_only(left_part)}")
74
+ side_prefix = prefix if node_type == "client" else "local"
75
+ side_text = _truncate(f"{side_prefix}: {_port_only(right_part)}")
76
+ return [line for line in (front_text, side_text) if line]
77
+ side_prefix = prefix if node_type == "client" else "local"
78
+ side_text = _truncate(f"{side_prefix}: {_port_only(port_label)}")
79
+ return [side_text]
80
+
81
+
82
+ def _wrap_text(label: str, *, max_len: int = 24) -> list[str]:
83
+ if len(label) <= max_len:
84
+ return [label]
85
+ split_at = label.rfind(" ", 0, max_len + 1)
86
+ if split_at == -1:
87
+ split_at = max_len
88
+ first = label[:split_at].rstrip()
89
+ rest = label[split_at:].lstrip()
90
+ return [first, rest] if rest else [first]
91
+
92
+
93
+ def _shorten_prefix(name: str, max_words: int = 2) -> str:
94
+ words = name.split()
95
+ if len(words) <= max_words:
96
+ return name
97
+ return " ".join(words[:max_words]) + "..."
98
+
99
+
100
+ def _label_metrics(
101
+ lines: list[str], *, font_size: int, padding_x: int = 6, padding_y: int = 3
102
+ ) -> tuple[float, float]:
103
+ max_len = max((len(line) for line in lines), default=0)
104
+ text_width = max_len * font_size * 0.6
105
+ text_height = len(lines) * (font_size + 2)
106
+ width = text_width + padding_x * 2
107
+ height = text_height + padding_y * 2
108
+ return width, height
109
+
110
+
111
+ def _format_wan_speed(speed_mbps: int | None) -> str | None:
112
+ """Format speed in Mbps to human-readable string (e.g., 10GbE, 100MbE)."""
113
+ if speed_mbps is None or speed_mbps == 0:
114
+ return None
115
+ if speed_mbps >= 1000:
116
+ gbps = speed_mbps / 1000
117
+ if gbps == int(gbps):
118
+ return f"{int(gbps)}GbE"
119
+ return f"{gbps:.1f}GbE"
120
+ return f"{speed_mbps}MbE"
121
+
122
+
123
+ def _format_wan_interface_line(
124
+ wan: WanInterface,
125
+ prefix: str,
126
+ *,
127
+ is_dual: bool,
128
+ include_speed: bool = True,
129
+ ) -> str:
130
+ """Format a single WAN interface line."""
131
+ status = "●" if wan.enabled else "○"
132
+ label = wan.label or prefix
133
+ speed_parts = []
134
+ if include_speed and wan.link_speed and wan.enabled:
135
+ speed_parts.append(f"Link {_format_wan_speed(wan.link_speed)}")
136
+ if wan.isp_speed:
137
+ speed_parts.append(f"ISP {wan.isp_speed}")
138
+ speed_str = " / ".join(speed_parts) if speed_parts else ""
139
+
140
+ if not wan.enabled and prefix == "WAN2":
141
+ return f"{prefix}: {label} (disabled) {status}"
142
+
143
+ if is_dual:
144
+ line = f"{prefix}: {label}"
145
+ if speed_str:
146
+ line += f" ({speed_str})"
147
+ return f"{line} {status}"
148
+
149
+ return label
150
+
151
+
152
+ def _build_wan_label_lines(wan_info: WanInfo) -> list[str]:
153
+ """Build label lines for WAN display."""
154
+ label_lines: list[str] = []
155
+ is_dual = wan_info.wan2 is not None
156
+
157
+ if wan_info.wan1:
158
+ wan1 = wan_info.wan1
159
+ if is_dual:
160
+ label_lines.append(
161
+ _format_wan_interface_line(wan1, "WAN1", is_dual=True, include_speed=True)
162
+ )
163
+ else:
164
+ # Single WAN format: multi-line
165
+ label_lines.append(wan1.label or "WAN1")
166
+ speed_parts = []
167
+ if wan1.link_speed:
168
+ speed_parts.append(f"Link {_format_wan_speed(wan1.link_speed)}")
169
+ if wan1.isp_speed:
170
+ speed_parts.append(f"ISP {wan1.isp_speed}")
171
+ if speed_parts:
172
+ label_lines.append(" / ".join(speed_parts))
173
+ if wan1.ip_address:
174
+ label_lines.append(wan1.ip_address)
175
+
176
+ if wan_info.wan2:
177
+ label_lines.append(
178
+ _format_wan_interface_line(wan_info.wan2, "WAN2", is_dual=True, include_speed=True)
179
+ )
180
+ # Add IP from WAN1 for dual WAN display
181
+ if wan_info.wan1 and wan_info.wan1.ip_address:
182
+ label_lines.append(wan_info.wan1.ip_address)
183
+
184
+ return label_lines
@@ -2,18 +2,103 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
 
7
7
 
8
8
  @dataclass(frozen=True)
9
9
  class SvgTheme:
10
+ # Links
10
11
  link_standard: tuple[str, str]
11
12
  link_poe: tuple[str, str]
13
+
14
+ # Nodes - infrastructure
12
15
  node_gateway: tuple[str, str]
13
16
  node_switch: tuple[str, str]
14
17
  node_ap: tuple[str, str]
15
18
  node_client: tuple[str, str]
16
19
  node_other: tuple[str, str]
20
+ node_client_cluster: tuple[str, str] = ("#d4b8ff", "#a080e0")
21
+
22
+ # Nodes - extended device types
23
+ node_camera: tuple[str, str] = ("#b3e5fc", "#64b5f6") # Light blue
24
+ node_tv: tuple[str, str] = ("#d1c4e9", "#9575cd") # Light violet
25
+ node_phone: tuple[str, str] = ("#c8e6c9", "#81c784") # Light green
26
+ node_printer: tuple[str, str] = ("#cfd8dc", "#90a4ae") # Blue-gray
27
+ node_nas: tuple[str, str] = ("#ffe0b2", "#ffb74d") # Amber
28
+ node_speaker: tuple[str, str] = ("#b2dfdb", "#4db6ac") # Teal
29
+ node_game_console: tuple[str, str] = ("#e1bee7", "#ba68c8") # Light purple
30
+ node_iot: tuple[str, str] = ("#b2ebf2", "#4dd0e1") # Cyan
31
+
32
+ # Groups
33
+ group_fill: str = "#f8f9fa"
34
+ group_stroke: str = "#dee2e6"
35
+ group_radius: int = 8
36
+ group_label_fill: str = "#495057"
37
+ group_stroke_width: int = 2
38
+
39
+ # VLANs
40
+ vlan_colors: dict[int, str] = field(default_factory=dict)
41
+
42
+ # Background & text
43
+ background: str = "#ffffff"
44
+ text_primary: str = "#1a1a1a"
45
+ text_secondary: str = "#6b7280"
46
+
47
+ # Status indicators
48
+ status_online: str = "#00a86b"
49
+ status_offline: str = "#ef4444"
50
+
51
+ # WAN globe
52
+ wan_globe: tuple[str, str] = ("#4fc3f7", "#0288d1")
53
+ wan_background: str = "#f0f9ff" # Light blue tint for WAN box
54
+
55
+ # PoE indicator
56
+ poe_fill: str = "#1565c0" # Dark blue
57
+ poe_stroke: str = "#ffc107" # Golden
58
+
59
+ # Icon set
60
+ icon_set: str = "isometric"
61
+
62
+ # Font
63
+ font_family: str | None = None
64
+
65
+ # Icon decal color (for modern icons rendered on node surface)
66
+ icon_decal: str = "#5A6878"
67
+
68
+ # Isometric grid
69
+ grid_color: str = "#efefef"
70
+
71
+ # Isometric node side face colors (SW=left, E=right)
72
+ node_side_left: str = "#dcdcdc"
73
+ node_side_right: str = "#c8c8c8"
74
+
75
+ def group_colors(self, group_name: str) -> tuple[str, str]:
76
+ """Return (fill, stroke) colors for a group based on its type."""
77
+ color_map = {
78
+ "gateway": self.node_gateway,
79
+ "switch": self.node_switch,
80
+ "ap": self.node_ap,
81
+ "client": self.node_client,
82
+ "client_cluster": self.node_client_cluster,
83
+ "camera": self.node_camera,
84
+ "tv": self.node_tv,
85
+ "phone": self.node_phone,
86
+ "printer": self.node_printer,
87
+ "nas": self.node_nas,
88
+ "speaker": self.node_speaker,
89
+ "game_console": self.node_game_console,
90
+ "iot": self.node_iot,
91
+ "other": self.node_other,
92
+ }
93
+ return color_map.get(group_name.lower(), (self.group_fill, self.group_stroke))
94
+
95
+ def vlan_color(self, vlan_id: int) -> str:
96
+ """Return color for a VLAN, using theme color or auto-generated fallback."""
97
+ if vlan_id in self.vlan_colors:
98
+ return self.vlan_colors[vlan_id]
99
+ # Golden angle HSL rotation for distinct, deterministic colors
100
+ hue = (vlan_id * 137) % 360
101
+ return f"hsl({hue}, 70%, 55%)"
17
102
 
18
103
 
19
104
  DEFAULT_THEME = SvgTheme(
@@ -27,38 +112,87 @@ DEFAULT_THEME = SvgTheme(
27
112
  )
28
113
 
29
114
 
115
+ def _gradient(grad_id: str, colors: tuple[str, str], *, horizontal: bool = False) -> str:
116
+ """Build a single linearGradient element."""
117
+ x2, y2 = ("100%", "0%") if horizontal else ("100%", "100%")
118
+ return (
119
+ f'<linearGradient id="{grad_id}" x1="0%" y1="0%" x2="{x2}" y2="{y2}">'
120
+ f'<stop offset="0%" stop-color="{colors[0]}"/>'
121
+ f'<stop offset="100%" stop-color="{colors[1]}"/>'
122
+ "</linearGradient>"
123
+ )
124
+
125
+
30
126
  def svg_defs(prefix: str, theme: SvgTheme = DEFAULT_THEME) -> str:
31
127
  gradient_prefix = f"{prefix}-" if prefix else ""
32
128
  node_prefix = f"{prefix}-node-" if prefix else "node-"
33
- return (
34
- "<defs>"
35
- f'<linearGradient id="{gradient_prefix}link-standard" x1="0%" y1="0%" x2="100%" y2="0%">'
36
- f'<stop offset="0%" stop-color="{theme.link_standard[0]}"/>'
37
- f'<stop offset="100%" stop-color="{theme.link_standard[1]}"/>'
38
- "</linearGradient>"
39
- f'<linearGradient id="{gradient_prefix}link-poe" x1="0%" y1="0%" x2="100%" y2="0%">'
40
- f'<stop offset="0%" stop-color="{theme.link_poe[0]}"/>'
41
- f'<stop offset="100%" stop-color="{theme.link_poe[1]}"/>'
42
- "</linearGradient>"
43
- f'<linearGradient id="{node_prefix}gateway" x1="0%" y1="0%" x2="100%" y2="100%">'
44
- f'<stop offset="0%" stop-color="{theme.node_gateway[0]}"/>'
45
- f'<stop offset="100%" stop-color="{theme.node_gateway[1]}"/>'
46
- "</linearGradient>"
47
- f'<linearGradient id="{node_prefix}switch" x1="0%" y1="0%" x2="100%" y2="100%">'
48
- f'<stop offset="0%" stop-color="{theme.node_switch[0]}"/>'
49
- f'<stop offset="100%" stop-color="{theme.node_switch[1]}"/>'
50
- "</linearGradient>"
51
- f'<linearGradient id="{node_prefix}ap" x1="0%" y1="0%" x2="100%" y2="100%">'
52
- f'<stop offset="0%" stop-color="{theme.node_ap[0]}"/>'
53
- f'<stop offset="100%" stop-color="{theme.node_ap[1]}"/>'
54
- "</linearGradient>"
55
- f'<linearGradient id="{node_prefix}client" x1="0%" y1="0%" x2="100%" y2="100%">'
56
- f'<stop offset="0%" stop-color="{theme.node_client[0]}"/>'
57
- f'<stop offset="100%" stop-color="{theme.node_client[1]}"/>'
58
- "</linearGradient>"
59
- f'<linearGradient id="{node_prefix}other" x1="0%" y1="0%" x2="100%" y2="100%">'
60
- f'<stop offset="0%" stop-color="{theme.node_other[0]}"/>'
61
- f'<stop offset="100%" stop-color="{theme.node_other[1]}"/>'
62
- "</linearGradient>"
63
- "</defs>"
129
+ filter_prefix = f"{prefix}-" if prefix else ""
130
+
131
+ parts = ["<defs>"]
132
+
133
+ # Link gradients (horizontal)
134
+ parts.append(_gradient(f"{gradient_prefix}link-standard", theme.link_standard, horizontal=True))
135
+ parts.append(_gradient(f"{gradient_prefix}link-poe", theme.link_poe, horizontal=True))
136
+
137
+ # Node gradients (diagonal)
138
+ node_types: list[tuple[str, tuple[str, str]]] = [
139
+ ("gateway", theme.node_gateway),
140
+ ("switch", theme.node_switch),
141
+ ("ap", theme.node_ap),
142
+ ("client", theme.node_client),
143
+ ("client_cluster", theme.node_client_cluster),
144
+ ("other", theme.node_other),
145
+ ("camera", theme.node_camera),
146
+ ("tv", theme.node_tv),
147
+ ("phone", theme.node_phone),
148
+ ("printer", theme.node_printer),
149
+ ("nas", theme.node_nas),
150
+ ("speaker", theme.node_speaker),
151
+ ("game_console", theme.node_game_console),
152
+ ("iot", theme.node_iot),
153
+ ]
154
+ for name, colors in node_types:
155
+ parts.append(_gradient(f"{node_prefix}{name}", colors))
156
+
157
+ # Filters
158
+ parts.append(
159
+ f'<filter id="{filter_prefix}edge-glow" x="-50%" y="-50%" width="200%" height="200%">'
160
+ '<feGaussianBlur stdDeviation="4" result="blur"/>'
161
+ "</filter>"
64
162
  )
163
+ # Emboss filter for icon decals - iOS glass effect
164
+ parts.append(
165
+ f'<filter id="{filter_prefix}icon-emboss" x="-50%" y="-50%" width="200%" height="200%">'
166
+ '<feGaussianBlur in="SourceAlpha" stdDeviation="2.5" result="blur"/>'
167
+ '<feOffset in="blur" dx="0" dy="2.5" result="dropShadow"/>'
168
+ '<feFlood flood-color="#000000" flood-opacity="0.4" result="shadowColor"/>'
169
+ '<feComposite in="shadowColor" in2="dropShadow" operator="in" result="shadow"/>'
170
+ '<feGaussianBlur in="SourceAlpha" stdDeviation="1.5" result="blurLight"/>'
171
+ '<feOffset in="blurLight" dx="-2" dy="-1.8" result="lightOffset"/>'
172
+ '<feFlood flood-color="#ffffff" flood-opacity="0.9" result="lightColor"/>'
173
+ '<feComposite in="lightColor" in2="lightOffset" operator="in" result="highlight"/>'
174
+ '<feComposite in="highlight" in2="SourceAlpha" operator="out" result="edgeHighlight"/>'
175
+ '<feGaussianBlur in="SourceAlpha" stdDeviation="1.5" result="blurDark"/>'
176
+ '<feOffset in="blurDark" dx="2" dy="1.8" result="darkOffset"/>'
177
+ '<feFlood flood-color="#000000" flood-opacity="0.6" result="darkColor"/>'
178
+ '<feComposite in="darkColor" in2="darkOffset" operator="in" result="innerShadow"/>'
179
+ '<feComposite in="innerShadow" in2="SourceAlpha" operator="out" result="edgeShadow"/>'
180
+ "<feMerge>"
181
+ '<feMergeNode in="shadow"/>'
182
+ '<feMergeNode in="edgeHighlight"/>'
183
+ '<feMergeNode in="edgeShadow"/>'
184
+ '<feMergeNode in="SourceGraphic"/>'
185
+ "</feMerge>"
186
+ "</filter>"
187
+ )
188
+
189
+ # Globe gradient and PoE bolt symbol
190
+ parts.append(_gradient(f"{gradient_prefix}globe", theme.wan_globe))
191
+ parts.append(
192
+ f'<symbol id="{filter_prefix}poe-bolt" viewBox="0 0 24 24">'
193
+ '<path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z" clip-rule="evenodd"/>'
194
+ "</symbol>"
195
+ )
196
+ parts.append("</defs>")
197
+
198
+ return "".join(parts)
@@ -12,6 +12,18 @@ from .mermaid_theme import MermaidTheme
12
12
  from .svg_theme import DEFAULT_THEME as DEFAULT_SVG_THEME
13
13
  from .svg_theme import SvgTheme
14
14
 
15
+ # Built-in theme names mapped to YAML files in assets/themes/
16
+ BUILTIN_THEMES = {
17
+ "unifi": "unifi.yaml",
18
+ "unifi-dark": "unifi-dark.yaml",
19
+ "minimal": "minimal.yaml",
20
+ "minimal-dark": "minimal-dark.yaml",
21
+ "classic": "default.yaml",
22
+ "classic-dark": "dark.yaml",
23
+ }
24
+
25
+ _ASSETS_DIR = Path(__file__).parent.parent / "assets" / "themes"
26
+
15
27
 
16
28
  def _coerce_pair(value: object, default: tuple[str, str]) -> tuple[str, str]:
17
29
  if isinstance(value, list | tuple) and len(value) == 2:
@@ -75,9 +87,39 @@ def _mermaid_theme_from_dict(data: dict, base: MermaidTheme) -> MermaidTheme:
75
87
  )
76
88
 
77
89
 
90
+ def _coerce_vlan_colors(value: object) -> dict[int, str]:
91
+ """Parse vlan_colors from theme YAML."""
92
+ if not isinstance(value, dict):
93
+ return {}
94
+ result: dict[int, str] = {}
95
+ for key, color in value.items():
96
+ if isinstance(color, str):
97
+ if isinstance(key, int):
98
+ result[key] = color
99
+ elif isinstance(key, str) and key.isdigit():
100
+ result[int(key)] = color
101
+ return result
102
+
103
+
104
+ def _coerce_font_family(value: object, default: str | None) -> str | None:
105
+ """Parse font_family from theme YAML."""
106
+ if isinstance(value, str) and value:
107
+ return value
108
+ return default
109
+
110
+
111
+ def _coerce_icon_set(value: object, default: str) -> str:
112
+ """Parse icon_set from theme YAML."""
113
+ if isinstance(value, str) and value in ("isometric", "modern"):
114
+ return value
115
+ return default
116
+
117
+
78
118
  def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
79
119
  nodes = data.get("nodes", {}) if isinstance(data.get("nodes"), dict) else {}
80
120
  links = data.get("links", {}) if isinstance(data.get("links"), dict) else {}
121
+ text = data.get("text", {}) if isinstance(data.get("text"), dict) else {}
122
+ status = data.get("status", {}) if isinstance(data.get("status"), dict) else {}
81
123
 
82
124
  return SvgTheme(
83
125
  link_standard=_coerce_pair(links.get("standard"), base.link_standard),
@@ -87,6 +129,31 @@ def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
87
129
  node_ap=_coerce_pair(nodes.get("ap"), base.node_ap),
88
130
  node_client=_coerce_pair(nodes.get("client"), base.node_client),
89
131
  node_other=_coerce_pair(nodes.get("other"), base.node_other),
132
+ node_client_cluster=_coerce_pair(nodes.get("client_cluster"), base.node_client_cluster),
133
+ node_camera=_coerce_pair(nodes.get("camera"), base.node_camera),
134
+ node_tv=_coerce_pair(nodes.get("tv"), base.node_tv),
135
+ node_phone=_coerce_pair(nodes.get("phone"), base.node_phone),
136
+ node_printer=_coerce_pair(nodes.get("printer"), base.node_printer),
137
+ node_nas=_coerce_pair(nodes.get("nas"), base.node_nas),
138
+ node_speaker=_coerce_pair(nodes.get("speaker"), base.node_speaker),
139
+ node_game_console=_coerce_pair(nodes.get("game_console"), base.node_game_console),
140
+ node_iot=_coerce_pair(nodes.get("iot"), base.node_iot),
141
+ vlan_colors=_coerce_vlan_colors(data.get("vlan_colors")),
142
+ background=_coerce_color(data.get("background"), base.background),
143
+ text_primary=_coerce_color(text.get("primary"), base.text_primary),
144
+ text_secondary=_coerce_color(text.get("secondary"), base.text_secondary),
145
+ status_online=_coerce_color(status.get("online"), base.status_online),
146
+ status_offline=_coerce_color(status.get("offline"), base.status_offline),
147
+ wan_globe=_coerce_pair(data.get("wan_globe"), base.wan_globe),
148
+ wan_background=_coerce_color(data.get("wan_background"), base.wan_background),
149
+ poe_fill=_coerce_color(data.get("poe_fill"), base.poe_fill),
150
+ poe_stroke=_coerce_color(data.get("poe_stroke"), base.poe_stroke),
151
+ icon_set=_coerce_icon_set(data.get("icon_set"), base.icon_set),
152
+ font_family=_coerce_font_family(data.get("font_family"), base.font_family),
153
+ icon_decal=_coerce_color(data.get("icon_decal"), base.icon_decal),
154
+ grid_color=_coerce_color(data.get("grid_color"), base.grid_color),
155
+ node_side_left=_coerce_color(data.get("node_side_left"), base.node_side_left),
156
+ node_side_right=_coerce_color(data.get("node_side_right"), base.node_side_right),
90
157
  )
91
158
 
92
159
 
@@ -104,7 +171,25 @@ def load_theme(path: str | Path) -> tuple[MermaidTheme, SvgTheme]:
104
171
  return mermaid_theme, svg_theme
105
172
 
106
173
 
107
- def resolve_themes(theme_file: str | Path | None) -> tuple[MermaidTheme, SvgTheme]:
174
+ def resolve_themes(
175
+ theme_name: str | None = None,
176
+ theme_file: str | Path | None = None,
177
+ ) -> tuple[MermaidTheme, SvgTheme]:
178
+ """Resolve theme from name or file path.
179
+
180
+ Args:
181
+ theme_name: Built-in theme name (e.g., "unifi", "classic").
182
+ theme_file: Custom theme file path. Takes priority over theme_name.
183
+
184
+ Returns:
185
+ Tuple of (MermaidTheme, SvgTheme).
186
+ """
108
187
  if theme_file:
109
188
  return load_theme(theme_file)
189
+ if theme_name:
190
+ if theme_name not in BUILTIN_THEMES:
191
+ valid = ", ".join(sorted(BUILTIN_THEMES.keys()))
192
+ raise ValueError(f"Unknown theme: {theme_name}. Valid themes: {valid}")
193
+ builtin_path = _ASSETS_DIR / BUILTIN_THEMES[theme_name]
194
+ return load_theme(builtin_path)
110
195
  return DEFAULT_MERMAID_THEME, DEFAULT_SVG_THEME