You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

565 lines
14 KiB

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[resource.ID] = 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")
}
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.Minute
if since > time.Second*30 && (sinceMod <= time.Second*5) {
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)}
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:
}
}