package hue import ( "context" "errors" lucifer3 "git.aiterp.net/lucifer3/server" "git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/events" "git.aiterp.net/lucifer3/server/internal/color" "git.aiterp.net/lucifer3/server/internal/gentools" "golang.org/x/sync/errgroup" "log" "math" "strings" "sync" "time" ) func NewBridge(host string, client *Client) *Bridge { return &Bridge{ client: client, host: host, ctx: context.Background(), cancel: func() {}, resources: map[string]*ResourceData{}, activeStates: map[string]device.State{}, desiredStates: map[string]device.State{}, colorFlags: map[string]device.ColorFlags{}, hasSeen: map[string]bool{}, triggerCongruenceCheckCh: make(chan struct{}, 2), } } type Bridge struct { mu sync.Mutex client *Client host string ctx context.Context cancel context.CancelFunc resources map[string]*ResourceData activeStates map[string]device.State desiredStates map[string]device.State colorFlags map[string]device.ColorFlags reachable map[string]bool hasSeen map[string]bool triggerCongruenceCheckCh chan struct{} lastDiscoverCancel context.CancelFunc } func (b *Bridge) SearchDevices(timeout time.Duration) error { discoverCtx, cancel := context.WithCancel(b.ctx) b.mu.Lock() if b.lastDiscoverCancel != nil { b.lastDiscoverCancel() } b.lastDiscoverCancel = cancel b.mu.Unlock() if timeout <= time.Second*10 { timeout = time.Second * 10 } // Spend half the time waiting for devices // TODO: Wait for v2 endpoint ctx, cancel := context.WithTimeout(discoverCtx, timeout/2) defer cancel() err := b.client.LegacyDiscover(ctx, "sensors") if err != nil { return err } <-ctx.Done() if discoverCtx.Err() != nil { return discoverCtx.Err() } // Spend half the time waiting for lights // TODO: Wait for v2 endpoint ctx, cancel = context.WithTimeout(discoverCtx, timeout/2) defer cancel() err = b.client.LegacyDiscover(ctx, "sensors") if err != nil { return err } <-ctx.Done() if discoverCtx.Err() != nil { return discoverCtx.Err() } // Let the main loop get the new light. return nil } func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) { ctx, cancel := context.WithTimeout(b.ctx, time.Second*15) defer cancel() allResources, err := b.client.AllResources(ctx) if err != nil { return nil, err } resources := make(map[string]*ResourceData, len(allResources)) for i := range allResources { resources[allResources[i].ID] = &allResources[i] } b.mu.Lock() hasSeen := b.hasSeen reachable := b.reachable b.mu.Unlock() hasSeen = gentools.CopyMap(hasSeen) reachable = gentools.CopyMap(reachable) colorFlags := make(map[string]device.ColorFlags) activeStates := make(map[string]device.State) newEvents := make([]lucifer3.Event, 0, 0) for id, res := range resources { if res.Type == "device" && res.Metadata.Archetype != "bridge_v2" { hwState, hwEvent := res.GenerateEvent(b.host, resources) if !hasSeen[id] { newEvents = append(newEvents, hwState, hwEvent, events.DeviceReady{ID: hwState.ID}) hasSeen[id] = true } else { newEvents = append(newEvents, hwState) } activeStates[id] = hwState.State colorFlags[id] = hwState.ColorFlags reachable[id] = !hwState.Unreachable } } b.mu.Lock() b.resources = resources b.hasSeen = hasSeen b.colorFlags = colorFlags b.activeStates = activeStates b.reachable = reachable b.mu.Unlock() return newEvents, nil } func (b *Bridge) ApplyPatches(resources []ResourceData) (events []lucifer3.Event, shouldRefresh bool) { b.mu.Lock() resourceMap := b.resources activeStates := b.activeStates reachable := b.reachable colorFlags := b.colorFlags b.mu.Unlock() mapCopy := gentools.CopyMap(resourceMap) activeStatesCopy := gentools.CopyMap(activeStates) reachableCopy := gentools.CopyMap(reachable) colorFlagsCopy := gentools.CopyMap(colorFlags) for _, resource := range resources { if mapCopy[resource.ID] != nil { mapCopy[resource.ID] = mapCopy[resource.ID].WithPatch(resource) } else { log.Println(resource.ID, resource.Type, "not seen!") shouldRefresh = true } } for _, resource := range resources { if resource.Owner != nil && resource.Owner.Kind == "device" { if parent, ok := mapCopy[resource.Owner.ID]; ok { hwState, _ := parent.GenerateEvent(b.host, mapCopy) events = append(events, hwState) activeStatesCopy[resource.Owner.ID] = hwState.State reachableCopy[resource.Owner.ID] = !hwState.Unreachable if hwState.ColorFlags != 0 { colorFlagsCopy[resource.Owner.ID] = hwState.ColorFlags } } } } b.mu.Lock() b.resources = mapCopy b.activeStates = activeStatesCopy b.reachable = reachableCopy b.colorFlags = colorFlagsCopy b.mu.Unlock() return } func (b *Bridge) SetStates(patch map[string]device.State) { b.mu.Lock() desiredStates := b.desiredStates resources := b.resources b.mu.Unlock() desiredStates = gentools.CopyMap(desiredStates) prefix := "hue:" + b.host + ":" for id, state := range patch { if !strings.HasPrefix(id, prefix) { continue } id = id[len(prefix):] resource := resources[id] if resource == nil { continue } if !state.Empty() { desiredStates[id] = resource.FixState(state, resources) } else { delete(desiredStates, id) } } b.mu.Lock() b.desiredStates = desiredStates b.mu.Unlock() b.triggerCongruenceCheck() } func (b *Bridge) Run(ctx context.Context, bus *lucifer3.EventBus) interface{} { hwEvents, err := b.RefreshAll() if err != nil { return errors.New("failed to connect to bridge") } go b.makeCongruentLoop(ctx) bus.RunEvents(hwEvents) sse := b.client.SSE(ctx) step := time.NewTicker(time.Second * 30) defer step.Stop() for { select { case updates, ok := <-sse: { if !ok { return errors.New("SSE lost connection") } newEvents, shouldUpdate := b.ApplyPatches( gentools.Flatten(gentools.Map(updates, func(update SSEUpdate) []ResourceData { return update.Data })), ) bus.RunEvents(newEvents) if shouldUpdate { hwEvents, err := b.RefreshAll() if err != nil { return errors.New("failed to refresh states") } bus.RunEvents(hwEvents) } b.triggerCongruenceCheck() } case <-step.C: hwEvents, err := b.RefreshAll() if err != nil { return nil } bus.RunEvents(hwEvents) b.triggerCongruenceCheck() case <-ctx.Done(): { return nil } } } } func (b *Bridge) makeCongruentLoop(ctx context.Context) { for range b.triggerCongruenceCheckCh { if ctx.Err() != nil { break } // Make sure this loop doesn't spam too hard rateLimit := time.After(time.Second / 15) // Take states b.mu.Lock() resources := b.resources desiredStates := b.desiredStates activeStates := b.activeStates reachable := b.reachable colorFlags := b.colorFlags b.mu.Unlock() newActiveStates := make(map[string]device.State, 0) updates := make(map[string]ResourceUpdate) for id, desired := range desiredStates { active, activeOK := activeStates[id] lightID := resources[id].ServiceID("light") if !reachable[id] || !activeOK || lightID == nil { log.Println("No light", !reachable[id], !activeOK, lightID == nil) continue } light := resources[*lightID] if light == nil { log.Println("No light", *lightID) continue } // Handle power first if desired.Power != nil && active.Power != nil && *desired.Power != *active.Power { updates["light/"+*lightID] = ResourceUpdate{Power: gentools.Ptr(*desired.Power)} newActiveState := activeStates[id] newActiveState.Power = gentools.Ptr(*desired.Power) newActiveStates[id] = newActiveState continue } if active.Power != nil && !*active.Power { // Don't do more with shut-off-light. continue } updated := false update := ResourceUpdate{} newActiveState := activeStates[id] if active.Color != nil && desired.Color != nil { ac := *active.Color dc := *desired.Color if !dc.IsKelvin() || !colorFlags[id].IsWarmWhite() { dc, _ = dc.ToXY() dc.XY = gentools.Ptr(light.Color.Gamut.Conform(*dc.XY)) } if dc.XY != nil { if ac.K != nil { ac.K = gentools.Ptr(1000000 / (1000000 / *ac.K)) } acXY, _ := ac.ToXY() dist := dc.XY.DistanceTo(*acXY.XY) if dist > 0.0002 { update.ColorXY = gentools.Ptr(*dc.XY) updated = true } } else { dcMirek := 1000000 / *dc.K if dcMirek < light.ColorTemperature.MirekSchema.MirekMinimum { dcMirek = light.ColorTemperature.MirekSchema.MirekMinimum } else if dcMirek > light.ColorTemperature.MirekSchema.MirekMaximum { dcMirek = light.ColorTemperature.MirekSchema.MirekMaximum } acMirek := 0 if ac.K != nil { acMirek = 1000000 / *ac.K } if acMirek != dcMirek { newActiveState.Color = &color.Color{K: gentools.Ptr(*dc.K)} update.Mirek = &dcMirek updated = true } } } if active.Intensity != nil && desired.Intensity != nil { if math.Abs(*active.Intensity-*desired.Intensity) >= 0.01 { update.Brightness = gentools.Ptr(*desired.Intensity * 100) newActiveState.Intensity = gentools.Ptr(*desired.Intensity) updated = true } } if updated { update.TransitionDuration = gentools.Ptr(time.Millisecond * 101) updates["light/"+*lightID] = update newActiveStates[id] = newActiveState } } if len(updates) > 0 { timeout, cancel := context.WithTimeout(ctx, time.Second) eg, ctx := errgroup.WithContext(timeout) 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 { log.Println("Failed to run update", err) } b.mu.Lock() activeStates = b.activeStates b.mu.Unlock() activeStates = gentools.CopyMap(activeStates) for id, state := range newActiveStates { activeStates[id] = state } b.mu.Lock() b.activeStates = activeStates b.mu.Unlock() cancel() // Wait the remaining time for the rate limit <-rateLimit } } } func (b *Bridge) triggerCongruenceCheck() { select { case b.triggerCongruenceCheckCh <- struct{}{}: default: } }