hue hue hue
This commit is contained in:
@@ -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