|
|
@ -2,9 +2,11 @@ package hue2 |
|
|
|
|
|
|
|
import ( |
|
|
|
"context" |
|
|
|
"errors" |
|
|
|
"fmt" |
|
|
|
"git.aiterp.net/lucifer/new-server/models" |
|
|
|
"golang.org/x/sync/errgroup" |
|
|
|
"log" |
|
|
|
"math" |
|
|
|
"strings" |
|
|
|
"sync" |
|
|
@ -12,17 +14,197 @@ import ( |
|
|
|
) |
|
|
|
|
|
|
|
type Bridge struct { |
|
|
|
mu sync.Mutex |
|
|
|
client *Client |
|
|
|
devices map[string]models.Device |
|
|
|
resources map[string]*ResourceData |
|
|
|
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, |
|
|
|
devices: make(map[string]models.Device, 64), |
|
|
|
resources: make(map[string]*ResourceData, 256), |
|
|
|
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 at 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) |
|
|
|
needFull := false |
|
|
|
|
|
|
|
for { |
|
|
|
select { |
|
|
|
case <-ctx.Done(): |
|
|
|
return ctx.Err() |
|
|
|
case <-lightRefreshTimer.C: |
|
|
|
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 { |
|
|
|
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)") |
|
|
|
} |
|
|
|
case <-b.needsUpdate: |
|
|
|
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 |
|
|
|
} |
|
|
|
|
|
|
|
if b.resources[patch.Owner.ID] == nil { |
|
|
|
needFull = true |
|
|
|
} |
|
|
|
|
|
|
|
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 = lastPress[patch.ID].Unix() != data.CreationTime.Unix() |
|
|
|
} |
|
|
|
|
|
|
|
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 |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
@ -32,9 +214,20 @@ func (b *Bridge) Update(devices ...models.Device) { |
|
|
|
b.devices[device.InternalID] = device |
|
|
|
} |
|
|
|
b.mu.Unlock() |
|
|
|
|
|
|
|
select { |
|
|
|
case b.needsUpdate <- struct{}{}: |
|
|
|
default: |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { |
|
|
|
// Eat the event if there's a pending update.
|
|
|
|
select { |
|
|
|
case <-b.needsUpdate: |
|
|
|
default: |
|
|
|
} |
|
|
|
|
|
|
|
b.mu.Lock() |
|
|
|
dur := time.Millisecond * 200 |
|
|
|
updates := make(map[string]ResourceUpdate) |
|
|
@ -45,32 +238,40 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { |
|
|
|
update := ResourceUpdate{TransitionDuration: &dur} |
|
|
|
changed := false |
|
|
|
|
|
|
|
if light.ColorTemperature != nil && device.State.Color.IsKelvin() { |
|
|
|
mirek := 1000000 / device.State.Color.Kelvin |
|
|
|
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 |
|
|
|
lightsOut := light.Power != nil && !device.State.Power |
|
|
|
if !lightsOut { |
|
|
|
if light.ColorTemperature != nil && device.State.Color.IsKelvin() { |
|
|
|
mirek := 1000000 / device.State.Color.Kelvin |
|
|
|
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 xy, ok := device.State.Color.ToXY(); ok && light.Color != nil { |
|
|
|
xy = light.Color.Gamut.Conform(xy).Round() |
|
|
|
if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) { |
|
|
|
update.ColorXY = &xy |
|
|
|
changed = true |
|
|
|
} |
|
|
|
} |
|
|
|
} else if xy, ok := device.State.Color.ToXY(); ok && light.Color != nil { |
|
|
|
xy = light.Color.Gamut.Conform(xy).Round() |
|
|
|
if xy.DistanceTo(light.Color.XY) > 0.00015 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) { |
|
|
|
update.ColorXY = &xy |
|
|
|
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 |
|
|
|
changed = true |
|
|
|
} |
|
|
|
if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.005 { |
|
|
|
brightness := math.Abs(math.Min(device.State.Intensity*100, 100)) |
|
|
|
update.Brightness = &brightness |
|
|
|
if device.State.Power { |
|
|
|
brightness := math.Abs(math.Min(device.State.Intensity*100, 100)) |
|
|
|
update.Brightness = &brightness |
|
|
|
} |
|
|
|
changed = true |
|
|
|
} |
|
|
|
|
|
|
@ -85,6 +286,8 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { |
|
|
|
return 0, nil |
|
|
|
} |
|
|
|
|
|
|
|
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updating", len(updates), "services...") |
|
|
|
|
|
|
|
eg, ctx := errgroup.WithContext(ctx) |
|
|
|
for key := range updates { |
|
|
|
update := updates[key] |
|
|
@ -95,7 +298,28 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) { |
|
|
|
}) |
|
|
|
} |
|
|
|
|
|
|
|
return len(updates), eg.Wait() |
|
|
|
err := eg.Wait() |
|
|
|
if err != nil { |
|
|
|
return len(updates), err |
|
|
|
} |
|
|
|
|
|
|
|
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.
|
|
|
|
b.mu.Lock() |
|
|
|
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() |
|
|
|
} |
|
|
|
|
|
|
|
return len(updates), nil |
|
|
|
} |
|
|
|
|
|
|
|
func (b *Bridge) GenerateDevices() []models.Device { |
|
|
@ -243,7 +467,6 @@ func (b *Bridge) RefreshAll(ctx context.Context) error { |
|
|
|
} |
|
|
|
|
|
|
|
resources := make(map[string]*ResourceData, len(allResources)) |
|
|
|
|
|
|
|
for i := range allResources { |
|
|
|
resource := allResources[i] |
|
|
|
resources[resource.ID] = &resource |
|
|
@ -255,3 +478,64 @@ func (b *Bridge) RefreshAll(ctx context.Context) error { |
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
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 |
|
|
|
} |
|
|
|
|
|
|
|
newResources[patch.ID] = &resCopy |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
b.mu.Lock() |
|
|
|
b.resources = newResources |
|
|
|
b.mu.Unlock() |
|
|
|
} |