package hue2 import ( "context" "errors" "fmt" "git.aiterp.net/lucifer/new-server/internal/color" "git.aiterp.net/lucifer/new-server/models" "golang.org/x/sync/errgroup" "log" "math" "strings" "sync" "time" ) type Bridge struct { mu sync.Mutex externalID int client *Client needsUpdate chan struct{} devices map[string]models.Device resources map[string]*ResourceData } func NewBridge(client *Client) *Bridge { return &Bridge{ client: client, needsUpdate: make(chan struct{}, 4), devices: make(map[string]models.Device, 64), resources: make(map[string]*ResourceData, 256), } } func (b *Bridge) Run(ctx context.Context, eventCh chan<- models.Event) error { log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Connecting SSE...") sse := b.client.SSE(ctx) log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Refreshing...") err := b.RefreshAll(ctx) if err != nil { return err } log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Running updates...") updated, err := b.MakeCongruent(ctx) if err != nil { return err } log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (startup)") lightRefreshTimer := time.NewTicker(time.Second * 5) defer lightRefreshTimer.Stop() motionTicker := time.NewTicker(time.Second * 10) defer motionTicker.Stop() absences := make(map[int]time.Time) lastPress := make(map[string]time.Time) lastUpdate := time.Now() needFull := false for { select { case <-ctx.Done(): return ctx.Err() case <-lightRefreshTimer.C: if time.Since(lastUpdate) < time.Second*2 { continue } if needFull { err := b.RefreshAll(ctx) if err != nil { return err } } else { err := b.Refresh(ctx, "light") if err != nil { return err } } updated, err := b.MakeCongruent(ctx) if err != nil { return err } if updated > 0 { // Force this to cool for 30 seconds unless a manual update occurs. if updated > 10 { lastUpdate = time.Now().Add(time.Second * 15) } log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)") } case <-b.needsUpdate: lastUpdate = time.Now() updated, err := b.MakeCongruent(ctx) if err != nil { return err } if updated > 0 { log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (publish)") } case <-motionTicker.C: for id, absenceTime := range absences { seconds := int(time.Since(absenceTime).Seconds()) if seconds < 10 || seconds%60 >= 10 { continue } eventCh <- models.Event{ Name: models.ENSensorPresenceEnded, Payload: map[string]string{ "deviceId": fmt.Sprint(id), "minutesElapsed": fmt.Sprint(seconds / 60), "secondsElapsed": fmt.Sprint(seconds), "lastUpdated": fmt.Sprint(absenceTime.Unix()), }, } } case data, ok := <-sse: if !ok { return errors.New("SSE Disconnected") } b.applyPatches(data.Data) updated, err := b.MakeCongruent(ctx) if err != nil { return err } if updated > 0 { log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (SSE)") } for _, patch := range data.Data { if patch.Owner == nil { continue } b.mu.Lock() if b.resources[patch.Owner.ID] == nil { needFull = true } b.mu.Unlock() device, deviceOK := b.devices[patch.Owner.ID] if !deviceOK || device.ID == 0 { continue } if patch.Button != nil { valid := false if patch.Button.LastEvent == "initial_press" || patch.Button.LastEvent == "repeat" { valid = true } else if patch.Button.LastEvent == "long_release" { valid = false } else { valid = data.CreationTime.Sub(lastPress[patch.ID]) >= time.Second*2 } if valid { lastPress[patch.ID] = data.CreationTime b.mu.Lock() owner := b.resources[patch.Owner.ID] b.mu.Unlock() if owner != nil { index := owner.ServiceIndex("button", patch.ID) if index != -1 { eventCh <- models.Event{ Name: models.ENButtonPressed, Payload: map[string]string{ "deviceId": fmt.Sprint(device.ID), "hueButtonEvent": patch.Button.LastEvent, "buttonIndex": fmt.Sprint(index), "buttonName": device.ButtonNames[index], }, } } } } } if patch.Temperature != nil && patch.Temperature.Valid { eventCh <- models.Event{ Name: models.ENSensorTemperature, Payload: map[string]string{ "deviceId": fmt.Sprint(device.ID), "deviceInternalId": patch.Owner.ID, "temperature": fmt.Sprint(patch.Temperature.Temperature), "lastUpdated": fmt.Sprint(data.CreationTime.Unix()), }, } } if patch.Motion != nil && patch.Motion.Valid { if patch.Motion.Motion { eventCh <- models.Event{ Name: models.ENSensorPresenceStarted, Payload: map[string]string{ "deviceId": fmt.Sprint(device.ID), "deviceInternalId": patch.Owner.ID, }, } delete(absences, device.ID) } else { eventCh <- models.Event{ Name: models.ENSensorPresenceEnded, Payload: map[string]string{ "deviceId": fmt.Sprint(device.ID), "deviceInternalId": device.InternalID, "minutesElapsed": "0", "secondsElapsed": "0", "lastUpdated": fmt.Sprint(data.CreationTime.Unix()), }, } absences[device.ID] = data.CreationTime } } } } } } func (b *Bridge) Update(devices ...models.Device) { b.mu.Lock() for _, device := range devices { b.devices[device.InternalID] = device } b.mu.Unlock() select { case b.needsUpdate <- struct{}{}: default: } } func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { // Exhaust the channel to avoid more updates. exhausted := false for !exhausted { select { case <-b.needsUpdate: default: exhausted = true } } b.mu.Lock() dur := time.Millisecond * 100 updates := make(map[string]ResourceUpdate) for _, device := range b.devices { resource := b.resources[device.InternalID] // Update device if resource.Metadata.Name != device.Name { name := device.Name updates["device/"+resource.ID] = ResourceUpdate{ Name: &name, } } // Update light if lightID := resource.ServiceID("light"); lightID != nil { light := b.resources[*lightID] update := ResourceUpdate{TransitionDuration: &dur} changed := false lightsOut := light.Power != nil && !device.State.Power if !lightsOut { if light.ColorTemperature != nil && device.State.Color.IsKelvin() { mirek := 1000000 / *device.State.Color.K if mirek < light.ColorTemperature.MirekSchema.MirekMinimum { mirek = light.ColorTemperature.MirekSchema.MirekMinimum } if mirek > light.ColorTemperature.MirekSchema.MirekMaximum { mirek = light.ColorTemperature.MirekSchema.MirekMaximum } if light.ColorTemperature.Mirek == nil || mirek != *light.ColorTemperature.Mirek { update.Mirek = &mirek changed = true } } else if xyColor, ok := device.State.Color.ToXY(); ok && light.Color != nil { xy := light.Color.Gamut.Conform(*xyColor.XY).Round() if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) { update.ColorXY = &xy changed = true } } if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.02 { brightness := math.Abs(math.Min(device.State.Intensity*100, 100)) update.Brightness = &brightness changed = true } } if light.Power != nil && light.Power.On != device.State.Power { update.Power = &device.State.Power if device.State.Power { brightness := math.Abs(math.Min(device.State.Intensity*100, 100)) update.Brightness = &brightness } changed = true } if changed { updates["light/"+light.ID] = update } } } if len(updates) > 0 { // Optimistically apply the updates to the states, so that the driver assumes they are set until // proven otherwise by the SSE client. newResources := make(map[string]*ResourceData, len(b.resources)) for key, value := range b.resources { newResources[key] = value } for key, update := range updates { id := strings.SplitN(key, "/", 2)[1] newResources[id] = newResources[id].WithUpdate(update) } b.resources = newResources } b.mu.Unlock() if len(updates) == 0 { return 0, nil } eg, ctx := errgroup.WithContext(ctx) for key := range updates { update := updates[key] split := strings.SplitN(key, "/", 2) link := ResourceLink{Kind: split[0], ID: split[1]} eg.Go(func() error { return b.client.UpdateResource(ctx, link, update) }) } err := eg.Wait() if err != nil { // Try to restore light states. _ = b.Refresh(ctx, "light") return len(updates), err } return len(updates), nil } func (b *Bridge) GenerateDevices() []models.Device { b.mu.Lock() resources := b.resources b.mu.Unlock() devices := make([]models.Device, 0, 16) for _, resource := range resources { if resource.Type != "device" || strings.HasPrefix(resource.Metadata.Archetype, "bridge") { continue } device := models.Device{ BridgeID: b.externalID, InternalID: resource.ID, Name: resource.Metadata.Name, DriverProperties: map[string]interface{}{ "archetype": resource.Metadata.Archetype, "name": resource.ProductData.ProductName, "product": resource.ProductData, "legacyId": resource.LegacyID, }, } // Set icon if resource.ProductData.ProductName == "Hue dimmer switch" { device.Icon = "switch" } else if resource.ProductData.ProductName == "Hue motion sensor" { device.Icon = "sensor" } else { device.Icon = "lightbulb" } buttonCount := 0 for _, ptr := range resource.Services { switch ptr.Kind { case "device_power": { device.DriverProperties["battery"] = resources[ptr.ID].PowerState } case "button": { buttonCount += 1 } case "zigbee_connectivity": { device.DriverProperties["zigbee"] = resources[ptr.ID].Status } case "motion": { device.Capabilities = append(device.Capabilities, models.DCPresence) } case "temperature": { device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor) } case "light": { light := resources[ptr.ID] if light.Power != nil { device.State.Power = light.Power.On device.Capabilities = append(device.Capabilities, models.DCPower) } if light.Dimming != nil { device.State.Intensity = light.Dimming.Brightness / 100 device.Capabilities = append(device.Capabilities, models.DCIntensity) } if light.ColorTemperature != nil { if light.ColorTemperature.Mirek != nil { device.State.Color.SetK(1000000 / *light.ColorTemperature.Mirek) } device.Capabilities = append(device.Capabilities, models.DCColorKelvin) device.DriverProperties["maxTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMinimum device.DriverProperties["minTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMaximum } if light.Color != nil { if device.State.Color.IsEmpty() { device.State.Color = color.Color{ XY: &light.Color.XY, } } device.DriverProperties["colorGamut"] = light.Color.Gamut device.DriverProperties["colorGamutType"] = light.Color.GamutType device.Capabilities = append(device.Capabilities, models.DCColorHS, models.DCColorXY) } } } } if buttonCount == 4 { device.ButtonNames = []string{"On", "DimUp", "DimDown", "Off"} } else if buttonCount == 1 { device.ButtonNames = []string{"Button"} } else { for n := 1; n <= buttonCount; n++ { device.ButtonNames = append(device.ButtonNames, fmt.Sprint("Button", n)) } } devices = append(devices, device) } return devices } func (b *Bridge) Refresh(ctx context.Context, kind string) error { if kind == "device" { // Device refresh requires the full deal as services are taken for granted. return b.RefreshAll(ctx) } resources, err := b.client.Resources(ctx, kind) if err != nil { return err } b.mu.Lock() oldResources := b.resources b.mu.Unlock() newResources := make(map[string]*ResourceData, len(b.resources)) for key, value := range oldResources { if value.Type != kind { newResources[key] = value } } for i := range resources { resource := resources[i] newResources[resource.ID] = &resource } b.mu.Lock() b.resources = newResources b.mu.Unlock() return nil } func (b *Bridge) RefreshAll(ctx context.Context) error { allResources, err := b.client.AllResources(ctx) if err != nil { return err } resources := make(map[string]*ResourceData, len(allResources)) for i := range allResources { resource := allResources[i] resources[resource.ID] = &resource } b.mu.Lock() b.resources = resources b.mu.Unlock() return nil } func (b *Bridge) applyPatches(patches []ResourceData) { b.mu.Lock() newResources := make(map[string]*ResourceData, len(b.resources)) for key, value := range b.resources { newResources[key] = value } b.mu.Unlock() for _, patch := range patches { if res := newResources[patch.ID]; res != nil { resCopy := *res if patch.Power != nil && resCopy.Power != nil { cp := *resCopy.Power resCopy.Power = &cp resCopy.Power.On = patch.Power.On } if patch.Color != nil && resCopy.Color != nil { cp := *resCopy.Color resCopy.Color = &cp resCopy.Color.XY = patch.Color.XY if resCopy.ColorTemperature != nil { cp2 := *resCopy.ColorTemperature resCopy.ColorTemperature = &cp2 resCopy.ColorTemperature.Mirek = nil } } if patch.ColorTemperature != nil && resCopy.ColorTemperature != nil { cp := *resCopy.ColorTemperature resCopy.ColorTemperature = &cp resCopy.ColorTemperature.Mirek = patch.ColorTemperature.Mirek } if patch.Dimming != nil && resCopy.Dimming != nil { cp := *resCopy.Dimming resCopy.Dimming = &cp resCopy.Dimming.Brightness = patch.Dimming.Brightness } if patch.Dynamics != nil { resCopy.Dynamics = patch.Dynamics } if patch.Alert != nil { resCopy.Alert = patch.Alert } if patch.PowerState != nil { resCopy.PowerState = patch.PowerState } if patch.Temperature != nil { resCopy.Temperature = patch.Temperature } if patch.Status != nil { resCopy.Status = patch.Status } resCopy.Metadata.Name = patch.Metadata.Name newResources[patch.ID] = &resCopy } } b.mu.Lock() b.resources = newResources b.mu.Unlock() } func (b *Bridge) SearchDevices(ctx context.Context, timeout time.Duration) ([]models.Device, error) { // Record the current state. b.mu.Lock() before := b.resources b.mu.Unlock() // Spend half the time waiting for devices // TODO: Wait for v2 endpoint err := b.client.LegacyDiscover(ctx, "sensors") if err != nil { return nil, err } select { case <-time.After(timeout / 1): case <-ctx.Done(): return nil, ctx.Err() } // Spend half the time waiting for lights // TODO: Wait for v2 endpoint err = b.client.LegacyDiscover(ctx, "lights") if err != nil { return nil, err } select { case <-time.After(timeout / 1): case <-ctx.Done(): return nil, ctx.Err() } // Perform a full refresh. err = b.RefreshAll(ctx) if err != nil { return nil, err } // Check for new devices devices := b.GenerateDevices() newDevices := make([]models.Device, 0) for _, device := range devices { if before[device.InternalID] == nil { newDevices = append(newDevices, device) } } // Return said devices. return newDevices, nil } func (b *Bridge) Forget(ctx context.Context, device models.Device) error { b.mu.Lock() resource := b.resources[device.InternalID] b.mu.Unlock() if resource == nil || resource.LegacyID == "" { return errors.New("resource not found") } return b.client.legacyDelete(ctx, resource.LegacyID, nil) }