hue hue hue
This commit is contained in:
@@ -65,16 +65,16 @@ func (b *EventBus) JoinPrivileged(service ActiveService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *EventBus) RunCommand(command Command) {
|
func (b *EventBus) RunCommand(command Command) {
|
||||||
if cd := command.CommandDescription(); !strings.HasPrefix(cd, "SetState") {
|
if cd := command.CommandDescription(); !strings.HasPrefix(cd, "SetStates") {
|
||||||
if setStates := atomic.LoadInt32(&b.setStates); setStates > 0 {
|
if setStates := atomic.LoadInt32(&b.setStates); setStates > 0 {
|
||||||
fmt.Println("[INFO]", setStates, "SetState commands hidden.")
|
fmt.Println("[INFO]", setStates, "SetStates commands hidden.")
|
||||||
atomic.AddInt32(&b.setStates, -setStates)
|
atomic.AddInt32(&b.setStates, -setStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("[COMMAND]", cd)
|
fmt.Println("[COMMAND]", cd)
|
||||||
} else {
|
} else {
|
||||||
if atomic.AddInt32(&b.setStates, 1) >= 1000 {
|
if atomic.AddInt32(&b.setStates, 1) >= 1000 {
|
||||||
fmt.Println("[INFO] 100 SetState commands hidden.")
|
fmt.Println("[INFO] 100 SetStates commands hidden.")
|
||||||
atomic.AddInt32(&b.setStates, -1000)
|
atomic.AddInt32(&b.setStates, -1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,6 +87,12 @@ func (b *EventBus) RunEvent(event Event) {
|
|||||||
b.send(serviceMessage{event: event})
|
b.send(serviceMessage{event: event})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) RunEvents(events []Event) {
|
||||||
|
for _, event := range events {
|
||||||
|
b.RunEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (b *EventBus) send(message serviceMessage) {
|
func (b *EventBus) send(message serviceMessage) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|||||||
+3
-51
@@ -3,11 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
lucifer3 "git.aiterp.net/lucifer3/server"
|
lucifer3 "git.aiterp.net/lucifer3/server"
|
||||||
"git.aiterp.net/lucifer3/server/commands"
|
"git.aiterp.net/lucifer3/server/commands"
|
||||||
"git.aiterp.net/lucifer3/server/device"
|
|
||||||
"git.aiterp.net/lucifer3/server/effects"
|
|
||||||
"git.aiterp.net/lucifer3/server/events"
|
|
||||||
"git.aiterp.net/lucifer3/server/internal/color"
|
|
||||||
"git.aiterp.net/lucifer3/server/services"
|
"git.aiterp.net/lucifer3/server/services"
|
||||||
|
"git.aiterp.net/lucifer3/server/services/hue"
|
||||||
"git.aiterp.net/lucifer3/server/services/nanoleaf"
|
"git.aiterp.net/lucifer3/server/services/nanoleaf"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -22,54 +19,9 @@ func main() {
|
|||||||
bus.JoinPrivileged(sceneMap)
|
bus.JoinPrivileged(sceneMap)
|
||||||
bus.Join(services.NewEffectEnforcer(resolver, sceneMap))
|
bus.Join(services.NewEffectEnforcer(resolver, sceneMap))
|
||||||
bus.Join(nanoleaf.NewService())
|
bus.Join(nanoleaf.NewService())
|
||||||
|
bus.Join(hue.NewService())
|
||||||
|
|
||||||
bus.JoinCallback(func(event lucifer3.Event) bool {
|
bus.RunCommand(commands.ConnectDevice{ID: "hue:10.80.1.5", APIKey: "0-Ch5MKQtYnXrA3b8jvE4408mS3tHo9Vn57Zv8pt"})
|
||||||
switch event.(type) {
|
|
||||||
case events.DeviceReady:
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:2d0c", Alias: "lucifer:name:Hex 5"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:542f", Alias: "lucifer:name:Hex 6"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:e760", Alias: "lucifer:name:Hex 7"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:207a", Alias: "lucifer:name:Hex 8"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:df9a", Alias: "lucifer:name:Hex 9"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:cdd5", Alias: "lucifer:name:Hex 4"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:4597", Alias: "lucifer:name:Hex 3"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:82cb", Alias: "lucifer:name:Hex 2"})
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:09fd", Alias: "lucifer:name:Hex 1"})
|
|
||||||
bus.RunCommand(commands.Assign{
|
|
||||||
Match: "nanoleaf:10.80.1.14:*",
|
|
||||||
Effect: effects.Pattern{
|
|
||||||
States: []device.State{
|
|
||||||
{Power: p(true), Intensity: p(1.0), Color: p(color.MustParse("xy:0.22,0.18"))},
|
|
||||||
{Power: p(true), Intensity: p(0.4), Color: p(color.MustParse("xy:0.22,0.18"))},
|
|
||||||
{Power: p(true), Intensity: p(0.5), Color: p(color.MustParse("xy:0.22,0.18"))},
|
|
||||||
},
|
|
||||||
AnimationMS: 200,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Second * 1)
|
|
||||||
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:*", Alias: "lucifer:tag:Magic Lamps"})
|
|
||||||
bus.RunCommand(commands.Assign{
|
|
||||||
Match: "lucifer:tag:Magic Lamps",
|
|
||||||
Effect: effects.Pattern{
|
|
||||||
States: []device.State{
|
|
||||||
{Power: p(true), Intensity: p(1.0), Color: p(color.MustParse("hs:0,0.9"))},
|
|
||||||
{Power: p(true), Intensity: p(0.7), Color: p(color.MustParse("hs:100,0.8"))},
|
|
||||||
},
|
|
||||||
AnimationMS: 500,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
bus.RunCommand(commands.ConnectDevice{
|
|
||||||
ID: "nanoleaf:10.80.1.14",
|
|
||||||
APIKey: "QRj5xcQxAQMQsjK4gvaprdhOwr1sCIcj",
|
|
||||||
})
|
|
||||||
|
|
||||||
time.Sleep(time.Hour)
|
time.Sleep(time.Hour)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ func (c SetState) Matches(driver string) (sub string, ok bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c SetState) CommandDescription() string {
|
func (c SetState) CommandDescription() string {
|
||||||
return fmt.Sprintf("SetState(%s, %s)", c.ID, c.State)
|
return fmt.Sprintf("SetStates(%s, %s)", c.ID, c.State)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetStateBatch map[string]device.State
|
type SetStateBatch map[string]device.State
|
||||||
|
|||||||
+11
-11
@@ -32,43 +32,43 @@ func (f SupportFlags) HasAll(d SupportFlags) bool {
|
|||||||
return bits.OnesCount32(uint32(f&d)) == bits.OnesCount32(uint32(d))
|
return bits.OnesCount32(uint32(f&d)) == bits.OnesCount32(uint32(d))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ColorFlag is primarily to detect warm-white lights, as XY/RGB/HS/HSK can convert without trouble.
|
// ColorFlags is primarily to detect warm-white lights, as XY/RGB/HS/HSK can convert without trouble.
|
||||||
type ColorFlag uint32
|
type ColorFlags uint32
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CFlagXY ColorFlag = 1 << iota
|
CFlagXY ColorFlags = 1 << iota
|
||||||
CFlagRGB
|
CFlagRGB
|
||||||
CFlagHS
|
CFlagHS
|
||||||
CFlagHSK
|
CFlagHSK
|
||||||
CFlagKelvin
|
CFlagKelvin
|
||||||
)
|
)
|
||||||
|
|
||||||
var colorFlags = []ColorFlag{CFlagXY, CFlagRGB, CFlagHS, CFlagHSK, CFlagKelvin}
|
var colorFlags = []ColorFlags{CFlagXY, CFlagRGB, CFlagHS, CFlagHSK, CFlagKelvin}
|
||||||
|
|
||||||
func (f ColorFlag) String() string {
|
func (f ColorFlags) String() string {
|
||||||
return gentools.FlagString(f, colorFlags, []string{"XY", "RGB", "HS", "HSK", "K"})
|
return gentools.FlagString(f, colorFlags, []string{"XY", "RGB", "HS", "HSK", "K"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f ColorFlag) IsColor() bool {
|
func (f ColorFlags) IsColor() bool {
|
||||||
return f.HasAny(CFlagXY | CFlagRGB | CFlagHS | CFlagHSK)
|
return f.HasAny(CFlagXY | CFlagRGB | CFlagHS | CFlagHSK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f ColorFlag) IsWarmWhite() bool {
|
func (f ColorFlags) IsWarmWhite() bool {
|
||||||
return f&CFlagKelvin == CFlagKelvin
|
return f&CFlagKelvin == CFlagKelvin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f ColorFlag) IsColorOnly() bool {
|
func (f ColorFlags) IsColorOnly() bool {
|
||||||
return f.IsColor() && !f.IsWarmWhite()
|
return f.IsColor() && !f.IsWarmWhite()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f ColorFlag) IsWarmWhiteOnly() bool {
|
func (f ColorFlags) IsWarmWhiteOnly() bool {
|
||||||
return f.IsWarmWhite() && !f.IsColor()
|
return f.IsWarmWhite() && !f.IsColor()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f ColorFlag) HasAny(d ColorFlag) bool {
|
func (f ColorFlags) HasAny(d ColorFlags) bool {
|
||||||
return (f & d) != 0
|
return (f & d) != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f ColorFlag) HasAll(d ColorFlag) bool {
|
func (f ColorFlags) HasAll(d ColorFlags) bool {
|
||||||
return bits.OnesCount32(uint32(f&d)) == bits.OnesCount32(uint32(d))
|
return bits.OnesCount32(uint32(f&d)) == bits.OnesCount32(uint32(d))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.aiterp.net/lucifer3/server/internal/color"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Info is
|
||||||
|
type Info struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
InternalName string `json:"internalName,omitempty"`
|
||||||
|
Aliases []string `json:"aliases"`
|
||||||
|
SupportFlags SupportFlags `json:"supportFlags"`
|
||||||
|
ColorFlags ColorFlags `json:"colorFlags"`
|
||||||
|
Buttons []string `json:"buttons"`
|
||||||
|
DesiredState *State `json:"desiredState"`
|
||||||
|
HardwareState *State `json:"hardwareState"`
|
||||||
|
ColorGamut *color.Gamut `json:"colorGamut,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package effects
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
lucifer3 "git.aiterp.net/lucifer3/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
type serializedEffect struct {
|
||||||
|
Manual *Manual `json:"manual,omitempty"`
|
||||||
|
Gradient *Gradient `json:"gradient,omitempty"`
|
||||||
|
Pattern *Pattern `json:"pattern,omitempty"`
|
||||||
|
Random *Random `json:"random,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Serializable struct {
|
||||||
|
lucifer3.Effect
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Serializable) UnmarshalJSON(raw []byte) error {
|
||||||
|
value := serializedEffect{}
|
||||||
|
err := json.Unmarshal(raw, &value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case value.Manual != nil:
|
||||||
|
s.Effect = *value.Manual
|
||||||
|
case value.Gradient != nil:
|
||||||
|
s.Effect = *value.Gradient
|
||||||
|
case value.Pattern != nil:
|
||||||
|
s.Effect = *value.Pattern
|
||||||
|
case value.Random != nil:
|
||||||
|
s.Effect = *value.Random
|
||||||
|
default:
|
||||||
|
return errors.New("unsupported effect")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Serializable) MarshalJSON() ([]byte, error) {
|
||||||
|
switch effect := s.Effect.(type) {
|
||||||
|
case Manual:
|
||||||
|
return json.Marshal(serializedEffect{Manual: &effect})
|
||||||
|
case Gradient:
|
||||||
|
return json.Marshal(serializedEffect{Gradient: &effect})
|
||||||
|
case Pattern:
|
||||||
|
return json.Marshal(serializedEffect{Pattern: &effect})
|
||||||
|
case Random:
|
||||||
|
return json.Marshal(serializedEffect{Random: &effect})
|
||||||
|
default:
|
||||||
|
panic(s.Effect.EffectDescription() + "is not understood by serializer")
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-7
@@ -3,6 +3,7 @@ package events
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.aiterp.net/lucifer3/server/device"
|
"git.aiterp.net/lucifer3/server/device"
|
||||||
|
"git.aiterp.net/lucifer3/server/internal/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DeviceConnected struct {
|
type DeviceConnected struct {
|
||||||
@@ -23,13 +24,16 @@ func (e DeviceDisconnected) EventDescription() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type HardwareState struct {
|
type HardwareState struct {
|
||||||
ID string `json:"internalId"`
|
ID string `json:"internalId"`
|
||||||
InternalName string `json:"internalName"`
|
InternalName string `json:"internalName"`
|
||||||
SupportFlags device.SupportFlags `json:"deviceFlags"`
|
SupportFlags device.SupportFlags `json:"supportFlags"`
|
||||||
ColorFlags device.ColorFlag `json:"colorFlags"`
|
ColorFlags device.ColorFlags `json:"colorFlags"`
|
||||||
Buttons []string `json:"buttons"`
|
ColorGamut *color.Gamut `json:"colorGamut,omitempty"`
|
||||||
State device.State `json:"state"`
|
TemperatureRange *[2]int `json:"temperatureRange,omitempty"`
|
||||||
Unreachable bool `json:"unreachable"`
|
Buttons []string `json:"buttons"`
|
||||||
|
State device.State `json:"state"`
|
||||||
|
BatteryPercentage *int `json:"batteryPercentage"`
|
||||||
|
Unreachable bool `json:"unreachable"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e HardwareState) EventDescription() string {
|
func (e HardwareState) EventDescription() string {
|
||||||
@@ -38,6 +42,8 @@ func (e HardwareState) EventDescription() string {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HardwareMetadata contains things that has no bearing on the functionality of
|
||||||
|
// lucifer, but may be interesting to have in the GUI.
|
||||||
type HardwareMetadata struct {
|
type HardwareMetadata struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
X int `json:"x,omitempty"`
|
X int `json:"x,omitempty"`
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ const eps = 0.0001
|
|||||||
const epsSquare = eps * eps
|
const epsSquare = eps * eps
|
||||||
|
|
||||||
type Gamut struct {
|
type Gamut struct {
|
||||||
Red XY `json:"red"`
|
Label string `json:"label,omitempty"`
|
||||||
Green XY `json:"green"`
|
Red XY `json:"red"`
|
||||||
Blue XY `json:"blue"`
|
Green XY `json:"green"`
|
||||||
|
Blue XY `json:"blue"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cg *Gamut) side(x1, y1, x2, y2, x, y float64) float64 {
|
func (cg *Gamut) side(x1, y1, x2, y2, x, y float64) float64 {
|
||||||
@@ -171,7 +172,8 @@ func (xy XY) ToHS() HueSat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (xy XY) DistanceTo(other XY) float64 {
|
func (xy XY) DistanceTo(other XY) float64 {
|
||||||
return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2))
|
return math.Abs(xy.X-other.X) + math.Abs(xy.Y-other.Y)
|
||||||
|
//return math.Sqrt(math.Pow(xy.X-other.X, 2) + math.Pow(xy.Y-other.Y, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (xy XY) Round() XY {
|
func (xy XY) Round() XY {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func CompactIDList(ids []string) []string {
|
func CompactIDList(ids []string) []string {
|
||||||
|
ids = append(ids[:0:0], ids...)
|
||||||
sort.Strings(ids)
|
sort.Strings(ids)
|
||||||
|
|
||||||
currentGroup := ""
|
currentGroup := ""
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package gentools
|
||||||
|
|
||||||
|
func CopyMap[K comparable, V any](m map[K]V) map[K]V {
|
||||||
|
m2 := make(map[K]V, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
m2[k] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return m2
|
||||||
|
}
|
||||||
|
|
||||||
|
func OneItemMap[K comparable, V any](key K, value V) map[K]V {
|
||||||
|
m := make(map[K]V, 1)
|
||||||
|
m[key] = value
|
||||||
|
return m
|
||||||
|
}
|
||||||
@@ -5,6 +5,16 @@ func Ptr[T any](t T) *T {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ShallowCopy[T any](t *T) *T {
|
func ShallowCopy[T any](t *T) *T {
|
||||||
|
if t == nil {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
tCopy := *t
|
tCopy := *t
|
||||||
return &tCopy
|
return &tCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ShallowCopyTo[T any](dst **T, src *T) {
|
||||||
|
if src != nil {
|
||||||
|
*dst = ShallowCopy(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,3 +22,21 @@ Outer:
|
|||||||
*arr = append(*arr, v)
|
*arr = append(*arr, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Map[T any, U any](arr []T, cb func(T) U) []U {
|
||||||
|
arr2 := make([]U, len(arr))
|
||||||
|
for i, v := range arr {
|
||||||
|
arr2[i] = cb(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr2
|
||||||
|
}
|
||||||
|
|
||||||
|
func Flatten[T any](arr [][]T) []T {
|
||||||
|
arr2 := make([]T, 0, 128)
|
||||||
|
for _, sub := range arr {
|
||||||
|
arr2 = sub
|
||||||
|
}
|
||||||
|
|
||||||
|
return arr2
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package hue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
lucifer3 "git.aiterp.net/lucifer3/server"
|
||||||
|
"git.aiterp.net/lucifer3/server/device"
|
||||||
|
"git.aiterp.net/lucifer3/server/events"
|
||||||
|
"git.aiterp.net/lucifer3/server/internal/gentools"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewBridge(host string, client *Client) *Bridge {
|
||||||
|
return &Bridge{
|
||||||
|
client: client,
|
||||||
|
host: host,
|
||||||
|
ctx: context.Background(),
|
||||||
|
cancel: func() {},
|
||||||
|
resources: map[string]*ResourceData{},
|
||||||
|
activeStates: map[string]device.State{},
|
||||||
|
desiredStates: map[string]device.State{},
|
||||||
|
hasSeen: map[string]bool{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bridge struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
client *Client
|
||||||
|
host string
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
resources map[string]*ResourceData
|
||||||
|
activeStates map[string]device.State
|
||||||
|
desiredStates map[string]device.State
|
||||||
|
hasSeen map[string]bool
|
||||||
|
|
||||||
|
lastDiscoverCancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) SearchDevices(timeout time.Duration) error {
|
||||||
|
discoverCtx, cancel := context.WithCancel(b.ctx)
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.lastDiscoverCancel != nil {
|
||||||
|
b.lastDiscoverCancel()
|
||||||
|
}
|
||||||
|
b.lastDiscoverCancel = cancel
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
if timeout <= time.Second*10 {
|
||||||
|
timeout = time.Second * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spend half the time waiting for devices
|
||||||
|
// TODO: Wait for v2 endpoint
|
||||||
|
ctx, cancel := context.WithTimeout(discoverCtx, timeout/2)
|
||||||
|
defer cancel()
|
||||||
|
err := b.client.LegacyDiscover(ctx, "sensors")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
if discoverCtx.Err() != nil {
|
||||||
|
return discoverCtx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spend half the time waiting for lights
|
||||||
|
// TODO: Wait for v2 endpoint
|
||||||
|
ctx, cancel = context.WithTimeout(discoverCtx, timeout/2)
|
||||||
|
defer cancel()
|
||||||
|
err = b.client.LegacyDiscover(ctx, "sensors")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
<-ctx.Done()
|
||||||
|
if discoverCtx.Err() != nil {
|
||||||
|
return discoverCtx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the main loop get the new light.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) RefreshAll() ([]lucifer3.Event, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(b.ctx, time.Second*15)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
allResources, err := b.client.AllResources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resources := make(map[string]*ResourceData, len(allResources))
|
||||||
|
for i := range allResources {
|
||||||
|
resources[allResources[i].ID] = &allResources[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
newEvents := make([]lucifer3.Event, 0, 0)
|
||||||
|
b.mu.Lock()
|
||||||
|
for id, res := range resources {
|
||||||
|
if res.Type == "device" {
|
||||||
|
hwState, hwEvent := res.GenerateEvent(b.host, resources)
|
||||||
|
if !b.hasSeen[id] {
|
||||||
|
newEvents = append(newEvents, hwState, hwEvent, events.DeviceReady{ID: hwState.ID})
|
||||||
|
b.hasSeen[id] = true
|
||||||
|
} else {
|
||||||
|
newEvents = append(newEvents, hwState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.resources = resources
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return newEvents, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) ApplyPatches(resources []ResourceData) (events []lucifer3.Event, shouldRefresh bool) {
|
||||||
|
b.mu.Lock()
|
||||||
|
mapCopy := gentools.CopyMap(b.resources)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
for _, resource := range resources {
|
||||||
|
if mapCopy[resource.ID] != nil {
|
||||||
|
mapCopy[resource.ID] = mapCopy[resource.ID].WithPatch(resource)
|
||||||
|
} else {
|
||||||
|
log.Println(resource.ID, resource.Type, "not seen!")
|
||||||
|
shouldRefresh = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, resource := range resources {
|
||||||
|
if resource.Owner != nil && resource.Owner.Kind == "device" {
|
||||||
|
if parent, ok := mapCopy[resource.Owner.ID]; ok {
|
||||||
|
hwState, _ := parent.GenerateEvent(b.host, mapCopy)
|
||||||
|
events = append(events, hwState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.resources = mapCopy
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) SetStates(patch map[string]device.State) {
|
||||||
|
b.mu.Lock()
|
||||||
|
newStates := gentools.CopyMap(b.desiredStates)
|
||||||
|
resources := b.resources
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
prefix := "hue:" + b.host + ":"
|
||||||
|
for id, state := range patch {
|
||||||
|
if !strings.HasPrefix(id, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id = id[len(prefix):]
|
||||||
|
|
||||||
|
resource := resources[id]
|
||||||
|
if resource == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newStates[id] = resource.FixState(state, resources)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.desiredStates = newStates
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Bridge) Run(ctx context.Context, bus *lucifer3.EventBus) interface{} {
|
||||||
|
hwEvents, err := b.RefreshAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.RunEvents(hwEvents)
|
||||||
|
|
||||||
|
sse := b.client.SSE(ctx)
|
||||||
|
step := time.NewTicker(time.Second * 30)
|
||||||
|
defer step.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case updates, ok := <-sse:
|
||||||
|
{
|
||||||
|
if !ok {
|
||||||
|
return errors.New("SSE lost connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
newEvents, shouldUpdate := b.ApplyPatches(
|
||||||
|
gentools.Flatten(gentools.Map(updates, func(update SSEUpdate) []ResourceData {
|
||||||
|
return update.Data
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
bus.RunEvents(newEvents)
|
||||||
|
|
||||||
|
if shouldUpdate {
|
||||||
|
hwEvents, err := b.RefreshAll()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.RunEvents(hwEvents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
{
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package hue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
lucifer3 "git.aiterp.net/lucifer3/server"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewClient(host, token string) *Client {
|
||||||
|
ch := make(chan struct{}, 5)
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
ch <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
token: token,
|
||||||
|
ch: ch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
token string
|
||||||
|
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 "", lucifer3.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{}
|
||||||
|
Data []ResourceData
|
||||||
|
}{}
|
||||||
|
|
||||||
|
err := c.get(ctx, "clip/v2/resource", &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Resources(ctx context.Context, kind string) ([]ResourceData, error) {
|
||||||
|
res := struct {
|
||||||
|
Error interface{}
|
||||||
|
Data []ResourceData
|
||||||
|
}{}
|
||||||
|
|
||||||
|
err := c.get(ctx, "clip/v2/resource/"+kind, &res)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateResource(ctx context.Context, link ResourceLink, update ResourceUpdate) error {
|
||||||
|
return c.put(ctx, link.Path(), update, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data) > 0 {
|
||||||
|
ch <- data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getReader(ctx context.Context, path string, headers map[string]string) (io.ReadCloser, error) {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-c.ch:
|
||||||
|
defer func() {
|
||||||
|
c.ch <- struct{}{}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", fmt.Sprintf("https://%s/%s", c.host, path), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range headers {
|
||||||
|
req.Header.Set(key, value)
|
||||||
|
}
|
||||||
|
req.Header.Set("hue-application-key", c.token)
|
||||||
|
|
||||||
|
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 body.Close()
|
||||||
|
|
||||||
|
if target == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(body).Decode(&target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) put(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("PUT", fmt.Sprintf("https://%s/%s", c.host, path), rb)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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) 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
MaxIdleConns: 256,
|
||||||
|
MaxIdleConnsPerHost: 16,
|
||||||
|
IdleConnTimeout: 10 * time.Minute,
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
},
|
||||||
|
Timeout: time.Minute,
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
package hue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"git.aiterp.net/lucifer3/server/device"
|
||||||
|
"git.aiterp.net/lucifer3/server/events"
|
||||||
|
"git.aiterp.net/lucifer3/server/internal/color"
|
||||||
|
"git.aiterp.net/lucifer3/server/internal/gentools"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeviceData struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LegacyID string `json:"id_v1"`
|
||||||
|
Metadata DeviceMetadata `json:"metadata"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
ProductData DeviceProductData `json:"product_data"`
|
||||||
|
Services []ResourceLink `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceMetadata struct {
|
||||||
|
Archetype string `json:"archetype"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceProductData struct {
|
||||||
|
Certified bool `json:"certified"`
|
||||||
|
ManufacturerName string `json:"manufacturer_name"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
ProductArchetype string `json:"product_archetype"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
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"`
|
||||||
|
Metadata DeviceMetadata `json:"metadata"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
Mode *string `json:"mode"`
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Dimming *LightDimming `json:"dimming"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *ResourceData) ServiceID(kind string) *string {
|
||||||
|
for _, ptr := range res.Services {
|
||||||
|
if ptr.Kind == kind {
|
||||||
|
return &ptr.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) FixState(state device.State, resources map[string]*ResourceData) device.State {
|
||||||
|
fixedState := device.State{}
|
||||||
|
if lightID := res.ServiceID("light"); lightID != nil {
|
||||||
|
if light := resources[*lightID]; light != nil {
|
||||||
|
if state.Color != nil {
|
||||||
|
if state.Color.IsKelvin() {
|
||||||
|
if light.ColorTemperature != nil {
|
||||||
|
mirek := 1000000 / *state.Color.K
|
||||||
|
if mirek < light.ColorTemperature.MirekSchema.MirekMinimum {
|
||||||
|
mirek = light.ColorTemperature.MirekSchema.MirekMinimum
|
||||||
|
}
|
||||||
|
if mirek > light.ColorTemperature.MirekSchema.MirekMaximum {
|
||||||
|
mirek = light.ColorTemperature.MirekSchema.MirekMaximum
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedState.Color = &color.Color{K: gentools.Ptr(1000000 / mirek)}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if light.Color != nil {
|
||||||
|
if col, ok := state.Color.ToXY(); ok {
|
||||||
|
col.XY = gentools.Ptr(light.Color.Gamut.Conform(*col.XY))
|
||||||
|
fixedState.Color = &col
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.Intensity != nil && light.Dimming != nil {
|
||||||
|
fixedState.Intensity = gentools.ShallowCopy(state.Intensity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.Power != nil && light.Power != nil {
|
||||||
|
fixedState.Power = gentools.ShallowCopy(state.Power)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixedState
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *ResourceData) WithPatch(patch ResourceData) *ResourceData {
|
||||||
|
res2 := *res
|
||||||
|
|
||||||
|
if patch.Color != nil {
|
||||||
|
res2.Color = gentools.ShallowCopy(res2.Color)
|
||||||
|
res2.Color.XY = patch.Color.XY
|
||||||
|
}
|
||||||
|
if patch.ColorTemperature != nil {
|
||||||
|
res2.ColorTemperature = gentools.ShallowCopy(res2.ColorTemperature)
|
||||||
|
res2.ColorTemperature.Mirek = gentools.ShallowCopy(patch.ColorTemperature.Mirek)
|
||||||
|
}
|
||||||
|
|
||||||
|
gentools.ShallowCopyTo(&res2.ProductData, patch.ProductData)
|
||||||
|
gentools.ShallowCopyTo(&res2.Button, patch.Button)
|
||||||
|
gentools.ShallowCopyTo(&res2.Power, patch.Power)
|
||||||
|
gentools.ShallowCopyTo(&res2.Color, patch.Color)
|
||||||
|
gentools.ShallowCopyTo(&res2.ColorTemperature, patch.ColorTemperature)
|
||||||
|
gentools.ShallowCopyTo(&res2.Dimming, patch.Dimming)
|
||||||
|
gentools.ShallowCopyTo(&res2.Dynamics, patch.Dynamics)
|
||||||
|
gentools.ShallowCopyTo(&res2.Alert, patch.Alert)
|
||||||
|
gentools.ShallowCopyTo(&res2.PowerState, patch.PowerState)
|
||||||
|
gentools.ShallowCopyTo(&res2.Temperature, patch.Temperature)
|
||||||
|
gentools.ShallowCopyTo(&res2.Motion, patch.Motion)
|
||||||
|
gentools.ShallowCopyTo(&res2.Status, patch.Status)
|
||||||
|
|
||||||
|
return &res2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *ResourceData) GenerateEvent(hostname string, resources map[string]*ResourceData) (events.HardwareState, events.HardwareMetadata) {
|
||||||
|
hwState := events.HardwareState{
|
||||||
|
ID: fmt.Sprintf("hue:%s:%s", hostname, res.ID),
|
||||||
|
InternalName: res.Metadata.Name,
|
||||||
|
State: res.GenerateState(resources),
|
||||||
|
Unreachable: false,
|
||||||
|
}
|
||||||
|
hwMeta := events.HardwareMetadata{
|
||||||
|
ID: hwState.ID,
|
||||||
|
}
|
||||||
|
buttonCount := 0
|
||||||
|
|
||||||
|
if res.ProductData != nil {
|
||||||
|
hwMeta.FirmwareVersion = res.ProductData.SoftwareVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ptr := range res.Services {
|
||||||
|
svc := resources[ptr.ID]
|
||||||
|
if svc == nil {
|
||||||
|
continue // I've never seen this happen, but safety first.
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ptr.Kind {
|
||||||
|
case "device_power":
|
||||||
|
hwState.BatteryPercentage = gentools.Ptr(int(svc.PowerState.BatteryLevel))
|
||||||
|
case "button":
|
||||||
|
buttonCount += 1
|
||||||
|
hwState.SupportFlags |= device.SFlagSensorButtons
|
||||||
|
case "motion":
|
||||||
|
hwState.SupportFlags |= device.SFlagSensorPresence
|
||||||
|
case "temperature":
|
||||||
|
hwState.SupportFlags |= device.SFlagSensorTemperature
|
||||||
|
case "light":
|
||||||
|
if svc.Power != nil {
|
||||||
|
hwState.SupportFlags |= device.SFlagPower
|
||||||
|
}
|
||||||
|
if svc.Dimming != nil {
|
||||||
|
hwState.SupportFlags |= device.SFlagIntensity
|
||||||
|
}
|
||||||
|
if svc.ColorTemperature != nil {
|
||||||
|
hwState.SupportFlags |= device.SFlagColor
|
||||||
|
hwState.ColorFlags |= device.CFlagKelvin
|
||||||
|
hwState.TemperatureRange = &[2]int{
|
||||||
|
1000000 / svc.ColorTemperature.MirekSchema.MirekMinimum,
|
||||||
|
1000000 / svc.ColorTemperature.MirekSchema.MirekMaximum,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if svc.Color != nil {
|
||||||
|
hwState.SupportFlags |= device.SFlagColor
|
||||||
|
hwState.ColorFlags |= device.CFlagXY
|
||||||
|
hwState.ColorGamut = gentools.Ptr(svc.Color.Gamut)
|
||||||
|
hwState.ColorGamut.Label = svc.Color.GamutType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if buttonCount == 4 {
|
||||||
|
hwState.Buttons = []string{"On", "DimUp", "DimDown", "Off"}
|
||||||
|
} else if buttonCount == 1 {
|
||||||
|
hwState.Buttons = []string{"Button"}
|
||||||
|
} else {
|
||||||
|
for n := 1; n <= buttonCount; n++ {
|
||||||
|
hwState.Buttons = append(hwState.Buttons, fmt.Sprint("Button", n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hwState, hwMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res *ResourceData) GenerateState(resources map[string]*ResourceData) device.State {
|
||||||
|
state := device.State{}
|
||||||
|
for _, ptr := range res.Services {
|
||||||
|
switch ptr.Kind {
|
||||||
|
case "light":
|
||||||
|
light := resources[ptr.ID]
|
||||||
|
if light == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if light.Power != nil {
|
||||||
|
state.Power = gentools.Ptr(light.Power.On)
|
||||||
|
}
|
||||||
|
if light.Dimming != nil {
|
||||||
|
state.Intensity = gentools.Ptr(light.Dimming.Brightness / 100.0)
|
||||||
|
}
|
||||||
|
if light.ColorTemperature != nil {
|
||||||
|
if light.ColorTemperature.Mirek != nil {
|
||||||
|
state.Color = &color.Color{K: gentools.Ptr(1000000 / *light.ColorTemperature.Mirek)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if light.Color != nil {
|
||||||
|
if state.Color == nil || state.Color.IsEmpty() {
|
||||||
|
state.Color = &color.Color{XY: &light.Color.XY}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightPower struct {
|
||||||
|
On bool `json:"on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightDimming struct {
|
||||||
|
Brightness float64 `json:"brightness"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightColor struct {
|
||||||
|
Gamut color.Gamut `json:"gamut"`
|
||||||
|
GamutType string `json:"gamut_type"`
|
||||||
|
XY color.XY `json:"xy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightCT struct {
|
||||||
|
Mirek *int `json:"mirek"`
|
||||||
|
MirekSchema LightCTMirekSchema `json:"mirek_schema"`
|
||||||
|
MirekValid bool `json:"mirek_valid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightCTMirekSchema struct {
|
||||||
|
MirekMaximum int `json:"mirek_maximum"`
|
||||||
|
MirekMinimum int `json:"mirek_minimum"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightDynamics struct {
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
SpeedValid bool `json:"speed_valid"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusValues []string `json:"status_values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LightAlert struct {
|
||||||
|
ActionValues []string `json:"action_values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceUpdate struct {
|
||||||
|
Name *string
|
||||||
|
Power *bool
|
||||||
|
ColorXY *color.XY
|
||||||
|
Brightness *float64
|
||||||
|
Mirek *int
|
||||||
|
TransitionDuration *time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
if r.ColorXY != nil {
|
||||||
|
chunks = append(chunks, fmt.Sprintf(`"color":{"xy":{"x":%f,"y":%f}}`, r.ColorXY.X, r.ColorXY.Y))
|
||||||
|
}
|
||||||
|
if r.Brightness != nil {
|
||||||
|
chunks = append(chunks, fmt.Sprintf(`"dimming":{"brightness":%f}`, *r.Brightness))
|
||||||
|
}
|
||||||
|
if r.Mirek != nil {
|
||||||
|
chunks = append(chunks, fmt.Sprintf(`"color_temperature":{"mirek":%d}`, *r.Mirek))
|
||||||
|
}
|
||||||
|
if r.TransitionDuration != nil {
|
||||||
|
chunks = append(chunks, fmt.Sprintf(`"dynamics":{"duration":%d}`, r.TransitionDuration.Truncate(time.Millisecond*100).Milliseconds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return []byte(fmt.Sprintf("{%s}", strings.Join(chunks, ","))), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceLink struct {
|
||||||
|
ID string `json:"rid"`
|
||||||
|
Kind string `json:"rtype"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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,105 @@
|
|||||||
|
package hue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
lucifer3 "git.aiterp.net/lucifer3/server"
|
||||||
|
"git.aiterp.net/lucifer3/server/commands"
|
||||||
|
"git.aiterp.net/lucifer3/server/events"
|
||||||
|
"git.aiterp.net/lucifer3/server/internal/gentools"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewService() lucifer3.ActiveService {
|
||||||
|
return &service{
|
||||||
|
bridges: map[string]*Bridge{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
bridges map[string]*Bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) Active() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command) {
|
||||||
|
switch command := command.(type) {
|
||||||
|
case commands.PairDevice:
|
||||||
|
{
|
||||||
|
if hostname, ok := command.Matches("hue"); ok {
|
||||||
|
go func() {
|
||||||
|
timeout, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
client := NewClient(hostname, "")
|
||||||
|
|
||||||
|
token, err := client.Register(timeout)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.RunEvent(events.DeviceAccepted{
|
||||||
|
ID: command.ID,
|
||||||
|
APIKey: token,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case commands.SetStateBatch:
|
||||||
|
for _, bridge := range s.bridges {
|
||||||
|
bridge.SetStates(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
case commands.SetState:
|
||||||
|
if sub, ok := command.Matches("hue"); ok {
|
||||||
|
if s.bridges[sub] != nil {
|
||||||
|
s.bridges[sub].SetStates(gentools.OneItemMap(command.ID, command.State))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case commands.ConnectDevice:
|
||||||
|
if sub, ok := command.Matches("hue"); ok {
|
||||||
|
if s.bridges[sub] != nil {
|
||||||
|
s.bridges[sub].cancel()
|
||||||
|
delete(s.bridges, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
client := NewClient(sub, command.APIKey)
|
||||||
|
|
||||||
|
bridge := NewBridge(sub, client)
|
||||||
|
bridge.ctx = ctx
|
||||||
|
bridge.cancel = cancel
|
||||||
|
s.bridges[sub] = bridge
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for bridge.ctx.Err() == nil {
|
||||||
|
ctx2, cancel2 := context.WithCancel(ctx)
|
||||||
|
|
||||||
|
err := bridge.Run(ctx2, bus)
|
||||||
|
cancel2()
|
||||||
|
if err != nil {
|
||||||
|
bus.RunEvent(events.DeviceFailed{
|
||||||
|
ID: command.ID,
|
||||||
|
Error: fmt.Sprintf("Run failed: %s", err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(time.Second * 5):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) HandleEvent(_ *lucifer3.EventBus, event lucifer3.Event) {}
|
||||||
Reference in New Issue
Block a user