Browse Source

hue hue hue

beelzebub
Gisle Aune 2 years ago
parent
commit
e00fe2a4d2
  1. 12
      bus.go
  2. 54
      cmd/bustest/main.go
  3. 2
      commands/state.go
  4. 22
      device/flags.go
  5. 19
      device/info.go
  6. 56
      effects/serializable.go
  7. 20
      events/device.go
  8. 10
      internal/color/xy.go
  9. 1
      internal/formattools/compactidlist.go
  10. 16
      internal/gentools/maps.go
  11. 10
      internal/gentools/ptr.go
  12. 18
      internal/gentools/slices.go
  13. 222
      services/hue/bridge.go
  14. 358
      services/hue/client.go
  15. 452
      services/hue/data.go
  16. 105
      services/hue/service.go

12
bus.go

@ -65,16 +65,16 @@ func (b *EventBus) JoinPrivileged(service ActiveService) {
}
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 {
fmt.Println("[INFO]", setStates, "SetState commands hidden.")
fmt.Println("[INFO]", setStates, "SetStates commands hidden.")
atomic.AddInt32(&b.setStates, -setStates)
}
fmt.Println("[COMMAND]", cd)
} else {
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)
}
}
@ -87,6 +87,12 @@ func (b *EventBus) RunEvent(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) {
b.mu.Lock()
defer b.mu.Unlock()

54
cmd/bustest/main.go

@ -3,11 +3,8 @@ package main
import (
lucifer3 "git.aiterp.net/lucifer3/server"
"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/hue"
"git.aiterp.net/lucifer3/server/services/nanoleaf"
"time"
)
@ -22,54 +19,9 @@ func main() {
bus.JoinPrivileged(sceneMap)
bus.Join(services.NewEffectEnforcer(resolver, sceneMap))
bus.Join(nanoleaf.NewService())
bus.Join(hue.NewService())
bus.JoinCallback(func(event lucifer3.Event) bool {
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",
})
bus.RunCommand(commands.ConnectDevice{ID: "hue:10.80.1.5", APIKey: "0-Ch5MKQtYnXrA3b8jvE4408mS3tHo9Vn57Zv8pt"})
time.Sleep(time.Hour)
}

2
commands/state.go

@ -20,7 +20,7 @@ func (c SetState) Matches(driver string) (sub string, ok bool) {
}
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

22
device/flags.go

@ -32,43 +32,43 @@ func (f SupportFlags) HasAll(d SupportFlags) bool {
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.
type ColorFlag uint32
// ColorFlags is primarily to detect warm-white lights, as XY/RGB/HS/HSK can convert without trouble.
type ColorFlags uint32
const (
CFlagXY ColorFlag = 1 << iota
CFlagXY ColorFlags = 1 << iota
CFlagRGB
CFlagHS
CFlagHSK
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"})
}
func (f ColorFlag) IsColor() bool {
func (f ColorFlags) IsColor() bool {
return f.HasAny(CFlagXY | CFlagRGB | CFlagHS | CFlagHSK)
}
func (f ColorFlag) IsWarmWhite() bool {
func (f ColorFlags) IsWarmWhite() bool {
return f&CFlagKelvin == CFlagKelvin
}
func (f ColorFlag) IsColorOnly() bool {
func (f ColorFlags) IsColorOnly() bool {
return f.IsColor() && !f.IsWarmWhite()
}
func (f ColorFlag) IsWarmWhiteOnly() bool {
func (f ColorFlags) IsWarmWhiteOnly() bool {
return f.IsWarmWhite() && !f.IsColor()
}
func (f ColorFlag) HasAny(d ColorFlag) bool {
func (f ColorFlags) HasAny(d ColorFlags) bool {
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))
}

19
device/info.go

@ -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"`
}

56
effects/serializable.go

@ -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")
}
}

20
events/device.go

@ -3,6 +3,7 @@ package events
import (
"fmt"
"git.aiterp.net/lucifer3/server/device"
"git.aiterp.net/lucifer3/server/internal/color"
)
type DeviceConnected struct {
@ -23,13 +24,16 @@ func (e DeviceDisconnected) EventDescription() string {
}
type HardwareState struct {
ID string `json:"internalId"`
InternalName string `json:"internalName"`
SupportFlags device.SupportFlags `json:"deviceFlags"`
ColorFlags device.ColorFlag `json:"colorFlags"`
Buttons []string `json:"buttons"`
State device.State `json:"state"`
Unreachable bool `json:"unreachable"`
ID string `json:"internalId"`
InternalName string `json:"internalName"`
SupportFlags device.SupportFlags `json:"supportFlags"`
ColorFlags device.ColorFlags `json:"colorFlags"`
ColorGamut *color.Gamut `json:"colorGamut,omitempty"`
TemperatureRange *[2]int `json:"temperatureRange,omitempty"`
Buttons []string `json:"buttons"`
State device.State `json:"state"`
BatteryPercentage *int `json:"batteryPercentage"`
Unreachable bool `json:"unreachable"`
}
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 {
ID string `json:"id"`
X int `json:"x,omitempty"`

10
internal/color/xy.go

@ -9,9 +9,10 @@ const eps = 0.0001
const epsSquare = eps * eps
type Gamut struct {
Red XY `json:"red"`
Green XY `json:"green"`
Blue XY `json:"blue"`
Label string `json:"label,omitempty"`
Red XY `json:"red"`
Green XY `json:"green"`
Blue XY `json:"blue"`
}
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 {
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 {

1
internal/formattools/compactidlist.go

@ -7,6 +7,7 @@ import (
)
func CompactIDList(ids []string) []string {
ids = append(ids[:0:0], ids...)
sort.Strings(ids)
currentGroup := ""

16
internal/gentools/maps.go

@ -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
}

10
internal/gentools/ptr.go

@ -5,6 +5,16 @@ func Ptr[T any](t T) *T {
}
func ShallowCopy[T any](t *T) *T {
if t == nil {
return t
}
tCopy := *t
return &tCopy
}
func ShallowCopyTo[T any](dst **T, src *T) {
if src != nil {
*dst = ShallowCopy(src)
}
}

18
internal/gentools/slices.go

@ -22,3 +22,21 @@ Outer:
*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
}

222
services/hue/bridge.go

@ -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
}
}
}
}

358
services/hue/client.go

@ -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,
}

452
services/hue/data.go

@ -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"`
}

105
services/hue/service.go

@ -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) {}
Loading…
Cancel
Save