fix(inputs.huebridge): Cleanup and fix linter issues (#16606)

This commit is contained in:
Sven Rebhan 2025-03-10 16:56:51 +01:00 committed by GitHub
parent 0001ae490a
commit 8974563967
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 131 deletions

View File

@ -347,7 +347,6 @@ following works:
- github.com/rivo/uniseg [MIT License](https://github.com/rivo/uniseg/blob/master/LICENSE.txt) - github.com/rivo/uniseg [MIT License](https://github.com/rivo/uniseg/blob/master/LICENSE.txt)
- github.com/robbiet480/go.nut [MIT License](https://github.com/robbiet480/go.nut/blob/master/LICENSE) - github.com/robbiet480/go.nut [MIT License](https://github.com/robbiet480/go.nut/blob/master/LICENSE)
- github.com/robinson/gos7 [BSD 3-Clause "New" or "Revised" License](https://github.com/robinson/gos7/blob/master/LICENSE) - github.com/robinson/gos7 [BSD 3-Clause "New" or "Revised" License](https://github.com/robinson/gos7/blob/master/LICENSE)
- github.com/rs/zerolog [MIT License](https://github.com/rs/zerolog/blob/master/LICENSE)
- github.com/russross/blackfriday [BSD 2-Clause "Simplified" License](https://github.com/russross/blackfriday/blob/master/LICENSE.txt) - github.com/russross/blackfriday [BSD 2-Clause "Simplified" License](https://github.com/russross/blackfriday/blob/master/LICENSE.txt)
- github.com/safchain/ethtool [Apache License 2.0](https://github.com/safchain/ethtool/blob/master/LICENSE) - github.com/safchain/ethtool [Apache License 2.0](https://github.com/safchain/ethtool/blob/master/LICENSE)
- github.com/samber/lo [MIT License](https://github.com/samber/lo/blob/master/LICENSE) - github.com/samber/lo [MIT License](https://github.com/samber/lo/blob/master/LICENSE)
@ -452,7 +451,6 @@ following works:
- gopkg.in/gorethink/gorethink.v3 [Apache License 2.0](https://github.com/rethinkdb/rethinkdb-go/blob/v3.0.5/LICENSE) - gopkg.in/gorethink/gorethink.v3 [Apache License 2.0](https://github.com/rethinkdb/rethinkdb-go/blob/v3.0.5/LICENSE)
- gopkg.in/inf.v0 [BSD 3-Clause "New" or "Revised" License](https://github.com/go-inf/inf/blob/v0.9.1/LICENSE) - gopkg.in/inf.v0 [BSD 3-Clause "New" or "Revised" License](https://github.com/go-inf/inf/blob/v0.9.1/LICENSE)
- gopkg.in/ini.v1 [Apache License 2.0](https://github.com/go-ini/ini/blob/master/LICENSE) - gopkg.in/ini.v1 [Apache License 2.0](https://github.com/go-ini/ini/blob/master/LICENSE)
- gopkg.in/natefinch/lumberjack.v2 [MIT License](https://github.com/natefinch/lumberjack/blob/v2.2.1/LICENSE)
- gopkg.in/olivere/elastic.v5 [MIT License](https://github.com/olivere/elastic/blob/v5.0.76/LICENSE) - gopkg.in/olivere/elastic.v5 [MIT License](https://github.com/olivere/elastic/blob/v5.0.76/LICENSE)
- gopkg.in/tomb.v1 [BSD 3-Clause Clear License](https://github.com/go-tomb/tomb/blob/v1/LICENSE) - gopkg.in/tomb.v1 [BSD 3-Clause Clear License](https://github.com/go-tomb/tomb/blob/v1/LICENSE)
- gopkg.in/tomb.v2 [BSD 3-Clause Clear License](https://github.com/go-tomb/tomb/blob/v2/LICENSE) - gopkg.in/tomb.v2 [BSD 3-Clause Clear License](https://github.com/go-tomb/tomb/blob/v2/LICENSE)

View File

@ -1,7 +1,7 @@
# HueBridge Input Plugin # HueBridge Input Plugin
This input plugin gathers status from [Hue Bridge][hue] devices This plugin gathers status from [Hue Bridge][hue] devices using the
using the [CLIP API][hue_api] interface of the devices. [CLIP API][hue_api] interface of the devices.
⭐ Telegraf v1.34.0 ⭐ Telegraf v1.34.0
🏷️ iot 🏷️ iot

View File

@ -1,6 +1,7 @@
package huebridge package huebridge
import ( import (
"crypto/tls"
"fmt" "fmt"
"maps" "maps"
"math" "math"
@ -11,55 +12,23 @@ import (
"strings" "strings"
"time" "time"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/plugins/common/tls"
"github.com/tdrn-org/go-hue" "github.com/tdrn-org/go-hue"
"github.com/influxdata/telegraf"
) )
type bridge struct { type bridge struct {
url *url.URL url *url.URL
configRoomAssignments map[string]string configRoomAssignments map[string]string
rcc *RemoteClientConfig remoteCfg *RemoteClientConfig
tcc *tls.ClientConfig tlsCfg *tls.Config
timeout config.Duration timeout time.Duration
log telegraf.Logger log telegraf.Logger
resolvedClient hue.BridgeClient
resourceTree map[string]string
deviceNames map[string]string
roomAssignments map[string]string
}
func newBridge(rawUrl string, roomAssignments map[string]string, rcc *RemoteClientConfig, tcc *tls.ClientConfig, timeout config.Duration, log telegraf.Logger) (*bridge, error) { resolvedClient hue.BridgeClient
parsedUrl, err := url.Parse(rawUrl) resourceTree map[string]string
if err != nil { deviceNames map[string]string
return nil, fmt.Errorf("failed to parse bridge URL %s: %w", rawUrl, err) roomAssignments map[string]string
}
switch parsedUrl.Scheme {
case "address", "cloud", "mdns", "remote":
// Do nothing, those are valid
default:
return nil, fmt.Errorf("unrecognized scheme %s in URL %s", parsedUrl.Scheme, parsedUrl)
}
// All schemes require a password in the URL
_, passwordSet := parsedUrl.User.Password()
if !passwordSet {
return nil, fmt.Errorf("missing password in URL %s", parsedUrl)
}
// Remote scheme also requires a configured rcc
if parsedUrl.Scheme == "remote" {
if rcc.RemoteClientId == "" || rcc.RemoteClientSecret == "" || rcc.RemoteTokenDir == "" {
return nil, fmt.Errorf("missing remote application credentials and/or token director not configured")
}
}
return &bridge{
url: parsedUrl,
configRoomAssignments: roomAssignments,
rcc: rcc,
tcc: tcc,
timeout: timeout,
log: log,
}, nil
} }
func (b *bridge) String() string { func (b *bridge) String() string {
@ -73,8 +42,7 @@ func (b *bridge) process(acc telegraf.Accumulator) error {
} }
} }
b.log.Tracef("Processing bridge %s", b) b.log.Tracef("Processing bridge %s", b)
err := b.fetchMetadata() if err := b.fetchMetadata(); err != nil {
if err != nil {
// Discard previously resolved client and re-resolve on next process call // Discard previously resolved client and re-resolve on next process call
b.resolvedClient = nil b.resolvedClient = nil
return err return err
@ -250,18 +218,13 @@ func (b *bridge) resolveViaAddress() error {
func (b *bridge) resolveViaCloud() error { func (b *bridge) resolveViaCloud() error {
locator := hue.NewCloudBridgeLocator() locator := hue.NewCloudBridgeLocator()
if b.url.Host != "" { if b.url.Host != "" {
discoveryEndpointUrl, err := url.Parse(fmt.Sprintf("https://%s/", b.url.Host)) u, err := url.Parse(fmt.Sprintf("https://%s/", b.url.Host))
if err != nil { if err != nil {
return err return err
} }
discoveryEndpointUrl = discoveryEndpointUrl.JoinPath(b.url.Path) locator.DiscoveryEndpointUrl = u.JoinPath(b.url.Path)
locator.DiscoveryEndpointUrl = discoveryEndpointUrl
} }
tlsConfig, err := b.tcc.TLSConfig() locator.TlsConfig = b.tlsCfg
if err != nil {
return err
}
locator.TlsConfig = tlsConfig
return b.resolveLocalBridge(locator) return b.resolveLocalBridge(locator)
} }
@ -271,12 +234,12 @@ func (b *bridge) resolveViaMDNS() error {
} }
func (b *bridge) resolveLocalBridge(locator hue.BridgeLocator) error { func (b *bridge) resolveLocalBridge(locator hue.BridgeLocator) error {
hueBridge, err := locator.Lookup(b.url.User.Username(), time.Duration(b.timeout)) hueBridge, err := locator.Lookup(b.url.User.Username(), b.timeout)
if err != nil { if err != nil {
return err return err
} }
urlPassword, _ := b.url.User.Password() urlPassword, _ := b.url.User.Password()
bridgeClient, err := hueBridge.NewClient(hue.NewLocalBridgeAuthenticator(urlPassword), time.Duration(b.timeout)) bridgeClient, err := hueBridge.NewClient(hue.NewLocalBridgeAuthenticator(urlPassword), b.timeout)
if err != nil { if err != nil {
return err return err
} }
@ -285,42 +248,46 @@ func (b *bridge) resolveLocalBridge(locator hue.BridgeLocator) error {
} }
func (b *bridge) resolveViaRemote() error { func (b *bridge) resolveViaRemote() error {
var redirectUrl *url.URL var redirectURL *url.URL
if b.rcc.RemoteCallbackUrl != "" { if b.remoteCfg.RemoteCallbackURL != "" {
parsedRedirectUrl, err := url.Parse(b.rcc.RemoteCallbackUrl) u, err := url.Parse(b.remoteCfg.RemoteCallbackURL)
if err != nil { if err != nil {
return err return err
} }
redirectUrl = parsedRedirectUrl redirectURL = u
} }
tokenFile := filepath.Join(b.rcc.RemoteTokenDir, b.rcc.RemoteClientId, strings.ToUpper(b.url.User.Username())+".json") tokenFile := filepath.Join(
locator, err := hue.NewRemoteBridgeLocator(b.rcc.RemoteClientId, b.rcc.RemoteClientSecret, redirectUrl, tokenFile) b.remoteCfg.RemoteTokenDir,
b.remoteCfg.RemoteClientID,
strings.ToUpper(b.url.User.Username())+".json",
)
locator, err := hue.NewRemoteBridgeLocator(
b.remoteCfg.RemoteClientID,
b.remoteCfg.RemoteClientSecret,
redirectURL,
tokenFile,
)
if err != nil { if err != nil {
return err return err
} }
if b.url.Host != "" { if b.url.Host != "" {
endpointUrl, err := url.Parse(fmt.Sprintf("https://%s/", b.url.Host)) u, err := url.Parse(fmt.Sprintf("https://%s/", b.url.Host))
if err != nil { if err != nil {
return err return err
} }
endpointUrl = endpointUrl.JoinPath(b.url.Path) locator.EndpointUrl = u.JoinPath(b.url.Path)
locator.EndpointUrl = endpointUrl
} }
tlsConfig, err := b.tcc.TLSConfig() locator.TlsConfig = b.tlsCfg
if err != nil {
return err
}
locator.TlsConfig = tlsConfig.Clone()
return b.resolveRemoteBridge(locator) return b.resolveRemoteBridge(locator)
} }
func (b *bridge) resolveRemoteBridge(locator *hue.RemoteBridgeLocator) error { func (b *bridge) resolveRemoteBridge(locator *hue.RemoteBridgeLocator) error {
hueBridge, err := locator.Lookup(b.url.User.Username(), time.Duration(b.timeout)) hueBridge, err := locator.Lookup(b.url.User.Username(), b.timeout)
if err != nil { if err != nil {
return err return err
} }
urlPassword, _ := b.url.User.Password() urlPassword, _ := b.url.User.Password()
bridgeClient, err := hueBridge.NewClient(hue.NewRemoteBridgeAuthenticator(locator, urlPassword), time.Duration(b.timeout)) bridgeClient, err := hueBridge.NewClient(hue.NewRemoteBridgeAuthenticator(locator, urlPassword), b.timeout)
if err != nil { if err != nil {
return err return err
} }
@ -405,7 +372,7 @@ func (b *bridge) fetchRoomAssignments() error {
return nil return nil
} }
func (b *bridge) resolveResourceRoom(resourceId string, resourceName string) string { func (b *bridge) resolveResourceRoom(resourceID, resourceName string) string {
roomName := b.roomAssignments[resourceName] roomName := b.roomAssignments[resourceName]
if roomName != "" { if roomName != "" {
return roomName return roomName
@ -414,15 +381,15 @@ func (b *bridge) resolveResourceRoom(resourceId string, resourceName string) str
// its owners until we find a room or there is no more owner. The latter // its owners until we find a room or there is no more owner. The latter
// may happen (e.g. for Motion Sensors) resulting in room name // may happen (e.g. for Motion Sensors) resulting in room name
// "<unassigned>". // "<unassigned>".
currentResourceId := resourceId currentResourceID := resourceID
for { for {
// Try next owner // Try next owner
currentResourceId = b.resourceTree[currentResourceId] currentResourceID = b.resourceTree[currentResourceID]
if currentResourceId == "" { if currentResourceID == "" {
// No owner left but no room found // No owner left but no room found
break break
} }
roomName = b.roomAssignments[currentResourceId] roomName = b.roomAssignments[currentResourceID]
if roomName != "" { if roomName != "" {
// Room name found, done // Room name found, done
return roomName return roomName
@ -431,23 +398,23 @@ func (b *bridge) resolveResourceRoom(resourceId string, resourceName string) str
return "<unassigned>" return "<unassigned>"
} }
func (b *bridge) resolveDeviceName(resourceId string) string { func (b *bridge) resolveDeviceName(resourceID string) string {
deviceName := b.deviceNames[resourceId] deviceName := b.deviceNames[resourceID]
if deviceName != "" { if deviceName != "" {
return deviceName return deviceName
} }
// If resource does not have a device name assigned directly, iterate // If resource does not have a device name assigned directly, iterate
// upwards via its owners until we find a room or there is no more // upwards via its owners until we find a room or there is no more
// owner. The latter may happen resulting in device name "<undefined>". // owner. The latter may happen resulting in device name "<undefined>".
currentResourceId := resourceId currentResourceID := resourceID
for { for {
// Try next owner // Try next owner
currentResourceId = b.resourceTree[currentResourceId] currentResourceID = b.resourceTree[currentResourceID]
if currentResourceId == "" { if currentResourceID == "" {
// No owner left but no device found // No owner left but no device found
break break
} }
deviceName = b.deviceNames[currentResourceId] deviceName = b.deviceNames[currentResourceID]
if deviceName != "" { if deviceName != "" {
// Device name found, done // Device name found, done
return deviceName return deviceName

View File

@ -3,6 +3,9 @@ package huebridge
import ( import (
_ "embed" _ "embed"
"errors"
"fmt"
"net/url"
"sync" "sync"
"time" "time"
@ -16,9 +19,9 @@ import (
var sampleConfig string var sampleConfig string
type RemoteClientConfig struct { type RemoteClientConfig struct {
RemoteClientId string `toml:"remote_client_id"` RemoteClientID string `toml:"remote_client_id"`
RemoteClientSecret string `toml:"remote_client_secret"` RemoteClientSecret string `toml:"remote_client_secret"`
RemoteCallbackUrl string `toml:"remote_callback_url"` RemoteCallbackURL string `toml:"remote_callback_url"`
RemoteTokenDir string `toml:"remote_token_dir"` RemoteTokenDir string `toml:"remote_token_dir"`
} }
@ -38,15 +41,45 @@ func (*HueBridge) SampleConfig() string {
} }
func (h *HueBridge) Init() error { func (h *HueBridge) Init() error {
h.bridges = make([]*bridge, 0, len(h.BridgeUrls)) tlsCfg, err := h.ClientConfig.TLSConfig()
for _, bridgeUrl := range h.BridgeUrls { if err != nil {
bridge, err := newBridge(bridgeUrl, h.RoomAssignments, &h.RemoteClientConfig, &h.ClientConfig, h.Timeout, h.Log) return fmt.Errorf("creating TLS configuration failed: %w", err)
if err != nil {
h.Log.Warnf("Failed to instantiate bridge for URL %s: %s", bridgeUrl, err)
continue
}
h.bridges = append(h.bridges, bridge)
} }
h.bridges = make([]*bridge, 0, len(h.BridgeUrls))
for _, b := range h.BridgeUrls {
u, err := url.Parse(b)
if err != nil {
return fmt.Errorf("failed to parse bridge URL %s: %w", b, err)
}
switch u.Scheme {
case "address", "cloud", "mdns":
// Do nothing, those are valid
case "remote":
// Remote scheme also requires a configured rcc
if h.RemoteClientID == "" || h.RemoteClientSecret == "" || h.RemoteTokenDir == "" {
return errors.New("missing remote application credentials and/or token director not configured")
}
default:
return fmt.Errorf("unrecognized scheme %s in URL %s", u.Scheme, b)
}
// All schemes require a password in the URL
if _, set := u.User.Password(); !set {
return fmt.Errorf("missing password in URL %s", u)
}
h.bridges = append(h.bridges, &bridge{
url: u,
configRoomAssignments: h.RoomAssignments,
remoteCfg: &h.RemoteClientConfig,
tlsCfg: tlsCfg,
timeout: time.Duration(h.Timeout),
log: h.Log,
})
}
return nil return nil
} }

View File

@ -2,6 +2,7 @@ package huebridge
import ( import (
"fmt" "fmt"
"path/filepath"
"testing" "testing"
"time" "time"
@ -28,9 +29,9 @@ func TestConfig(t *testing.T) {
// Verify everything is setup according to config file // Verify everything is setup according to config file
require.Len(t, h.BridgeUrls, 4) require.Len(t, h.BridgeUrls, 4)
require.Equal(t, "client", h.RemoteClientId) require.Equal(t, "client", h.RemoteClientID)
require.Equal(t, "secret", h.RemoteClientSecret) require.Equal(t, "secret", h.RemoteClientSecret)
require.Equal(t, "url", h.RemoteCallbackUrl) require.Equal(t, "url", h.RemoteCallbackURL)
require.Equal(t, "dir", h.RemoteTokenDir) require.Equal(t, "dir", h.RemoteTokenDir)
require.Len(t, h.RoomAssignments, 2) require.Len(t, h.RoomAssignments, 2)
require.Equal(t, config.Duration(60*time.Second), h.Timeout) require.Equal(t, config.Duration(60*time.Second), h.Timeout)
@ -48,7 +49,7 @@ func TestInitSuccess(t *testing.T) {
"remote://12345678:secret@localhost/", "remote://12345678:secret@localhost/",
}, },
RemoteClientConfig: RemoteClientConfig{ RemoteClientConfig: RemoteClientConfig{
RemoteClientId: mock.MockClientId, RemoteClientID: mock.MockClientId,
RemoteClientSecret: mock.MockClientSecret, RemoteClientSecret: mock.MockClientSecret,
RemoteTokenDir: ".", RemoteTokenDir: ".",
}, },
@ -67,32 +68,71 @@ func TestInitSuccess(t *testing.T) {
} }
func TestInitIgnoreInvalidUrls(t *testing.T) { func TestInitIgnoreInvalidUrls(t *testing.T) {
// The following URLs are all invalid must all be ignored during Init tests := []struct {
h := &HueBridge{ addr string
BridgeUrls: []string{ expected string
"invalid://12345678:secret@invalid-scheme.net/", }{
"address://12345678@missing-password.net/", {
"cloud://12345678@missing-password.net/", addr: "invalid://12345678:secret@invalid-scheme.net/",
"mdns://12345678@missing-password.net/", expected: "unrecognized scheme",
"remote://12345678@missing-password.net/", },
"remote://12345678:secret@missing-remote-config.net/", {
addr: "address://12345678@missing-password.net/",
expected: "missing password in URL",
},
{
addr: "cloud://12345678@missing-password.net/",
expected: "missing password in URL",
},
{
addr: "mdns://12345678@missing-password.net/",
expected: "missing password in URL",
},
{
addr: "remote://12345678@missing-password.net/",
expected: "missing remote application credentials and/or token director not configured",
},
{
addr: "remote://12345678:secret@missing-remote-config.net/",
expected: "missing remote application credentials and/or token director not configured",
}, },
Timeout: config.Duration(10 * time.Second),
Log: &testutil.Logger{Name: "huebridge"},
} }
// Verify successful Init for _, tt := range tests {
require.NoError(t, h.Init()) t.Run(tt.addr, func(t *testing.T) {
// The following URLs are all invalid must all be ignored during Init
plugin := &HueBridge{
BridgeUrls: []string{tt.addr},
Timeout: config.Duration(10 * time.Second),
Log: &testutil.Logger{Name: "huebridge"},
}
// Verify no bridge have been configured // Verify successful Init
require.Len(t, h.bridges, 0) require.ErrorContains(t, plugin.Init(), tt.expected)
// Verify no bridge have been configured
require.Empty(t, plugin.bridges)
})
}
} }
func TestGatherLocal(t *testing.T) { func TestGatherLocal(t *testing.T) {
// Load the expected metrics
parser := &influx.Parser{}
require.NoError(t, parser.Init())
fn := filepath.Join("testdata", "metrics", "huebridge.txt")
expected, err := testutil.ParseMetricsFromFile(fn, parser)
require.NoError(t, err)
for i := range expected {
expected[i].SetType(telegraf.Gauge)
}
// Start mock server and make plugin targing it // Start mock server and make plugin targing it
bridgeMock := mock.Start() bridgeMock := mock.Start()
require.NotNil(t, bridgeMock) require.NotNil(t, bridgeMock)
defer bridgeMock.Shutdown() defer bridgeMock.Shutdown()
// Setup the plugin
h := &HueBridge{ h := &HueBridge{
BridgeUrls: []string{ BridgeUrls: []string{
fmt.Sprintf("address://%s:%s@%s/", mock.MockBridgeId, mock.MockBridgeUsername, bridgeMock.Server().Host), fmt.Sprintf("address://%s:%s@%s/", mock.MockBridgeId, mock.MockBridgeUsername, bridgeMock.Server().Host),
@ -101,26 +141,10 @@ func TestGatherLocal(t *testing.T) {
Timeout: config.Duration(10 * time.Second), Timeout: config.Duration(10 * time.Second),
Log: &testutil.Logger{Name: "huebridge"}, Log: &testutil.Logger{Name: "huebridge"},
} }
// Verify successful Init
require.NoError(t, h.Init()) require.NoError(t, h.Init())
// Verify successfull Gather // Verify successfull collection
acc := &testutil.Accumulator{} var acc testutil.Accumulator
require.NoError(t, acc.GatherError(h.Gather)) require.NoError(t, acc.GatherError(h.Gather))
testutil.RequireMetricsEqual(t, expected, acc.GetTelegrafMetrics(), testutil.IgnoreTime(), testutil.SortMetrics())
// Verify collected metrics are as expected
expectedMetrics := loadExpectedMetrics(t, "testdata/metrics/huebridge.txt", telegraf.Gauge)
testutil.RequireMetricsEqual(t, expectedMetrics, acc.GetTelegrafMetrics(), testutil.IgnoreTime(), testutil.SortMetrics())
}
func loadExpectedMetrics(t *testing.T, file string, vt telegraf.ValueType) []telegraf.Metric {
parser := &influx.Parser{}
require.NoError(t, parser.Init())
expectedMetrics, err := testutil.ParseMetricsFromFile(file, parser)
require.NoError(t, err)
for index := range expectedMetrics {
expectedMetrics[index].SetType(vt)
}
return expectedMetrics
} }