it has the trappings of a driver now, but essential features are missing: discovery and forgetting
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
+313
-29
@@ -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 at 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,9 +214,20 @@ 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) {
|
||||
// Eat the event if there's a pending update.
|
||||
select {
|
||||
case <-b.needsUpdate:
|
||||
default:
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
dur := time.Millisecond * 200
|
||||
updates := make(map[string]ResourceUpdate)
|
||||
@@ -45,32 +238,40 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -85,6 +286,8 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
log.Println(fmt.Sprintf("[Bridge %d]", b.externalID), "Updating", len(updates), "services...")
|
||||
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
for key := range updates {
|
||||
update := updates[key]
|
||||
@@ -95,7 +298,28 @@ func (b *Bridge) MakeCongruent(ctx context.Context) (int, error) {
|
||||
})
|
||||
}
|
||||
|
||||
return len(updates), eg.Wait()
|
||||
err := eg.Wait()
|
||||
if err != nil {
|
||||
return len(updates), err
|
||||
}
|
||||
|
||||
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.
|
||||
b.mu.Lock()
|
||||
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()
|
||||
}
|
||||
|
||||
return len(updates), nil
|
||||
}
|
||||
|
||||
func (b *Bridge) GenerateDevices() []models.Device {
|
||||
@@ -243,7 +467,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 +478,64 @@ 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
|
||||
}
|
||||
|
||||
newResources[patch.ID] = &resCopy
|
||||
}
|
||||
}
|
||||
|
||||
b.mu.Lock()
|
||||
b.resources = newResources
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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,59 @@ 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) 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 +153,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 +227,43 @@ 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 reqBody(body interface{}) (io.Reader, error) {
|
||||
if body == nil {
|
||||
return nil, nil
|
||||
@@ -151,6 +286,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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package hue2
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"git.aiterp.net/lucifer/new-server/models"
|
||||
"strings"
|
||||
@@ -31,6 +32,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 +50,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 +58,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 +73,61 @@ 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.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 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"`
|
||||
@@ -129,8 +195,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 +206,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"`
|
||||
}
|
||||
|
||||
@@ -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 nil, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user