diff --git a/app/config/driver.go b/app/config/driver.go index 51fa613..ad95ab3 100644 --- a/app/config/driver.go +++ b/app/config/driver.go @@ -2,6 +2,7 @@ package config import ( "git.aiterp.net/lucifer/new-server/internal/drivers" + "git.aiterp.net/lucifer/new-server/internal/drivers/hue" "git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf" "git.aiterp.net/lucifer/new-server/models" "sync" @@ -17,6 +18,7 @@ func DriverProvider() models.DriverProvider { if dp == nil { dp = drivers.DriverMap{ models.DTNanoLeaf: &nanoleaf.Driver{}, + models.DTHue: &hue.Driver{}, } } diff --git a/cmd/bridgetest/main.go b/cmd/bridgetest/main.go index 06dc50b..cd7d70e 100644 --- a/cmd/bridgetest/main.go +++ b/cmd/bridgetest/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/json" "flag" "fmt" "git.aiterp.net/lucifer/new-server/app/config" @@ -63,8 +64,6 @@ func main() { devices[i].ID = i + 1 } - _ = driver.Publish(context.Background(), bridge, devices) - ch := config.EventChannel go func() { err := driver.Run(context.Background(), bridge, ch) @@ -86,6 +85,11 @@ func main() { text, _ := reader.ReadString('\n') text = strings.Trim(text, "\t  \r\n") + if text == "json" { + j, _ := json.MarshalIndent(devices, "", " ") + fmt.Println(string(j)) + } + tokens := strings.Split(text, " ") if len(tokens) < 4 { continue @@ -122,15 +126,15 @@ func main() { for _, id := range ids { if id == -1 || id == device.ID { if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) { - device.State.Color = color if device.HasCapability(models.DCPower) { device.State.Power = power } if device.HasCapability(models.DCIntensity) { device.State.Intensity = intensity } - updatedDevices = append(updatedDevices, device) } + + updatedDevices = append(updatedDevices, device) } } } diff --git a/go.mod b/go.mod index 20a2922..0b46bb6 100644 --- a/go.mod +++ b/go.mod @@ -12,4 +12,5 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pressly/goose v2.7.0+incompatible golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect ) diff --git a/go.sum b/go.sum index a8ed0d3..7325885 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrO golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/drivers/hue/bridge.go b/internal/drivers/hue/bridge.go new file mode 100644 index 0000000..00a1c44 --- /dev/null +++ b/internal/drivers/hue/bridge.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 + } +} diff --git a/internal/drivers/hue/data.go b/internal/drivers/hue/data.go new file mode 100644 index 0000000..a0c492b --- /dev/null +++ b/internal/drivers/hue/data.go @@ -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") diff --git a/internal/drivers/hue/driver.go b/internal/drivers/hue/driver.go new file mode 100644 index 0000000..485810b --- /dev/null +++ b/internal/drivers/hue/driver.go @@ -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 +} diff --git a/internal/drivers/hue/state.go b/internal/drivers/hue/state.go new file mode 100644 index 0000000..665ede5 --- /dev/null +++ b/internal/drivers/hue/state.go @@ -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 +} diff --git a/internal/drivers/nanoleaf/bridge.go b/internal/drivers/nanoleaf/bridge.go index 0b32ce6..864eff3 100644 --- a/internal/drivers/nanoleaf/bridge.go +++ b/internal/drivers/nanoleaf/bridge.go @@ -50,12 +50,12 @@ func (b *bridge) Devices() []models.Device { models.DCButtons, }, ButtonNames: []string{"Touch"}, - DriverProperties: map[string]string{ - "x": strconv.Itoa(panel.X), - "y": strconv.Itoa(panel.Y), - "o": strconv.Itoa(panel.O), + DriverProperties: map[string]interface{}{ + "x": panel.X, + "y": panel.Y, + "o": panel.O, "shapeType": shapeTypeMap[panel.ShapeType], - "shapeWidth": strconv.Itoa(shapeWidthMap[panel.ShapeType]), + "shapeWidth": shapeWidthMap[panel.ShapeType], }, UserProperties: nil, State: models.DeviceState{ @@ -393,7 +393,7 @@ func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch c } event := models.Event{ - Name: "ButtonPressed", + Name: models.ENButtonPressed, Payload: map[string]string{ "buttonIndex": "0", "buttonName": "Touch", diff --git a/models/device.go b/models/device.go index affc24f..3347056 100644 --- a/models/device.go +++ b/models/device.go @@ -6,17 +6,17 @@ import ( ) type Device struct { - ID int `json:"id"` - BridgeID int `json:"bridgeID"` - InternalID string `json:"internalId"` - Icon string `json:"icon"` - Name string `json:"name"` - Capabilities []DeviceCapability `json:"capabilities"` - ButtonNames []string `json:"buttonNames"` - DriverProperties map[string]string `json:"driverProperties"` - UserProperties map[string]string `json:"userProperties"` - State DeviceState `json:"state"` - Tags []string `json:"tags"` + ID int `json:"id"` + BridgeID int `json:"bridgeID"` + InternalID string `json:"internalId"` + Icon string `json:"icon"` + Name string `json:"name"` + Capabilities []DeviceCapability `json:"capabilities"` + ButtonNames []string `json:"buttonNames"` + DriverProperties map[string]interface{} `json:"driverProperties"` + UserProperties map[string]string `json:"userProperties"` + State DeviceState `json:"state"` + Tags []string `json:"tags"` } // DeviceState contains optional state values that @@ -52,6 +52,7 @@ var ( DCColorHS DeviceCapability = "ColorHS" DCColorKelvin DeviceCapability = "ColorKelvin" DCButtons DeviceCapability = "Buttons" + DCPresence DeviceCapability = "Presence" DCIntensity DeviceCapability = "Intensity" DCTemperature DeviceCapability = "Temperature" ) diff --git a/models/event.go b/models/event.go index 38f1a44..22a3f2d 100644 --- a/models/event.go +++ b/models/event.go @@ -20,8 +20,11 @@ func (e *Event) HasPayload(key string) bool { } var ( - ENBridgeConnected = "BridgeConnected" - ENBridgeDisconnected = "BridgeDisconnected" + ENBridgeConnected = "BridgeConnected" + ENBridgeDisconnected = "BridgeDisconnected" + ENButtonPressed = "ButtonPressed" + ENSensorPresenceStarted = "SensorPresenceStart" + ENSensorPresenceEnded = "SensorPresenceEnd" ) func BridgeConnectedEvent(bridge Bridge) Event {