5 Commits
Author SHA1 Message Date
gisle c72a7c3f43 fix xy color conversion using srgb instead of rgb.
continuous-integration/drone/push Build is passing
2022-02-18 17:01:37 +01:00
gisle 8a20bab709 reduce Hue2 'update storms'..
continuous-integration/drone/push Build is passing
2022-02-18 16:24:09 +01:00
gisle 27fd858c04 add device search.
continuous-integration/drone/push Build is passing
2022-02-17 21:58:03 +01:00
gisle d19a8e6ca0 add device rename and tweaks.
continuous-integration/drone/push Build is failing
2022-02-17 16:57:33 +01:00
gisle 107b29620d it has the trappings of a driver now, but essential features are missing: discovery and forgetting
continuous-integration/drone/push Build is passing
2022-02-15 22:31:51 +01:00
9 changed files with 880 additions and 60 deletions
+2
View File
@@ -3,6 +3,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/hue2"
"git.aiterp.net/lucifer/new-server/internal/drivers/lifx"
"git.aiterp.net/lucifer/new-server/internal/drivers/mill"
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf"
@@ -18,6 +19,7 @@ func DriverProvider() models.DriverProvider {
dp = drivers.DriverMap{
models.DTNanoLeaf: &nanoleaf.Driver{},
models.DTHue: &hue.Driver{},
models.DTHue2: &hue2.Driver{},
models.DTLIFX: &lifx.Driver{},
models.DTMill: &mill.Driver{},
}
+24 -9
View File
@@ -87,6 +87,8 @@ func (p *Publisher) Publish(devices ...models.Device) {
return
}
unwaitMap := make(map[int]bool)
p.mu.Lock()
defer p.mu.Unlock()
@@ -106,10 +108,14 @@ func (p *Publisher) Publish(devices ...models.Device) {
}
} else {
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
if p.waiting[device.BridgeID] != nil {
close(p.waiting[device.BridgeID])
p.waiting[device.BridgeID] = nil
}
unwaitMap[device.BridgeID] = true
}
}
for bridgeID := range unwaitMap {
if p.waiting[bridgeID] != nil {
close(p.waiting[bridgeID])
p.waiting[bridgeID] = nil
}
}
}
@@ -124,11 +130,16 @@ func (p *Publisher) Run() {
ticker := time.NewTicker(time.Millisecond * 100)
deleteList := make([]int, 0, 8)
updatedList := make([]models.Device, 0, 16)
unwaitMap := make(map[int]bool)
for range ticker.C {
deleteList = deleteList[:0]
updatedList = updatedList[:0]
for key := range unwaitMap {
delete(unwaitMap, key)
}
p.mu.Lock()
for i, scene := range p.scenes {
if (!scene.endTime.IsZero() && time.Now().After(scene.endTime)) || scene.Empty() {
@@ -160,9 +171,13 @@ func (p *Publisher) Run() {
}
p.pending[device.BridgeID] = append(p.pending[device.BridgeID], device)
if p.waiting[device.BridgeID] != nil {
close(p.waiting[device.BridgeID])
p.waiting[device.BridgeID] = nil
unwaitMap[device.BridgeID] = true
}
for bridgeID := range unwaitMap {
if p.waiting[bridgeID] != nil {
close(p.waiting[bridgeID])
p.waiting[bridgeID] = nil
}
}
p.mu.Unlock()
@@ -270,8 +285,8 @@ func (p *Publisher) runBridge(id int) {
p.mu.Lock()
}
updates := p.pending[id]
p.pending[id] = p.pending[id][:0:0]
updates := append(p.pending[id][:0:0], p.pending[id]...)
p.pending[id] = p.pending[id][:0]
p.mu.Unlock()
// Only allow the latest update per device (this avoids slow bridges causing a backlog of cations).
+16 -6
View File
@@ -20,20 +20,30 @@ func main() {
j, _ := json.Marshal(bridge.GenerateDevices())
fmt.Println(string(j))
for _, device := range bridge.GenerateDevices() {
ch := make(chan models.Event)
go func() {
for event := range ch {
log.Println("EVENT", event.Name, event.Payload)
}
}()
for i, dev := range bridge.GenerateDevices() {
device := dev
switch device.InternalID {
case "6d5a45b0-ec69-4417-8588-717358b05086":
c, _ := models.ParseColorValue("xy:0.22,0.18")
device.State.Color = c
device.State.Intensity = 1
bridge.Update(device)
device.State.Intensity = 0.3
case "a71128f4-5295-4ae4-9fbc-5541abc8739b":
c, _ := models.ParseColorValue("k:2000")
c, _ := models.ParseColorValue("k:6500")
device.State.Color = c
device.State.Intensity = 0.2
bridge.Update(device)
}
device.ID = i + 1
bridge.Update(device)
}
fmt.Println(bridge.MakeCongruent(context.Background()))
err = bridge.Run(context.Background(), ch)
log.Println(err)
}
+379 -30
View File
@@ -2,9 +2,11 @@ package hue2
import (
"context"
"errors"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"golang.org/x/sync/errgroup"
"log"
"math"
"strings"
"sync"
@@ -12,17 +14,197 @@ import (
)
type Bridge struct {
mu sync.Mutex
client *Client
devices map[string]models.Device
resources map[string]*ResourceData
mu sync.Mutex
externalID int
client *Client
needsUpdate chan struct{}
devices map[string]models.Device
resources map[string]*ResourceData
}
func NewBridge(client *Client) *Bridge {
return &Bridge{
client: client,
devices: make(map[string]models.Device, 64),
resources: make(map[string]*ResourceData, 256),
client: client,
needsUpdate: make(chan struct{}, 4),
devices: make(map[string]models.Device, 64),
resources: make(map[string]*ResourceData, 256),
}
}
func (b *Bridge) Run(ctx context.Context, eventCh chan<- models.Event) error {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Connecting SSE...")
sse := b.client.SSE(ctx)
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Refreshing...")
err := b.RefreshAll(ctx)
if err != nil {
return err
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Running updates...")
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (startup)")
lightRefreshTimer := time.NewTicker(time.Second * 5)
defer lightRefreshTimer.Stop()
motionTicker := time.NewTicker(time.Second * 10)
defer motionTicker.Stop()
absences := make(map[int]time.Time)
lastPress := make(map[string]time.Time)
needFull := false
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-lightRefreshTimer.C:
if needFull {
err := b.RefreshAll(ctx)
if err != nil {
return err
}
} else {
err := b.Refresh(ctx, "light")
if err != nil {
return err
}
}
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (regular check)")
}
case <-b.needsUpdate:
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (publish)")
}
case <-motionTicker.C:
for id, absenceTime := range absences {
seconds := int(time.Since(absenceTime).Seconds())
if seconds < 10 || seconds%60 >= 10 {
continue
}
eventCh <- models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": fmt.Sprint(id),
"minutesElapsed": fmt.Sprint(seconds / 60),
"secondsElapsed": fmt.Sprint(seconds),
"lastUpdated": fmt.Sprint(absenceTime.Unix()),
},
}
}
case data, ok := <-sse:
if !ok {
return errors.New("SSE Disconnected")
}
b.applyPatches(data.Data)
updated, err := b.MakeCongruent(ctx)
if err != nil {
return err
}
if updated > 0 {
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updated", updated, "hue services (SSE)")
}
for _, patch := range data.Data {
if patch.Owner == nil {
continue
}
if b.resources[patch.Owner.ID] == nil {
needFull = true
}
device, deviceOK := b.devices[patch.Owner.ID]
if !deviceOK || device.ID == 0 {
continue
}
if patch.Button != nil {
valid := false
if patch.Button.LastEvent == "initial_press" || patch.Button.LastEvent == "repeat" {
valid = true
} else if patch.Button.LastEvent == "long_release" {
valid = false
} else {
valid = lastPress[patch.ID].Unix() != data.CreationTime.Unix()
}
if valid {
lastPress[patch.ID] = data.CreationTime
b.mu.Lock()
owner := b.resources[patch.Owner.ID]
b.mu.Unlock()
if owner != nil {
index := owner.ServiceIndex("button", patch.ID)
if index != -1 {
eventCh <- models.Event{
Name: models.ENButtonPressed,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"hueButtonEvent": patch.Button.LastEvent,
"buttonIndex": fmt.Sprint(index),
"buttonName": device.ButtonNames[index],
},
}
}
}
}
}
if patch.Temperature != nil && patch.Temperature.Valid {
eventCh <- models.Event{
Name: models.ENSensorTemperature,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": patch.Owner.ID,
"temperature": fmt.Sprint(patch.Temperature.Temperature),
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
},
}
}
if patch.Motion != nil && patch.Motion.Valid {
if patch.Motion.Motion {
eventCh <- models.Event{
Name: models.ENSensorPresenceStarted,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": patch.Owner.ID,
},
}
delete(absences, device.ID)
} else {
eventCh <- models.Event{
Name: models.ENSensorPresenceEnded,
Payload: map[string]string{
"deviceId": fmt.Sprint(device.ID),
"deviceInternalId": device.InternalID,
"minutesElapsed": "0",
"secondsElapsed": "0",
"lastUpdated": fmt.Sprint(data.CreationTime.Unix()),
},
}
absences[device.ID] = data.CreationTime
}
}
}
}
}
}
@@ -32,45 +214,78 @@ func (b *Bridge) Update(devices ...models.Device) {
b.devices[device.InternalID] = device
}
b.mu.Unlock()
select {
case b.needsUpdate <- struct{}{}:
default:
}
}
func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
// Exhaust the channel to avoid more updates.
exhausted := false
for !exhausted {
select {
case <-b.needsUpdate:
default:
exhausted = true
}
}
b.mu.Lock()
dur := time.Millisecond * 200
dur := time.Millisecond * 100
updates := make(map[string]ResourceUpdate)
for _, device := range b.devices {
resource := b.resources[device.InternalID]
// Update device
if resource.Metadata.Name != device.Name {
name := device.Name
updates["device/"+resource.ID] = ResourceUpdate{
Name: &name,
}
}
// Update light
if lightID := resource.ServiceID("light"); lightID != nil {
light := b.resources[*lightID]
update := ResourceUpdate{TransitionDuration: &dur}
changed := false
if light.ColorTemperature != nil && device.State.Color.IsKelvin() {
mirek := 1000000 / device.State.Color.Kelvin
if mirek < light.ColorTemperature.MirekSchema.MirekMinimum {
mirek = light.ColorTemperature.MirekSchema.MirekMinimum
lightsOut := light.Power != nil && !device.State.Power
if !lightsOut {
if light.ColorTemperature != nil && device.State.Color.IsKelvin() {
mirek := 1000000 / device.State.Color.Kelvin
if mirek < light.ColorTemperature.MirekSchema.MirekMinimum {
mirek = light.ColorTemperature.MirekSchema.MirekMinimum
}
if mirek > light.ColorTemperature.MirekSchema.MirekMaximum {
mirek = light.ColorTemperature.MirekSchema.MirekMaximum
}
if light.ColorTemperature.Mirek == nil || mirek != *light.ColorTemperature.Mirek {
update.Mirek = &mirek
changed = true
}
} else if xy, ok := device.State.Color.ToXY(); ok && light.Color != nil {
xy = light.Color.Gamut.Conform(xy).Round()
if xy.DistanceTo(light.Color.XY) > 0.0009 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) {
update.ColorXY = &xy
changed = true
}
}
if mirek > light.ColorTemperature.MirekSchema.MirekMaximum {
mirek = light.ColorTemperature.MirekSchema.MirekMaximum
}
if light.ColorTemperature.Mirek == nil || mirek != *light.ColorTemperature.Mirek {
update.Mirek = &mirek
changed = true
}
} else if xy, ok := device.State.Color.ToXY(); ok && light.Color != nil {
xy = light.Color.Gamut.Conform(xy).Round()
if xy.DistanceTo(light.Color.XY) > 0.00015 || (light.ColorTemperature != nil && light.ColorTemperature.Mirek != nil) {
update.ColorXY = &xy
if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.02 {
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
update.Brightness = &brightness
changed = true
}
}
if light.Power != nil && light.Power.On != device.State.Power {
update.Power = &device.State.Power
changed = true
}
if light.Dimming != nil && math.Abs(light.Dimming.Brightness/100-device.State.Intensity) > 0.005 {
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
update.Brightness = &brightness
if device.State.Power {
brightness := math.Abs(math.Min(device.State.Intensity*100, 100))
update.Brightness = &brightness
}
changed = true
}
@@ -79,6 +294,20 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
}
}
}
if len(updates) > 0 {
// Optimistically apply the updates to the states, so that the driver assumes they are set until
// proven otherwise by the SSE client.
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range b.resources {
newResources[key] = value
}
for key, update := range updates {
id := strings.SplitN(key, "/", 2)[1]
newResources[id] = newResources[id].WithUpdate(update)
}
b.resources = newResources
}
b.mu.Unlock()
if len(updates) == 0 {
@@ -90,12 +319,21 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
update := updates[key]
split := strings.SplitN(key, "/", 2)
link := ResourceLink{Kind: split[0], ID: split[1]}
eg.Go(func() error {
return b.client.UpdateResource(ctx, link, update)
})
}
return len(updates), eg.Wait()
err := eg.Wait()
if err != nil {
// Try to restore light states.
_ = b.Refresh(ctx, "light")
return len(updates), err
}
return len(updates), nil
}
func (b *Bridge) GenerateDevices() []models.Device {
@@ -110,6 +348,7 @@ func (b *Bridge) GenerateDevices() []models.Device {
}
device := models.Device{
BridgeID: b.externalID,
InternalID: resource.ID,
Name: resource.Metadata.Name,
DriverProperties: map[string]interface{}{
@@ -243,7 +482,6 @@ func (b *Bridge) RefreshAll(ctx context.Context) error {
}
resources := make(map[string]*ResourceData, len(allResources))
for i := range allResources {
resource := allResources[i]
resources[resource.ID] = &resource
@@ -255,3 +493,114 @@ func (b *Bridge) RefreshAll(ctx context.Context) error {
return nil
}
func (b *Bridge) applyPatches(patches []ResourceData) {
b.mu.Lock()
newResources := make(map[string]*ResourceData, len(b.resources))
for key, value := range b.resources {
newResources[key] = value
}
b.mu.Unlock()
for _, patch := range patches {
if res := newResources[patch.ID]; res != nil {
resCopy := *res
if patch.Power != nil && resCopy.Power != nil {
cp := *resCopy.Power
resCopy.Power = &cp
resCopy.Power.On = patch.Power.On
}
if patch.Color != nil && resCopy.Color != nil {
cp := *resCopy.Color
resCopy.Color = &cp
resCopy.Color.XY = patch.Color.XY
cp2 := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp2
resCopy.ColorTemperature.Mirek = nil
}
if patch.ColorTemperature != nil && resCopy.ColorTemperature != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
resCopy.ColorTemperature.Mirek = patch.ColorTemperature.Mirek
}
if patch.Dimming != nil && resCopy.Dimming != nil {
cp := *resCopy.Dimming
resCopy.Dimming = &cp
resCopy.Dimming.Brightness = patch.Dimming.Brightness
}
if patch.Dynamics != nil {
resCopy.Dynamics = patch.Dynamics
}
if patch.Alert != nil {
resCopy.Alert = patch.Alert
}
if patch.PowerState != nil {
resCopy.PowerState = patch.PowerState
}
if patch.Temperature != nil {
resCopy.Temperature = patch.Temperature
}
if patch.Status != nil {
resCopy.Status = patch.Status
}
resCopy.Metadata.Name = patch.Metadata.Name
newResources[patch.ID] = &resCopy
}
}
b.mu.Lock()
b.resources = newResources
b.mu.Unlock()
}
func (b *Bridge) SearchDevices(ctx context.Context, timeout time.Duration) ([]models.Device, error) {
// Record the current state.
b.mu.Lock()
before := b.resources
b.mu.Unlock()
// Spend half the time waiting for devices
// TODO: Wait for v2 endpoint
err := b.client.LegacyDiscover(ctx, "sensors")
if err != nil {
return nil, err
}
select {
case <-time.After(timeout / 1):
case <-ctx.Done():
return nil, ctx.Err()
}
// Spend half the time waiting for lights
// TODO: Wait for v2 endpoint
err = b.client.LegacyDiscover(ctx, "lights")
if err != nil {
return nil, err
}
select {
case <-time.After(timeout / 1):
case <-ctx.Done():
return nil, ctx.Err()
}
// Perform a full refresh.
err = b.RefreshAll(ctx)
if err != nil {
return nil, err
}
// Check for new devices
devices := b.GenerateDevices()
newDevices := make([]models.Device, 0)
for _, device := range devices {
if before[device.InternalID] == nil {
newDevices = append(newDevices, device)
}
}
// Return said devices.
return newDevices, nil
}
+199 -8
View File
@@ -1,12 +1,16 @@
package hue2
import (
"bufio"
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"io"
"log"
"net"
"net/http"
"strings"
@@ -15,7 +19,7 @@ import (
func NewClient(host, token string) *Client {
ch := make(chan struct{}, 5)
for i := 0; i < 3; i++ {
for i := 0; i < 2; i++ {
ch <- struct{}{}
}
@@ -32,6 +36,30 @@ type Client struct {
ch chan struct{}
}
func (c *Client) Register(ctx context.Context) (string, error) {
result := make([]CreateUserResponse, 0, 1)
err := c.post(ctx, "api/", CreateUserInput{DeviceType: "git.aiterp.net/lucifer"}, &result)
if err != nil {
return "", err
}
if len(result) == 0 || result[0].Error != nil {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Second):
return c.Register(ctx)
}
}
if result[0].Success == nil {
return "", models.ErrUnexpectedResponse
}
c.token = result[0].Success.Username
return result[0].Success.Username, nil
}
func (c *Client) AllResources(ctx context.Context) ([]ResourceData, error) {
res := struct {
Error interface{}
@@ -64,10 +92,63 @@ func (c *Client) UpdateResource(ctx context.Context, link ResourceLink, update R
return c.put(ctx, link.Path(), update, nil)
}
func (c *Client) get(ctx context.Context, path string, target interface{}) error {
func (c *Client) LegacyDiscover(ctx context.Context, kind string) error {
return c.legacyPost(ctx, kind, nil, nil)
}
func (c *Client) SSE(ctx context.Context) <-chan SSEUpdate {
ch := make(chan SSEUpdate, 4)
go func() {
defer close(ch)
reader, err := c.getReader(ctx, "/eventstream/clip/v2", map[string]string{
"Accept": "text/event-stream",
})
if err != nil {
log.Println("SSE Connect error:", err)
return
}
defer reader.Close()
br := bufio.NewReader(reader)
for {
line, err := br.ReadString('\n')
if err != nil {
log.Println("SSE Read error:", err)
return
}
line = strings.Trim(line, "  \t\r\n")
kv := strings.SplitN(line, ": ", 2)
if len(kv) < 2 {
continue
}
switch kv[0] {
case "data":
var data []SSEUpdate
err := json.Unmarshal([]byte(kv[1]), &data)
if err != nil {
log.Println("Parsing SSE event failed:", err)
log.Println(" json:", kv[1])
return
}
for _, obj := range data {
ch <- obj
}
}
}
}()
return ch
}
func (c *Client) getReader(ctx context.Context, path string, headers map[string]string) (io.ReadCloser, error) {
select {
case <-ctx.Done():
return ctx.Err()
return nil, ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
@@ -76,22 +157,43 @@ func (c *Client) get(ctx context.Context, path string, target interface{}) error
req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/%s", c.host, path), nil)
if err != nil {
return err
return nil, err
}
for key, value := range headers {
req.Header.Set(key, value)
}
req.Header.Set("hue-application-key", c.token)
res, err := httpClient.Do(req.WithContext(ctx))
client := httpClient
if headers["Accept"] == "text/event-stream" {
client = sseClient
}
res, err := client.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
if res.StatusCode != 200 {
_ = res.Body.Close()
return nil, errors.New(res.Status)
}
return res.Body, nil
}
func (c *Client) get(ctx context.Context, path string, target interface{}) error {
body, err := c.getReader(ctx, path, map[string]string{})
if err != nil {
return err
}
defer res.Body.Close()
defer body.Close()
if target == nil {
return nil
}
return json.NewDecoder(res.Body).Decode(&target)
return json.NewDecoder(body).Decode(&target)
}
func (c *Client) put(ctx context.Context, path string, body interface{}, target interface{}) error {
@@ -129,6 +231,80 @@ func (c *Client) put(ctx context.Context, path string, body interface{}, target
return json.NewDecoder(res.Body).Decode(&target)
}
func (c *Client) post(ctx context.Context, path string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
req, err := http.NewRequest("POST", fmt.Sprintf("https://%s/%s", c.host, path), rb)
if err != nil {
return err
}
if c.token != "" {
req.Header.Set("hue-application-key", c.token)
}
res, err := httpClient.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 (c *Client) legacyPost(ctx context.Context, resource string, body interface{}, target interface{}) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-c.ch:
defer func() {
c.ch <- struct{}{}
}()
}
rb, err := reqBody(body)
if err != nil {
return err
}
if c.token != "" {
resource = c.token + "/" + resource
}
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s/api/%s", c.host, resource), rb)
if err != nil {
return err
}
res, err := httpClient.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
@@ -151,6 +327,21 @@ func reqBody(body interface{}) (io.Reader, error) {
}
}
var sseClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: time.Hour * 1000000,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 5,
MaxIdleConnsPerHost: 1,
IdleConnTimeout: 0,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: time.Hour * 1000000,
}
var httpClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
+138 -2
View File
@@ -1,6 +1,8 @@
package hue2
import (
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"strings"
@@ -31,6 +33,13 @@ type DeviceProductData struct {
SoftwareVersion string `json:"software_version"`
}
type SSEUpdate struct {
CreationTime time.Time `json:"creationTime"`
ID string `json:"id"`
Type string `json:"type"`
Data []ResourceData `json:"data"`
}
type ResourceData struct {
ID string `json:"id"`
LegacyID string `json:"id_v1"`
@@ -42,6 +51,7 @@ type ResourceData struct {
Owner *ResourceLink `json:"owner"`
ProductData *DeviceProductData `json:"product_data"`
Services []ResourceLink `json:"services"`
Button *SensorButton `json:"button"`
Power *LightPower `json:"on"`
Color *LightColor `json:"color"`
ColorTemperature *LightCT `json:"color_temperature"`
@@ -49,6 +59,8 @@ type ResourceData struct {
Dynamics *LightDynamics `json:"dynamics"`
Alert *LightAlert `json:"alert"`
PowerState *PowerState `json:"power_state"`
Temperature *SensorTemperature `json:"temperature"`
Motion *SensorMotion `json:"motion"`
Status *string `json:"status"`
}
@@ -62,6 +74,70 @@ func (res *ResourceData) ServiceID(kind string) *string {
return nil
}
func (res *ResourceData) ServiceIndex(kind string, id string) int {
idx := 0
for _, link := range res.Services {
if link.ID == id {
return idx
} else if link.Kind == kind {
idx += 1
}
}
return -1
}
func (res *ResourceData) WithUpdate(update ResourceUpdate) *ResourceData {
resCopy := *res
if update.Name != nil {
resCopy.Metadata.Name = *update.Name
}
if update.Power != nil {
cp := *resCopy.Power
resCopy.Power = &cp
resCopy.Power.On = *update.Power
}
if update.ColorXY != nil {
cp := *resCopy.Color
resCopy.Color = &cp
resCopy.Color.XY = *update.ColorXY
if resCopy.ColorTemperature != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
resCopy.ColorTemperature.Mirek = nil
}
}
if update.Mirek != nil {
cp := *resCopy.ColorTemperature
resCopy.ColorTemperature = &cp
mirek := *update.Mirek
resCopy.ColorTemperature.Mirek = &mirek
}
if update.Brightness != nil {
cp := *resCopy.Dimming
resCopy.Dimming = &cp
resCopy.Dimming.Brightness = *update.Brightness
}
return &resCopy
}
type SensorButton struct {
LastEvent string `json:"last_event"`
}
type SensorMotion struct {
Motion bool `json:"motion"`
Valid bool `json:"motion_valid"`
}
type SensorTemperature struct {
Temperature float64 `json:"temperature"`
Valid bool `json:"temperature_valid"`
}
type PowerState struct {
BatteryState string `json:"battery_state"`
BatteryLevel float64 `json:"battery_level"`
@@ -104,6 +180,7 @@ type LightAlert struct {
}
type ResourceUpdate struct {
Name *string
Power *bool
ColorXY *models.ColorXY
Brightness *float64
@@ -113,6 +190,10 @@ type ResourceUpdate struct {
func (r ResourceUpdate) MarshalJSON() ([]byte, error) {
chunks := make([]string, 0, 4)
if r.Name != nil {
s, _ := json.Marshal(*r.Name)
chunks = append(chunks, fmt.Sprintf(`"metadata":{"name":%s}`, string(s)))
}
if r.Power != nil {
chunks = append(chunks, fmt.Sprintf(`"on":{"on":%v}`, *r.Power))
}
@@ -129,8 +210,6 @@ func (r ResourceUpdate) MarshalJSON() ([]byte, error) {
chunks = append(chunks, fmt.Sprintf(`"dynamics":{"duration":%d}`, r.TransitionDuration.Truncate(time.Millisecond*100).Milliseconds()))
}
fmt.Println(fmt.Sprintf("{%s}", strings.Join(chunks, ",")))
return []byte(fmt.Sprintf("{%s}", strings.Join(chunks, ","))), nil
}
@@ -142,3 +221,60 @@ type ResourceLink struct {
func (rl *ResourceLink) Path() string {
return fmt.Sprintf("/clip/v2/resource/%s/%s", rl.Kind, rl.ID)
}
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 DiscoveryEntry struct {
Id string `json:"id"`
InternalIPAddress string `json:"internalipaddress"`
}
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"`
}
+116
View File
@@ -0,0 +1,116 @@
package hue2
import (
"context"
"encoding/json"
"encoding/xml"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"net/http"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges []*Bridge
}
func (d *Driver) SearchBridge(ctx context.Context, address, token 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.DTHue2,
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.DTHue2,
Address: address,
Token: "",
}
if !dryRun {
client := NewClient(address, "")
timeout, cancel := context.WithTimeout(ctx, time.Second*30)
defer cancel()
bridge.Token, err = client.Register(timeout)
if err != nil {
return nil, err
}
}
return []models.Bridge{bridge}, nil
}
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
return d.ensureBridge(bridge).SearchDevices(ctx, timeout)
}
func (d *Driver) ListDevices(_ context.Context, bridge models.Bridge) ([]models.Device, error) {
return d.ensureBridge(bridge).GenerateDevices(), nil
}
func (d *Driver) Publish(_ context.Context, bridge models.Bridge, devices []models.Device) error {
d.ensureBridge(bridge).Update(devices...)
return nil
}
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
return d.ensureBridge(bridge).Run(ctx, ch)
}
func (d *Driver) ensureBridge(info models.Bridge) *Bridge {
d.mu.Lock()
for _, bridge := range d.bridges {
if bridge.client.host == info.Address {
d.mu.Unlock()
return bridge
}
}
bridge := NewBridge(NewClient(info.Address, info.Token))
bridge.externalID = info.ID
d.bridges = append(d.bridges, bridge)
d.mu.Unlock()
return bridge
}
+1
View File
@@ -21,6 +21,7 @@ type DriverKind string
var (
DTHue DriverKind = "Hue"
DTHue2 DriverKind = "Hue2"
DTNanoLeaf DriverKind = "Nanoleaf"
DTLIFX DriverKind = "LIFX"
DTMill DriverKind = "Mill"
+5 -5
View File
@@ -127,8 +127,8 @@ func (cg *ColorGamut) Conform(color ColorXY) ColorXY {
return color
}
for x := best.X - 0.001; x < best.X+0.001; x += 0.0001 {
for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0001 {
for x := best.X - 0.001; x < best.X+0.001; x += 0.0002 {
for y := best.Y - 0.001; y < best.Y+0.001; y += 0.0002 {
color2 := ColorXY{X: x, Y: y}
if cg.atTheEdge(color2) {
@@ -139,8 +139,8 @@ func (cg *ColorGamut) Conform(color ColorXY) ColorXY {
}
}
for x := best.X - 0.0001; x < best.X+0.0001; x += 0.00001 {
for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00001 {
for x := best.X - 0.0001; x < best.X+0.0001; x += 0.00003 {
for y := best.Y - 0.0001; y < best.Y+0.0001; y += 0.00003 {
color2 := ColorXY{X: x, Y: y}
if cg.atTheEdge(color2) {
@@ -177,7 +177,7 @@ func hsToXY(hue, sat float64) ColorXY {
green := float64(green255) / 255.0
blue := float64(blue255) / 255.0
return screenRGBToXY(red, green, blue)
return rgbToXY(red, green, blue)
}
func rgbToXY(red float64, green float64, blue float64) ColorXY {