Skip to content

Commit 54a0c2a

Browse files
authored
feat(unifi): use switch port rates for wired clients (#40)
Co-authored-by: tbaur <tbaur@users.noreply.github.com>
1 parent 2b920e3 commit 54a0c2a

6 files changed

Lines changed: 360 additions & 85 deletions

File tree

cli.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,13 +169,18 @@ def discover(self) -> None:
169169
print("-" * 40)
170170
print()
171171

172-
def status(self, pattern: Optional[str] = None, json_mode: bool = False):
172+
def status(
173+
self,
174+
pattern: Optional[str] = None,
175+
json_mode: bool = False,
176+
force_refresh: bool = False,
177+
):
173178
"""Display device status."""
174179
if not self.ctl.ips:
175180
print(f"{RED}Error: No IPs found (Try --scan).{RESET}\n")
176181
return
177182

178-
ustatus = self.ctl.sync_unifi()
183+
ustatus = self.ctl.sync_unifi(force_refresh=force_refresh)
179184
devices: List[PlayerStatus] = []
180185

181186
max_workers = min(MAX_WORKERS_STATUS, len(self.ctl.ips)) if self.ctl.ips else MAX_WORKERS_STATUS
@@ -258,7 +263,11 @@ def _print_device_status(self, devices: List[PlayerStatus], json_mode: bool) ->
258263
print(f" IP: {endpoint_disp} | {conn_str}")
259264

260265
if d.unifi and d.unifi.uplink != 'Unknown':
261-
print(f" Net: ↓ {format_rate(d.unifi.down_rate)}{format_rate(d.unifi.up_rate)} (Total: {format_bytes(d.unifi.down_tot)} / {format_bytes(d.unifi.up_tot)})")
266+
rate_src = f" [{d.unifi.rate_source}]" if d.unifi.rate_source else ""
267+
print(
268+
f" Net: ↓ {format_rate(d.unifi.down_rate)}{format_rate(d.unifi.up_rate)} "
269+
f"(Total: {format_bytes(d.unifi.down_tot)} / {format_bytes(d.unifi.up_tot)}){rate_src}"
270+
)
262271
print(f" Link: Connected to '{d.unifi.uplink}' ({d.unifi.port_info}) | Conn Time: {format_uptime(d.unifi.uptime)}")
263272

264273
sys_line = f"System: FW {d.fw or 'N/A'}"
@@ -603,11 +612,18 @@ def diagnose(self, target: str) -> None:
603612
print(f"ARP MAC: {CYAN}{arp_mac}{RESET}")
604613
print(f"Sys Uptime: {GREEN}{sys_uptime}{RESET}")
605614

606-
self.ctl.sync_unifi()
615+
self.ctl.sync_unifi(force_refresh=True)
607616
u = self.ctl.unifi_map.get(tgt_ip)
608617
if u:
609-
print(f"UniFi DB: {GREEN}FOUND{RESET} -> Wired: {u.is_wired}, Uplink: {u.uplink}")
610-
print(f" Conn Time: {format_uptime(u.uptime)}")
618+
rate_note = f", Rates: {u.rate_source}" if u.rate_source else ""
619+
print(
620+
f"UniFi DB: {GREEN}FOUND{RESET} -> Wired: {u.is_wired}, "
621+
f"Uplink: {u.uplink}{rate_note}"
622+
)
623+
print(
624+
f" ↓ {format_rate(u.down_rate)}{format_rate(u.up_rate)} | "
625+
f"Conn Time: {format_uptime(u.uptime)}"
626+
)
611627
else:
612628
print(f"UniFi DB: {DIM}Not Found{RESET}")
613629

controller.py

Lines changed: 198 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -430,101 +430,223 @@ def _resolve_hosts(self, hosts: Set[str]) -> Set[str]:
430430
continue
431431
return found_ips
432432

433-
def sync_unifi(self) -> str:
434-
"""Fetches client data from UniFi Controller."""
433+
@staticmethod
434+
def _unifi_api_headers(api_key: str) -> Dict[str, str]:
435+
return {
436+
'X-API-KEY': api_key,
437+
'Accept': 'application/json',
438+
'Content-Type': 'application/json',
439+
}
440+
441+
def _fetch_unifi_json(
442+
self, base: str, site: str, api_key: str, path: str, timeout: int = 4
443+
) -> Optional[Dict]:
444+
"""GET a UniFi Network API path and return parsed JSON, or None on failure."""
445+
url = f"https://{base}/proxy/network/api/s/{site}/{path}"
446+
resp_bytes = Network.get(
447+
url, timeout=timeout, headers=self._unifi_api_headers(api_key)
448+
)
449+
if not resp_bytes:
450+
return None
451+
try:
452+
parsed = json.loads(resp_bytes)
453+
return parsed if isinstance(parsed, dict) else None
454+
except (json.JSONDecodeError, TypeError):
455+
return None
456+
457+
@staticmethod
458+
def _switch_port_rate_index(
459+
devices: List[Dict],
460+
) -> Dict[Tuple[str, int], Tuple[float, float]]:
461+
"""Map ``(switch_mac, port_idx)`` → ``(down_Bps, up_Bps)`` from port_table.
462+
463+
Switch TX is traffic to the client (download); RX is from the client (upload).
464+
"""
465+
index: Dict[Tuple[str, int], Tuple[float, float]] = {}
466+
for device in devices:
467+
port_table = device.get('port_table')
468+
if not port_table:
469+
continue
470+
sw_mac = str(device.get('mac') or '').lower()
471+
if not sw_mac:
472+
continue
473+
for port in port_table:
474+
port_idx = port.get('port_idx')
475+
if port_idx is None:
476+
continue
477+
try:
478+
idx = int(port_idx)
479+
except (TypeError, ValueError):
480+
continue
481+
down = port.get('tx_bytes-r', port.get('tx_bytes_r', 0)) or 0
482+
up = port.get('rx_bytes-r', port.get('rx_bytes_r', 0)) or 0
483+
try:
484+
index[(sw_mac, idx)] = (float(down), float(up))
485+
except (TypeError, ValueError):
486+
continue
487+
return index
488+
489+
@staticmethod
490+
def _client_traffic_fields(client: Dict, is_wired: bool) -> Tuple[float, float, float, float]:
491+
"""Return ``(down_tot, up_tot, down_rate, up_rate)`` from a ``stat/sta`` client."""
492+
if is_wired:
493+
return (
494+
float(client.get('wired-tx_bytes', 0) or 0),
495+
float(client.get('wired-rx_bytes', 0) or 0),
496+
float(client.get('wired-tx_bytes-r', 0) or 0),
497+
float(client.get('wired-rx_bytes-r', 0) or 0),
498+
)
499+
return (
500+
float(client.get('tx_bytes', 0) or 0),
501+
float(client.get('rx_bytes', 0) or 0),
502+
float(client.get('tx_bytes-r', 0) or 0),
503+
float(client.get('rx_bytes-r', 0) or 0),
504+
)
505+
506+
def _unifi_client_from_sta(
507+
self,
508+
client: Dict,
509+
port_rates: Dict[Tuple[str, int], Tuple[float, float]],
510+
) -> UniFiClient:
511+
"""Build a UniFiClient: Wi‑Fi uses STA rates; wired prefers switch port rates."""
512+
is_wired = bool(client.get('is_wired', False)) or str(client.get('type', '')).upper() == 'WIRED'
513+
down_tot, up_tot, down_rate, up_rate = self._client_traffic_fields(client, is_wired)
514+
rate_source = "wifi"
515+
516+
if is_wired:
517+
uplink = client.get('last_uplink_name') or 'Unknown Switch'
518+
port_raw = client.get('sw_port')
519+
if port_raw is None:
520+
port_raw = client.get('last_uplink_remote_port')
521+
port_info = str(port_raw) if port_raw is not None else ''
522+
rate_source = "client"
523+
sw_mac = str(client.get('sw_mac') or '').lower()
524+
try:
525+
port_idx = int(port_raw) if port_raw is not None else None
526+
except (TypeError, ValueError):
527+
port_idx = None
528+
if sw_mac and port_idx is not None:
529+
port_rate = port_rates.get((sw_mac, port_idx))
530+
if port_rate is not None:
531+
down_rate, up_rate = port_rate
532+
rate_source = "switch-port"
533+
else:
534+
uplink = (
535+
client.get('ap_name')
536+
or client.get('last_uplink_name')
537+
or client.get('ap_mac')
538+
or 'Unknown AP'
539+
)
540+
essid = client.get('essid', '')
541+
port_info = f"WiFi: {essid}" if essid else "WiFi"
542+
543+
return UniFiClient(
544+
mac=str(client.get('mac', '')).lower(),
545+
is_wired=is_wired,
546+
uplink=uplink,
547+
port_info=port_info,
548+
down_tot=int(down_tot),
549+
up_tot=int(up_tot),
550+
down_rate=int(down_rate),
551+
up_rate=int(up_rate),
552+
uptime=int(client.get('uptime', 0) or 0),
553+
rate_source=rate_source,
554+
)
555+
556+
def _load_unifi_cache(self, target_ips: Set[str]) -> Optional[Dict[str, UniFiClient]]:
557+
"""Return a usable UniFi cache covering ``target_ips``, else None."""
558+
if not os.path.exists(UNIFI_CACHE_FILE):
559+
return None
560+
try:
561+
with open(UNIFI_CACHE_FILE, "r") as f:
562+
data = json.load(f)
563+
if time.time() - float(data.get('ts', 0)) >= int(self.config.get('CACHE_TTL', 300)):
564+
return None
565+
cached_clients = data.get('clients', {}) or {}
566+
cached_map: Dict[str, UniFiClient] = {}
567+
for ip, payload in cached_clients.items():
568+
if not sanitize_ip(str(ip)):
569+
continue
570+
try:
571+
cached_map[ip] = UniFiClient(**payload)
572+
except TypeError:
573+
# Drop entries from incompatible/older cache shapes
574+
continue
575+
if cached_map and (target_ips & set(cached_map.keys())):
576+
return cached_map
577+
logger.debug(
578+
"UniFi cache miss for discovered players; refreshing "
579+
f"(targets={sorted(target_ips)}, cached={sorted(cached_map.keys())})"
580+
)
581+
except Exception:
582+
return None
583+
return None
584+
585+
def sync_unifi(self, force_refresh: bool = False) -> str:
586+
"""Fetches client data from UniFi Controller.
587+
588+
Live rates:
589+
- Wi‑Fi clients → ``stat/sta`` AP/client counters
590+
- Wired clients → switch ``port_table`` rates via ``sw_mac`` + ``sw_port``
591+
(falls back to client ``wired-*`` rates if port stats are unavailable)
592+
"""
435593
if self.config.get('UNIFI_ENABLED') != 'true' or not self.ips:
436594
return "SKIPPED"
437-
438-
# Check config first before checking cache
595+
439596
base = self.config.get('UNIFI_CONTROLLER')
440597
site = self.config.get('UNIFI_SITE', 'default')
441598
key = self.config.get_unifi_api_key()
442-
599+
443600
if not base or not key:
444601
return "MISSING_CONFIG"
445-
602+
446603
# self.ips holds ip:port endpoints; UniFi keys are chassis IPs
447604
target_ips = {parse_bluos_host(ep) for ep in self.ips}
448605
target_ips.discard("")
449606
if not target_ips:
450607
return "SKIPPED"
451608

452-
# Check Cache — only reuse if it still covers discovered players
453-
if os.path.exists(UNIFI_CACHE_FILE):
454-
try:
455-
with open(UNIFI_CACHE_FILE, "r") as f:
456-
data = json.load(f)
457-
if time.time() - float(data.get('ts', 0)) < int(self.config.get('CACHE_TTL', 300)):
458-
cached_clients = data.get('clients', {}) or {}
459-
cached_map = {
460-
ip: UniFiClient(**d)
461-
for ip, d in cached_clients.items()
462-
if sanitize_ip(str(ip))
463-
}
464-
# Ignore stale/empty caches that don't match current players
465-
if cached_map and (target_ips & set(cached_map.keys())):
466-
self.unifi_map = cached_map
467-
return "CACHED"
468-
logger.debug(
469-
"UniFi cache miss for discovered players; refreshing "
470-
f"(targets={sorted(target_ips)}, cached={sorted(cached_map.keys())})"
471-
)
472-
except Exception:
473-
pass
474-
475-
# Fetch Fresh
476-
477-
url = f"https://{base}/proxy/network/api/s/{site}/stat/sta"
478-
headers = {
479-
'X-API-KEY': key,
480-
'Accept': 'application/json',
481-
'Content-Type': 'application/json'
482-
}
483-
484-
resp_bytes = Network.get(url, timeout=4, headers=headers)
485-
if not resp_bytes:
486-
# Graceful degradation: continue without UniFi data
609+
if not force_refresh:
610+
cached = self._load_unifi_cache(target_ips)
611+
if cached is not None:
612+
self.unifi_map = cached
613+
return "CACHED"
614+
615+
sta = self._fetch_unifi_json(base, site, key, "stat/sta")
616+
if not sta:
487617
logger.warning("UniFi fetch failed, continuing without network stats")
488618
return "ERROR_FETCH"
489-
619+
490620
try:
491-
raw = json.loads(resp_bytes)
492-
temp_map = {}
493-
494-
for c in raw.get('data', []):
495-
ip = c.get('ip')
621+
stations = [
622+
c for c in sta.get('data', [])
623+
if isinstance(c, dict)
624+
and sanitize_ip(str(c.get('ip') or '')) in target_ips
625+
]
626+
needs_port_stats = any(
627+
bool(c.get('is_wired', False)) or str(c.get('type', '')).upper() == 'WIRED'
628+
for c in stations
629+
)
630+
port_rates: Dict[Tuple[str, int], Tuple[float, float]] = {}
631+
if needs_port_stats:
632+
devices = self._fetch_unifi_json(base, site, key, "stat/device")
633+
if devices:
634+
port_rates = self._switch_port_rate_index(
635+
[d for d in devices.get('data', []) if isinstance(d, dict)]
636+
)
637+
else:
638+
logger.warning(
639+
"UniFi switch stats unavailable; falling back to wired client rates"
640+
)
641+
642+
temp_map: Dict[str, UniFiClient] = {}
643+
for client in stations:
644+
ip = sanitize_ip(str(client.get('ip') or ''))
496645
if not ip:
497646
continue
498-
# Validate IP from UniFi response
499-
sanitized_ip = sanitize_ip(str(ip))
500-
if not sanitized_ip or sanitized_ip not in target_ips:
501-
continue
502-
ip = sanitized_ip
503-
504-
is_wired = c.get('is_wired', False) or str(c.get('type', '')).upper() == 'WIRED'
505-
506-
if is_wired:
507-
uplink = c.get('last_uplink_name', 'Unknown Switch')
508-
port_info = str(c.get('sw_port') or c.get('last_uplink_remote_port') or '')
509-
else:
510-
uplink = c.get('ap_name') or c.get('last_uplink_name') or c.get('ap_mac') or 'Unknown AP'
511-
essid = c.get('essid', '')
512-
port_info = f"WiFi: {essid}" if essid else "WiFi"
513-
514-
temp_map[ip] = UniFiClient(
515-
mac=c.get('mac', '').lower(),
516-
is_wired=is_wired,
517-
uplink=uplink,
518-
port_info=port_info,
519-
down_tot=c.get('tx_bytes', 0) if not is_wired else c.get('wired-tx_bytes', 0),
520-
up_tot=c.get('rx_bytes', 0) if not is_wired else c.get('wired-rx_bytes', 0),
521-
down_rate=c.get('tx_bytes-r', 0) if not is_wired else c.get('wired-tx_bytes-r', 0),
522-
up_rate=c.get('rx_bytes-r', 0) if not is_wired else c.get('wired-rx_bytes-r', 0),
523-
uptime=c.get('uptime', 0)
524-
)
525-
647+
temp_map[ip] = self._unifi_client_from_sta(client, port_rates)
648+
526649
self.unifi_map = temp_map
527-
# Don't overwrite a good cache with an empty fetch result
528650
if temp_map:
529651
cache_payload = {ip: asdict(obj) for ip, obj in temp_map.items()}
530652
atomic_write(UNIFI_CACHE_FILE, {'ts': time.time(), 'clients': cache_payload})

main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,11 @@ def main() -> None:
338338
if args.command == "discover":
339339
cli.discover()
340340
elif args.command == "status":
341-
cli.status(pattern=args.pattern, json_mode=args.json)
341+
cli.status(
342+
pattern=args.pattern,
343+
json_mode=args.json,
344+
force_refresh=getattr(args, 'scan', False),
345+
)
342346
elif args.command == "volume":
343347
cli.volume(args)
344348
elif args.command == "play":

models.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ class UniFiClient:
3434
down_rate: int = 0
3535
up_rate: int = 0
3636
uptime: int = 0
37+
# Where live rates came from: "wifi", "switch-port", or "client" (fallback).
38+
rate_source: str = ""
3739

3840

3941
@dataclass

0 commit comments

Comments
 (0)