unifi-network-maps 1.4.13__py3-none-any.whl → 1.4.15__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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +105 -0
- unifi_network_maps/cli/args.py +1 -1
- unifi_network_maps/cli/main.py +39 -1
- unifi_network_maps/io/mock_data.py +17 -0
- unifi_network_maps/io/paths.py +7 -3
- unifi_network_maps/model/mock.py +17 -1
- unifi_network_maps/model/topology.py +21 -1
- unifi_network_maps/model/vlans.py +119 -0
- {unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/METADATA +8 -2
- {unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/RECORD +15 -14
- {unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/WHEEL +1 -1
- {unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/top_level.txt +0 -0
unifi_network_maps/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "1.4.
|
|
1
|
+
__version__ = "1.4.15"
|
|
@@ -17,6 +17,7 @@ from pathlib import Path
|
|
|
17
17
|
from typing import IO, TYPE_CHECKING
|
|
18
18
|
|
|
19
19
|
from ..io.paths import resolve_cache_dir
|
|
20
|
+
from ..model.vlans import build_vlan_info, normalize_networks
|
|
20
21
|
from .config import Config
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
@@ -170,6 +171,19 @@ def _serialize_devices_for_cache(devices: Sequence[object]) -> list[dict[str, ob
|
|
|
170
171
|
return [_serialize_device_for_cache(device) for device in devices]
|
|
171
172
|
|
|
172
173
|
|
|
174
|
+
def _serialize_network_for_cache(network: object) -> dict[str, object]:
|
|
175
|
+
return {
|
|
176
|
+
"name": _first_attr(network, "name", "network_name", "networkName"),
|
|
177
|
+
"vlan": _first_attr(network, "vlan", "vlan_id", "vlanId", "vlanid"),
|
|
178
|
+
"vlan_enabled": _first_attr(network, "vlan_enabled", "vlanEnabled"),
|
|
179
|
+
"purpose": _first_attr(network, "purpose"),
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _serialize_networks_for_cache(networks: Sequence[object]) -> list[dict[str, object]]:
|
|
184
|
+
return [_serialize_network_for_cache(network) for network in networks]
|
|
185
|
+
|
|
186
|
+
|
|
173
187
|
def _cache_lock_path(path: Path) -> Path:
|
|
174
188
|
return path.with_suffix(path.suffix + ".lock")
|
|
175
189
|
|
|
@@ -465,3 +479,94 @@ def fetch_clients(
|
|
|
465
479
|
_save_cache(cache_path, clients)
|
|
466
480
|
logger.debug("Fetched %d clients", len(clients))
|
|
467
481
|
return clients
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def fetch_networks(
|
|
485
|
+
config: Config,
|
|
486
|
+
*,
|
|
487
|
+
site: str | None = None,
|
|
488
|
+
use_cache: bool = True,
|
|
489
|
+
) -> Sequence[object]:
|
|
490
|
+
"""Fetch network inventory from UniFi Controller."""
|
|
491
|
+
try:
|
|
492
|
+
from unifi_controller_api import UnifiAuthenticationError
|
|
493
|
+
except ImportError as exc:
|
|
494
|
+
raise RuntimeError("Missing dependency: unifi-controller-api") from exc
|
|
495
|
+
|
|
496
|
+
site_name = site or config.site
|
|
497
|
+
ttl_seconds = _cache_ttl_seconds()
|
|
498
|
+
cache_path = _cache_dir() / f"networks_{_cache_key(config.url, site_name)}.json"
|
|
499
|
+
if use_cache and _is_cache_dir_safe(cache_path.parent):
|
|
500
|
+
cached = _load_cache(cache_path, ttl_seconds)
|
|
501
|
+
stale_cached, cache_age = _load_cache_with_age(cache_path)
|
|
502
|
+
else:
|
|
503
|
+
cached = None
|
|
504
|
+
stale_cached, cache_age = None, None
|
|
505
|
+
if cached is not None:
|
|
506
|
+
logger.debug("Using cached networks (%d)", len(cached))
|
|
507
|
+
return cached
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
controller = _init_controller(config, is_udm_pro=True)
|
|
511
|
+
except UnifiAuthenticationError:
|
|
512
|
+
logger.debug("UDM Pro authentication failed, retrying legacy auth")
|
|
513
|
+
controller = _init_controller(config, is_udm_pro=False)
|
|
514
|
+
|
|
515
|
+
def _fetch() -> Sequence[object]:
|
|
516
|
+
# Always use raw=True to avoid model parsing issues with disabled WAN interfaces
|
|
517
|
+
# (the UnifiNetworkConf model requires an 'enabled' field that may be absent)
|
|
518
|
+
return controller.get_unifi_site_networkconf(site_name=site_name, raw=True)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
networks = _call_with_retries("network fetch", _fetch)
|
|
522
|
+
except Exception as exc: # noqa: BLE001 - fallback to cache
|
|
523
|
+
if stale_cached is not None:
|
|
524
|
+
logger.warning(
|
|
525
|
+
"Network fetch failed; using stale cache (%ds old): %s",
|
|
526
|
+
int(cache_age or 0),
|
|
527
|
+
exc,
|
|
528
|
+
)
|
|
529
|
+
return stale_cached
|
|
530
|
+
raise
|
|
531
|
+
if use_cache:
|
|
532
|
+
_save_cache(cache_path, _serialize_networks_for_cache(networks))
|
|
533
|
+
logger.debug("Fetched %d networks", len(networks))
|
|
534
|
+
return networks
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def fetch_payload(
|
|
538
|
+
config: Config,
|
|
539
|
+
*,
|
|
540
|
+
site: str | None = None,
|
|
541
|
+
include_clients: bool = True,
|
|
542
|
+
use_cache: bool = True,
|
|
543
|
+
) -> dict[str, list[object] | list[dict[str, object]]]:
|
|
544
|
+
"""Fetch devices, clients, and VLAN inventory for payload output."""
|
|
545
|
+
devices = list(fetch_devices(config, site=site, detailed=True, use_cache=use_cache))
|
|
546
|
+
clients = _fetch_payload_clients(
|
|
547
|
+
config,
|
|
548
|
+
site=site,
|
|
549
|
+
include_clients=include_clients,
|
|
550
|
+
use_cache=use_cache,
|
|
551
|
+
)
|
|
552
|
+
networks = list(fetch_networks(config, site=site, use_cache=use_cache))
|
|
553
|
+
normalized_networks = normalize_networks(networks)
|
|
554
|
+
vlan_info = build_vlan_info(clients, normalized_networks)
|
|
555
|
+
return {
|
|
556
|
+
"devices": devices,
|
|
557
|
+
"clients": clients,
|
|
558
|
+
"networks": normalized_networks,
|
|
559
|
+
"vlan_info": vlan_info,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _fetch_payload_clients(
|
|
564
|
+
config: Config,
|
|
565
|
+
*,
|
|
566
|
+
site: str | None,
|
|
567
|
+
include_clients: bool,
|
|
568
|
+
use_cache: bool,
|
|
569
|
+
) -> list[object]:
|
|
570
|
+
if not include_clients:
|
|
571
|
+
return []
|
|
572
|
+
return list(fetch_clients(config, site=site, use_cache=use_cache))
|
unifi_network_maps/cli/args.py
CHANGED
|
@@ -125,7 +125,7 @@ def add_general_render_args(parser: argparse._ArgumentGroup) -> None:
|
|
|
125
125
|
parser.add_argument(
|
|
126
126
|
"--format",
|
|
127
127
|
default="mermaid",
|
|
128
|
-
choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs"],
|
|
128
|
+
choices=["mermaid", "svg", "svg-iso", "lldp-md", "mkdocs", "json"],
|
|
129
129
|
help="Output format",
|
|
130
130
|
)
|
|
131
131
|
parser.add_argument(
|
unifi_network_maps/cli/main.py
CHANGED
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import json
|
|
6
7
|
import logging
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from ..adapters.config import Config
|
|
11
|
+
from ..adapters.unifi import fetch_payload
|
|
10
12
|
from ..io.export import write_output
|
|
11
|
-
from ..io.mock_data import load_mock_data
|
|
13
|
+
from ..io.mock_data import load_mock_data, load_mock_payload
|
|
12
14
|
from ..io.paths import (
|
|
13
15
|
resolve_env_file,
|
|
14
16
|
resolve_mock_data_path,
|
|
15
17
|
resolve_output_path,
|
|
16
18
|
resolve_theme_path,
|
|
17
19
|
)
|
|
20
|
+
from ..model.vlans import build_vlan_info, normalize_networks
|
|
18
21
|
from ..render.legend import render_legend_only, resolve_legend_style
|
|
19
22
|
from ..render.theme import resolve_themes
|
|
20
23
|
from .args import build_parser
|
|
@@ -117,6 +120,38 @@ def _load_runtime_context(
|
|
|
117
120
|
return config, site, None, None
|
|
118
121
|
|
|
119
122
|
|
|
123
|
+
def _handle_json_format(
|
|
124
|
+
args: argparse.Namespace,
|
|
125
|
+
*,
|
|
126
|
+
config: Config | None,
|
|
127
|
+
site: str,
|
|
128
|
+
) -> int | None:
|
|
129
|
+
if args.format != "json":
|
|
130
|
+
return None
|
|
131
|
+
payload: dict[str, list[object] | list[dict[str, object]]]
|
|
132
|
+
if args.mock_data:
|
|
133
|
+
payload = load_mock_payload(args.mock_data)
|
|
134
|
+
if not args.include_clients:
|
|
135
|
+
payload["clients"] = []
|
|
136
|
+
networks = normalize_networks(payload.get("networks", []))
|
|
137
|
+
payload["networks"] = networks
|
|
138
|
+
payload["vlan_info"] = build_vlan_info(payload.get("clients", []), networks)
|
|
139
|
+
else:
|
|
140
|
+
if config is None:
|
|
141
|
+
logging.error("Config required to run")
|
|
142
|
+
return 2
|
|
143
|
+
payload = fetch_payload(
|
|
144
|
+
config,
|
|
145
|
+
site=site,
|
|
146
|
+
include_clients=args.include_clients,
|
|
147
|
+
use_cache=not args.no_cache,
|
|
148
|
+
)
|
|
149
|
+
content = json.dumps(payload, indent=2, sort_keys=True)
|
|
150
|
+
output_kwargs = {"format_name": args.format} if args.output else {}
|
|
151
|
+
write_output(content, output_path=args.output, stdout=args.stdout, **output_kwargs)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
|
|
120
155
|
def main(argv: list[str] | None = None) -> int:
|
|
121
156
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
122
157
|
for handler in logging.getLogger().handlers:
|
|
@@ -132,6 +167,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
132
167
|
except ValueError as exc:
|
|
133
168
|
logging.error(str(exc))
|
|
134
169
|
return 2
|
|
170
|
+
payload_result = _handle_json_format(args, config=config, site=site)
|
|
171
|
+
if payload_result is not None:
|
|
172
|
+
return payload_result
|
|
135
173
|
try:
|
|
136
174
|
mermaid_theme, svg_theme = resolve_themes(args.theme_file)
|
|
137
175
|
except Exception as exc: # noqa: BLE001
|
|
@@ -23,3 +23,20 @@ def load_mock_data(path: str) -> tuple[list[object], list[object]]:
|
|
|
23
23
|
devices = _as_list(payload.get("devices"), "devices")
|
|
24
24
|
clients = _as_list(payload.get("clients"), "clients")
|
|
25
25
|
return devices, clients
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_mock_payload(path: str) -> dict[str, list[object] | list[dict[str, object]]]:
|
|
29
|
+
resolved = resolve_mock_data_path(path)
|
|
30
|
+
payload = json.loads(resolved.read_text(encoding="utf-8"))
|
|
31
|
+
if not isinstance(payload, dict):
|
|
32
|
+
raise ValueError("Mock data must be a JSON object")
|
|
33
|
+
devices = _as_list(payload.get("devices"), "devices")
|
|
34
|
+
clients = _as_list(payload.get("clients"), "clients")
|
|
35
|
+
networks = _as_list(payload.get("networks"), "networks")
|
|
36
|
+
vlan_info = _as_list(payload.get("vlan_info"), "vlan_info")
|
|
37
|
+
return {
|
|
38
|
+
"devices": devices,
|
|
39
|
+
"clients": clients,
|
|
40
|
+
"networks": networks,
|
|
41
|
+
"vlan_info": vlan_info,
|
|
42
|
+
}
|
unifi_network_maps/io/paths.py
CHANGED
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
import os
|
|
6
7
|
import tempfile
|
|
7
8
|
from collections.abc import Iterable
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
def _safe_home_dir() -> Path | None:
|
|
12
15
|
try:
|
|
@@ -22,8 +25,9 @@ def _base_roots() -> list[Path]:
|
|
|
22
25
|
roots.append(home)
|
|
23
26
|
try:
|
|
24
27
|
roots.append(Path(tempfile.gettempdir()).resolve())
|
|
25
|
-
except
|
|
26
|
-
|
|
28
|
+
except OSError as exc:
|
|
29
|
+
# Best-effort temp dir; resolution can fail in restricted environments.
|
|
30
|
+
logger.debug("Failed to resolve temp directory: %s", exc)
|
|
27
31
|
return roots
|
|
28
32
|
|
|
29
33
|
|
|
@@ -176,7 +180,7 @@ def resolve_output_path(path: str | Path, *, format_name: str | None) -> Path:
|
|
|
176
180
|
extensions: set[str] | None
|
|
177
181
|
if format_name == "svg" or format_name == "svg-iso":
|
|
178
182
|
extensions = {".svg"}
|
|
179
|
-
elif format_name
|
|
183
|
+
elif format_name in {"mock", "json"}:
|
|
180
184
|
extensions = {".json"}
|
|
181
185
|
elif format_name == "mermaid":
|
|
182
186
|
extensions = {".md", ".mermaid", ".mmd"}
|
unifi_network_maps/model/mock.py
CHANGED
|
@@ -9,6 +9,8 @@ from typing import Any
|
|
|
9
9
|
|
|
10
10
|
from faker import Faker
|
|
11
11
|
|
|
12
|
+
from .vlans import build_vlan_info, normalize_networks
|
|
13
|
+
|
|
12
14
|
|
|
13
15
|
@dataclass(frozen=True)
|
|
14
16
|
class MockOptions:
|
|
@@ -34,7 +36,14 @@ def generate_mock_payload(options: MockOptions) -> dict[str, list[dict[str, Any]
|
|
|
34
36
|
state = _build_state(options.seed)
|
|
35
37
|
devices, core_switch, aps = _build_devices(options, state)
|
|
36
38
|
clients = _build_clients(options, state, core_switch, aps)
|
|
37
|
-
|
|
39
|
+
networks = _build_networks()
|
|
40
|
+
vlan_info = build_vlan_info(clients, networks)
|
|
41
|
+
return {
|
|
42
|
+
"devices": devices,
|
|
43
|
+
"clients": clients,
|
|
44
|
+
"networks": normalize_networks(networks),
|
|
45
|
+
"vlan_info": vlan_info,
|
|
46
|
+
}
|
|
38
47
|
|
|
39
48
|
|
|
40
49
|
def mock_payload_json(options: MockOptions) -> str:
|
|
@@ -108,6 +117,13 @@ def _build_clients(
|
|
|
108
117
|
return clients
|
|
109
118
|
|
|
110
119
|
|
|
120
|
+
def _build_networks() -> list[dict[str, Any]]:
|
|
121
|
+
return [
|
|
122
|
+
{"name": "LAN", "vlan_enabled": False, "purpose": "corporate"},
|
|
123
|
+
{"name": "Guest", "vlan": 20, "vlan_enabled": True, "purpose": "guest"},
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
111
127
|
def _build_wired_clients(
|
|
112
128
|
count: int, state: _MockState, core_switch: dict[str, Any]
|
|
113
129
|
) -> list[dict[str, Any]]:
|
|
@@ -302,9 +302,29 @@ def _uplink_info(device: DeviceSource) -> tuple[UplinkInfo | None, UplinkInfo |
|
|
|
302
302
|
return uplink, last_uplink
|
|
303
303
|
|
|
304
304
|
|
|
305
|
+
def _get_model_display_name(device: DeviceSource) -> str | None:
|
|
306
|
+
"""Extract the human-readable model name from device data.
|
|
307
|
+
|
|
308
|
+
UniFi stores the friendly model name (e.g., 'USW Flex 2.5G 8 PoE') in various
|
|
309
|
+
fields depending on controller version. This function checks multiple candidates
|
|
310
|
+
and returns the first non-empty value found.
|
|
311
|
+
"""
|
|
312
|
+
candidates = (
|
|
313
|
+
"model_in_lts",
|
|
314
|
+
"model_in_eol",
|
|
315
|
+
"shortname",
|
|
316
|
+
"model_name",
|
|
317
|
+
)
|
|
318
|
+
for key in candidates:
|
|
319
|
+
value = _get_attr(device, key)
|
|
320
|
+
if isinstance(value, str) and value.strip():
|
|
321
|
+
return value.strip()
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
|
|
305
325
|
def coerce_device(device: DeviceSource) -> Device:
|
|
306
326
|
name = _get_attr(device, "name")
|
|
307
|
-
model_name =
|
|
327
|
+
model_name = _get_model_display_name(device) or _get_attr(device, "model")
|
|
308
328
|
model = _get_attr(device, "model")
|
|
309
329
|
mac = _get_attr(device, "mac")
|
|
310
330
|
ip = _get_attr(device, "ip") or _get_attr(device, "ip_address")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""VLAN inventory helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _as_list(value: object | None) -> list[object]:
|
|
9
|
+
if value is None:
|
|
10
|
+
return []
|
|
11
|
+
if isinstance(value, list):
|
|
12
|
+
return value
|
|
13
|
+
if isinstance(value, dict):
|
|
14
|
+
return [value]
|
|
15
|
+
if isinstance(value, str | bytes):
|
|
16
|
+
return []
|
|
17
|
+
if isinstance(value, Iterable):
|
|
18
|
+
return list(value)
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_attr(obj: object, name: str) -> object | None:
|
|
23
|
+
if isinstance(obj, dict):
|
|
24
|
+
return obj.get(name)
|
|
25
|
+
return getattr(obj, name, None)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _first_attr(obj: object, *names: str) -> object | None:
|
|
29
|
+
for name in names:
|
|
30
|
+
value = _get_attr(obj, name)
|
|
31
|
+
if value is not None:
|
|
32
|
+
return value
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _as_bool(value: object | None) -> bool:
|
|
37
|
+
if isinstance(value, bool):
|
|
38
|
+
return value
|
|
39
|
+
if isinstance(value, int | float):
|
|
40
|
+
return value != 0
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _as_vlan_id(value: object | None) -> int | None:
|
|
47
|
+
if isinstance(value, int):
|
|
48
|
+
return value if value > 0 else None
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return int(value) if value.isdigit() and int(value) > 0 else None
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _network_vlan_id(network: object) -> int | None:
|
|
55
|
+
vlan_value = _first_attr(network, "vlan", "vlan_id", "vlanId", "vlanid")
|
|
56
|
+
vlan_enabled = _as_bool(_first_attr(network, "vlan_enabled", "vlanEnabled"))
|
|
57
|
+
vlan_id = _as_vlan_id(vlan_value)
|
|
58
|
+
if vlan_id is not None:
|
|
59
|
+
return vlan_id
|
|
60
|
+
if not vlan_enabled:
|
|
61
|
+
return 1
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def normalize_networks(networks: Iterable[object]) -> list[dict[str, object]]:
|
|
66
|
+
normalized: list[dict[str, object]] = []
|
|
67
|
+
for network in _as_list(networks):
|
|
68
|
+
if network is None:
|
|
69
|
+
continue
|
|
70
|
+
normalized.append(
|
|
71
|
+
{
|
|
72
|
+
"network_id": _first_attr(network, "_id", "id", "network_id", "networkId"),
|
|
73
|
+
"name": _first_attr(network, "name", "network_name", "networkName"),
|
|
74
|
+
"vlan_id": _network_vlan_id(network),
|
|
75
|
+
"vlan_enabled": _as_bool(_first_attr(network, "vlan_enabled", "vlanEnabled")),
|
|
76
|
+
"purpose": _first_attr(network, "purpose"),
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
return normalized
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_vlan_info(
|
|
83
|
+
clients: Iterable[object], networks: Iterable[object]
|
|
84
|
+
) -> list[dict[str, object]]:
|
|
85
|
+
vlan_counts = _client_vlan_counts(clients)
|
|
86
|
+
vlan_entries = _network_vlan_entries(networks)
|
|
87
|
+
for vlan_id, count in vlan_counts.items():
|
|
88
|
+
entry = vlan_entries.setdefault(
|
|
89
|
+
vlan_id,
|
|
90
|
+
{"id": vlan_id, "name": None, "client_count": 0},
|
|
91
|
+
)
|
|
92
|
+
entry["client_count"] = count
|
|
93
|
+
return [vlan_entries[key] for key in sorted(vlan_entries)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _client_vlan_counts(clients: Iterable[object]) -> dict[int, int]:
|
|
97
|
+
vlan_counts: dict[int, int] = {}
|
|
98
|
+
for client in _as_list(clients):
|
|
99
|
+
vlan_id = _as_vlan_id(_first_attr(client, "vlan", "vlan_id", "vlanId", "vlanid"))
|
|
100
|
+
if vlan_id is None:
|
|
101
|
+
continue
|
|
102
|
+
vlan_counts[vlan_id] = vlan_counts.get(vlan_id, 0) + 1
|
|
103
|
+
return vlan_counts
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _network_vlan_entries(networks: Iterable[object]) -> dict[int, dict[str, object]]:
|
|
107
|
+
vlan_entries: dict[int, dict[str, object]] = {}
|
|
108
|
+
for network in normalize_networks(networks):
|
|
109
|
+
vlan_id = network.get("vlan_id")
|
|
110
|
+
if not isinstance(vlan_id, int):
|
|
111
|
+
continue
|
|
112
|
+
entry = vlan_entries.setdefault(
|
|
113
|
+
vlan_id,
|
|
114
|
+
{"id": vlan_id, "name": None, "client_count": 0},
|
|
115
|
+
)
|
|
116
|
+
name = network.get("name")
|
|
117
|
+
if name and not entry["name"]:
|
|
118
|
+
entry["name"] = name
|
|
119
|
+
return vlan_entries
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-network-maps
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.15
|
|
4
4
|
Summary: Dynamic UniFi -> network maps in mermaid or svg
|
|
5
5
|
Author: Merlijn
|
|
6
6
|
License-Expression: MIT
|
|
@@ -147,6 +147,12 @@ Legend only:
|
|
|
147
147
|
unifi-network-maps --legend-only --stdout
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
+
JSON payload (devices + clients + VLAN inventory):
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
unifi-network-maps --format json --output ./payload.json
|
|
154
|
+
```
|
|
155
|
+
|
|
150
156
|
## Home Assistant integration
|
|
151
157
|
|
|
152
158
|
The live Home Assistant integration (Config Flow + coordinator + custom card) lives in a separate repo:
|
|
@@ -238,7 +244,7 @@ SVG:
|
|
|
238
244
|
- `--theme-file`: load a YAML theme for Mermaid + SVG colors (see `examples/theme.yaml` and `examples/theme-dark.yaml`).
|
|
239
245
|
|
|
240
246
|
Output:
|
|
241
|
-
- `--format mermaid|svg|svg-iso|lldp-md|mkdocs`: output format (default mermaid).
|
|
247
|
+
- `--format mermaid|svg|svg-iso|lldp-md|mkdocs|json`: output format (default mermaid).
|
|
242
248
|
- `--stdout`: write output to stdout.
|
|
243
249
|
- `--markdown`: wrap Mermaid output in a code fence.
|
|
244
250
|
- `--mkdocs-sidebar-legend`: write assets to place the compact legend in the MkDocs right sidebar.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
unifi_network_maps/__init__.py,sha256=
|
|
1
|
+
unifi_network_maps/__init__.py,sha256=hbkjjRMZQ6Z1DxtYr5zQQkJBgg7BX1jvecmMzfI1XL8,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
4
|
unifi_network_maps/adapters/config.py,sha256=gnvgAj9wPmYGGQfhcZSHRkcVGojODUYIINU_ztTcuUc,1671
|
|
5
|
-
unifi_network_maps/adapters/unifi.py,sha256=
|
|
5
|
+
unifi_network_maps/adapters/unifi.py,sha256=1a7NBLszpBhLnepp9WhQ5cF-HRToIG0foOA-H5xUiDI,19328
|
|
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
|
|
@@ -52,23 +52,24 @@ unifi_network_maps/assets/themes/dark.yaml,sha256=6n4_H6MZbA6DKLdF2eaNcCXYLKNJjV
|
|
|
52
52
|
unifi_network_maps/assets/themes/default.yaml,sha256=F2Jj18NmdaJ_zyERvGAn8NEWBwapjtozrtZUxayd5AU,849
|
|
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
|
-
unifi_network_maps/cli/args.py,sha256=
|
|
56
|
-
unifi_network_maps/cli/main.py,sha256=
|
|
55
|
+
unifi_network_maps/cli/args.py,sha256=ro3x8Yn77j7gcmJIyLDmpVsmYiYrXrzkF2LZ6QUQmHA,5560
|
|
56
|
+
unifi_network_maps/cli/main.py,sha256=W-iScP6x4zGRTrc3v_3bTgxkeyOCOBiRfqIjJqRiq3o,7088
|
|
57
57
|
unifi_network_maps/cli/render.py,sha256=wuvsC-In-ApbaQxBn8-UofB7jCiqUnjluGXRCRZpg5A,7535
|
|
58
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
61
|
unifi_network_maps/io/export.py,sha256=1_RcXpPqoerCt7WsiheV4dwKgY2fFJlpjUGRs-EkHE4,1013
|
|
62
62
|
unifi_network_maps/io/mkdocs_assets.py,sha256=pszCivIql3maaLw7KFcAioQaVcyLltXgg-fPXPBvvEk,716
|
|
63
|
-
unifi_network_maps/io/mock_data.py,sha256=
|
|
63
|
+
unifi_network_maps/io/mock_data.py,sha256=D5OT0iYipuGKT-gHGx6yNGujOcOsxjmQDCfSzxmJzkI,1446
|
|
64
64
|
unifi_network_maps/io/mock_generate.py,sha256=xiJz_qtNW7iVj7dezLNyVXAHtCHAi8U5CSXK5mrJE4U,233
|
|
65
|
-
unifi_network_maps/io/paths.py,sha256=
|
|
65
|
+
unifi_network_maps/io/paths.py,sha256=AgrF16EkFc63NNDNRzSnxwv1nIjq24zqNgKL7Y6VOVE,5930
|
|
66
66
|
unifi_network_maps/model/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
67
67
|
unifi_network_maps/model/labels.py,sha256=m_k8mbzWtOSDOjjHhLUqwIw93pg98HAtGtHkiERXmek,1135
|
|
68
68
|
unifi_network_maps/model/lldp.py,sha256=SrPW5XC2lfJgaGeVx-KnSFNltyok7gIWWQNg1SkOaj4,3300
|
|
69
|
-
unifi_network_maps/model/mock.py,sha256=
|
|
69
|
+
unifi_network_maps/model/mock.py,sha256=kkzt7BWt5Q8dZ2wgfgK3HLmVdHHmOjAgS_snJsWawAo,9172
|
|
70
70
|
unifi_network_maps/model/ports.py,sha256=o3NBlXcC5VV5iPWJsO4Ll1mRKJZC0f8zTHdlkkE34GU,609
|
|
71
|
-
unifi_network_maps/model/topology.py,sha256=
|
|
71
|
+
unifi_network_maps/model/topology.py,sha256=bkKSy1WlZMMkzya3bTbpYLrseXpwjhOPPoGOf4FHInM,32114
|
|
72
|
+
unifi_network_maps/model/vlans.py,sha256=EeRKvIuUs0FgEzrqbo2xi8Xlm5n5V_Z6b7jpXLiNUaY,3788
|
|
72
73
|
unifi_network_maps/render/__init__.py,sha256=nzx1KsiYalL_YuXKE6acW8Dj5flmMg0-i4gyYO0gV54,22
|
|
73
74
|
unifi_network_maps/render/device_ports_md.py,sha256=PLbP4_EDwLNkUACVJB_uM0ywaQTbEc6OWMv4QBcoJbU,16425
|
|
74
75
|
unifi_network_maps/render/legend.py,sha256=TmZsxgCVOM2CZImI9zgRVyzrcg01HZFDj9F715d4CGo,772
|
|
@@ -92,9 +93,9 @@ unifi_network_maps/render/templates/mkdocs_html_block.html.j2,sha256=5l5-BbNujOc
|
|
|
92
93
|
unifi_network_maps/render/templates/mkdocs_legend.css.j2,sha256=tkTI-RagBSgdjUygVenlTsQFenU09ePbXOfDt_Q7YRM,612
|
|
93
94
|
unifi_network_maps/render/templates/mkdocs_legend.js.j2,sha256=qMYyCKsJ84uXf1wGgzbc7Bc49RU4oyuaGK9KrgQDQEI,685
|
|
94
95
|
unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2,sha256=9IncllWQpoI8BN3A7b2zOQ5cksj97ddsjHJ-aBhpw7o,66
|
|
95
|
-
unifi_network_maps-1.4.
|
|
96
|
-
unifi_network_maps-1.4.
|
|
97
|
-
unifi_network_maps-1.4.
|
|
98
|
-
unifi_network_maps-1.4.
|
|
99
|
-
unifi_network_maps-1.4.
|
|
100
|
-
unifi_network_maps-1.4.
|
|
96
|
+
unifi_network_maps-1.4.15.dist-info/licenses/LICENSE,sha256=mYo1siIIfIwyfdOuK2-Zt0ij2xBTii2hnpeTu79nD80,1074
|
|
97
|
+
unifi_network_maps-1.4.15.dist-info/METADATA,sha256=Mc5M1u-ACVLVZm6xX-Giv4KhP7r7wZ5V1jfvBoKriYY,10079
|
|
98
|
+
unifi_network_maps-1.4.15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
99
|
+
unifi_network_maps-1.4.15.dist-info/entry_points.txt,sha256=cdJ7jsBgNgHxSflYUOqgz5BbvuM0Nnh-x8_Hbyh_LFg,67
|
|
100
|
+
unifi_network_maps-1.4.15.dist-info/top_level.txt,sha256=G0rUX1PNfVCn1u-KtB6QjFQHopCOVLnPMczvPOoraHg,19
|
|
101
|
+
unifi_network_maps-1.4.15.dist-info/RECORD,,
|
{unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{unifi_network_maps-1.4.13.dist-info → unifi_network_maps-1.4.15.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|