Gisle Aune
4 years ago
11 changed files with 1096 additions and 23 deletions
-
2app/config/driver.go
-
12cmd/bridgetest/main.go
-
1go.mod
-
2go.sum
-
286internal/drivers/hue/bridge.go
-
196internal/drivers/hue/data.go
-
399internal/drivers/hue/driver.go
-
179internal/drivers/hue/state.go
-
12internal/drivers/nanoleaf/bridge.go
-
3models/device.go
-
3models/event.go
@ -0,0 +1,286 @@ |
|||
package hue |
|||
|
|||
import ( |
|||
"bytes" |
|||
"context" |
|||
"encoding/json" |
|||
"fmt" |
|||
"git.aiterp.net/lucifer/new-server/models" |
|||
"golang.org/x/sync/errgroup" |
|||
"io" |
|||
"net/http" |
|||
"strings" |
|||
"sync" |
|||
) |
|||
|
|||
type Bridge struct { |
|||
mu sync.Mutex |
|||
host string |
|||
token string |
|||
externalID int |
|||
lightStates []*hueLightState |
|||
sensorStates []*hueSensorState |
|||
} |
|||
|
|||
func (b *Bridge) Refresh(ctx context.Context) error { |
|||
lightMap, err := b.getLights(ctx) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
b.mu.Lock() |
|||
for index, light := range lightMap { |
|||
var state *hueLightState |
|||
for _, existingState := range b.lightStates { |
|||
if existingState.index == index { |
|||
state = existingState |
|||
} |
|||
} |
|||
|
|||
if state == nil { |
|||
state = &hueLightState{ |
|||
index: index, |
|||
uniqueID: light.Uniqueid, |
|||
externalID: -1, |
|||
info: light, |
|||
} |
|||
|
|||
b.lightStates = append(b.lightStates, state) |
|||
} else { |
|||
if light.Uniqueid != state.uniqueID { |
|||
state.uniqueID = light.Uniqueid |
|||
state.externalID = -1 |
|||
} |
|||
} |
|||
|
|||
state.CheckStaleness(light.State) |
|||
} |
|||
b.mu.Unlock() |
|||
|
|||
sensorMap, err := b.getSensors(ctx) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
b.mu.Lock() |
|||
for index, sensor := range sensorMap { |
|||
var state *hueSensorState |
|||
for _, existingState := range b.sensorStates { |
|||
if existingState.index == index { |
|||
state = existingState |
|||
} |
|||
} |
|||
|
|||
if state == nil { |
|||
state = &hueSensorState{ |
|||
index: index, |
|||
uniqueID: sensor.UniqueID, |
|||
externalID: -1, |
|||
} |
|||
|
|||
b.sensorStates = append(b.sensorStates, state) |
|||
} else { |
|||
if sensor.UniqueID != state.uniqueID { |
|||
state.uniqueID = sensor.UniqueID |
|||
state.externalID = -1 |
|||
} |
|||
} |
|||
} |
|||
b.mu.Unlock() |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func (b *Bridge) SyncStale(ctx context.Context) error { |
|||
indices := make([]int, 0, 4) |
|||
inputs := make([]LightStateInput, 0, 4) |
|||
|
|||
eg, ctx := errgroup.WithContext(ctx) |
|||
|
|||
b.mu.Lock() |
|||
for _, state := range b.lightStates { |
|||
if !state.stale { |
|||
continue |
|||
} |
|||
|
|||
indices = append(indices, state.index) |
|||
inputs = append(inputs, state.input) |
|||
} |
|||
b.mu.Unlock() |
|||
|
|||
if len(inputs) == 0 { |
|||
return nil |
|||
} |
|||
|
|||
for i, input := range inputs { |
|||
index := indices[i] |
|||
|
|||
eg.Go(func() error { return b.putLightState(ctx, index, input) }) |
|||
} |
|||
|
|||
return eg.Wait() |
|||
} |
|||
|
|||
func (b *Bridge) SyncSensors(ctx context.Context) ([]models.Event, error) { |
|||
sensorMap, err := b.getSensors(ctx) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
var events []models.Event |
|||
|
|||
b.mu.Lock() |
|||
for idx, sensorData := range sensorMap { |
|||
for _, state := range b.sensorStates { |
|||
if idx == state.index { |
|||
event := state.Update(sensorData) |
|||
if event != nil { |
|||
events = append(events, *event) |
|||
} |
|||
|
|||
break |
|||
} |
|||
} |
|||
} |
|||
b.mu.Unlock() |
|||
|
|||
return events, nil |
|||
} |
|||
|
|||
func (b *Bridge) StartDiscovery(ctx context.Context, model string) error { |
|||
return b.post(ctx, model, nil, nil) |
|||
} |
|||
|
|||
func (b *Bridge) putLightState(ctx context.Context, index int, input LightStateInput) error { |
|||
return b.put(ctx, fmt.Sprintf("lights/%d/state", index), input, nil) |
|||
} |
|||
|
|||
func (b *Bridge) getToken(ctx context.Context) (string, error) { |
|||
result := make([]CreateUserResponse, 0, 1) |
|||
err := b.post(ctx, "", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
|
|||
if len(result) == 0 || result[0].Error != nil { |
|||
return "", errLinkButtonNotPressed |
|||
} |
|||
if result[0].Success == nil { |
|||
return "", models.ErrUnexpectedResponse |
|||
} |
|||
|
|||
return result[0].Success.Username, nil |
|||
} |
|||
|
|||
func (b *Bridge) getLights(ctx context.Context) (map[int]LightData, error) { |
|||
result := make(map[int]LightData, 16) |
|||
err := b.get(ctx, "lights", &result) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return result, nil |
|||
} |
|||
|
|||
func (b *Bridge) getSensors(ctx context.Context) (map[int]SensorData, error) { |
|||
result := make(map[int]SensorData, 16) |
|||
err := b.get(ctx, "sensors", &result) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return result, nil |
|||
} |
|||
|
|||
func (b *Bridge) get(ctx context.Context, resource string, target interface{}) error { |
|||
if b.token != "" { |
|||
resource = b.token + "/" + resource |
|||
} |
|||
|
|||
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/api/%s", b.host, resource), nil) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
res, err := http.DefaultClient.Do(req.WithContext(ctx)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer res.Body.Close() |
|||
|
|||
return json.NewDecoder(res.Body).Decode(target) |
|||
} |
|||
|
|||
func (b *Bridge) post(ctx context.Context, resource string, body interface{}, target interface{}) error { |
|||
rb, err := reqBody(body) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
if b.token != "" { |
|||
resource = b.token + "/" + resource |
|||
} |
|||
|
|||
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/api/%s", b.host, resource), rb) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
res, err := http.DefaultClient.Do(req.WithContext(ctx)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer res.Body.Close() |
|||
|
|||
return json.NewDecoder(res.Body).Decode(target) |
|||
} |
|||
|
|||
func (b *Bridge) put(ctx context.Context, resource string, body interface{}, target interface{}) error { |
|||
rb, err := reqBody(body) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
if b.token != "" { |
|||
resource = b.token + "/" + resource |
|||
} |
|||
|
|||
req, err := http.NewRequest("PUT", fmt.Sprintf("http://%s/api/%s", b.host, resource), rb) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
|
|||
res, err := http.DefaultClient.Do(req.WithContext(ctx)) |
|||
if err != nil { |
|||
return err |
|||
} |
|||
defer res.Body.Close() |
|||
|
|||
if target == nil { |
|||
return nil |
|||
} |
|||
|
|||
return json.NewDecoder(res.Body).Decode(target) |
|||
} |
|||
|
|||
func reqBody(body interface{}) (io.Reader, error) { |
|||
if body == nil { |
|||
return nil, nil |
|||
} |
|||
|
|||
switch v := body.(type) { |
|||
case []byte: |
|||
return bytes.NewReader(v), nil |
|||
case string: |
|||
return strings.NewReader(v), nil |
|||
case io.Reader: |
|||
return v, nil |
|||
default: |
|||
jsonData, err := json.Marshal(v) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return bytes.NewReader(jsonData), nil |
|||
} |
|||
} |
@ -0,0 +1,196 @@ |
|||
package hue |
|||
|
|||
import ( |
|||
"encoding/xml" |
|||
"errors" |
|||
) |
|||
|
|||
type DiscoveryEntry struct { |
|||
Id string `json:"id"` |
|||
InternalIPAddress string `json:"internalipaddress"` |
|||
} |
|||
|
|||
type CreateUserInput struct { |
|||
DeviceType string `json:"devicetype"` |
|||
} |
|||
|
|||
type CreateUserResponse struct { |
|||
Success *struct { |
|||
Username string `json:"username"` |
|||
} `json:"success"` |
|||
Error *struct { |
|||
Type int `json:"type"` |
|||
Address string `json:"address"` |
|||
Description string `json:"description"` |
|||
} `json:"error"` |
|||
} |
|||
|
|||
type Overview struct { |
|||
Lights map[int]LightData `json:"lights"` |
|||
Config BridgeConfig `json:"config"` |
|||
Sensors map[int]SensorData `json:"sensors"` |
|||
} |
|||
|
|||
type BridgeConfig struct { |
|||
Name string `json:"name"` |
|||
ZigbeeChannel int `json:"zigbeechannel"` |
|||
BridgeID string `json:"bridgeid"` |
|||
Mac string `json:"mac"` |
|||
DHCP bool `json:"dhcp"` |
|||
IPAddress string `json:"ipaddress"` |
|||
NetMask string `json:"netmask"` |
|||
Gateway string `json:"gateway"` |
|||
ProxyAddress string `json:"proxyaddress"` |
|||
ProxyPort int `json:"proxyport"` |
|||
UTC string `json:"UTC"` |
|||
LocalTime string `json:"localtime"` |
|||
TimeZone string `json:"timezone"` |
|||
ModelID string `json:"modelid"` |
|||
DataStoreVersion string `json:"datastoreversion"` |
|||
SWVersion string `json:"swversion"` |
|||
APIVersion string `json:"apiversion"` |
|||
LinkButton bool `json:"linkbutton"` |
|||
PortalServices bool `json:"portalservices"` |
|||
PortalConnection string `json:"portalconnection"` |
|||
FactoryNew bool `json:"factorynew"` |
|||
StarterKitID string `json:"starterkitid"` |
|||
Whitelist map[string]WhitelistEntry `json:"whitelist"` |
|||
} |
|||
|
|||
type LightState struct { |
|||
On bool `json:"on"` |
|||
Bri int `json:"bri"` |
|||
Hue int `json:"hue"` |
|||
Sat int `json:"sat"` |
|||
Effect string `json:"effect"` |
|||
XY []float64 `json:"xy"` |
|||
CT int `json:"ct"` |
|||
Alert string `json:"alert"` |
|||
ColorMode string `json:"colormode"` |
|||
Mode string `json:"mode"` |
|||
Reachable bool `json:"reachable"` |
|||
} |
|||
|
|||
type LightStateInput struct { |
|||
On *bool `json:"on,omitempty"` |
|||
Bri *int `json:"bri,omitempty"` |
|||
Hue *int `json:"hue,omitempty"` |
|||
Sat *int `json:"sat,omitempty"` |
|||
Effect *string `json:"effect,omitempty"` |
|||
XY *[2]float64 `json:"xy,omitempty"` |
|||
CT *int `json:"ct,omitempty"` |
|||
Alert *string `json:"alert,omitempty"` |
|||
TransitionTime *int `json:"transitiontime,omitempty"` |
|||
} |
|||
|
|||
type LightData struct { |
|||
State LightState `json:"state"` |
|||
Type string `json:"type"` |
|||
Name string `json:"name"` |
|||
Modelid string `json:"modelid"` |
|||
Manufacturername string `json:"manufacturername"` |
|||
Productname string `json:"productname"` |
|||
Capabilities struct { |
|||
Certified bool `json:"certified"` |
|||
Control struct { |
|||
Mindimlevel int `json:"mindimlevel"` |
|||
Maxlumen int `json:"maxlumen"` |
|||
Colorgamuttype string `json:"colorgamuttype"` |
|||
Colorgamut [][]float64 `json:"colorgamut"` |
|||
CT struct { |
|||
Min int `json:"min"` |
|||
Max int `json:"max"` |
|||
} `json:"ct"` |
|||
} `json:"control"` |
|||
Streaming struct { |
|||
Renderer bool `json:"renderer"` |
|||
Proxy bool `json:"proxy"` |
|||
} `json:"streaming"` |
|||
} `json:"capabilities"` |
|||
Config struct { |
|||
Archetype string `json:"archetype"` |
|||
Function string `json:"function"` |
|||
Direction string `json:"direction"` |
|||
Startup struct { |
|||
Mode string `json:"mode"` |
|||
Configured bool `json:"configured"` |
|||
} `json:"startup"` |
|||
} `json:"config"` |
|||
Swupdate struct { |
|||
State string `json:"state"` |
|||
Lastinstall string `json:"lastinstall"` |
|||
} `json:"swupdate"` |
|||
Uniqueid string `json:"uniqueid"` |
|||
Swversion string `json:"swversion"` |
|||
Swconfigid string `json:"swconfigid"` |
|||
Productid string `json:"productid"` |
|||
} |
|||
|
|||
type SensorData struct { |
|||
State struct { |
|||
Daylight interface{} `json:"daylight"` |
|||
ButtonEvent int `json:"buttonevent"` |
|||
LastUpdated string `json:"lastupdated"` |
|||
Presence bool `json:"presence"` |
|||
} `json:"state"` |
|||
Config struct { |
|||
On bool `json:"on"` |
|||
Configured bool `json:"configured"` |
|||
Sunriseoffset int `json:"sunriseoffset"` |
|||
Sunsetoffset int `json:"sunsetoffset"` |
|||
} `json:"config"` |
|||
Name string `json:"name"` |
|||
Type string `json:"type"` |
|||
Modelid string `json:"modelid"` |
|||
Manufacturername string `json:"manufacturername"` |
|||
Productname string `json:"productname"` |
|||
Swversion string `json:"swversion"` |
|||
UniqueID string `json:"uniqueid"` |
|||
} |
|||
|
|||
type WhitelistEntry struct { |
|||
LastUseDate string `json:"last use date"` |
|||
CreateDate string `json:"create date"` |
|||
Name string `json:"name"` |
|||
} |
|||
|
|||
type BridgeDeviceInfo struct { |
|||
XMLName xml.Name `xml:"root"` |
|||
Text string `xml:",chardata"` |
|||
Xmlns string `xml:"xmlns,attr"` |
|||
SpecVersion struct { |
|||
Text string `xml:",chardata"` |
|||
Major string `xml:"major"` |
|||
Minor string `xml:"minor"` |
|||
} `xml:"specVersion"` |
|||
URLBase string `xml:"URLBase"` |
|||
Device struct { |
|||
Text string `xml:",chardata"` |
|||
DeviceType string `xml:"deviceType"` |
|||
FriendlyName string `xml:"friendlyName"` |
|||
Manufacturer string `xml:"manufacturer"` |
|||
ManufacturerURL string `xml:"manufacturerURL"` |
|||
ModelDescription string `xml:"modelDescription"` |
|||
ModelName string `xml:"modelName"` |
|||
ModelNumber string `xml:"modelNumber"` |
|||
ModelURL string `xml:"modelURL"` |
|||
SerialNumber string `xml:"serialNumber"` |
|||
UDN string `xml:"UDN"` |
|||
PresentationURL string `xml:"presentationURL"` |
|||
IconList struct { |
|||
Text string `xml:",chardata"` |
|||
Icon struct { |
|||
Text string `xml:",chardata"` |
|||
Mimetype string `xml:"mimetype"` |
|||
Height string `xml:"height"` |
|||
Width string `xml:"width"` |
|||
Depth string `xml:"depth"` |
|||
URL string `xml:"url"` |
|||
} `xml:"icon"` |
|||
} `xml:"iconList"` |
|||
} `xml:"device"` |
|||
} |
|||
|
|||
var buttonNames = []string{"On", "DimUp", "DimDown", "Off"} |
|||
|
|||
var errLinkButtonNotPressed = errors.New("link button not pressed") |
@ -0,0 +1,399 @@ |
|||
package hue |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
"encoding/xml" |
|||
"fmt" |
|||
"git.aiterp.net/lucifer/new-server/models" |
|||
"log" |
|||
"net/http" |
|||
"strconv" |
|||
"sync" |
|||
"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 "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() |
|||
|
|||
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 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 |
|||
} |
@ -0,0 +1,179 @@ |
|||
package hue |
|||
|
|||
import ( |
|||
"git.aiterp.net/lucifer/new-server/models" |
|||
"strconv" |
|||
"time" |
|||
) |
|||
|
|||
type hueLightState struct { |
|||
index int |
|||
uniqueID string |
|||
externalID int |
|||
info LightData |
|||
input LightStateInput |
|||
|
|||
stale bool |
|||
} |
|||
|
|||
func (s *hueLightState) Update(state models.DeviceState) { |
|||
input := LightStateInput{} |
|||
if state.Power { |
|||
input.On = ptrBool(true) |
|||
if state.Color.IsKelvin() { |
|||
input.CT = ptrInt(1000000 / state.Color.Kelvin) |
|||
if s.input.CT == nil || *s.input.CT != *input.CT { |
|||
s.stale = true |
|||
} |
|||
} else { |
|||
input.Hue = ptrInt(int(state.Color.Hue*(65536/360)) % 65536) |
|||
if s.input.Hue == nil || *s.input.Hue != *input.Hue { |
|||
s.stale = true |
|||
} |
|||
|
|||
input.Sat = ptrInt(int(state.Color.Hue * 255)) |
|||
if *input.Sat > 254 { |
|||
*input.Sat = 254 |
|||
} |
|||
if *input.Sat < 0 { |
|||
*input.Sat = 0 |
|||
} |
|||
if s.input.Sat == nil || *s.input.Sat != *input.Sat { |
|||
s.stale = true |
|||
} |
|||
} |
|||
|
|||
input.Bri = ptrInt(int(state.Intensity * 255)) |
|||
if *input.Bri > 254 { |
|||
*input.Bri = 254 |
|||
} else if *input.Bri < 0 { |
|||
*input.Bri = 0 |
|||
} |
|||
|
|||
if s.input.Bri == nil || *s.input.Bri != *input.Bri { |
|||
s.stale = true |
|||
} |
|||
} else { |
|||
input.On = ptrBool(false) |
|||
} |
|||
|
|||
if s.input.On == nil || *s.input.On != *input.On { |
|||
s.stale = true |
|||
} |
|||
|
|||
input.TransitionTime = ptrInt(1) |
|||
|
|||
s.input = input |
|||
} |
|||
|
|||
func (s *hueLightState) CheckStaleness(state LightState) { |
|||
if state.ColorMode == "xy" { |
|||
s.stale = true |
|||
if s.input.CT == nil && s.input.Hue == nil { |
|||
s.input.Hue = ptrInt(state.Hue) |
|||
s.input.Sat = ptrInt(state.Sat) |
|||
s.input.Bri = ptrInt(state.Bri) |
|||
} |
|||
return |
|||
} else if state.ColorMode == "ct" { |
|||
if s.input.CT == nil || state.CT != *s.input.CT { |
|||
s.stale = true |
|||
} |
|||
} else { |
|||
if s.input.Hue == nil || state.Hue != *s.input.Hue || s.input.Sat == nil || state.Sat == *s.input.Sat { |
|||
s.stale = true |
|||
} |
|||
} |
|||
} |
|||
|
|||
type hueSensorState struct { |
|||
index int |
|||
externalID int |
|||
uniqueID string |
|||
prevData *SensorData |
|||
prevTime time.Time |
|||
} |
|||
|
|||
func (state *hueSensorState) Update(newData SensorData) *models.Event { |
|||
stateTime, err := time.ParseInLocation("2006-01-02T15:04:05", newData.State.LastUpdated, time.UTC) |
|||
if err != nil { |
|||
// Invalid time is probably "none".
|
|||
return nil |
|||
} |
|||
|
|||
defer func() { |
|||
state.prevData = &newData |
|||
state.prevTime = stateTime |
|||
}() |
|||
|
|||
if state.uniqueID != newData.UniqueID { |
|||
state.uniqueID = newData.UniqueID |
|||
} |
|||
|
|||
if state.prevData != nil && newData.Type != state.prevData.Type { |
|||
return nil |
|||
} |
|||
if time.Since(stateTime) > time.Second*3 { |
|||
return nil |
|||
} |
|||
|
|||
switch newData.Type { |
|||
case "ZLLSwitch": |
|||
{ |
|||
pe := state.prevData.State.ButtonEvent |
|||
ce := newData.State.ButtonEvent |
|||
|
|||
td := stateTime.Sub(state.prevTime) >= time.Second |
|||
pIdx := (pe / 1000) - 1 |
|||
cIdx := (ce / 1000) - 1 |
|||
pBtn := pe % 1000 / 2 // 0 = pressed, 1 = released
|
|||
cBtn := ce % 1000 / 2 // 0 = pressed, 1 = released
|
|||
|
|||
if pIdx == cIdx && cBtn == 1 && pBtn == 0 { |
|||
// Do not allow 4002 after 4000.
|
|||
// 4000 after 4002 is fine, though.
|
|||
break |
|||
} |
|||
|
|||
if cBtn != pBtn || cIdx != pIdx || td { |
|||
return &models.Event{ |
|||
Name: models.ENButtonPressed, |
|||
Payload: map[string]string{ |
|||
"buttonIndex": strconv.Itoa(cIdx), |
|||
"buttonName": buttonNames[cIdx], |
|||
"deviceId": strconv.Itoa(state.externalID), |
|||
"hueButtonEvent": strconv.Itoa(ce), |
|||
}, |
|||
} |
|||
} |
|||
} |
|||
case "ZLLPresence": |
|||
{ |
|||
// TODO: Test this!
|
|||
if state.prevData != nil && state.prevData.State.Presence != newData.State.Presence { |
|||
name := models.ENSensorPresenceStarted |
|||
if !newData.State.Presence { |
|||
name = models.ENSensorPresenceEnded |
|||
} |
|||
|
|||
return &models.Event{ |
|||
Name: name, |
|||
Payload: map[string]string{ |
|||
"deviceId": strconv.Itoa(state.externalID), |
|||
"deviceInternalId": newData.UniqueID, |
|||
}, |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return nil |
|||
} |
|||
|
|||
func ptrBool(v bool) *bool { |
|||
return &v |
|||
} |
|||
|
|||
func ptrInt(v int) *int { |
|||
return &v |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue