|
|
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/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 { // I hate that I have to do this, but the SSE is not being reliable.
_, _ = b.RefreshAll()
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()
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)
continue } if active.Power != nil && !*active.Power { // Don't do more with shut-off-light.
continue }
updated := false update := ResourceUpdate{}
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 { 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) updated = true } }
if updated { update.TransitionDuration = gentools.Ptr(time.Millisecond * 101) updates["light/"+*lightID] = update } }
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) }
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: } }
|