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.
639 lines
16 KiB
639 lines
16 KiB
package hue2
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"git.aiterp.net/lucifer/new-server/internal/color"
|
|
"git.aiterp.net/lucifer/new-server/models"
|
|
"golang.org/x/sync/errgroup"
|
|
"log"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Bridge struct {
|
|
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,
|
|
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 (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)
|
|
lastUpdate := time.Now()
|
|
needFull := false
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-lightRefreshTimer.C:
|
|
if time.Since(lastUpdate) < time.Second*2 {
|
|
continue
|
|
}
|
|
|
|
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 {
|
|
// Force this to cool for 30 seconds unless a manual update occurs.
|
|
if updated > 10 {
|
|
lastUpdate = time.Now().Add(time.Second * 15)
|
|
}
|
|
|
|
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)")
|
|
}
|
|
case <-b.needsUpdate:
|
|
lastUpdate = time.Now()
|
|
|
|
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
|
|
}
|
|
|
|
b.mu.Lock()
|
|
if b.resources[patch.Owner.ID] == nil {
|
|
needFull = true
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
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 = data.CreationTime.Sub(lastPress[patch.ID]) >= time.Second*2
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *Bridge) Update(devices ...models.Device) {
|
|
b.mu.Lock()
|
|
for _, device := range devices {
|
|
b.devices[device.InternalID] = device
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
select {
|
|
case b.needsUpdate <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
|
|
// Exhaust the channel to avoid more updates.
|
|
exhausted := false
|
|
for !exhausted {
|
|
select {
|
|
case <-b.needsUpdate:
|
|
default:
|
|
exhausted = true
|
|
}
|
|
}
|
|
|
|
b.mu.Lock()
|
|
dur := time.Millisecond * 100
|
|
updates := make(map[string]ResourceUpdate)
|
|
for _, device := range b.devices {
|
|
resource := b.resources[device.InternalID]
|
|
if resource == nil {
|
|
continue
|
|
}
|
|
|
|
// Update device
|
|
if resource.Metadata.Name != device.Name {
|
|
name := device.Name
|
|
updates["device/"+resource.ID] = ResourceUpdate{
|
|
Name: &name,
|
|
}
|
|
}
|
|
|
|
// Update light
|
|
if lightID := resource.ServiceID("light"); lightID != nil {
|
|
light := b.resources[*lightID]
|
|
update := ResourceUpdate{TransitionDuration: &dur}
|
|
changed := false
|
|
|
|
lightsOut := light.Power != nil && !device.State.Power
|
|
if !lightsOut {
|
|
if light.ColorTemperature != nil && device.State.Color.IsKelvin() {
|
|
mirek := 1000000 / *device.State.Color.K
|
|
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 xyColor, ok := device.State.Color.ToXY(); ok && light.Color != nil {
|
|
xy := light.Color.Gamut.Conform(*xyColor.XY).Round()
|
|
if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) {
|
|
update.ColorXY = &xy
|
|
changed = true
|
|
}
|
|
}
|
|
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
|
|
if device.State.Power {
|
|
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
|
|
update.Brightness = &brightness
|
|
}
|
|
changed = true
|
|
}
|
|
|
|
if changed {
|
|
updates["light/"+light.ID] = update
|
|
}
|
|
}
|
|
}
|
|
|
|
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.
|
|
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()
|
|
|
|
if len(updates) == 0 {
|
|
return 0, nil
|
|
}
|
|
|
|
eg, ctx := errgroup.WithContext(ctx)
|
|
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 {
|
|
// Try to restore light states.
|
|
_ = b.Refresh(ctx, "light")
|
|
|
|
return len(updates), err
|
|
}
|
|
|
|
return len(updates), nil
|
|
}
|
|
|
|
func (b *Bridge) GenerateDevices() []models.Device {
|
|
b.mu.Lock()
|
|
resources := b.resources
|
|
b.mu.Unlock()
|
|
|
|
devices := make([]models.Device, 0, 16)
|
|
for _, resource := range resources {
|
|
if resource.Type != "device" || strings.HasPrefix(resource.Metadata.Archetype, "bridge") {
|
|
continue
|
|
}
|
|
|
|
device := models.Device{
|
|
BridgeID: b.externalID,
|
|
InternalID: resource.ID,
|
|
Name: resource.Metadata.Name,
|
|
DriverProperties: map[string]interface{}{
|
|
"archetype": resource.Metadata.Archetype,
|
|
"name": resource.ProductData.ProductName,
|
|
"product": resource.ProductData,
|
|
"legacyId": resource.LegacyID,
|
|
},
|
|
}
|
|
|
|
// Set icon
|
|
if resource.ProductData.ProductName == "Hue dimmer switch" {
|
|
device.Icon = "switch"
|
|
} else if resource.ProductData.ProductName == "Hue motion sensor" {
|
|
device.Icon = "sensor"
|
|
} else {
|
|
device.Icon = "lightbulb"
|
|
}
|
|
|
|
buttonCount := 0
|
|
for _, ptr := range resource.Services {
|
|
switch ptr.Kind {
|
|
case "device_power":
|
|
{
|
|
device.DriverProperties["battery"] = resources[ptr.ID].PowerState
|
|
}
|
|
case "button":
|
|
{
|
|
buttonCount += 1
|
|
}
|
|
case "zigbee_connectivity":
|
|
{
|
|
device.DriverProperties["zigbee"] = resources[ptr.ID].Status
|
|
}
|
|
case "motion":
|
|
{
|
|
device.Capabilities = append(device.Capabilities, models.DCPresence)
|
|
}
|
|
case "temperature":
|
|
{
|
|
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
|
|
}
|
|
case "light":
|
|
{
|
|
light := resources[ptr.ID]
|
|
|
|
if light.Power != nil {
|
|
device.State.Power = light.Power.On
|
|
device.Capabilities = append(device.Capabilities, models.DCPower)
|
|
}
|
|
if light.Dimming != nil {
|
|
device.State.Intensity = light.Dimming.Brightness / 100
|
|
device.Capabilities = append(device.Capabilities, models.DCIntensity)
|
|
}
|
|
if light.ColorTemperature != nil {
|
|
if light.ColorTemperature.Mirek != nil {
|
|
device.State.Color.SetK(1000000 / *light.ColorTemperature.Mirek)
|
|
}
|
|
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
|
|
device.DriverProperties["maxTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMinimum
|
|
device.DriverProperties["minTemperature"] = 1000000 / light.ColorTemperature.MirekSchema.MirekMaximum
|
|
}
|
|
if light.Color != nil {
|
|
if device.State.Color.IsEmpty() {
|
|
device.State.Color = color.Color{
|
|
XY: &light.Color.XY,
|
|
}
|
|
}
|
|
device.DriverProperties["colorGamut"] = light.Color.Gamut
|
|
device.DriverProperties["colorGamutType"] = light.Color.GamutType
|
|
device.Capabilities = append(device.Capabilities, models.DCColorHS, models.DCColorXY)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if buttonCount == 4 {
|
|
device.ButtonNames = []string{"On", "DimUp", "DimDown", "Off"}
|
|
} else if buttonCount == 1 {
|
|
device.ButtonNames = []string{"Button"}
|
|
} else {
|
|
for n := 1; n <= buttonCount; n++ {
|
|
device.ButtonNames = append(device.ButtonNames, fmt.Sprint("Button", n))
|
|
}
|
|
}
|
|
|
|
devices = append(devices, device)
|
|
}
|
|
|
|
return devices
|
|
}
|
|
|
|
func (b *Bridge) Refresh(ctx context.Context, kind string) error {
|
|
if kind == "device" {
|
|
// Device refresh requires the full deal as services are taken for granted.
|
|
return b.RefreshAll(ctx)
|
|
}
|
|
|
|
resources, err := b.client.Resources(ctx, kind)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.mu.Lock()
|
|
oldResources := b.resources
|
|
b.mu.Unlock()
|
|
|
|
newResources := make(map[string]*ResourceData, len(b.resources))
|
|
for key, value := range oldResources {
|
|
if value.Type != kind {
|
|
newResources[key] = value
|
|
}
|
|
}
|
|
for i := range resources {
|
|
resource := resources[i]
|
|
newResources[resource.ID] = &resource
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.resources = newResources
|
|
b.mu.Unlock()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (b *Bridge) RefreshAll(ctx context.Context) error {
|
|
allResources, err := b.client.AllResources(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resources := make(map[string]*ResourceData, len(allResources))
|
|
for i := range allResources {
|
|
resource := allResources[i]
|
|
resources[resource.ID] = &resource
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.resources = resources
|
|
b.mu.Unlock()
|
|
|
|
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
|
|
|
|
if resCopy.ColorTemperature != nil {
|
|
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
|
|
}
|
|
resCopy.Metadata.Name = patch.Metadata.Name
|
|
|
|
newResources[patch.ID] = &resCopy
|
|
}
|
|
}
|
|
|
|
b.mu.Lock()
|
|
b.resources = newResources
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *Bridge) SearchDevices(ctx context.Context, timeout time.Duration) ([]models.Device, error) {
|
|
// Record the current state.
|
|
b.mu.Lock()
|
|
before := b.resources
|
|
b.mu.Unlock()
|
|
|
|
// Spend half the time waiting for devices
|
|
// TODO: Wait for v2 endpoint
|
|
err := b.client.LegacyDiscover(ctx, "sensors")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
select {
|
|
case <-time.After(timeout / 1):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// Spend half the time waiting for lights
|
|
// TODO: Wait for v2 endpoint
|
|
err = b.client.LegacyDiscover(ctx, "lights")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
select {
|
|
case <-time.After(timeout / 1):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
// Perform a full refresh.
|
|
err = b.RefreshAll(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check for new devices
|
|
devices := b.GenerateDevices()
|
|
newDevices := make([]models.Device, 0)
|
|
for _, device := range devices {
|
|
if before[device.InternalID] == nil {
|
|
newDevices = append(newDevices, device)
|
|
}
|
|
}
|
|
|
|
// Return said devices.
|
|
return newDevices, nil
|
|
}
|
|
|
|
func (b *Bridge) Forget(ctx context.Context, device models.Device) error {
|
|
b.mu.Lock()
|
|
resource := b.resources[device.InternalID]
|
|
b.mu.Unlock()
|
|
|
|
if resource == nil {
|
|
return errors.New("resource not found")
|
|
}
|
|
|
|
return b.client.DeleteResource(ctx, ResourceLink{
|
|
ID: device.InternalID,
|
|
Kind: "device",
|
|
})
|
|
}
|