diff --git a/bus.go b/bus.go index ccc1201..ee7e4fe 100644 --- a/bus.go +++ b/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() diff --git a/cmd/bustest/main.go b/cmd/bustest/main.go index 4be119d..eda00d8 100644 --- a/cmd/bustest/main.go +++ b/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) } diff --git a/commands/state.go b/commands/state.go index bc5e0f8..e034ba3 100644 --- a/commands/state.go +++ b/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 diff --git a/device/flags.go b/device/flags.go index a87b1cc..9e84516 100644 --- a/device/flags.go +++ b/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)) } diff --git a/device/info.go b/device/info.go new file mode 100644 index 0000000..72d7259 --- /dev/null +++ b/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"` +} diff --git a/effects/serializable.go b/effects/serializable.go new file mode 100644 index 0000000..5a3b641 --- /dev/null +++ b/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") + } +} diff --git a/events/device.go b/events/device.go index fcd51ab..d7e92d1 100644 --- a/events/device.go +++ b/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"` diff --git a/internal/color/xy.go b/internal/color/xy.go index d2665d0..03a9e34 100644 --- a/internal/color/xy.go +++ b/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 { diff --git a/internal/formattools/compactidlist.go b/internal/formattools/compactidlist.go index 0e54313..0781781 100644 --- a/internal/formattools/compactidlist.go +++ b/internal/formattools/compactidlist.go @@ -7,6 +7,7 @@ import ( ) func CompactIDList(ids []string) []string { + ids = append(ids[:0:0], ids...) sort.Strings(ids) currentGroup := "" diff --git a/internal/gentools/maps.go b/internal/gentools/maps.go new file mode 100644 index 0000000..ded77b4 --- /dev/null +++ b/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 +} diff --git a/internal/gentools/ptr.go b/internal/gentools/ptr.go index bf95190..b736026 100644 --- a/internal/gentools/ptr.go +++ b/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) + } +} diff --git a/internal/gentools/slices.go b/internal/gentools/slices.go index 5578a3f..d9a8d78 100644 --- a/internal/gentools/slices.go +++ b/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 +} diff --git a/services/hue/bridge.go b/services/hue/bridge.go new file mode 100644 index 0000000..dd97b93 --- /dev/null +++ b/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 + } + } + } + +} diff --git a/services/hue/client.go b/services/hue/client.go new file mode 100644 index 0000000..049d7b6 --- /dev/null +++ b/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, +} diff --git a/services/hue/data.go b/services/hue/data.go new file mode 100644 index 0000000..4d98dae --- /dev/null +++ b/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"` +} diff --git a/services/hue/service.go b/services/hue/service.go new file mode 100644 index 0000000..e55698a --- /dev/null +++ b/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) {}