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.
 
 
 
 

632 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]
// 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 || resource.LegacyID == "" {
return errors.New("resource not found")
}
return b.client.legacyDelete(ctx, resource.LegacyID, nil)
}