package huebridge import ( "crypto/tls" "fmt" "maps" "math" "net/http" "net/url" "path/filepath" "strconv" "strings" "time" "github.com/tdrn-org/go-hue" "github.com/influxdata/telegraf" ) type bridge struct { url *url.URL configRoomAssignments map[string]string remoteCfg *remoteClientConfig tlsCfg *tls.Config timeout time.Duration log telegraf.Logger resolvedClient hue.BridgeClient resourceTree map[string]string deviceNames map[string]string roomAssignments map[string]string } func (b *bridge) String() string { return b.url.Redacted() } func (b *bridge) process(acc telegraf.Accumulator) error { if b.resolvedClient == nil { if err := b.resolve(); err != nil { return err } } b.log.Tracef("Processing bridge %s", b) if err := b.fetchMetadata(); err != nil { // Discard previously resolved client and re-resolve on next process call b.resolvedClient = nil return err } acc.AddError(b.processLights(acc)) acc.AddError(b.processTemperatures(acc)) acc.AddError(b.processLightLevels(acc)) acc.AddError(b.processMotionSensors(acc)) acc.AddError(b.processDevicePowers(acc)) return nil } func (b *bridge) processLights(acc telegraf.Accumulator) error { getLightsResponse, err := b.resolvedClient.GetLights() if err != nil { return fmt.Errorf("failed to access bridge lights on %s: %w", b, err) } if getLightsResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge lights from %s: %s", b, getLightsResponse.HTTPResponse.Status) } responseData := getLightsResponse.JSON200.Data if responseData != nil { for _, light := range *responseData { tags := make(map[string]string) tags["bridge_id"] = b.resolvedClient.Bridge().BridgeId tags["room"] = b.resolveResourceRoom(*light.Id, *light.Metadata.Name) tags["device"] = *light.Metadata.Name fields := make(map[string]interface{}) if *light.On.On { fields["on"] = 1 } else { fields["on"] = 0 } acc.AddGauge("huebridge_light", fields, tags) } } return nil } func (b *bridge) processTemperatures(acc telegraf.Accumulator) error { getTemperaturesResponse, err := b.resolvedClient.GetTemperatures() if err != nil { return fmt.Errorf("failed to access bridge temperatures on %s: %w", b, err) } if getTemperaturesResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge temperatures from %s: %s", b, getTemperaturesResponse.HTTPResponse.Status) } responseData := getTemperaturesResponse.JSON200.Data if responseData != nil { for _, temperature := range *responseData { temperatureName := b.resolveDeviceName(*temperature.Id) tags := make(map[string]string) tags["bridge_id"] = b.resolvedClient.Bridge().BridgeId tags["room"] = b.resolveResourceRoom(*temperature.Id, temperatureName) tags["device"] = temperatureName tags["enabled"] = strconv.FormatBool(*temperature.Enabled) fields := make(map[string]interface{}) fields["temperature"] = *temperature.Temperature.TemperatureReport.Temperature acc.AddGauge("huebridge_temperature", fields, tags) } } return nil } func (b *bridge) processLightLevels(acc telegraf.Accumulator) error { getLightLevelsResponse, err := b.resolvedClient.GetLightLevels() if err != nil { return fmt.Errorf("failed to access bridge lights levels on %s: %w", b, err) } if getLightLevelsResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge light levels from %s: %s", b, getLightLevelsResponse.HTTPResponse.Status) } responseData := getLightLevelsResponse.JSON200.Data if responseData != nil { for _, lightLevel := range *responseData { lightLevelName := b.resolveDeviceName(*lightLevel.Id) tags := make(map[string]string) tags["bridge_id"] = b.resolvedClient.Bridge().BridgeId tags["room"] = b.resolveResourceRoom(*lightLevel.Id, lightLevelName) tags["device"] = lightLevelName tags["enabled"] = strconv.FormatBool(*lightLevel.Enabled) fields := make(map[string]interface{}) fields["light_level"] = *lightLevel.Light.LightLevelReport.LightLevel fields["light_level_lux"] = math.Pow(10.0, (float64(*lightLevel.Light.LightLevelReport.LightLevel)-1.0)/10000.0) acc.AddGauge("huebridge_light_level", fields, tags) } } return nil } func (b *bridge) processMotionSensors(acc telegraf.Accumulator) error { getMotionSensorsResponse, err := b.resolvedClient.GetMotionSensors() if err != nil { return fmt.Errorf("failed to access bridge motion sensors on %s: %w", b, err) } if getMotionSensorsResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge motion sensors from %s: %s", b, getMotionSensorsResponse.HTTPResponse.Status) } responseData := getMotionSensorsResponse.JSON200.Data if responseData != nil { for _, motionSensor := range *responseData { motionSensorName := b.resolveDeviceName(*motionSensor.Id) tags := make(map[string]string) tags["bridge_id"] = b.resolvedClient.Bridge().BridgeId tags["room"] = b.resolveResourceRoom(*motionSensor.Id, motionSensorName) tags["device"] = motionSensorName tags["enabled"] = strconv.FormatBool(*motionSensor.Enabled) fields := make(map[string]interface{}) if *motionSensor.Motion.MotionReport.Motion { fields["motion"] = 1 } else { fields["motion"] = 0 } acc.AddGauge("huebridge_motion_sensor", fields, tags) } } return nil } func (b *bridge) processDevicePowers(acc telegraf.Accumulator) error { getDevicePowersResponse, err := b.resolvedClient.GetDevicePowers() if err != nil { return fmt.Errorf("failed to access bridge device powers on %s: %w", b, err) } if getDevicePowersResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge device powers from %s: %s", b, getDevicePowersResponse.HTTPResponse.Status) } responseData := getDevicePowersResponse.JSON200.Data if responseData != nil { for _, devicePower := range *responseData { if devicePower.PowerState.BatteryLevel == nil && devicePower.PowerState.BatteryState == nil { continue } devicePowerName := b.resolveDeviceName(*devicePower.Id) tags := make(map[string]string) tags["bridge_id"] = b.resolvedClient.Bridge().BridgeId tags["room"] = b.resolveResourceRoom(*devicePower.Id, devicePowerName) tags["device"] = devicePowerName fields := make(map[string]interface{}) fields["battery_level"] = *devicePower.PowerState.BatteryLevel fields["battery_state"] = *devicePower.PowerState.BatteryState acc.AddGauge("huebridge_device_power", fields, tags) } } return nil } func (b *bridge) resolve() error { if b.resolvedClient != nil { return nil } switch b.url.Scheme { case "address": return b.resolveViaAddress() case "cloud": return b.resolveViaCloud() case "mdns": return b.resolveViaMDNS() case "remote": return b.resolveViaRemote() } return fmt.Errorf("unrecognized bridge URL %s", b) } func (b *bridge) resolveViaAddress() error { locator, err := hue.NewAddressBridgeLocator(b.url.Host) if err != nil { return err } return b.resolveLocalBridge(locator) } func (b *bridge) resolveViaCloud() error { locator := hue.NewCloudBridgeLocator() if b.url.Host != "" { u, err := url.Parse(fmt.Sprintf("https://%s/", b.url.Host)) if err != nil { return err } locator.DiscoveryEndpointUrl = u.JoinPath(b.url.Path) } locator.TlsConfig = b.tlsCfg return b.resolveLocalBridge(locator) } func (b *bridge) resolveViaMDNS() error { locator := hue.NewMDNSBridgeLocator() return b.resolveLocalBridge(locator) } func (b *bridge) resolveLocalBridge(locator hue.BridgeLocator) error { hueBridge, err := locator.Lookup(b.url.User.Username(), b.timeout) if err != nil { return err } urlPassword, _ := b.url.User.Password() bridgeClient, err := hueBridge.NewClient(hue.NewLocalBridgeAuthenticator(urlPassword), b.timeout) if err != nil { return err } b.resolvedClient = bridgeClient return nil } func (b *bridge) resolveViaRemote() error { var redirectURL *url.URL if b.remoteCfg.RemoteCallbackURL != "" { u, err := url.Parse(b.remoteCfg.RemoteCallbackURL) if err != nil { return err } redirectURL = u } tokenFile := filepath.Join( 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 { return err } if b.url.Host != "" { u, err := url.Parse(fmt.Sprintf("https://%s/", b.url.Host)) if err != nil { return err } locator.EndpointUrl = u.JoinPath(b.url.Path) } locator.TlsConfig = b.tlsCfg return b.resolveRemoteBridge(locator) } func (b *bridge) resolveRemoteBridge(locator *hue.RemoteBridgeLocator) error { hueBridge, err := locator.Lookup(b.url.User.Username(), b.timeout) if err != nil { return err } urlPassword, _ := b.url.User.Password() bridgeClient, err := hueBridge.NewClient(hue.NewRemoteBridgeAuthenticator(locator, urlPassword), b.timeout) if err != nil { return err } b.resolvedClient = bridgeClient return nil } func (b *bridge) fetchMetadata() error { err := b.fetchResourceTree() if err != nil { return err } err = b.fetchDeviceNames() if err != nil { return err } return b.fetchRoomAssignments() } func (b *bridge) fetchResourceTree() error { getResourcesResponse, err := b.resolvedClient.GetResources() if err != nil { return fmt.Errorf("failed to access bridge resources on %s: %w", b, err) } if getResourcesResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge resources from %s: %s", b, getResourcesResponse.HTTPResponse.Status) } responseData := getResourcesResponse.JSON200.Data if responseData == nil { b.resourceTree = make(map[string]string) return nil } b.resourceTree = make(map[string]string, len(*responseData)) for _, resource := range *responseData { if resource.Owner != nil { b.resourceTree[*resource.Id] = *resource.Owner.Rid } } return nil } func (b *bridge) fetchDeviceNames() error { getDevicesResponse, err := b.resolvedClient.GetDevices() if err != nil { return fmt.Errorf("failed to access bridge devices on %s: %w", b, err) } if getDevicesResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge devices from %s: %s", b, getDevicesResponse.HTTPResponse.Status) } responseData := getDevicesResponse.JSON200.Data if responseData == nil { b.deviceNames = make(map[string]string) return nil } b.deviceNames = make(map[string]string, len(*responseData)) for _, device := range *responseData { b.deviceNames[*device.Id] = *device.Metadata.Name } return nil } func (b *bridge) fetchRoomAssignments() error { getRoomsResponse, err := b.resolvedClient.GetRooms() if err != nil { return fmt.Errorf("failed to access bridge rooms on %s: %w", b, err) } if getRoomsResponse.HTTPResponse.StatusCode != http.StatusOK { return fmt.Errorf("failed to fetch bridge rooms from %s: %s", b, getRoomsResponse.HTTPResponse.Status) } responseData := getRoomsResponse.JSON200.Data if responseData == nil { b.roomAssignments = maps.Clone(b.configRoomAssignments) return nil } b.roomAssignments = make(map[string]string, len(*responseData)) for _, roomGet := range *responseData { for _, children := range *roomGet.Children { b.roomAssignments[*children.Rid] = *roomGet.Metadata.Name } } maps.Copy(b.roomAssignments, b.configRoomAssignments) return nil } func (b *bridge) resolveResourceRoom(resourceID, resourceName string) string { roomName := b.roomAssignments[resourceName] if roomName != "" { return roomName } // If resource does not have a room assigned directly, iterate upwards via // 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 // "". currentResourceID := resourceID for { // Try next owner currentResourceID = b.resourceTree[currentResourceID] if currentResourceID == "" { // No owner left but no room found break } roomName = b.roomAssignments[currentResourceID] if roomName != "" { // Room name found, done return roomName } } return "" } func (b *bridge) resolveDeviceName(resourceID string) string { deviceName := b.deviceNames[resourceID] if deviceName != "" { return deviceName } // 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 // owner. The latter may happen resulting in device name "". currentResourceID := resourceID for { // Try next owner currentResourceID = b.resourceTree[currentResourceID] if currentResourceID == "" { // No owner left but no device found break } deviceName = b.deviceNames[currentResourceID] if deviceName != "" { // Device name found, done return deviceName } } return "" }