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