@@ -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 })
0 commit comments