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