fix(influxunifi): use CelsiusSafe() for temp fields to fix InfluxDB type conflict (#944) (#945)

* fix(influxunifi): use CelsiusSafe() for temp fields to fix InfluxDB type conflict

Write temp_* fields as float64 instead of int64 so InfluxDB does not
report 'field type conflict' when the measurement already has float.

Requires github.com/unpoller/unifi/v5 with CelsiusSafe() (unpoller/unifi#195).
Fixes #944.

Co-authored-by: Cursor <cursoragent@cursor.com>

* deps: unifi v5.17.0; nil guards and 429 retry (unpoller#943)

- Bump github.com/unpoller/unifi/v5 to v5.17.0 (CelsiusSafe, ErrNilUnifi, RateLimitError)
- inputunifi: guard pollController for nil c.Unifi; controllerID(c) in formatSites/Clients/Devices
- inputunifi: getUnifi retry with backoff on 429 (up to 5 attempts, Retry-After or exponential backoff)

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(influxunifi): expect temp_* as float after CelsiusSafe() (fix #944)

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Brian Gates
2026-02-03 21:12:26 -05:00
committed by GitHub
parent 6488feff06
commit b4fa16b2fd
7 changed files with 91 additions and 46 deletions

2
go.mod
View File

@@ -12,7 +12,7 @@ require (
github.com/prometheus/common v0.67.5 github.com/prometheus/common v0.67.5
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/unpoller/unifi/v5 v5.16.0 github.com/unpoller/unifi/v5 v5.17.0
golang.org/x/crypto v0.47.0 golang.org/x/crypto v0.47.0
golang.org/x/term v0.39.0 golang.org/x/term v0.39.0
golift.io/cnfg v0.2.3 golift.io/cnfg v0.2.3

4
go.sum
View File

@@ -77,8 +77,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/unpoller/unifi/v5 v5.16.0 h1:FowfkJ7wbMoySFcqOJG2IJH9pOGTUnPpKNNG9vHl2/I= github.com/unpoller/unifi/v5 v5.17.0 h1:e2yES/35+/Ddd6BsXOjXRhsO663uqI99PKleS9plF/w=
github.com/unpoller/unifi/v5 v5.16.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo= github.com/unpoller/unifi/v5 v5.17.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=

View File

@@ -115,11 +115,11 @@ points:
stat_tx_retries: float stat_tx_retries: float
state: float state: float
system_uptime: float system_uptime: float
temp_cpu: int temp_cpu: float
temp_memory: int temp_memory: float
temp_network: int temp_network: float
temp_probe: int temp_probe: float
temp_sys: int temp_sys: float
total_max_power: float total_max_power: float
tx_bytes: float tx_bytes: float
upgradeable: bool upgradeable: bool
@@ -275,11 +275,11 @@ points:
stat_user-tx_retries: float stat_user-tx_retries: float
state: string state: string
system_uptime: float system_uptime: float
temp_cpu: int temp_cpu: float
temp_memory: int temp_memory: float
temp_network: int temp_network: float
temp_probe: int temp_probe: float
temp_sys: int temp_sys: float
tx_bytes: float tx_bytes: float
upgradeable: bool upgradeable: bool
uptime: float uptime: float
@@ -520,11 +520,11 @@ points:
stat_wifi_tx_dropped: float stat_wifi_tx_dropped: float
state: float state: float
system_uptime: float system_uptime: float
temp_cpu: int temp_cpu: float
temp_memory: int temp_memory: float
temp_network: int temp_network: float
temp_probe: int temp_probe: float
temp_sys: int temp_sys: float
tx_bytes: float tx_bytes: float
uplink_latency: float uplink_latency: float
uplink_max_speed: float uplink_max_speed: float
@@ -573,11 +573,11 @@ points:
stat_tx_retries: float stat_tx_retries: float
state: float state: float
system_uptime: float system_uptime: float
temp_cpu: int temp_cpu: float
temp_memory: int temp_memory: float
temp_network: int temp_network: float
temp_probe: int temp_probe: float
temp_sys: int temp_sys: float
tx_bytes: float tx_bytes: float
uptime: float uptime: float
version: string version: string
@@ -769,11 +769,11 @@ points:
storage_foo_size: float storage_foo_size: float
storage_foo_used: float storage_foo_used: float
system_uptime: float system_uptime: float
temp_cpu: int temp_cpu: float
temp_memory: int temp_memory: float
temp_network: int temp_network: float
temp_probe: int temp_probe: float
temp_sys: int temp_sys: float
tx_bytes: float tx_bytes: float
upgradeable: bool upgradeable: bool
uplink_latency: float uplink_latency: float

View File

@@ -41,7 +41,7 @@ func (u *InfluxUnifi) batchSysStats(s unifi.SysStats, ss unifi.SystemStats) map[
} }
for k, v := range ss.Temps { for k, v := range ss.Temps {
temp := v.CelsiusInt64() temp := v.CelsiusSafe()
if temp != 0 && k != "" { if temp != 0 && k != "" {
m["temp_"+sanitizeName(k)] = temp m["temp_"+sanitizeName(k)] = temp

View File

@@ -100,6 +100,10 @@ func (u *InputUnifi) pollController(c *Controller) (*poller.Metrics, error) {
u.RLock() u.RLock()
defer u.RUnlock() defer u.RUnlock()
if c.Unifi == nil {
return nil, fmt.Errorf("controller client is nil (e.g. after 429 or auth failure): %s", c.URL)
}
u.LogDebugf("Polling controller: %s (%s)", c.URL, c.ID) u.LogDebugf("Polling controller: %s (%s)", c.URL, c.ID)
// Get the sites we care about. // Get the sites we care about.

View File

@@ -3,6 +3,7 @@
package inputunifi package inputunifi
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"strings" "strings"
@@ -123,8 +124,11 @@ func (c *Controller) getCerts() ([][]byte, error) {
return b, nil return b, nil
} }
const maxAuthRetries = 5
// getUnifi (re-)authenticates to a unifi controller. // getUnifi (re-)authenticates to a unifi controller.
// If certificate files are provided, they are re-read. // If certificate files are provided, they are re-read.
// On 429 Too Many Requests, retries with exponential backoff (and Retry-After when present) up to maxAuthRetries.
func (u *InputUnifi) getUnifi(c *Controller) error { func (u *InputUnifi) getUnifi(c *Controller) error {
u.Lock() u.Lock()
defer u.Unlock() defer u.Unlock()
@@ -138,8 +142,7 @@ func (u *InputUnifi) getUnifi(c *Controller) error {
return err return err
} }
// Create an authenticated session to the Unifi Controller. cfg := &unifi.Config{
c.Unifi, err = unifi.NewUnifi(&unifi.Config{
User: c.User, User: c.User,
Pass: c.Pass, Pass: c.Pass,
APIKey: c.APIKey, APIKey: c.APIKey,
@@ -147,18 +150,42 @@ func (u *InputUnifi) getUnifi(c *Controller) error {
SSLCert: certs, SSLCert: certs,
VerifySSL: *c.VerifySSL, VerifySSL: *c.VerifySSL,
Timeout: c.Timeout.Duration, Timeout: c.Timeout.Duration,
ErrorLog: u.LogErrorf, // Log all errors. ErrorLog: u.LogErrorf,
DebugLog: u.LogDebugf, // Log debug messages. DebugLog: u.LogDebugf,
})
if err != nil {
c.Unifi = nil
return fmt.Errorf("unifi controller: %w", err)
} }
u.LogDebugf("Authenticated with controller successfully, %s", c.URL) var lastErr error
backoff := 30 * time.Second
for attempt := 0; attempt < maxAuthRetries; attempt++ {
c.Unifi, lastErr = unifi.NewUnifi(cfg)
if lastErr == nil {
u.LogDebugf("Authenticated with controller successfully, %s", c.URL)
return nil return nil
}
if !errors.Is(lastErr, unifi.ErrTooManyRequests) {
c.Unifi = nil
return fmt.Errorf("unifi controller: %w", lastErr)
}
var rl *unifi.RateLimitError
if errors.As(lastErr, &rl) && rl.RetryAfter > 0 {
backoff = rl.RetryAfter
}
if attempt < maxAuthRetries-1 {
u.Logf("Controller %s returned 429 Too Many Requests; waiting %v before retry (%d/%d)",
c.URL, backoff, attempt+1, maxAuthRetries)
time.Sleep(backoff)
if backoff < 5*time.Minute {
backoff = backoff * 2
}
}
}
c.Unifi = nil
return fmt.Errorf("unifi controller: %w (gave up after %d retries)", lastErr, maxAuthRetries)
} }
// checkSites makes sure the list of provided sites exists on the controller. // checkSites makes sure the list of provided sites exists on the controller.

View File

@@ -11,6 +11,16 @@ import (
/* This code reformats our data to be displayed on the built-in web interface. */ /* This code reformats our data to be displayed on the built-in web interface. */
// controllerID returns the controller UUID for display, or "" if the client is nil (e.g. after 429 re-auth failure).
// Avoids SIGSEGV when updateWeb runs while c.Unifi is nil (see unpoller/unpoller#943).
func controllerID(c *Controller) string {
if c == nil || c.Unifi == nil {
return ""
}
return c.Unifi.UUID
}
func updateWeb(c *Controller, metrics *Metrics) { func updateWeb(c *Controller, metrics *Metrics) {
webserver.UpdateInput(&webserver.Input{ webserver.UpdateInput(&webserver.Input{
Name: PluginName, // Forgetting this leads to 3 hours of head scratching. Name: PluginName, // Forgetting this leads to 3 hours of head scratching.
@@ -65,13 +75,15 @@ func formatControllers(controllers []*Controller) []*Controller {
} }
func formatSites(c *Controller, sites []*unifi.Site) (s webserver.Sites) { func formatSites(c *Controller, sites []*unifi.Site) (s webserver.Sites) {
id := controllerID(c)
for _, site := range sites { for _, site := range sites {
s = append(s, &webserver.Site{ s = append(s, &webserver.Site{
ID: site.ID, ID: site.ID,
Name: site.Name, Name: site.Name,
Desc: site.Desc, Desc: site.Desc,
Source: site.SourceName, Source: site.SourceName,
Controller: c.Unifi.UUID, Controller: id,
}) })
} }
@@ -97,7 +109,7 @@ func formatClients(c *Controller, clients []*unifi.Client) (d webserver.Clients)
Name: client.Name, Name: client.Name,
SiteID: client.SiteID, SiteID: client.SiteID,
Source: client.SourceName, Source: client.SourceName,
Controller: c.Unifi.UUID, Controller: controllerID(c),
MAC: client.Mac, MAC: client.Mac,
IP: client.IP, IP: client.IP,
Type: clientType, Type: clientType,
@@ -117,12 +129,14 @@ func formatDevices(c *Controller, devices *unifi.Devices) (d webserver.Devices)
return d return d
} }
id := controllerID(c)
for _, device := range devices.UAPs { for _, device := range devices.UAPs {
d = append(d, &webserver.Device{ d = append(d, &webserver.Device{
Name: device.Name, Name: device.Name,
SiteID: device.SiteID, SiteID: device.SiteID,
Source: device.SourceName, Source: device.SourceName,
Controller: c.Unifi.UUID, Controller: id,
MAC: device.Mac, MAC: device.Mac,
IP: device.IP, IP: device.IP,
Type: device.Type, Type: device.Type,
@@ -139,7 +153,7 @@ func formatDevices(c *Controller, devices *unifi.Devices) (d webserver.Devices)
Name: device.Name, Name: device.Name,
SiteID: device.SiteID, SiteID: device.SiteID,
Source: device.SourceName, Source: device.SourceName,
Controller: c.Unifi.UUID, Controller: id,
MAC: device.Mac, MAC: device.Mac,
IP: device.IP, IP: device.IP,
Type: device.Type, Type: device.Type,
@@ -156,7 +170,7 @@ func formatDevices(c *Controller, devices *unifi.Devices) (d webserver.Devices)
Name: device.Name, Name: device.Name,
SiteID: device.SiteID, SiteID: device.SiteID,
Source: device.SourceName, Source: device.SourceName,
Controller: c.Unifi.UUID, Controller: id,
MAC: device.Mac, MAC: device.Mac,
IP: device.IP, IP: device.IP,
Type: device.Type, Type: device.Type,
@@ -173,7 +187,7 @@ func formatDevices(c *Controller, devices *unifi.Devices) (d webserver.Devices)
Name: device.Name, Name: device.Name,
SiteID: device.SiteID, SiteID: device.SiteID,
Source: device.SourceName, Source: device.SourceName,
Controller: c.Unifi.UUID, Controller: id,
MAC: device.Mac, MAC: device.Mac,
IP: device.IP, IP: device.IP,
Type: device.Type, Type: device.Type,