|
|
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) }
|