package hue import ( "context" "errors" "fmt" 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 lastMotion map[string]time.Time lastButton map[string]time.Time 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 lastMotion := b.lastMotion b.mu.Unlock() oldHasSeen := hasSeen hasSeen = gentools.CopyMap(hasSeen) reachable = gentools.CopyMap(reachable) lastMotion = gentools.CopyMap(lastMotion) colorFlags := make(map[string]device.ColorFlags) activeStates := make(map[string]device.State) newEvents := make([]lucifer3.Event, 0, 0) extraEvents := make([]lucifer3.Event, 0, 0) for id, res := range resources { if res.Owner != nil && !oldHasSeen[res.Owner.ID] { if res.Temperature != nil { extraEvents = append(extraEvents, events.TemperatureChanged{ ID: b.fullId(*res), Temperature: res.Temperature.Temperature, }) } if res.Motion != nil { if res.Motion.Motion { extraEvents = append(extraEvents, events.MotionSensed{ ID: b.fullId(*res), SecondsSince: 0, }) lastMotion[b.fullId(*res)] = time.Now() } else { extraEvents = append(extraEvents, events.MotionSensed{ ID: b.fullId(*res), SecondsSince: 301, }) lastMotion[b.fullId(*res)] = time.Now().Add(-time.Millisecond * 301) } } } if res.Type == "device" { hwState, hwEvent := res.GenerateEvent(b.host, resources) if hwState.SupportFlags == 0 { continue } newEvents = append(newEvents, hwState) if !hasSeen[id] { newEvents = append(newEvents, hwEvent, events.DeviceReady{ID: hwState.ID}) hasSeen[id] = true } 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.lastMotion = lastMotion b.mu.Unlock() return append(newEvents, extraEvents...), nil } func (b *Bridge) ApplyPatches(date time.Time, resources []ResourceData) (eventList []lucifer3.Event, shouldRefresh bool) { b.mu.Lock() resourceMap := b.resources activeStates := b.activeStates reachable := b.reachable colorFlags := b.colorFlags lastMotion := b.lastMotion lastButton := b.lastButton b.mu.Unlock() mapCopy := gentools.CopyMap(resourceMap) activeStatesCopy := gentools.CopyMap(activeStates) reachableCopy := gentools.CopyMap(reachable) colorFlagsCopy := gentools.CopyMap(colorFlags) lastMotionCopy := gentools.CopyMap(lastMotion) lastButtonCopy := gentools.CopyMap(lastButton) for _, resource := range resources { if mapCopy[resource.ID] != nil { mapCopy[resource.ID] = mapCopy[resource.ID].WithPatch(resource) } else { eventList = append(eventList, events.Log{ ID: b.fullId(resource), Level: "info", Code: "hue_patch_found_unknown_device", Message: "Refresh triggered, because of unknown device", }) 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) eventList = append(eventList, hwState) activeStatesCopy[resource.Owner.ID] = hwState.State reachableCopy[resource.Owner.ID] = !hwState.Unreachable if hwState.ColorFlags != 0 { colorFlagsCopy[resource.Owner.ID] = hwState.ColorFlags } if resource.Temperature != nil { eventList = append(eventList, events.TemperatureChanged{ ID: b.fullId(resource), Temperature: resource.Temperature.Temperature, }) } if resource.Motion != nil { if resource.Motion.Motion { eventList = append(eventList, events.MotionSensed{ ID: b.fullId(resource), SecondsSince: 0, }) lastMotionCopy[b.fullId(resource)] = time.Now() } } if resource.Button != nil { valid := false if resource.Button.LastEvent == "initial_press" { valid = true } else if resource.Button.LastEvent == "long_release" { valid = false } else if resource.Button.LastEvent == "repeat" { valid = date.Sub(lastButtonCopy[resource.ID]) >= time.Millisecond*500 } else { valid = date.Sub(lastButtonCopy[resource.ID]) >= time.Millisecond*990 } if valid { lastButtonCopy[resource.ID] = date owner := resourceMap[resource.Owner.ID] if owner != nil { index := owner.ServiceIndex("button", resource.ID) if index != -1 { eventList = append(eventList, events.ButtonPressed{ ID: b.fullId(*owner), Name: []string{"On", "DimUp", "DimDown", "Off"}[index], }) } } } } } else { shouldRefresh = true } } } b.mu.Lock() b.resources = mapCopy b.activeStates = activeStatesCopy b.reachable = reachableCopy b.colorFlags = colorFlagsCopy b.lastMotion = lastMotionCopy b.lastButton = lastButtonCopy 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") } bus.RunEvent(events.Log{ ID: "hue:" + b.host, Level: "info", Code: "hue_bridge_starting", Message: "Bridge is connecting...", }) go b.makeCongruentLoop(ctx) bus.RunEvents(hwEvents) sse := b.client.SSE(ctx) step := time.NewTicker(time.Second * 30) defer step.Stop() quickStep := time.NewTicker(time.Second * 5) defer quickStep.Stop() for { select { case updates, ok := <-sse: { if !ok { return errors.New("SSE lost connection") } if len(updates) == 0 { continue } newEvents, shouldUpdate := b.ApplyPatches( updates[0].CreationTime, 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 <-quickStep.C: b.mu.Lock() lastMotion := b.lastMotion b.mu.Unlock() for id, value := range lastMotion { since := time.Since(value) sinceMod := since % (time.Second * 30) if (since > time.Second*20) && (sinceMod >= time.Second*27 || sinceMod <= time.Second*3) { bus.RunEvent(events.MotionSensed{ID: id, SecondsSince: since.Seconds()}) } } 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 { continue } light := resources[*lightID] if light == nil { continue } // Handle power first if desired.Power != nil && active.Power != nil && *desired.Power != *active.Power { updates["light/"+*lightID] = ResourceUpdate{ Power: gentools.Ptr(*desired.Power), TransitionDuration: gentools.Ptr(time.Millisecond * 101), } 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) fullId(res ResourceData) string { id := res.ID if res.Owner != nil && res.Owner.Kind == "device" { id = res.Owner.ID } return fmt.Sprintf("hue:%s:%s", b.host, id) } func (b *Bridge) triggerCongruenceCheck() { select { case b.triggerCongruenceCheckCh <- struct{}{}: default: } }