Gisle Aune
2 years ago
16 changed files with 1300 additions and 77 deletions
-
12bus.go
-
54cmd/bustest/main.go
-
2commands/state.go
-
22device/flags.go
-
19device/info.go
-
56effects/serializable.go
-
20events/device.go
-
10internal/color/xy.go
-
1internal/formattools/compactidlist.go
-
16internal/gentools/maps.go
-
10internal/gentools/ptr.go
-
18internal/gentools/slices.go
-
222services/hue/bridge.go
-
358services/hue/client.go
-
452services/hue/data.go
-
105services/hue/service.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"` |
|||
} |
@ -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") |
|||
} |
|||
} |
@ -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 |
|||
} |
@ -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) {} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue