unifi-network-maps 1.4.11__py3-none-any.whl → 1.4.13__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.
@@ -1 +1 @@
1
- __version__ = "1.4.11"
1
+ __version__ = "1.4.13"
@@ -4,6 +4,9 @@ from __future__ import annotations
4
4
 
5
5
  import os
6
6
  from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..io.paths import resolve_env_file
7
10
 
8
11
 
9
12
  def _parse_bool(value: str | None, default: bool = True) -> bool:
@@ -26,13 +29,14 @@ class Config:
26
29
  verify_ssl: bool
27
30
 
28
31
  @classmethod
29
- def from_env(cls, *, env_file: str | None = None) -> Config:
32
+ def from_env(cls, *, env_file: str | Path | None = None) -> Config:
30
33
  if env_file:
31
34
  try:
32
35
  from dotenv import load_dotenv
33
36
  except ImportError:
34
37
  raise ValueError("python-dotenv required for --env-file") from None
35
- load_dotenv(dotenv_path=env_file)
38
+ env_path = resolve_env_file(env_file)
39
+ load_dotenv(dotenv_path=env_path)
36
40
  url = os.environ.get("UNIFI_URL", "").strip()
37
41
  site = os.environ.get("UNIFI_SITE", "default").strip()
38
42
  user = os.environ.get("UNIFI_USER", "").strip()
@@ -7,6 +7,7 @@ import json
7
7
  import logging
8
8
  import os
9
9
  import stat
10
+ import tempfile
10
11
  import time
11
12
  from collections.abc import Callable, Iterator, Sequence
12
13
  from concurrent.futures import ThreadPoolExecutor
@@ -15,6 +16,7 @@ from contextlib import contextmanager
15
16
  from pathlib import Path
16
17
  from typing import IO, TYPE_CHECKING
17
18
 
19
+ from ..io.paths import resolve_cache_dir
18
20
  from .config import Config
19
21
 
20
22
  if TYPE_CHECKING:
@@ -24,7 +26,15 @@ logger = logging.getLogger(__name__)
24
26
 
25
27
 
26
28
  def _cache_dir() -> Path:
27
- return Path(os.environ.get("UNIFI_CACHE_DIR", ".cache/unifi_network_maps"))
29
+ default_dir = ".cache/unifi_network_maps"
30
+ if os.environ.get("PYTEST_CURRENT_TEST"):
31
+ default_dir = str(Path(tempfile.gettempdir()) / f"unifi_network_maps_pytest_{os.getpid()}")
32
+ value = os.environ.get("UNIFI_CACHE_DIR", default_dir)
33
+ try:
34
+ return resolve_cache_dir(value)
35
+ except ValueError as exc:
36
+ logger.warning("Invalid UNIFI_CACHE_DIR (%s); using default: %s", value, exc)
37
+ return resolve_cache_dir(".cache/unifi_network_maps")
28
38
 
29
39
 
30
40
  def _device_attr(device: object, name: str) -> object | None:
@@ -377,13 +387,13 @@ def fetch_devices(
377
387
  cached = None
378
388
  stale_cached, cache_age = None, None
379
389
  if cached is not None:
380
- logger.info("Using cached devices (%d)", len(cached))
390
+ logger.debug("Using cached devices (%d)", len(cached))
381
391
  return cached
382
392
 
383
393
  try:
384
394
  controller = _init_controller(config, is_udm_pro=True)
385
395
  except UnifiAuthenticationError:
386
- logger.info("UDM Pro authentication failed, retrying legacy auth")
396
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
387
397
  controller = _init_controller(config, is_udm_pro=False)
388
398
 
389
399
  def _fetch() -> Sequence[object]:
@@ -402,7 +412,7 @@ def fetch_devices(
402
412
  raise
403
413
  if use_cache:
404
414
  _save_cache(cache_path, _serialize_devices_for_cache(devices))
405
- logger.info("Fetched %d devices", len(devices))
415
+ logger.debug("Fetched %d devices", len(devices))
406
416
  return devices
407
417
 
408
418
 
@@ -428,13 +438,13 @@ def fetch_clients(
428
438
  cached = None
429
439
  stale_cached, cache_age = None, None
430
440
  if cached is not None:
431
- logger.info("Using cached clients (%d)", len(cached))
441
+ logger.debug("Using cached clients (%d)", len(cached))
432
442
  return cached
433
443
 
434
444
  try:
435
445
  controller = _init_controller(config, is_udm_pro=True)
436
446
  except UnifiAuthenticationError:
437
- logger.info("UDM Pro authentication failed, retrying legacy auth")
447
+ logger.debug("UDM Pro authentication failed, retrying legacy auth")
438
448
  controller = _init_controller(config, is_udm_pro=False)
439
449
 
440
450
  def _fetch() -> Sequence[object]:
@@ -453,5 +463,5 @@ def fetch_clients(
453
463
  raise
454
464
  if use_cache:
455
465
  _save_cache(cache_path, clients)
456
- logger.info("Fetched %d clients", len(clients))
466
+ logger.debug("Fetched %d clients", len(clients))
457
467
  return clients
@@ -4,10 +4,17 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import logging
7
+ from pathlib import Path
7
8
 
8
9
  from ..adapters.config import Config
9
10
  from ..io.export import write_output
10
11
  from ..io.mock_data import load_mock_data
12
+ from ..io.paths import (
13
+ resolve_env_file,
14
+ resolve_mock_data_path,
15
+ resolve_output_path,
16
+ resolve_theme_path,
17
+ )
11
18
  from ..render.legend import render_legend_only, resolve_legend_style
12
19
  from ..render.theme import resolve_themes
13
20
  from .args import build_parser
@@ -16,7 +23,7 @@ from .render import render_lldp_format, render_standard_format
16
23
  logger = logging.getLogger(__name__)
17
24
 
18
25
 
19
- def _load_dotenv(env_file: str | None = None) -> None:
26
+ def _load_dotenv(env_file: str | Path | None = None) -> None:
20
27
  try:
21
28
  from dotenv import load_dotenv
22
29
  except ImportError:
@@ -30,6 +37,36 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
30
37
  return parser.parse_args(argv)
31
38
 
32
39
 
40
+ class _DowngradeInfoToDebugFilter(logging.Filter):
41
+ def filter(self, record: logging.LogRecord) -> bool:
42
+ if record.name.startswith("unifi_controller_api") and record.levelno == logging.INFO:
43
+ record.levelno = logging.DEBUG
44
+ record.levelname = logging.getLevelName(logging.DEBUG)
45
+ return True
46
+
47
+
48
+ def _downgrade_unifi_controller_logs() -> logging.Filter:
49
+ return _DowngradeInfoToDebugFilter()
50
+
51
+
52
+ def _validate_paths(args: argparse.Namespace) -> bool:
53
+ try:
54
+ if args.env_file:
55
+ resolve_env_file(args.env_file)
56
+ if args.mock_data:
57
+ resolve_mock_data_path(args.mock_data, require_exists=False)
58
+ if args.theme_file:
59
+ resolve_theme_path(args.theme_file, require_exists=False)
60
+ if args.generate_mock:
61
+ resolve_output_path(args.generate_mock, format_name="mock")
62
+ if args.output:
63
+ resolve_output_path(args.output, format_name=args.format)
64
+ except ValueError as exc:
65
+ logging.error(str(exc))
66
+ return False
67
+ return True
68
+
69
+
33
70
  def _load_config(args: argparse.Namespace) -> Config | None:
34
71
  try:
35
72
  _load_dotenv(args.env_file)
@@ -59,7 +96,8 @@ def _handle_generate_mock(args: argparse.Namespace) -> int | None:
59
96
  wireless_client_count=max(0, args.mock_wireless_clients),
60
97
  )
61
98
  content = mock_payload_json(options)
62
- write_output(content, output_path=args.generate_mock, stdout=args.stdout)
99
+ output_kwargs = {"format_name": "mock"} if args.generate_mock else {}
100
+ write_output(content, output_path=args.generate_mock, stdout=args.stdout, **output_kwargs)
63
101
  return 0
64
102
 
65
103
 
@@ -81,7 +119,11 @@ def _load_runtime_context(
81
119
 
82
120
  def main(argv: list[str] | None = None) -> int:
83
121
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
122
+ for handler in logging.getLogger().handlers:
123
+ handler.addFilter(_downgrade_unifi_controller_logs())
84
124
  args = _parse_args(argv)
125
+ if not _validate_paths(args):
126
+ return 2
85
127
  mock_result = _handle_generate_mock(args)
86
128
  if mock_result is not None:
87
129
  return mock_result
@@ -107,7 +149,8 @@ def main(argv: list[str] | None = None) -> int:
107
149
  markdown=args.markdown,
108
150
  theme=mermaid_theme,
109
151
  )
110
- write_output(content, output_path=args.output, stdout=args.stdout)
152
+ output_kwargs = {"format_name": args.format} if args.output else {}
153
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
111
154
  return 0
112
155
 
113
156
  if args.format == "lldp-md":
@@ -62,7 +62,12 @@ def render_mermaid_output(
62
62
  direction=args.direction,
63
63
  groups=groups,
64
64
  group_order=group_order,
65
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
65
+ node_types=build_node_type_map(
66
+ devices,
67
+ clients,
68
+ client_mode=args.client_scope,
69
+ only_unifi=args.only_unifi,
70
+ ),
66
71
  theme=mermaid_theme,
67
72
  )
68
73
  if args.markdown:
@@ -97,13 +102,23 @@ def render_svg_output(
97
102
 
98
103
  return render_svg_isometric(
99
104
  edges,
100
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
105
+ node_types=build_node_type_map(
106
+ devices,
107
+ clients,
108
+ client_mode=args.client_scope,
109
+ only_unifi=args.only_unifi,
110
+ ),
101
111
  options=options,
102
112
  theme=svg_theme,
103
113
  )
104
114
  return render_svg(
105
115
  edges,
106
- node_types=build_node_type_map(devices, clients, client_mode=args.client_scope),
116
+ node_types=build_node_type_map(
117
+ devices,
118
+ clients,
119
+ client_mode=args.client_scope,
120
+ only_unifi=args.only_unifi,
121
+ ),
107
122
  options=options,
108
123
  theme=svg_theme,
109
124
  )
@@ -190,8 +205,10 @@ def render_lldp_format(
190
205
  include_ports=args.include_ports,
191
206
  show_clients=args.include_clients,
192
207
  client_mode=args.client_scope,
208
+ only_unifi=args.only_unifi,
193
209
  )
194
- write_output(content, output_path=args.output, stdout=args.stdout)
210
+ output_kwargs = {"format_name": args.format} if args.output else {}
211
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
195
212
  return 0
196
213
 
197
214
 
@@ -251,5 +268,6 @@ def render_standard_format(
251
268
  logging.error("Unsupported format: %s", args.format)
252
269
  return 2
253
270
 
254
- write_output(content, output_path=args.output, stdout=args.stdout)
271
+ output_kwargs = {"format_name": args.format} if args.output else {}
272
+ write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
255
273
  return 0
@@ -95,6 +95,7 @@ def build_edges_with_clients(
95
95
  device_index,
96
96
  include_ports=args.include_ports,
97
97
  client_mode=args.client_scope,
98
+ only_unifi=args.only_unifi,
98
99
  )
99
100
  return edges, clients
100
101
 
@@ -153,5 +154,10 @@ def resolve_mkdocs_client_ports(
153
154
  clients = list(fetch_clients(config, site=site))
154
155
  else:
155
156
  clients = mock_clients
156
- client_ports = build_client_port_map(devices, clients, client_mode=args.client_scope)
157
+ client_ports = build_client_port_map(
158
+ devices,
159
+ clients,
160
+ client_mode=args.client_scope,
161
+ only_unifi=args.only_unifi,
162
+ )
157
163
  return client_ports, None
@@ -7,10 +7,19 @@ import sys
7
7
  import tempfile
8
8
  from pathlib import Path
9
9
 
10
+ from .paths import resolve_output_path
10
11
 
11
- def write_output(content: str, *, output_path: str | None, stdout: bool) -> None:
12
+
13
+ def write_output(
14
+ content: str,
15
+ *,
16
+ output_path: str | Path | None,
17
+ stdout: bool,
18
+ format_name: str | None = None,
19
+ ) -> None:
12
20
  if output_path:
13
- _write_atomic(Path(output_path), content)
21
+ resolved = resolve_output_path(output_path, format_name=format_name)
22
+ _write_atomic(resolved, content)
14
23
  if stdout or not output_path:
15
24
  sys.stdout.write(content)
16
25
 
@@ -5,10 +5,12 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from ..render.templating import render_template
8
+ from .paths import resolve_output_file
8
9
 
9
10
 
10
- def write_mkdocs_sidebar_assets(output_path: str) -> None:
11
- output_dir = Path(output_path).resolve().parent
11
+ def write_mkdocs_sidebar_assets(output_path: str | Path) -> None:
12
+ resolved = resolve_output_file(output_path, extensions=None, label="MkDocs output file")
13
+ output_dir = resolved.parent
12
14
  assets_dir = output_dir / "assets"
13
15
  assets_dir.mkdir(parents=True, exist_ok=True)
14
16
  (assets_dir / "legend.js").write_text(
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from pathlib import Path
6
+
7
+ from .paths import resolve_mock_data_path
7
8
 
8
9
 
9
10
  def _as_list(value: object, name: str) -> list[object]:
@@ -15,7 +16,8 @@ def _as_list(value: object, name: str) -> list[object]:
15
16
 
16
17
 
17
18
  def load_mock_data(path: str) -> tuple[list[object], list[object]]:
18
- payload = json.loads(Path(path).read_text(encoding="utf-8"))
19
+ resolved = resolve_mock_data_path(path)
20
+ payload = json.loads(resolved.read_text(encoding="utf-8"))
19
21
  if not isinstance(payload, dict):
20
22
  raise ValueError("Mock data must be a JSON object")
21
23
  devices = _as_list(payload.get("devices"), "devices")
@@ -0,0 +1,197 @@
1
+ """Path validation helpers for user-supplied file system inputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+
10
+
11
+ def _safe_home_dir() -> Path | None:
12
+ try:
13
+ return Path.home().resolve()
14
+ except Exception:
15
+ return None
16
+
17
+
18
+ def _base_roots() -> list[Path]:
19
+ roots = [Path.cwd().resolve()]
20
+ home = _safe_home_dir()
21
+ if home:
22
+ roots.append(home)
23
+ try:
24
+ roots.append(Path(tempfile.gettempdir()).resolve())
25
+ except Exception:
26
+ pass
27
+ return roots
28
+
29
+
30
+ def _extra_roots_from_env() -> list[Path]:
31
+ extra = os.environ.get("UNIFI_ALLOWED_PATHS", "")
32
+ roots: list[Path] = []
33
+ if extra:
34
+ for raw in extra.split(os.pathsep):
35
+ raw = raw.strip()
36
+ if raw:
37
+ roots.append(Path(raw).expanduser().resolve())
38
+ return roots
39
+
40
+
41
+ def _allowed_roots() -> tuple[Path, ...]:
42
+ roots = _base_roots() + _extra_roots_from_env()
43
+ seen: set[str] = set()
44
+ unique: list[Path] = []
45
+ for root in roots:
46
+ key = str(root)
47
+ if key not in seen:
48
+ seen.add(key)
49
+ unique.append(root)
50
+ return tuple(unique)
51
+
52
+
53
+ def _resolve_user_path(path: str | Path) -> Path:
54
+ return Path(path).expanduser().resolve(strict=False)
55
+
56
+
57
+ def _ensure_within_allowed(path: Path, roots: Iterable[Path], *, label: str) -> None:
58
+ for root in roots:
59
+ try:
60
+ path.relative_to(root)
61
+ except ValueError:
62
+ continue
63
+ else:
64
+ return
65
+ root_list = ", ".join(str(root) for root in roots)
66
+ raise ValueError(f"{label} must be within: {root_list}")
67
+
68
+
69
+ def _ensure_no_symlink(path: Path, *, label: str) -> None:
70
+ if path.exists() and path.is_symlink():
71
+ raise ValueError(f"{label} must not be a symlink: {path}")
72
+
73
+
74
+ def _ensure_no_symlink_in_parents(path: Path, *, label: str) -> None:
75
+ for parent in path.parents:
76
+ if parent.exists() and parent.is_symlink():
77
+ raise ValueError(f"{label} parent must not be a symlink: {parent}")
78
+
79
+
80
+ def _normalize_extensions(extensions: Iterable[str]) -> set[str]:
81
+ normalized = set()
82
+ for ext in extensions:
83
+ ext = ext.strip().lower()
84
+ if not ext:
85
+ continue
86
+ if not ext.startswith("."):
87
+ ext = f".{ext}"
88
+ normalized.add(ext)
89
+ return normalized
90
+
91
+
92
+ def _ensure_extension(
93
+ path: Path,
94
+ extensions: Iterable[str] | None,
95
+ *,
96
+ label: str,
97
+ allow_missing: bool = False,
98
+ ) -> None:
99
+ if not extensions:
100
+ return
101
+ allowed = _normalize_extensions(extensions)
102
+ suffix = path.suffix.lower()
103
+ if not suffix:
104
+ if allow_missing:
105
+ return
106
+ raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
107
+ if suffix not in allowed:
108
+ raise ValueError(f"{label} must have one of: {', '.join(sorted(allowed))}")
109
+
110
+
111
+ def resolve_input_file(
112
+ path: str | Path,
113
+ *,
114
+ extensions: Iterable[str] | None,
115
+ label: str,
116
+ require_exists: bool = True,
117
+ ) -> Path:
118
+ resolved = _resolve_user_path(path)
119
+ _ensure_within_allowed(resolved, _allowed_roots(), label=label)
120
+ _ensure_extension(resolved, extensions, label=label)
121
+ if require_exists:
122
+ if not resolved.exists():
123
+ raise ValueError(f"{label} does not exist: {resolved}")
124
+ if not resolved.is_file():
125
+ raise ValueError(f"{label} must be a file: {resolved}")
126
+ return resolved
127
+
128
+
129
+ def resolve_output_file(
130
+ path: str | Path,
131
+ *,
132
+ extensions: Iterable[str] | None,
133
+ label: str,
134
+ allow_missing_extension: bool = False,
135
+ ) -> Path:
136
+ resolved = _resolve_user_path(path)
137
+ _ensure_within_allowed(resolved, _allowed_roots(), label=label)
138
+ _ensure_extension(
139
+ resolved,
140
+ extensions,
141
+ label=label,
142
+ allow_missing=allow_missing_extension,
143
+ )
144
+ return resolved
145
+
146
+
147
+ def resolve_env_file(path: str | Path) -> Path:
148
+ resolved = _resolve_user_path(path)
149
+ _ensure_within_allowed(resolved, _allowed_roots(), label="Env file")
150
+ if not (resolved.name.startswith(".env") or resolved.name.endswith(".env")):
151
+ raise ValueError("Env file must end with .env")
152
+ if resolved.exists() and not resolved.is_file():
153
+ raise ValueError(f"Env file must be a file: {resolved}")
154
+ return resolved
155
+
156
+
157
+ def resolve_mock_data_path(path: str | Path, *, require_exists: bool = True) -> Path:
158
+ return resolve_input_file(
159
+ path,
160
+ extensions={".json"},
161
+ label="Mock data file",
162
+ require_exists=require_exists,
163
+ )
164
+
165
+
166
+ def resolve_theme_path(path: str | Path, *, require_exists: bool = True) -> Path:
167
+ return resolve_input_file(
168
+ path,
169
+ extensions={".yml", ".yaml"},
170
+ label="Theme file",
171
+ require_exists=require_exists,
172
+ )
173
+
174
+
175
+ def resolve_output_path(path: str | Path, *, format_name: str | None) -> Path:
176
+ extensions: set[str] | None
177
+ if format_name == "svg" or format_name == "svg-iso":
178
+ extensions = {".svg"}
179
+ elif format_name == "mock":
180
+ extensions = {".json"}
181
+ elif format_name == "mermaid":
182
+ extensions = {".md", ".mermaid", ".mmd"}
183
+ elif format_name == "lldp-md":
184
+ extensions = {".md"}
185
+ elif format_name == "mkdocs":
186
+ extensions = {".md"}
187
+ else:
188
+ extensions = None
189
+ return resolve_output_file(path, extensions=extensions, label="Output file")
190
+
191
+
192
+ def resolve_cache_dir(path: str | Path) -> Path:
193
+ resolved = _resolve_user_path(path)
194
+ _ensure_within_allowed(resolved, _allowed_roots(), label="Cache directory")
195
+ _ensure_no_symlink(resolved, label="Cache directory")
196
+ _ensure_no_symlink_in_parents(resolved, label="Cache directory")
197
+ return resolved
@@ -460,7 +460,13 @@ def _client_field(client: object, name: str) -> object | None:
460
460
 
461
461
 
462
462
  def _client_display_name(client: object) -> str | None:
463
- for key in ("name", "hostname", "mac"):
463
+ raw_name = _client_field(client, "name")
464
+ if isinstance(raw_name, str) and raw_name.strip():
465
+ return raw_name.strip()
466
+ preferred = _client_ucore_display_name(client)
467
+ if preferred:
468
+ return preferred
469
+ for key in ("hostname", "mac"):
464
470
  value = _client_field(client, key)
465
471
  if isinstance(value, str) and value.strip():
466
472
  return value.strip()
@@ -514,6 +520,73 @@ def _client_is_wired(client: object) -> bool:
514
520
  return bool(_client_field(client, "is_wired"))
515
521
 
516
522
 
523
+ def _client_unifi_flag(client: object) -> bool | None:
524
+ for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
525
+ value = _client_field(client, key)
526
+ if isinstance(value, bool):
527
+ return value
528
+ if isinstance(value, int):
529
+ return value != 0
530
+ return None
531
+
532
+
533
+ def _client_vendor(client: object) -> str | None:
534
+ for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
535
+ value = _client_field(client, key)
536
+ if isinstance(value, str) and value.strip():
537
+ return value.strip()
538
+ return None
539
+
540
+
541
+ def _client_ucore_info(client: object) -> dict[str, object] | None:
542
+ info = _client_field(client, "unifi_device_info_from_ucore")
543
+ if isinstance(info, dict):
544
+ return info
545
+ return None
546
+
547
+
548
+ def _client_ucore_display_name(client: object) -> str | None:
549
+ ucore = _client_ucore_info(client)
550
+ if not ucore:
551
+ return None
552
+ for key in ("name", "computed_model", "product_model", "product_shortname"):
553
+ value = ucore.get(key)
554
+ if isinstance(value, str) and value.strip():
555
+ return value.strip()
556
+ return None
557
+
558
+
559
+ def _client_hostname_source(client: object) -> str | None:
560
+ value = _client_field(client, "hostname_source")
561
+ if isinstance(value, str) and value.strip():
562
+ return value.strip()
563
+ return None
564
+
565
+
566
+ def _client_is_unifi(client: object) -> bool:
567
+ flag = _client_unifi_flag(client)
568
+ if flag is not None:
569
+ return flag
570
+ ucore = _client_ucore_info(client)
571
+ if ucore:
572
+ managed = ucore.get("managed")
573
+ if isinstance(managed, bool) and managed:
574
+ return True
575
+ if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
576
+ return True
577
+ if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
578
+ return True
579
+ for key in ("name", "computed_model", "product_model"):
580
+ value = ucore.get(key)
581
+ if isinstance(value, str) and value.strip():
582
+ return True
583
+ vendor = _client_vendor(client)
584
+ if not vendor:
585
+ return False
586
+ normalized = vendor.lower()
587
+ return "ubiquiti" in normalized or "unifi" in normalized
588
+
589
+
517
590
  def _client_channel(client: object) -> int | None:
518
591
  for key in ("channel", "radio_channel", "wifi_channel"):
519
592
  value = _client_field(client, key)
@@ -533,17 +606,26 @@ def _client_matches_mode(client: object, mode: str) -> bool:
533
606
  return wired
534
607
 
535
608
 
609
+ def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
610
+ if not _client_matches_mode(client, client_mode):
611
+ return False
612
+ if only_unifi and not _client_is_unifi(client):
613
+ return False
614
+ return True
615
+
616
+
536
617
  def build_client_edges(
537
618
  clients: Iterable[object],
538
619
  device_index: dict[str, str],
539
620
  *,
540
621
  include_ports: bool = False,
541
622
  client_mode: str = "wired",
623
+ only_unifi: bool = False,
542
624
  ) -> list[Edge]:
543
625
  edges: list[Edge] = []
544
626
  seen: set[tuple[str, str]] = set()
545
627
  for client in clients:
546
- if not _client_matches_mode(client, client_mode):
628
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
547
629
  continue
548
630
  name = _client_display_name(client)
549
631
  uplink_mac = _client_uplink_mac(client)
@@ -580,13 +662,14 @@ def build_node_type_map(
580
662
  clients: Iterable[object] | None = None,
581
663
  *,
582
664
  client_mode: str = "wired",
665
+ only_unifi: bool = False,
583
666
  ) -> dict[str, str]:
584
667
  node_types: dict[str, str] = {}
585
668
  for device in devices:
586
669
  node_types[device.name] = classify_device_type(device)
587
670
  if clients:
588
671
  for client in clients:
589
- if not _client_matches_mode(client, client_mode):
672
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
590
673
  continue
591
674
  name = _client_display_name(client)
592
675
  if name:
@@ -640,7 +723,7 @@ def build_edges(
640
723
  )
641
724
 
642
725
  poe_edges = sum(1 for edge in edges if edge.poe)
643
- logger.info("Built %d unique edges (%d PoE)", len(edges), poe_edges)
726
+ logger.debug("Built %d unique edges (%d PoE)", len(edges), poe_edges)
644
727
  return edges
645
728
 
646
729
 
@@ -683,11 +766,12 @@ def build_client_port_map(
683
766
  clients: Iterable[object],
684
767
  *,
685
768
  client_mode: str,
769
+ only_unifi: bool = False,
686
770
  ) -> ClientPortMap:
687
771
  device_index = build_device_index(devices)
688
772
  port_map: ClientPortMap = {}
689
773
  for client in clients:
690
- if not _client_matches_mode(client, client_mode):
774
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
691
775
  continue
692
776
  name = _client_display_name(client)
693
777
  uplink_mac = _client_uplink_mac(client)
@@ -894,14 +978,14 @@ def build_topology(
894
978
  ) -> TopologyResult:
895
979
  normalized_devices = list(devices)
896
980
  lldp_entries = sum(len(device.lldp_info) for device in normalized_devices)
897
- logger.info(
981
+ logger.debug(
898
982
  "Normalized %d devices (%d LLDP entries)",
899
983
  len(normalized_devices),
900
984
  lldp_entries,
901
985
  )
902
986
  raw_edges = build_edges(normalized_devices, include_ports=include_ports, only_unifi=only_unifi)
903
987
  tree_edges = build_tree_edges_by_topology(raw_edges, gateways)
904
- logger.info(
988
+ logger.debug(
905
989
  "Built %d hierarchy edges (gateways=%d)",
906
990
  len(tree_edges),
907
991
  len(gateways),
@@ -83,11 +83,11 @@ def _render_device_ports(
83
83
  rows = _build_port_rows(device, port_map, client_ports)
84
84
  table_rows = [
85
85
  [
86
- _escape_cell(port_label),
87
- _escape_cell(connected or "-"),
88
- _escape_cell(speed),
89
- _escape_cell(poe_state),
90
- _escape_cell(power),
86
+ _escape_markdown_text(port_label),
87
+ _escape_connected_cell(connected or "-"),
88
+ _escape_markdown_text(speed),
89
+ _escape_markdown_text(poe_state),
90
+ _escape_markdown_text(power),
91
91
  ]
92
92
  for port_label, connected, speed, poe_state, power in rows
93
93
  ]
@@ -224,9 +224,11 @@ def _format_connections(
224
224
  for peer in sorted(peers, key=str.lower):
225
225
  peer_label = port_map.get((peer, device_name))
226
226
  if peer_label:
227
- peer_entries.append(f"{peer} ({peer_label})")
227
+ peer_entries.append(
228
+ f"{_escape_markdown_text(peer)} ({_escape_markdown_text(peer_label)})"
229
+ )
228
230
  else:
229
- peer_entries.append(peer)
231
+ peer_entries.append(_escape_markdown_text(peer))
230
232
  peer_text = ", ".join(peer_entries)
231
233
  client_text = _format_client_connections(clients)
232
234
  if peer_text and client_text:
@@ -292,8 +294,15 @@ def _port_sort_key(port: object) -> tuple[int, str]:
292
294
  return (1, name.lower())
293
295
 
294
296
 
295
- def _escape_cell(value: str) -> str:
296
- return value.replace("|", "\\|")
297
+ def _escape_markdown_text(value: str) -> str:
298
+ escaped = value.replace("\\", "\\\\")
299
+ for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
300
+ escaped = escaped.replace(char, f"\\{char}")
301
+ return escaped
302
+
303
+
304
+ def _escape_connected_cell(value: str) -> str:
305
+ return value
297
306
 
298
307
 
299
308
  def _render_device_details(device: Device) -> list[str]:
@@ -302,14 +311,14 @@ def _render_device_details(device: Device) -> list[str]:
302
311
  "",
303
312
  "| Field | Value |",
304
313
  "| --- | --- |",
305
- f"| Model | {_escape_cell(_device_model_label(device))} |",
306
- f"| Type | {_escape_cell(device.type or '-')} |",
307
- f"| IP | {_escape_cell(device.ip or '-')} |",
308
- f"| MAC | {_escape_cell(device.mac or '-')} |",
309
- f"| Firmware | {_escape_cell(device.version or '-')} |",
310
- f"| Uplink | {_escape_cell(_uplink_summary(device))} |",
311
- f"| Ports | {_escape_cell(_port_summary(device))} |",
312
- f"| PoE | {_escape_cell(_poe_summary(device))} |",
314
+ f"| Model | {_escape_markdown_text(_device_model_label(device))} |",
315
+ f"| Type | {_escape_markdown_text(device.type or '-')} |",
316
+ f"| IP | {_escape_markdown_text(device.ip or '-')} |",
317
+ f"| MAC | {_escape_markdown_text(device.mac or '-')} |",
318
+ f"| Firmware | {_escape_markdown_text(device.version or '-')} |",
319
+ f"| Uplink | {_escape_markdown_text(_uplink_summary(device))} |",
320
+ f"| Ports | {_escape_markdown_text(_port_summary(device))} |",
321
+ f"| PoE | {_escape_markdown_text(_poe_summary(device))} |",
313
322
  "",
314
323
  ]
315
324
  return lines
@@ -367,7 +376,7 @@ def _format_client_connections(clients: list[str]) -> str:
367
376
  if not clients:
368
377
  return ""
369
378
  if len(clients) == 1:
370
- return f"{clients[0]} (client)"
379
+ return f"{_escape_markdown_text(clients[0])} (client)"
371
380
  items = "".join(f"<li>{_escape_html(name)}</li>" for name in clients)
372
381
  return f'<ul class="unifi-port-clients">{items}</ul>'
373
382
 
@@ -23,7 +23,13 @@ def _client_field(client: object, name: str) -> object | None:
23
23
 
24
24
 
25
25
  def _client_display_name(client: object) -> str | None:
26
- for key in ("name", "hostname", "mac"):
26
+ raw_name = _client_field(client, "name")
27
+ if isinstance(raw_name, str) and raw_name.strip():
28
+ return raw_name.strip()
29
+ preferred = _client_ucore_display_name(client)
30
+ if preferred:
31
+ return preferred
32
+ for key in ("hostname", "mac"):
27
33
  value = _client_field(client, key)
28
34
  if isinstance(value, str) and value.strip():
29
35
  return value.strip()
@@ -77,6 +83,73 @@ def _client_is_wired(client: object) -> bool:
77
83
  return bool(_client_field(client, "is_wired"))
78
84
 
79
85
 
86
+ def _client_unifi_flag(client: object) -> bool | None:
87
+ for key in ("is_unifi", "is_unifi_device", "is_ubnt", "is_uap", "is_managed"):
88
+ value = _client_field(client, key)
89
+ if isinstance(value, bool):
90
+ return value
91
+ if isinstance(value, int):
92
+ return value != 0
93
+ return None
94
+
95
+
96
+ def _client_vendor(client: object) -> str | None:
97
+ for key in ("oui", "vendor", "vendor_name", "manufacturer", "manufacturer_name"):
98
+ value = _client_field(client, key)
99
+ if isinstance(value, str) and value.strip():
100
+ return value.strip()
101
+ return None
102
+
103
+
104
+ def _client_ucore_info(client: object) -> dict[str, object] | None:
105
+ info = _client_field(client, "unifi_device_info_from_ucore")
106
+ if isinstance(info, dict):
107
+ return info
108
+ return None
109
+
110
+
111
+ def _client_ucore_display_name(client: object) -> str | None:
112
+ ucore = _client_ucore_info(client)
113
+ if not ucore:
114
+ return None
115
+ for key in ("name", "computed_model", "product_model", "product_shortname"):
116
+ value = ucore.get(key)
117
+ if isinstance(value, str) and value.strip():
118
+ return value.strip()
119
+ return None
120
+
121
+
122
+ def _client_hostname_source(client: object) -> str | None:
123
+ value = _client_field(client, "hostname_source")
124
+ if isinstance(value, str) and value.strip():
125
+ return value.strip()
126
+ return None
127
+
128
+
129
+ def _client_is_unifi(client: object) -> bool:
130
+ flag = _client_unifi_flag(client)
131
+ if flag is not None:
132
+ return flag
133
+ ucore = _client_ucore_info(client)
134
+ if ucore:
135
+ managed = ucore.get("managed")
136
+ if isinstance(managed, bool) and managed:
137
+ return True
138
+ if isinstance(ucore.get("product_line"), str) and ucore.get("product_line"):
139
+ return True
140
+ if isinstance(ucore.get("product_shortname"), str) and ucore.get("product_shortname"):
141
+ return True
142
+ for key in ("name", "computed_model", "product_model"):
143
+ value = ucore.get(key)
144
+ if isinstance(value, str) and value.strip():
145
+ return True
146
+ vendor = _client_vendor(client)
147
+ if not vendor:
148
+ return False
149
+ normalized = vendor.lower()
150
+ return "ubiquiti" in normalized or "unifi" in normalized
151
+
152
+
80
153
  def _client_matches_mode(client: object, mode: str) -> bool:
81
154
  wired = _client_is_wired(client)
82
155
  if mode == "all":
@@ -86,6 +159,14 @@ def _client_matches_mode(client: object, mode: str) -> bool:
86
159
  return wired
87
160
 
88
161
 
162
+ def _client_matches_filters(client: object, *, client_mode: str, only_unifi: bool) -> bool:
163
+ if not _client_matches_mode(client, client_mode):
164
+ return False
165
+ if only_unifi and not _client_is_unifi(client):
166
+ return False
167
+ return True
168
+
169
+
89
170
  def _lldp_sort_key(entry: LLDPEntry) -> tuple[int, str, str]:
90
171
  port_label = local_port_label(entry) or ""
91
172
  port_number = "".join(ch for ch in port_label if ch.isdigit())
@@ -190,7 +271,10 @@ def _lldp_rows(
190
271
 
191
272
 
192
273
  def _escape_cell(value: str) -> str:
193
- return value.replace("|", "\\|")
274
+ escaped = value.replace("\\", "\\\\")
275
+ for char in ("|", "[", "]", "*", "_", "`", "<", ">"):
276
+ escaped = escaped.replace(char, f"\\{char}")
277
+ return escaped
194
278
 
195
279
 
196
280
  def _client_rows(
@@ -199,10 +283,11 @@ def _client_rows(
199
283
  *,
200
284
  include_ports: bool,
201
285
  client_mode: str,
286
+ only_unifi: bool,
202
287
  ) -> dict[str, list[tuple[str, str | None]]]:
203
288
  rows_by_device: dict[str, list[tuple[str, str | None]]] = {}
204
289
  for client in clients:
205
- if not _client_matches_mode(client, client_mode):
290
+ if not _client_matches_filters(client, client_mode=client_mode, only_unifi=only_unifi):
206
291
  continue
207
292
  name = _client_display_name(client)
208
293
  uplink_mac = _client_uplink_mac(client)
@@ -227,6 +312,7 @@ def _prepare_lldp_maps(
227
312
  include_ports: bool,
228
313
  show_clients: bool,
229
314
  client_mode: str,
315
+ only_unifi: bool,
230
316
  ) -> tuple[
231
317
  dict[tuple[str, str], str],
232
318
  dict[str, list[tuple[int, str]]] | None,
@@ -234,7 +320,13 @@ def _prepare_lldp_maps(
234
320
  ]:
235
321
  device_index = build_device_index(devices)
236
322
  client_rows = (
237
- _client_rows(clients, device_index, include_ports=include_ports, client_mode=client_mode)
323
+ _client_rows(
324
+ clients,
325
+ device_index,
326
+ include_ports=include_ports,
327
+ client_mode=client_mode,
328
+ only_unifi=only_unifi,
329
+ )
238
330
  if clients
239
331
  else {}
240
332
  )
@@ -243,7 +335,12 @@ def _prepare_lldp_maps(
243
335
  if include_ports:
244
336
  port_map = build_port_map(devices, only_unifi=False)
245
337
  if clients and show_clients:
246
- client_port_map = build_client_port_map(devices, clients, client_mode=client_mode)
338
+ client_port_map = build_client_port_map(
339
+ devices,
340
+ clients,
341
+ client_mode=client_mode,
342
+ only_unifi=only_unifi,
343
+ )
247
344
  return port_map, client_port_map, client_rows
248
345
 
249
346
 
@@ -318,6 +415,7 @@ def render_lldp_md(
318
415
  include_ports: bool = False,
319
416
  show_clients: bool = False,
320
417
  client_mode: str = "wired",
418
+ only_unifi: bool = False,
321
419
  ) -> str:
322
420
  device_index = build_device_index(devices)
323
421
  port_map, client_port_map, client_rows = _prepare_lldp_maps(
@@ -326,6 +424,7 @@ def render_lldp_md(
326
424
  include_ports=include_ports,
327
425
  show_clients=show_clients,
328
426
  client_mode=client_mode,
427
+ only_unifi=only_unifi,
329
428
  )
330
429
  sections: list[str] = []
331
430
  for device in sorted(devices, key=lambda item: item.name.lower()):
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
 
7
7
  import yaml
8
8
 
9
+ from ..io.paths import resolve_theme_path
9
10
  from .mermaid_theme import DEFAULT_THEME as DEFAULT_MERMAID_THEME
10
11
  from .mermaid_theme import MermaidTheme
11
12
  from .svg_theme import DEFAULT_THEME as DEFAULT_SVG_THEME
@@ -90,7 +91,7 @@ def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
90
91
 
91
92
 
92
93
  def load_theme(path: str | Path) -> tuple[MermaidTheme, SvgTheme]:
93
- theme_path = Path(path)
94
+ theme_path = resolve_theme_path(path, require_exists=False)
94
95
  payload = yaml.safe_load(theme_path.read_text(encoding="utf-8"))
95
96
  if not isinstance(payload, dict):
96
97
  raise ValueError("Theme file must contain a YAML mapping")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-network-maps
3
- Version: 1.4.11
3
+ Version: 1.4.13
4
4
  Summary: Dynamic UniFi -> network maps in mermaid or svg
5
5
  Author: Merlijn
6
6
  License-Expression: MIT
@@ -32,7 +32,7 @@ Requires-Dist: pre-commit==4.5.1; extra == "dev"
32
32
  Requires-Dist: pytest==9.0.2; extra == "dev"
33
33
  Requires-Dist: pytest-cov==7.0.0; extra == "dev"
34
34
  Requires-Dist: pyright==1.1.408; extra == "dev"
35
- Requires-Dist: ruff==0.14.13; extra == "dev"
35
+ Requires-Dist: ruff==0.14.14; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # unifi-network-maps
@@ -223,7 +223,7 @@ Functional:
223
223
  - `--include-ports`: show port labels (Mermaid shows both ends; SVG shows compact labels).
224
224
  - `--include-clients`: add active wired clients as leaf nodes.
225
225
  - `--client-scope wired|wireless|all`: which client types to include (default wired).
226
- - `--only-unifi`: only include neighbors that are UniFi devices.
226
+ - `--only-unifi`: only include neighbors that are UniFi devices; when clients are included, filters to UniFi-managed clients (by explicit UniFi flags or vendor/OUI).
227
227
  - `--no-cache`: disable UniFi API cache reads and writes.
228
228
 
229
229
  Mermaid:
@@ -1,8 +1,8 @@
1
- unifi_network_maps/__init__.py,sha256=E_3GCl6XTRrqUMJy7bs7bnXqi2aoli2GL8PC4NQJkSE,23
1
+ unifi_network_maps/__init__.py,sha256=3Jd0G1BzDzSEXU_O-VQeqAYwxMAmK7PBiIm9SYiPxoE,23
2
2
  unifi_network_maps/__main__.py,sha256=XsOjaqslAVgyVlOTokjVddZ2iT8apZXpJ_OB-9WEEe4,179
3
3
  unifi_network_maps/adapters/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
4
- unifi_network_maps/adapters/config.py,sha256=Bx9JDZxxY7Gjxyb8FDT0dxiKfgXt_TmzTDbgvpwB53s,1548
5
- unifi_network_maps/adapters/unifi.py,sha256=olrs-K7EWK7Uhib8pjtPnkZg3LhnnSz-lcGuHG8RsOI,15140
4
+ unifi_network_maps/adapters/config.py,sha256=gnvgAj9wPmYGGQfhcZSHRkcVGojODUYIINU_ztTcuUc,1671
5
+ unifi_network_maps/adapters/unifi.py,sha256=MCW-kpLcmAF_9QZviT9T2oPfDI9hurtxALCBY8tbmfk,15601
6
6
  unifi_network_maps/assets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  unifi_network_maps/assets/icons/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  unifi_network_maps/assets/icons/access-point.svg,sha256=RJOgO2s9Ino5lWRrh4V7q8Jdwffros5bQq3UeDuQYF4,742
@@ -53,25 +53,26 @@ unifi_network_maps/assets/themes/default.yaml,sha256=F2Jj18NmdaJ_zyERvGAn8NEWBwa
53
53
  unifi_network_maps/cli/__init__.py,sha256=cds9GvFNZmYAR22Ab3TSzfriSAW--kf9jvC5U-21AoA,70
54
54
  unifi_network_maps/cli/__main__.py,sha256=nK_jh78VW3h3DRvSpjzpcf64zkCqniP2k82xUR9Hw2I,147
55
55
  unifi_network_maps/cli/args.py,sha256=lIgQDeob_SIhjXg76hJsnpgNOKupSjSYum_MqarWOkE,5552
56
- unifi_network_maps/cli/main.py,sha256=jQXesuHJLTQl4lBk1DD6em67Wj9oEjBmH9X-X1zA6MI,4150
57
- unifi_network_maps/cli/render.py,sha256=sUyDWm_I_zbEcKuNEpKUXDxhe1XptgOYsmdMP9BJ3Eg,7040
58
- unifi_network_maps/cli/runtime.py,sha256=Hln4LMpuTrEsy6gIBmqkOrUpMb4nTeZ-AH72KyxpZwA,4723
56
+ unifi_network_maps/cli/main.py,sha256=Fedf93kc6f9w_V_Ik28gnrUGlB7oD0Yn5cdmHnj9nI4,5719
57
+ unifi_network_maps/cli/render.py,sha256=wuvsC-In-ApbaQxBn8-UofB7jCiqUnjluGXRCRZpg5A,7535
58
+ unifi_network_maps/cli/runtime.py,sha256=cMtIShERup2z2_uCcEGcaJFJptvdI0L3FDWqKFwSevY,4830
59
59
  unifi_network_maps/io/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
60
60
  unifi_network_maps/io/debug.py,sha256=eUVs6GLfs6VqI6ma8ra7NLhC3q4ek2K8OSRLnpQDF9s,1745
61
- unifi_network_maps/io/export.py,sha256=2vURunQDja-_fKKMb-gQG-n34aeM8GFprNJqcGaP4qg,843
62
- unifi_network_maps/io/mkdocs_assets.py,sha256=oueDLiiIW05mN-mZ2BWV7oECX-EXDjJt8msCJHMcQwg,596
63
- unifi_network_maps/io/mock_data.py,sha256=uiQ8Ta8oxs5D1MUA1ONWmp09sxH0-FNlz3SJBXMeezo,714
61
+ unifi_network_maps/io/export.py,sha256=1_RcXpPqoerCt7WsiheV4dwKgY2fFJlpjUGRs-EkHE4,1013
62
+ unifi_network_maps/io/mkdocs_assets.py,sha256=pszCivIql3maaLw7KFcAioQaVcyLltXgg-fPXPBvvEk,716
63
+ unifi_network_maps/io/mock_data.py,sha256=3nbg83APrdhmNW2w0Ha3FWoguMNfJy1IMtolinUPu0E,774
64
64
  unifi_network_maps/io/mock_generate.py,sha256=xiJz_qtNW7iVj7dezLNyVXAHtCHAi8U5CSXK5mrJE4U,233
65
+ unifi_network_maps/io/paths.py,sha256=zE_kYkRXgHmPaRgzE45angYQk59aMC84JW7kcH11Qk0,5729
65
66
  unifi_network_maps/model/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
66
67
  unifi_network_maps/model/labels.py,sha256=m_k8mbzWtOSDOjjHhLUqwIw93pg98HAtGtHkiERXmek,1135
67
68
  unifi_network_maps/model/lldp.py,sha256=SrPW5XC2lfJgaGeVx-KnSFNltyok7gIWWQNg1SkOaj4,3300
68
69
  unifi_network_maps/model/mock.py,sha256=kd1MSiIn-7KKu_nMVmheYPfTyAN5DHt4dzRrBifF_lI,8706
69
70
  unifi_network_maps/model/ports.py,sha256=o3NBlXcC5VV5iPWJsO4Ll1mRKJZC0f8zTHdlkkE34GU,609
70
- unifi_network_maps/model/topology.py,sha256=YOiLGKFEl4AQncaiM0C_uHt85lX1rsNM4n0HEZ5K-ns,28541
71
+ unifi_network_maps/model/topology.py,sha256=rC1J1h70JIpkm6eIuFz7AjoNtKobPpVfAbiWgMyow2w,31475
71
72
  unifi_network_maps/render/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
72
- unifi_network_maps/render/device_ports_md.py,sha256=vt5kGFSIAabMbcSxIuVVLSzdb_i58NHGi4hJM2ZLZR4,15975
73
+ unifi_network_maps/render/device_ports_md.py,sha256=PLbP4_EDwLNkUACVJB_uM0ywaQTbEc6OWMv4QBcoJbU,16425
73
74
  unifi_network_maps/render/legend.py,sha256=TmZsxgCVOM2CZImI9zgRVyzrcg01HZFDj9F715d4CGo,772
74
- unifi_network_maps/render/lldp_md.py,sha256=13g9G_oQ-riTDakt-_qmGX8YtEmL0eikjFMbtiTKKVo,11672
75
+ unifi_network_maps/render/lldp_md.py,sha256=oHPAYAfyCPJCuAJM4NJ4RIqgf52ihh555oNAlMB0DFg,14873
75
76
  unifi_network_maps/render/markdown_tables.py,sha256=VvM0fSnSmpeeDPcD5pXaL_j_PTF0STrMCaqnr2BVHn4,547
76
77
  unifi_network_maps/render/mermaid.py,sha256=xsC57Xg-nKhmlVATzEbwLkMM2BOeDYlBjZuxBIPhHeI,8324
77
78
  unifi_network_maps/render/mermaid_theme.py,sha256=7nqLlvhaUA4z0YOs0ByEx_yHWcQD_hJJjhDtRcbSpg4,1781
@@ -79,7 +80,7 @@ unifi_network_maps/render/mkdocs.py,sha256=EOST9_eP1ZoZQax-p-2fjlelrl3AKEJ9Gn-KX
79
80
  unifi_network_maps/render/svg.py,sha256=Zd6TFaBhqa92O2Z7G_k3LnRXIbuaOAj-dYu2qzEwWvI,41171
80
81
  unifi_network_maps/render/svg_theme.py,sha256=Si1ArM3v_-wAvHZyLFPiOZ0ohQRd6ezIckwC3_b-WIw,2684
81
82
  unifi_network_maps/render/templating.py,sha256=VJbXzZFBPjL8LFFPcLf_EU5Eu53GN9_vpten2Mf9A-k,576
82
- unifi_network_maps/render/theme.py,sha256=vKYdPhcGEOV1o_irwqzJlIXPgRvZqQEzYYV2_TxZn4E,4301
83
+ unifi_network_maps/render/theme.py,sha256=LI-2dWaR0cRapvYBqeudGCT3tnT8B-nt-5h0yxkU8vU,4379
83
84
  unifi_network_maps/render/templates/device_port_block.md.j2,sha256=ZfyE_lEHz6ZyYRxAYhAhwpxlLczn_U9eTkRLUrwU5Io,50
84
85
  unifi_network_maps/render/templates/legend_compact.html.j2,sha256=DaY2m6N6Yd7qNKLuB5Bc395MHfrKOU9myAZvgcJrhpE,738
85
86
  unifi_network_maps/render/templates/lldp_device_section.md.j2,sha256=ewX-SBrL5Hn1kuwDKo4fqqetj9tTpR7LQavSn6No_yY,206
@@ -91,9 +92,9 @@ unifi_network_maps/render/templates/mkdocs_html_block.html.j2,sha256=5l5-BbNujOc
91
92
  unifi_network_maps/render/templates/mkdocs_legend.css.j2,sha256=tkTI-RagBSgdjUygVenlTsQFenU09ePbXOfDt_Q7YRM,612
92
93
  unifi_network_maps/render/templates/mkdocs_legend.js.j2,sha256=qMYyCKsJ84uXf1wGgzbc7Bc49RU4oyuaGK9KrgQDQEI,685
93
94
  unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2,sha256=9IncllWQpoI8BN3A7b2zOQ5cksj97ddsjHJ-aBhpw7o,66
94
- unifi_network_maps-1.4.11.dist-info/licenses/LICENSE,sha256=mYo1siIIfIwyfdOuK2-Zt0ij2xBTii2hnpeTu79nD80,1074
95
- unifi_network_maps-1.4.11.dist-info/METADATA,sha256=ihN2YNJUN-9W1RR2USmMtMGJq72XSSULMtG8k9qry4g,9851
96
- unifi_network_maps-1.4.11.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
97
- unifi_network_maps-1.4.11.dist-info/entry_points.txt,sha256=cdJ7jsBgNgHxSflYUOqgz5BbvuM0Nnh-x8_Hbyh_LFg,67
98
- unifi_network_maps-1.4.11.dist-info/top_level.txt,sha256=G0rUX1PNfVCn1u-KtB6QjFQHopCOVLnPMczvPOoraHg,19
99
- unifi_network_maps-1.4.11.dist-info/RECORD,,
95
+ unifi_network_maps-1.4.13.dist-info/licenses/LICENSE,sha256=mYo1siIIfIwyfdOuK2-Zt0ij2xBTii2hnpeTu79nD80,1074
96
+ unifi_network_maps-1.4.13.dist-info/METADATA,sha256=HzxFdG6F9RwTXSAqF7o-Zl3a3a1dofXewvXZGYtKXlM,9952
97
+ unifi_network_maps-1.4.13.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
98
+ unifi_network_maps-1.4.13.dist-info/entry_points.txt,sha256=cdJ7jsBgNgHxSflYUOqgz5BbvuM0Nnh-x8_Hbyh_LFg,67
99
+ unifi_network_maps-1.4.13.dist-info/top_level.txt,sha256=G0rUX1PNfVCn1u-KtB6QjFQHopCOVLnPMczvPOoraHg,19
100
+ unifi_network_maps-1.4.13.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5