From c596e82cf29d445cd6f239b78f99ac2081d52b87 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Sat, 28 Mar 2026 09:42:35 -0500 Subject: [PATCH] fix: use v2 traffic API as DPI fallback for Network 9.1+ firmware (#985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy /stat/stadpi and /stat/sitedpi endpoints return empty data on UniFi Network 9.1+ (issue #834). The v2 /traffic endpoint already existed in the unifi library and in the collector, but was only called when both SaveTraffic and SaveDPI were enabled — most users only set SaveDPI=true and never saw any data. - Remove the SaveTraffic gate on GetClientTraffic; call it whenever SaveDPI is enabled, treating it as a DPI data source - Downgrade GetClientTraffic errors to debug-log so old firmware that lacks the v2 endpoint continues to use the legacy API without error - Add convertToSiteDPI to aggregate per-client v2 data into per-site DPITable entries, filling SitesDPI when the legacy endpoint is empty - Legacy API results are preserved; v2 data only supplements sites not already covered, so old-firmware users are unaffected Co-authored-by: Claude Sonnet 4.6 (1M context) --- pkg/inputunifi/collector.go | 114 +++++++++++++++++++++++++++++++++--- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index ef99c6e9..6d5ed209 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -152,16 +152,23 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) { u.LogDebugf("Found %d CountryTraffic entries", len(m.CountryTraffic)) } - if c.SaveTraffic != nil && *c.SaveTraffic && c.SaveDPI != nil && *c.SaveDPI { + if c.SaveDPI != nil && *c.SaveDPI { + // Supplement DPI data with the v2 traffic API, which works on newer firmware + // (Network 9.1+) where the legacy /stat/stadpi and /stat/sitedpi endpoints + // return empty results. GetClientTraffic is called regardless of SaveTraffic + // because it provides DPI-equivalent per-client app/category breakdowns. clientUsageByApp, err := c.Unifi.GetClientTraffic(sites, &tp, true) if err != nil { - return nil, fmt.Errorf("unifi.GetClientTraffic(%s): %w", c.URL, err) + u.LogDebugf("unifi.GetClientTraffic(%s): %v (legacy DPI endpoints will be used if available)", c.URL, err) + } else { + u.LogDebugf("Found %d ClientUsageByApp entries", len(clientUsageByApp)) + b4 := len(m.ClientsDPI) + u.convertToClientDPI(clientUsageByApp, m) + u.LogDebugf("Added %d ClientDPI entries from v2 traffic API for a total of %d", len(m.ClientsDPI)-b4, len(m.ClientsDPI)) + b4Sites := len(m.SitesDPI) + u.convertToSiteDPI(clientUsageByApp, m) + u.LogDebugf("Added %d SitesDPI entries from v2 traffic API for a total of %d", len(m.SitesDPI)-b4Sites, len(m.SitesDPI)) } - - u.LogDebugf("Found %d ClientUsageByApp entries", len(clientUsageByApp)) - b4 := len(m.ClientsDPI) - u.convertToClientDPI(clientUsageByApp, m) - u.LogDebugf("Added %d ClientDPI entries for a total of %d", len(m.ClientsDPI)-b4, len(m.ClientsDPI)) } // Get all the points. @@ -358,6 +365,99 @@ func (u *InputUnifi) convertToClientDPI(clientUsageByApp []*unifi.ClientUsageByA } } +// convertToSiteDPI aggregates v2 client traffic data into per-site DPITable entries. +// It only adds a site entry if the site doesn't already have one from the legacy API, +// so old-firmware users are unaffected. +func (u *InputUnifi) convertToSiteDPI(clientUsageByApp []*unifi.ClientUsageByApp, metrics *Metrics) { + // Build a set of sites already covered by the legacy API. + existing := make(map[string]bool) + + for _, s := range metrics.SitesDPI { + existing[s.SiteName] = true + } + + type appKey struct { + App int + Cat int + } + + type siteAgg struct { + byApp map[appKey]*unifi.DPIData + byCat map[int]*unifi.DPIData + sourceName string + } + + siteMap := make(map[string]*siteAgg) + + for _, client := range clientUsageByApp { + siteName := client.TrafficSite.SiteName + if existing[siteName] { + continue + } + + agg, ok := siteMap[siteName] + if !ok { + agg = &siteAgg{ + byApp: make(map[appKey]*unifi.DPIData), + byCat: make(map[int]*unifi.DPIData), + sourceName: client.TrafficSite.SourceName, + } + siteMap[siteName] = agg + } + + for _, app := range client.UsageByApp { + k := appKey{App: app.Application, Cat: app.Category} + + if d, ok := agg.byApp[k]; ok { + d.RxBytes.Val += float64(app.BytesReceived) + d.TxBytes.Val += float64(app.BytesTransmitted) + } else { + agg.byApp[k] = &unifi.DPIData{ + App: u.intToFlexInt(app.Application), + Cat: u.intToFlexInt(app.Category), + RxBytes: u.int64ToFlexInt(app.BytesReceived), + RxPackets: u.int64ToFlexInt(0), + TxBytes: u.int64ToFlexInt(app.BytesTransmitted), + TxPackets: u.int64ToFlexInt(0), + } + } + + if d, ok := agg.byCat[app.Category]; ok { + d.RxBytes.Val += float64(app.BytesReceived) + d.TxBytes.Val += float64(app.BytesTransmitted) + } else { + agg.byCat[app.Category] = &unifi.DPIData{ + App: u.intToFlexInt(16777215), // unknown app — category aggregate + Cat: u.intToFlexInt(app.Category), + RxBytes: u.int64ToFlexInt(app.BytesReceived), + RxPackets: u.int64ToFlexInt(0), + TxBytes: u.int64ToFlexInt(app.BytesTransmitted), + TxPackets: u.int64ToFlexInt(0), + } + } + } + } + + for siteName, agg := range siteMap { + byApp := make([]unifi.DPIData, 0, len(agg.byApp)) + for _, d := range agg.byApp { + byApp = append(byApp, *d) + } + + byCat := make([]unifi.DPIData, 0, len(agg.byCat)) + for _, d := range agg.byCat { + byCat = append(byCat, *d) + } + + metrics.SitesDPI = append(metrics.SitesDPI, &unifi.DPITable{ + ByApp: byApp, + ByCat: byCat, + SiteName: siteName, + SourceName: agg.sourceName, + }) + } +} + // augmentMetrics is our middleware layer between collecting metrics and writing them. // This is where we can manipuate the returned data or make arbitrary decisions. // This method currently adds parent device names to client metrics and hashes PII.