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.
410 lines
8.6 KiB
410 lines
8.6 KiB
package hue
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"git.aiterp.net/lucifer/new-server/models"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
type Driver struct {
|
|
mu sync.Mutex
|
|
bridges []*Bridge
|
|
}
|
|
|
|
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
|
|
if address == "" {
|
|
if !dryRun {
|
|
return nil, models.ErrAddressOnlyDryRunnable
|
|
}
|
|
|
|
res, err := http.Get("https://discovery.meethue.com")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
|
|
entries := make([]DiscoveryEntry, 0, 8)
|
|
err = json.NewDecoder(res.Body).Decode(&entries)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bridges := make([]models.Bridge, 0, len(entries))
|
|
for _, entry := range entries {
|
|
bridges = append(bridges, models.Bridge{
|
|
ID: -1,
|
|
Name: entry.Id,
|
|
Driver: models.DTHue,
|
|
Address: entry.InternalIPAddress,
|
|
Token: "",
|
|
})
|
|
}
|
|
|
|
return bridges, nil
|
|
}
|
|
|
|
deviceInfo := BridgeDeviceInfo{}
|
|
res, err := http.Get(fmt.Sprintf("http://%s/description.xml", address))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer res.Body.Close()
|
|
err = xml.NewDecoder(res.Body).Decode(&deviceInfo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
bridge := models.Bridge{
|
|
ID: -1,
|
|
Name: deviceInfo.Device.FriendlyName,
|
|
Driver: models.DTHue,
|
|
Address: address,
|
|
Token: "",
|
|
}
|
|
|
|
if !dryRun {
|
|
b := &Bridge{host: address}
|
|
|
|
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for range ticker.C {
|
|
token, err := b.getToken(timeout)
|
|
if err != nil {
|
|
if err == errLinkButtonNotPressed {
|
|
continue
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
bridge.Token = token
|
|
b.token = token
|
|
break
|
|
}
|
|
}
|
|
|
|
return []models.Bridge{bridge}, nil
|
|
}
|
|
|
|
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
|
|
b, err := d.ensureBridge(ctx, bridge)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
before, err := d.ListDevices(ctx, bridge)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if timeout.Seconds() < 10 {
|
|
timeout = time.Second * 10
|
|
}
|
|
halfTime := timeout / 2
|
|
|
|
err = b.StartDiscovery(ctx, "sensors")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
select {
|
|
case <-time.After(halfTime):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
err = b.StartDiscovery(ctx, "lights")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
select {
|
|
case <-time.After(halfTime):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
err = b.Refresh(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
after, err := d.ListDevices(ctx, bridge)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
intersection := make([]models.Device, 0, 4)
|
|
for _, device := range after {
|
|
found := false
|
|
for _, device2 := range before {
|
|
if device2.InternalID == device.InternalID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
intersection = append(intersection, device)
|
|
}
|
|
}
|
|
|
|
return intersection, nil
|
|
}
|
|
|
|
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
|
|
b, err := d.ensureBridge(ctx, bridge)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
devices := make([]models.Device, 0, 8)
|
|
|
|
lightMap, err := b.getLights(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, lightInfo := range lightMap {
|
|
device := models.Device{
|
|
ID: -1,
|
|
BridgeID: b.externalID,
|
|
InternalID: lightInfo.Uniqueid,
|
|
Icon: "lightbulb",
|
|
Name: lightInfo.Name,
|
|
Capabilities: []models.DeviceCapability{
|
|
models.DCPower,
|
|
},
|
|
ButtonNames: nil,
|
|
DriverProperties: map[string]interface{}{
|
|
"modelId": lightInfo.Modelid,
|
|
"productName": lightInfo.Productname,
|
|
"swVersion": lightInfo.Swversion,
|
|
"hueLightType": lightInfo.Type,
|
|
},
|
|
UserProperties: nil,
|
|
State: models.DeviceState{},
|
|
Tags: nil,
|
|
}
|
|
|
|
hasDimming := false
|
|
hasCT := false
|
|
hasColor := false
|
|
|
|
switch lightInfo.Type {
|
|
case "On/off light":
|
|
// Always take DCPower for granted anyway.
|
|
case "Dimmable light":
|
|
hasDimming = true
|
|
case "Color temperature light":
|
|
hasDimming = true
|
|
hasCT = true
|
|
case "Color light":
|
|
hasDimming = true
|
|
hasColor = true
|
|
case "Extended color light":
|
|
hasDimming = true
|
|
hasColor = true
|
|
hasCT = true
|
|
}
|
|
|
|
ctrl := lightInfo.Capabilities.Control
|
|
if hasDimming {
|
|
device.Capabilities = append(device.Capabilities, models.DCIntensity)
|
|
}
|
|
if hasCT {
|
|
device.Capabilities = append(device.Capabilities, models.DCColorKelvin)
|
|
device.DriverProperties["minKelvin"] = 1000000 / ctrl.CT.Max
|
|
device.DriverProperties["maxKelvin"] = 1000000 / ctrl.CT.Min
|
|
}
|
|
if hasColor {
|
|
device.Capabilities = append(device.Capabilities, models.DCColorHS)
|
|
device.DriverProperties["gamutType"] = ctrl.Colorgamuttype
|
|
device.DriverProperties["gamutData"] = ctrl.Colorgamut
|
|
}
|
|
device.DriverProperties["maxLumen"] = strconv.Itoa(ctrl.Maxlumen)
|
|
|
|
devices = append(devices, device)
|
|
}
|
|
|
|
sensorMap, err := b.getSensors(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, sensorInfo := range sensorMap {
|
|
device := models.Device{
|
|
ID: -1,
|
|
BridgeID: b.externalID,
|
|
InternalID: sensorInfo.UniqueID,
|
|
Name: sensorInfo.Name,
|
|
Capabilities: []models.DeviceCapability{},
|
|
ButtonNames: []string{},
|
|
DriverProperties: map[string]interface{}{
|
|
"modelId": sensorInfo.Modelid,
|
|
"productName": sensorInfo.Productname,
|
|
"swVersion": sensorInfo.Swversion,
|
|
"hueLightType": sensorInfo.Type,
|
|
},
|
|
UserProperties: nil,
|
|
State: models.DeviceState{},
|
|
Tags: nil,
|
|
}
|
|
|
|
switch sensorInfo.Type {
|
|
case "ZLLSwitch":
|
|
device.Capabilities = append(device.Capabilities, models.DCButtons)
|
|
device.ButtonNames = append(buttonNames[:0:0], buttonNames...)
|
|
device.Icon = "lightswitch"
|
|
case "ZLLPresence":
|
|
device.Capabilities = append(device.Capabilities, models.DCPresence)
|
|
device.Icon = "sensor"
|
|
case "ZLLTemperature":
|
|
device.Capabilities = append(device.Capabilities, models.DCTemperatureSensor)
|
|
device.Icon = "thermometer"
|
|
case "Daylight":
|
|
continue
|
|
}
|
|
|
|
devices = append(devices, device)
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
|
|
b, err := d.ensureBridge(ctx, bridge)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = b.Refresh(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.mu.Lock()
|
|
for _, device := range devices {
|
|
for _, state := range b.lightStates {
|
|
if device.InternalID == state.uniqueID {
|
|
state.externalID = device.ID
|
|
state.Update(device.State)
|
|
break
|
|
}
|
|
}
|
|
|
|
for _, state := range b.sensorStates {
|
|
if device.InternalID == state.uniqueID {
|
|
state.externalID = device.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
b.mu.Unlock()
|
|
|
|
atomic.StoreUint32(&b.syncingPublish, 1)
|
|
defer atomic.StoreUint32(&b.syncingPublish, 0)
|
|
|
|
return b.SyncStale(ctx)
|
|
}
|
|
|
|
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
|
|
b, err := d.ensureBridge(ctx, bridge)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fastTicker := time.NewTicker(time.Second / 10)
|
|
slowTicker := time.NewTicker(time.Second / 3)
|
|
selectedTicker := fastTicker
|
|
ticksUntilRefresh := 0
|
|
ticksSinceChange := 0
|
|
|
|
for {
|
|
select {
|
|
case <-selectedTicker.C:
|
|
if atomic.LoadUint32(&b.syncingPublish) == 1 {
|
|
continue
|
|
}
|
|
|
|
if ticksUntilRefresh <= 0 {
|
|
err := b.Refresh(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = b.SyncStale(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ticksUntilRefresh = 60
|
|
}
|
|
|
|
events, err := b.SyncSensors(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, event := range events {
|
|
ch <- event
|
|
ticksSinceChange = 0
|
|
}
|
|
|
|
if ticksSinceChange > 30 {
|
|
selectedTicker = slowTicker
|
|
} else if ticksSinceChange == 0 {
|
|
selectedTicker = fastTicker
|
|
}
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
|
|
ticksUntilRefresh -= 1
|
|
ticksSinceChange += 1
|
|
}
|
|
}
|
|
|
|
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) {
|
|
d.mu.Lock()
|
|
for _, bridge := range d.bridges {
|
|
if bridge.host == info.Address {
|
|
d.mu.Unlock()
|
|
return bridge, nil
|
|
}
|
|
}
|
|
d.mu.Unlock()
|
|
|
|
bridge := &Bridge{
|
|
host: info.Address,
|
|
token: info.Token,
|
|
externalID: info.ID,
|
|
}
|
|
|
|
// If this call succeeds, then the token is ok.
|
|
lightMap, err := bridge.getLights(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
log.Printf("Found %d lights on bridge %d", len(lightMap), bridge.externalID)
|
|
|
|
// To avoid a potential duplicate, try looking for it again before inserting
|
|
d.mu.Lock()
|
|
for _, bridge := range d.bridges {
|
|
if bridge.host == info.Address {
|
|
d.mu.Unlock()
|
|
return bridge, nil
|
|
}
|
|
}
|
|
d.bridges = append(d.bridges, bridge)
|
|
d.mu.Unlock()
|
|
|
|
return bridge, nil
|
|
}
|