Browse Source

add nanoleaf service

beelzebub
Gisle Aune 2 years ago
parent
commit
ae68679228
  1. 2
      bus.go
  2. 103
      cmd/bustest/main.go
  3. 60
      commands/device.go
  4. 9
      commands/state.go
  5. 12
      device/state.go
  6. 6
      errors.go
  7. 38
      events/button.go
  8. 20
      events/connection.go
  9. 82
      events/device.go
  10. 8
      internal/color/rgb.go
  11. 11
      internal/formattools/asterisks.go
  12. 462
      services/nanoleaf/client.go
  13. 231
      services/nanoleaf/data.go
  14. 55
      services/nanoleaf/discover.go
  15. 128
      services/nanoleaf/service.go

2
bus.go

@ -96,7 +96,7 @@ func (b *EventBus) send(message serviceMessage) {
listener.mu.Unlock() listener.mu.Unlock()
} }
for i := range deleteList {
for _, i := range deleteList {
b.listeners = append(b.listeners[:i], b.listeners[i+1:]...) b.listeners = append(b.listeners[:i], b.listeners[i+1:]...)
} }

103
cmd/bustest/main.go

@ -1,16 +1,14 @@
package main package main
import ( import (
"fmt"
lucifer3 "git.aiterp.net/lucifer3/server" lucifer3 "git.aiterp.net/lucifer3/server"
"git.aiterp.net/lucifer3/server/commands" "git.aiterp.net/lucifer3/server/commands"
"git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/device"
"git.aiterp.net/lucifer3/server/effects" "git.aiterp.net/lucifer3/server/effects"
"git.aiterp.net/lucifer3/server/events" "git.aiterp.net/lucifer3/server/events"
"git.aiterp.net/lucifer3/server/internal/color" "git.aiterp.net/lucifer3/server/internal/color"
"git.aiterp.net/lucifer3/server/internal/gentools"
"git.aiterp.net/lucifer3/server/services" "git.aiterp.net/lucifer3/server/services"
"log"
"git.aiterp.net/lucifer3/server/services/nanoleaf"
"time" "time"
) )
@ -23,76 +21,47 @@ func main() {
bus.JoinPrivileged(resolver) bus.JoinPrivileged(resolver)
bus.JoinPrivileged(sceneMap) bus.JoinPrivileged(sceneMap)
bus.Join(services.NewEffectEnforcer(resolver, sceneMap)) bus.Join(services.NewEffectEnforcer(resolver, sceneMap))
bus.Join(nanoleaf.NewService())
bus.RunEvent(events.Connected{Prefix: "nanoleaf:10.80.1.11"})
numbers := []int{5, 2, 3, 1, 4}
for i, id := range []string{"e28c", "67db", "f744", "d057", "73c1"} {
bus.RunEvent(events.HardwareState{
ID: "nanoleaf:10.80.1.11:" + id,
InternalName: fmt.Sprintf("Hexagon %d", numbers[i]),
SupportFlags: device.SFlagPower | device.SFlagColor | device.SFlagIntensity,
ColorFlags: device.CFlagRGB,
State: device.State{},
})
}
bus.RunCommand(commands.ReplaceScene{
Match: "lucifer:name:Hex*",
SceneName: "Evening",
})
bus.RunCommand(commands.AddAlias{
Match: "nanoleaf:10.80.1.{11,7,16,5}:*",
Alias: "lucifer:tag:Magic Lamps",
})
bus.JoinCallback(func(event lucifer3.Event) bool {
switch event.(type) {
case events.DevicesReady:
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:2d0c", Alias: "lucifer:name:Hex 5"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:542f", Alias: "lucifer:name:Hex 4"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:e760", Alias: "lucifer:name:Hex 3"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:207a", Alias: "lucifer:name:Hex 2"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:df9a", Alias: "lucifer:name:Hex 1"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:cdd5", Alias: "lucifer:name:Hex 6"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:4597", Alias: "lucifer:name:Hex 7"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:82cb", Alias: "lucifer:name:Hex 8"})
bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:09fd", Alias: "lucifer:name:Hex 9"})
bus.RunCommand(commands.Assign{
Match: "nanoleaf:10.80.1.14:*",
Effect: effects.Gradient{
States: []device.State{
{Power: p(true), Intensity: p(0.3), Color: p(color.MustParse("xy:0.22,0.18"))},
{Power: p(true), Intensity: p(0.5), Color: p(color.MustParse("xy:0.22,0.18"))},
},
Interpolate: true,
AnimationMS: 1000,
Reverse: false,
},
})
for i, id := range []string{"40e5", "dead", "beef", "cafe", "1337"} {
bus.RunEvent(events.HardwareState{
ID: "nanoleaf:10.80.1.11:" + id,
InternalName: fmt.Sprintf("Hexagon %d", 6+i),
SupportFlags: device.SFlagPower | device.SFlagColor | device.SFlagIntensity,
ColorFlags: device.CFlagRGB,
State: device.State{},
})
}
return false
}
bus.RunEvent(events.ExternalEvent{
Kind: "weather",
Values: map[string]string{
"location": "Brekstad",
"temperature_celsius": "21.00",
"precipitation_mm": "3.21",
},
return true
}) })
c1 := gentools.Ptr(color.MustParse("rgb:#ff0000"))
c2 := gentools.Ptr(color.MustParse("rgb:#00ff00"))
bus.RunCommand(commands.Assign{
Match: "**:Hexagon *",
Effect: effects.Gradient{
States: []device.State{
{
Power: gentools.Ptr(true),
Color: c1,
Intensity: gentools.Ptr(1.0),
},
{
Power: gentools.Ptr(true),
Color: c2,
Intensity: gentools.Ptr(0.7),
},
},
AnimationMS: 1000,
Interpolate: true,
},
bus.RunCommand(commands.ConnectDevice{
ID: "nanoleaf:10.80.1.14",
APIKey: "",
}) })
log.Println("Search \"**:Hexagon {1,5,6}\"")
for _, dev := range resolver.Resolve("lucifer:name:Hexagon {1,5,6}") {
log.Println("- ID:", dev.ID)
log.Println(" Aliases:", dev.Aliases)
}
time.Sleep(time.Hour)
}
time.Sleep(time.Second * 15)
func p[T any](v T) *T {
return &v
} }

60
commands/device.go

@ -0,0 +1,60 @@
package commands
import (
"fmt"
"git.aiterp.net/lucifer3/server/internal/formattools"
"strings"
)
type PairDevice struct {
ID string `json:"id"`
}
func (c PairDevice) CommandDescription() string {
return fmt.Sprintf("PairDevice(%s)", c.ID)
}
func (c PairDevice) Matches(driver string) (sub string, ok bool) {
split := strings.SplitN(c.ID, ":", 2)
if split[0] != driver {
return "", false
}
return split[1], true
}
type ConnectDevice struct {
ID string `json:"id"`
APIKey string `json:"apiKey"`
}
func (c ConnectDevice) Matches(driver string) (sub string, ok bool) {
split := strings.SplitN(c.ID, ":", 2)
if split[0] != driver {
return "", false
}
return split[1], true
}
func (c ConnectDevice) CommandDescription() string {
return fmt.Sprintf("ConnectDevice(%s, %s)", c.ID, formattools.Asterisks(c.APIKey))
}
type SearchDevices struct {
ID string `json:"id"`
Hint string `json:"hint"`
}
func (c SearchDevices) Matches(driver string) (string, bool) {
split := strings.SplitN(c.ID, ":", 2)
if split[0] != driver {
return "", false
}
return split[1], true
}
func (c SearchDevices) CommandDescription() string {
return fmt.Sprintf("SearchDevices(%s, %#+v)", c.ID, c.Hint)
}

9
commands/state.go

@ -3,6 +3,7 @@ package commands
import ( import (
"fmt" "fmt"
"git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/device"
"strings"
) )
type SetState struct { type SetState struct {
@ -10,6 +11,14 @@ type SetState struct {
State device.State State device.State
} }
func (c SetState) Matches(driver string) (sub string, ok bool) {
if strings.HasPrefix(c.ID, driver) && strings.HasPrefix(c.ID[len(driver):], ":") {
return strings.SplitN(c.ID[len(driver)+1:], ":", 2)[0], true
} else {
return "", false
}
}
func (c SetState) CommandDescription() string { func (c SetState) CommandDescription() string {
return fmt.Sprintf("SetState(%s, %s)", c.ID, c.State) return fmt.Sprintf("SetState(%s, %s)", c.ID, c.State)
} }

12
device/state.go

@ -17,16 +17,20 @@ type State struct {
func (s State) String() string { func (s State) String() string {
parts := make([]string, 0, 4) parts := make([]string, 0, 4)
if s.Power != nil { if s.Power != nil {
parts = append(parts, fmt.Sprintf("power:%t", *s.Power))
if *s.Power {
parts = append(parts, "on")
} else {
parts = append(parts, "off")
}
} }
if s.Temperature != nil { if s.Temperature != nil {
parts = append(parts, fmt.Sprintf("temperature:%f", *s.Temperature))
parts = append(parts, fmt.Sprintf("%f°C", *s.Temperature))
} }
if s.Intensity != nil { if s.Intensity != nil {
parts = append(parts, fmt.Sprintf("intensity:%.2f", *s.Intensity))
parts = append(parts, fmt.Sprintf("%.1f%%", *s.Intensity*100))
} }
if s.Color != nil { if s.Color != nil {
parts = append(parts, fmt.Sprintf("color:%s", s.Color.String()))
parts = append(parts, s.Color.String())
} }
return fmt.Sprint("(", strings.Join(parts, ", "), ")") return fmt.Sprint("(", strings.Join(parts, ", "), ")")

6
errors.go

@ -0,0 +1,6 @@
package lucifer3
import "errors"
var ErrUnexpectedResponse = errors.New("unexpected response from device")
var ErrIncorrectToken = errors.New("api token not accepted by device")

38
events/button.go

@ -0,0 +1,38 @@
package events
import "fmt"
type ButtonPressed struct {
ID string `json:"id"`
SwipedID string `json:"swipedId,omitempty"`
Name string `json:"name"`
}
func (e ButtonPressed) EventDescription() string {
if e.SwipedID != "" {
return fmt.Sprintf("ButtonPressed(name:%s, swipe:%s->%s)", e.Name, e.SwipedID, e.ID)
} else {
return fmt.Sprintf("ButtonPressed(name:%s, id:%s)", e.Name, e.ID)
}
}
func (e ButtonPressed) TriggerKind() string {
return "ButtonPressed:" + e.Name
}
func (e ButtonPressed) TriggerValue(key string) (string, bool) {
switch key {
case "name":
return e.Name, true
case "id":
return e.ID, true
case "swipedFromId", "swipedId":
if e.SwipedID != "" {
return e.SwipedID, true
} else {
return "", false
}
default:
return "", false
}
}

20
events/connection.go

@ -1,20 +0,0 @@
package events
import "fmt"
type Connected struct {
Prefix string `json:"prefix"`
}
func (e Connected) EventDescription() string {
return fmt.Sprintf("Connect(prefix:%s)", e.Prefix)
}
type Disconnected struct {
Prefix string `json:"prefix"`
Reason string `json:"reason"`
}
func (e Disconnected) EventDescription() string {
return fmt.Sprintf("Disconnected(prefix:%s, reason:%s)", e.Prefix, e.Reason)
}

82
events/device.go

@ -5,6 +5,23 @@ import (
"git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/device"
) )
type DeviceConnected struct {
Prefix string `json:"prefix"`
}
func (e DeviceConnected) EventDescription() string {
return fmt.Sprintf("DeviceConnected(prefix:%s)", e.Prefix)
}
type DeviceDisconnected struct {
Prefix string `json:"prefix"`
Reason string `json:"reason"`
}
func (e DeviceDisconnected) EventDescription() string {
return fmt.Sprintf("DeviceDisconnected(prefix:%s, reason:%s)", e.Prefix, e.Reason)
}
type HardwareState struct { type HardwareState struct {
ID string `json:"internalId"` ID string `json:"internalId"`
InternalName string `json:"internalName"` InternalName string `json:"internalName"`
@ -14,16 +31,69 @@ type HardwareState struct {
State device.State `json:"state"` State device.State `json:"state"`
} }
func (d HardwareState) EventDescription() string {
func (e HardwareState) EventDescription() string {
return fmt.Sprintf("HardwareState(id:%s, iname:%#+v, sflags:%s, cflags:%s, buttons:%v, state:%s)", return fmt.Sprintf("HardwareState(id:%s, iname:%#+v, sflags:%s, cflags:%s, buttons:%v, state:%s)",
d.ID, d.InternalName, d.SupportFlags, d.ColorFlags, d.Buttons, d.State,
e.ID, e.InternalName, e.SupportFlags, e.ColorFlags, e.Buttons, e.State,
) )
} }
type Unreachable struct {
DeviceID string `json:"deviceId"`
type HardwareMetadata struct {
ID string `json:"id"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
O int `json:"o,omitempty"`
ShapeType string `json:"shapeType,omitempty"`
Icon string `json:"icon,omitempty"`
SerialNumber string `json:"serialNumber,omitempty"`
FirmwareVersion string `json:"firmwareVersion,omitempty"`
}
func (e HardwareMetadata) EventDescription() string {
return fmt.Sprintf("HardwareMetadata(id:%s, icon:%s, ...)", e.ID, e.Icon)
}
// DevicesReady is triggered to indicate that all hardware states have been pushed to the bus ahead of this event.
type DevicesReady struct {
ID string `json:"id"`
}
func (d DevicesReady) EventDescription() string {
return fmt.Sprintf("DevicesReady(id:%s)", d.ID)
}
type DevicesUnreachable struct {
ID string `json:"id"`
}
func (d DevicesUnreachable) EventDescription() string {
return fmt.Sprintf("DevicesUnreachable(id:%s)", d.ID)
}
type DeviceFailed struct {
ID string `json:"id"`
Error string `json:"error"`
}
func (e DeviceFailed) EventDescription() string {
return fmt.Sprintf("DeviceFailed(id:%s, err:%s)", e.ID, e.Error)
}
type DeviceAvailable struct {
ID string
Name string
}
func (e DeviceAvailable) EventDescription() string {
return fmt.Sprintf("DeviceAvailable(id:%s, name:%s)")
}
type DeviceAccepted struct {
ID string `json:"id"`
APIKey string `json:"apiKey"`
Extras map[string]string `json:"extras"`
} }
func (d Unreachable) EventDescription() string {
return fmt.Sprintf("Unreachable(id:%s)", d.DeviceID)
func (e DeviceAccepted) EventDescription() string {
// TODO: Use formattools.Asterisks
return fmt.Sprintf("DeviceAccepted(id:%s, apiKey:%s)", e.ID, e.APIKey)
} }

8
internal/color/rgb.go

@ -35,3 +35,11 @@ func (rgb RGB) ToXY() XY {
Y: y / (x + y + z), Y: y / (x + y + z),
} }
} }
func (rgb RGB) Normalize() (RGB, float64) {
hue, sat, value := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv()
hs := HueSat{Hue: hue, Sat: sat}
newRGB := hs.ToRGB()
return newRGB, value
}

11
internal/formattools/asterisks.go

@ -0,0 +1,11 @@
package formattools
const firmament = "****************..."
func Asterisks(s string) string {
if len(s) < 16 {
return firmament[:len(s)]
} else {
return firmament
}
}

462
services/nanoleaf/client.go

@ -0,0 +1,462 @@
package nanoleaf
import (
"bytes"
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
lucifer3 "git.aiterp.net/lucifer3/server"
"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"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type bridge struct {
mu sync.Mutex
host string
apiKey string
panels []*panel
panelIDMap map[uint16]string
}
func (b *bridge) HardwareEvents() []lucifer3.Event {
results := make([]lucifer3.Event, 0, len(b.panels)*2)
for i, panel := range b.panels {
// Find normalized RGB and intensity
rgb := color.RGB{
Red: float64(panel.ColorRGBA[0]) / 255.0,
Green: float64(panel.ColorRGBA[1]) / 255.0,
Blue: float64(panel.ColorRGBA[2]) / 255.0,
}
normalized, intensity := rgb.Normalize()
col := color.Color{RGB: &normalized}
shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType]
if !shapeTypeOK {
shapeType = "Unknown"
}
shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType]
if !shapeIconOK {
shapeIcon = "lightbulb"
}
state := device.State{}
if panel.ColorRGBA != [4]byte{0, 0, 0, 0} {
state = device.State{
Power: gentools.Ptr(panel.ColorRGBA[3] == 0),
Color: &col,
Intensity: &intensity,
}
}
results = append(results, events.HardwareState{
ID: panel.FullID,
InternalName: fmt.Sprintf("%s %d (%s)", shapeType, i+1, strings.SplitN(panel.FullID, ":", 3)[2]),
SupportFlags: device.SFlagPower | device.SFlagIntensity | device.SFlagColor,
ColorFlags: device.CFlagRGB,
Buttons: []string{"Touch"},
State: state,
}, events.HardwareMetadata{
ID: panel.FullID,
X: panel.X,
Y: panel.Y,
ShapeType: shapeType,
Icon: shapeIcon,
})
}
return append(results, events.DevicesReady{
ID: fmt.Sprintf("nanoleaf:%s", b.host),
})
}
func (b *bridge) Refresh(ctx context.Context) (bool, error) {
overview, err := b.Overview(ctx)
if err != nil {
return false, err
}
changed := false
b.mu.Lock()
PanelLoop:
for _, panelInfo := range overview.PanelLayout.Data.PositionData {
if shapeTypeMap[panelInfo.ShapeType] == "Shapes Controller" {
continue
}
for _, existingPanel := range b.panels {
if existingPanel.ID == panelInfo.PanelID {
existingPanel.O = panelInfo.O
existingPanel.X = panelInfo.X
existingPanel.Y = panelInfo.Y
existingPanel.ShapeType = panelInfo.ShapeType
continue PanelLoop
}
}
changed = true
idBytes := [2]byte{0, 0}
binary.BigEndian.PutUint16(idBytes[:], panelInfo.PanelID)
hexID := hex.EncodeToString(idBytes[:])
fullID := fmt.Sprintf("nanoleaf:%s:%s", b.host, hexID)
b.panelIDMap[panelInfo.PanelID] = fullID
b.panels = append(b.panels, &panel{
ID: panelInfo.PanelID,
FullID: fullID,
ColorRGBA: [4]byte{0, 0, 0, 0},
TransitionAt: time.Time{},
O: panelInfo.O,
X: panelInfo.X,
Y: panelInfo.Y,
ShapeType: panelInfo.ShapeType,
Stale: true,
SlowUpdates: 5,
})
}
b.mu.Unlock()
return changed, nil
}
func (b *bridge) Overview(ctx context.Context) (*Overview, error) {
req, err := http.NewRequest("GET", b.URL(), nil)
if err != nil {
return nil, err
}
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
defer res.Body.Close()
switch res.StatusCode {
case 400, 403, 500, 503:
return nil, lucifer3.ErrUnexpectedResponse
case 401:
return nil, lucifer3.ErrIncorrectToken
}
overview := Overview{}
err = json.NewDecoder(res.Body).Decode(&overview)
if err != nil {
return nil, err
}
return &overview, nil
}
func (b *bridge) URL(resource ...string) string {
return fmt.Sprintf("http://%s:16021/api/v1/%s/%s", b.host, b.apiKey, strings.Join(resource, "/"))
}
func (b *bridge) Update(id string, change device.State) {
b.mu.Lock()
defer b.mu.Unlock()
transitionTime := time.Now().Add(time.Millisecond * 255)
for _, panel := range b.panels {
if panel.FullID == id {
if change.Intensity != nil {
panel.Intensity = *change.Intensity
}
if change.Color != nil {
rgbColor, ok := change.Color.ToRGB()
if !ok {
newColor := [4]byte{255, 255, 255, 255}
if newColor != panel.ColorRGBA {
panel.update(newColor, transitionTime)
}
continue
}
rgb := rgbColor.RGB.AtIntensity(panel.Intensity)
red := byte(rgb.Red * 255.0001)
green := byte(rgb.Green * 255.0001)
blue := byte(rgb.Blue * 255.0001)
newColor := [4]byte{red, green, blue, 255}
if newColor != panel.ColorRGBA {
panel.update(newColor, time.Now().Add(time.Millisecond*220))
}
}
if change.Power != nil {
panel.On = *change.Power
}
break
}
}
}
func (b *bridge) Run(ctx context.Context, bus *lucifer3.EventBus) error {
err := b.updateEffect(ctx)
if err != nil {
return err
}
conn, err := net.DialUDP("udp", nil, &net.UDPAddr{
IP: net.ParseIP(b.host),
Port: 60222,
})
if err != nil {
return err
}
defer conn.Close()
// Notify connections and disconnections
bus.RunEvent(events.DeviceConnected{
Prefix: fmt.Sprintf("nanoleaf:%s", b.host),
})
_, err = b.Refresh(ctx)
if err != nil {
return err
}
// Start touch listener. This one should go down together with this one, though, so it needs a new context.
ctx2, cancel := context.WithCancel(ctx)
defer cancel()
go b.runTouchListener(ctx2, bus)
go func() {
ticker := time.NewTicker(time.Second * 5)
hadErrors := false
for {
select {
case <-ticker.C:
case <-ctx2.Done():
return
}
reqTimeout, cancel := context.WithTimeout(ctx2, time.Second*4)
err := b.updateEffect(reqTimeout)
cancel()
if err != nil {
log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.")
hadErrors = true
} else if hadErrors {
b.mu.Lock()
for _, panel := range b.panels {
panel.Stale = true
panel.SlowUpdates = 3
panel.TicksUntilSlowUpdate = 10
}
b.mu.Unlock()
hadErrors = false
}
}
}()
ticker := time.NewTicker(time.Millisecond * 100)
defer ticker.Stop()
strikes := 0
for _, event := range b.HardwareEvents() {
bus.RunEvent(event)
}
for range ticker.C {
if ctx.Err() != nil {
break
}
panelUpdate := make(PanelUpdate, 2)
b.mu.Lock()
for _, panel := range b.panels {
if !panel.Stale {
if panel.SlowUpdates > 0 {
panel.TicksUntilSlowUpdate -= 1
if panel.TicksUntilSlowUpdate > 0 {
continue
}
panel.TicksUntilSlowUpdate = 10
panel.SlowUpdates -= 1
} else {
continue
}
} else {
panel.Stale = false
}
panelUpdate.Add(panel.message())
if panelUpdate.Len() > 150 {
break
}
}
b.mu.Unlock()
if panelUpdate.Len() == 0 {
continue
}
_, err := conn.Write(panelUpdate)
if err != nil {
strikes++
if strikes >= 3 {
return err
}
} else {
strikes = 0
}
}
return nil
}
func (b *bridge) updateEffect(ctx context.Context) error {
overview, err := b.Overview(ctx)
if err != nil {
return err
}
if overview.Effects.Select == "*Dynamic*" && overview.State.ColorMode == "effect" {
return nil
}
req, err := http.NewRequest("PUT", b.URL("effects"), bytes.NewReader(httpMessage))
if err != nil {
return err
}
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode != 204 {
return lucifer3.ErrUnexpectedResponse
}
b.mu.Lock()
for _, panel := range b.panels {
panel.Stale = true
}
b.mu.Unlock()
return nil
}
func (b *bridge) runTouchListener(ctx context.Context, bus *lucifer3.EventBus) {
cooldownID := ""
cooldownUntil := time.Now()
message := make(PanelEventMessage, 65536)
reqCloser := io.Closer(nil)
for {
// Set up touch event receiver
touchListener, err := net.ListenUDP("udp4", &net.UDPAddr{
Port: 0,
IP: net.IPv4(0, 0, 0, 0),
})
if err != nil {
log.Println("Socket error:", err)
goto teardownAndRetry
}
{
// Create touch event sender on the remote end.
req, err := http.NewRequest("GET", fmt.Sprintf("http://%s:16021/api/v1/%s/events?id=4", b.host, b.apiKey), nil)
if err != nil {
log.Println("HTTP error:", err)
goto teardownAndRetry
}
req.Header["TouchEventsPort"] = []string{strconv.Itoa(touchListener.LocalAddr().(*net.UDPAddr).Port)}
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
log.Println("HTTP error:", err)
goto teardownAndRetry
}
// Discard all data coming over http.
reqCloser = res.Body
go io.Copy(ioutil.Discard, res.Body)
for {
if ctx.Err() != nil {
goto teardownAndRetry
}
// Check in with the context every so often
_ = touchListener.SetReadDeadline(time.Now().Add(time.Second))
n, _, err := touchListener.ReadFromUDP(message)
if err != nil {
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
} else if ctx.Err() == nil {
log.Println("UDP error:", err)
}
goto teardownAndRetry
}
if !message[:n].ValidateLength() {
log.Println("Bad message length field")
continue
}
for i := 0; i < message.Count(); i += 1 {
b.mu.Lock()
id, hasID := b.panelIDMap[message.PanelID(i)]
swipedFromID := b.panelIDMap[message.SwipedFromPanelID(i)]
b.mu.Unlock()
if !hasID || (id == cooldownID && time.Now().Before(cooldownUntil)) {
continue
}
bus.RunEvent(events.ButtonPressed{
ID: id,
SwipedID: swipedFromID,
Name: "Touch",
})
cooldownID = id
cooldownUntil = time.Now().Add(time.Second)
}
}
}
teardownAndRetry:
if touchListener != nil {
_ = touchListener.Close()
}
if reqCloser != nil {
_ = reqCloser.Close()
reqCloser = nil
}
select {
case <-time.After(time.Second * 3):
case <-ctx.Done():
return
}
}
}

231
services/nanoleaf/data.go

@ -0,0 +1,231 @@
package nanoleaf
import (
"encoding/binary"
"time"
)
type EffectInfo struct {
EffectsList []string `json:"effectsList"`
Select string `json:"select"`
}
type PanelLayout struct {
GlobalOrientation GlobalOrientation `json:"globalOrientation"`
Data PanelLayoutData `json:"layout"`
}
type GlobalOrientation struct {
Value int `json:"value"`
Max int `json:"max"`
Min int `json:"min"`
}
type PanelLayoutData struct {
NumPanels int `json:"numPanels"`
SideLength int `json:"sideLength"`
PositionData []PositionData `json:"positionData"`
}
type PositionData struct {
PanelID uint16 `json:"panelId"`
X int `json:"x"`
Y int `json:"y"`
O int `json:"o"`
ShapeType int `json:"shapeType"`
}
type StateBool struct {
Value bool `json:"value"`
}
type StateInt struct {
Value int `json:"value"`
Max int `json:"max"`
Min int `json:"min"`
}
type State struct {
Brightness StateInt `json:"brightness"`
ColorMode string `json:"colorMode"`
Ct StateInt `json:"ct"`
Hue StateInt `json:"hue"`
On StateBool `json:"on"`
Sat StateInt `json:"sat"`
}
type Overview struct {
Name string `json:"name"`
SerialNumber string `json:"serialNo"`
Manufacturer string `json:"manufacturer"`
FirmwareVersion string `json:"firmwareVersion"`
HardwareVersion string `json:"hardwareVersion"`
Model string `json:"model"`
Effects EffectInfo `json:"effects"`
PanelLayout PanelLayout `json:"panelLayout"`
State State `json:"state"`
}
type DeviceInfo struct {
SerialNumber string `json:"serialNumber"`
HardwareVersion string `json:"hardwareVersion"`
FirmwareVersion string `json:"firmwareVersion"`
BootloaderVersion string `json:"bootloaderVersion"`
ModelNumber string `json:"modelNumber"`
}
type TokenResponse struct {
Token string `json:"auth_token"`
}
type PanelUpdate []byte
func (u *PanelUpdate) Add(message [8]byte) {
if len(*u) < 2 {
*u = make([]byte, 2, 10)
}
binary.BigEndian.PutUint16(*u, binary.BigEndian.Uint16(*u)+1)
*u = append(*u, message[:]...)
}
func (u *PanelUpdate) Len() int {
if len(*u) < 2 {
return 0
}
return int(binary.BigEndian.Uint16(*u))
}
type PanelEventMessage []byte
func (remote PanelEventMessage) Count() int {
return int(binary.BigEndian.Uint16(remote[0:]))
}
func (remote PanelEventMessage) ValidateLength() bool {
return len(remote) >= (2 + remote.Count()*5)
}
func (remote PanelEventMessage) PanelID(idx int) uint16 {
return binary.BigEndian.Uint16(remote[2+(idx*5):])
}
func (remote PanelEventMessage) TouchType(idx int) int {
value := int(remote[2+(idx*5)])
return (value & 0b11100000) >> 5
}
func (remote PanelEventMessage) TouchStrength(idx int) int {
value := int(remote[2+(idx*5)])
return (value & 0b00011110) >> 1
}
func (remote PanelEventMessage) SwipedFromPanelID(idx int) uint16 {
return binary.BigEndian.Uint16(remote[2+(idx*5)+3:])
}
var shapeTypeMap = map[int]string{
0: "Legacy Triangle",
1: "Rhythm",
2: "Square",
3: "Control Square Master",
4: "Control Square Passive",
7: "Hexagon",
8: "Triangle",
9: "Mini Triangle",
12: "Shapes Controller",
}
var shapeIconMap = map[int]string{
0: "triangle",
1: "rhythm",
2: "Square",
3: "square",
4: "square",
7: "hexagon",
8: "triangle",
9: "triangle-small",
12: "hexagon",
}
var shapeWidthMap = map[int]int{
0: 150,
1: -1,
2: 100,
3: 100,
4: 100,
7: 67,
8: 134,
9: 67,
12: -1,
}
var httpMessage = []byte(`{ "write": { "command": "display", "animType": "extControl", "extControlVersion": "v2" }}`)
type panel struct {
ID uint16
FullID string
On bool
Intensity float64
ColorRGBA [4]byte
TransitionAt time.Time
O int
X int
Y int
ShapeType int
Stale bool
SlowUpdates int
TicksUntilSlowUpdate int
}
func (p *panel) message() (message [8]byte) {
transitionTime := p.TransitionAt.Sub(time.Now()).Round(time.Millisecond * 100)
if transitionTime > maxTransitionTime {
transitionTime = maxTransitionTime
} else if transitionTime < 0 {
transitionTime = 0
}
binary.BigEndian.PutUint16(message[0:], p.ID)
copy(message[2:], p.ColorRGBA[:])
binary.BigEndian.PutUint16(message[6:], uint16(transitionTime/(time.Millisecond*100)))
return
}
func (p *panel) update(colorRGBA [4]byte, transitionAt time.Time) {
if p.ColorRGBA != colorRGBA {
p.ColorRGBA = colorRGBA
p.Stale = true
p.SlowUpdates = 3
p.TicksUntilSlowUpdate = 10
}
p.TransitionAt = transitionAt
}
type panelUpdate []byte
func (u *panelUpdate) Add(message [8]byte) {
if len(*u) < 2 {
*u = make([]byte, 2, 10)
}
binary.BigEndian.PutUint16(*u, binary.BigEndian.Uint16(*u)+1)
*u = append(*u, message[:]...)
}
func (u *panelUpdate) Len() int {
if len(*u) < 2 {
return 0
}
return int(binary.BigEndian.Uint16(*u))
}
const maxTransitionTime = time.Minute * 109

55
services/nanoleaf/discover.go

@ -0,0 +1,55 @@
package nanoleaf
import (
"context"
"encoding/json"
"errors"
"fmt"
lucifer3 "git.aiterp.net/lucifer3/server"
"net/http"
)
func Discover(ctx context.Context, ip string, register bool) (string, *DeviceInfo, error) {
res, err := http.Get(fmt.Sprintf("http://%s/device_info", ip))
if err != nil {
return "", nil, err
}
defer res.Body.Close()
deviceInfo := DeviceInfo{}
err = json.NewDecoder(res.Body).Decode(&deviceInfo)
if err != nil {
return "", nil, err
}
if deviceInfo.ModelNumber == "" {
return "", nil, lucifer3.ErrUnexpectedResponse
}
token := ""
if register {
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s:16021/api/v1/new/", ip), nil)
if err != nil {
return "", nil, err
}
res, err := http.DefaultClient.Do(req.WithContext(ctx))
if err != nil {
return "", nil, err
}
defer res.Body.Close()
if res.StatusCode != 200 {
return "", nil, errors.New(res.Status)
}
tokenResponse := TokenResponse{}
err = json.NewDecoder(res.Body).Decode(&tokenResponse)
if err != nil {
return "", nil, err
}
token = tokenResponse.Token
}
return token, &deviceInfo, nil
}

128
services/nanoleaf/service.go

@ -0,0 +1,128 @@
package nanoleaf
import (
"context"
"fmt"
lucifer3 "git.aiterp.net/lucifer3/server"
"git.aiterp.net/lucifer3/server/commands"
"git.aiterp.net/lucifer3/server/events"
"strings"
"time"
)
func NewService() lucifer3.ActiveService {
return &service{
bridges: make(map[string]*bridge),
cancels: make(map[string]context.CancelFunc),
}
}
type service struct {
bridges map[string]*bridge
cancels map[string]context.CancelFunc
}
func (s *service) Active() bool {
return true
}
func (s *service) HandleEvent(*lucifer3.EventBus, lucifer3.Event) {}
func (s *service) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command) {
switch command := command.(type) {
case commands.SetState:
if sub, ok := command.Matches("nanoleaf"); ok && s.bridges[sub] != nil {
s.bridges[sub].Update(command.ID, command.State)
}
case commands.SearchDevices:
if sub, ok := command.Matches("nanoleaf"); ok {
if s.bridges[sub] != nil {
go func(bridge *bridge) {
changed, err := bridge.Refresh(context.Background())
if err != nil {
bus.RunEvent(events.DeviceFailed{
ID: command.ID,
Error: fmt.Sprintf("Search failed: %s", err),
})
return
}
if changed {
for _, event := range bridge.HardwareEvents() {
bus.RunEvent(event)
}
}
}(s.bridges[sub])
}
}
case commands.PairDevice:
if address, ok := command.Matches("nanoleaf"); ok {
go func() {
timeout, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
apiKey, info, err := Discover(timeout, address, true)
if err != nil {
bus.RunEvent(events.DeviceFailed{
ID: command.ID,
Error: fmt.Sprintf("Pairing failed: %s", err),
})
return
}
bus.RunEvent(events.DeviceAccepted{
ID: command.ID,
APIKey: apiKey,
Extras: nil,
})
bus.RunEvent(events.HardwareMetadata{
ID: command.ID,
Icon: "bridge",
SerialNumber: info.SerialNumber,
FirmwareVersion: strings.Join([]string{
info.FirmwareVersion, info.BootloaderVersion, info.HardwareVersion,
}, "; "),
})
}()
}
case commands.ConnectDevice:
if sub, ok := command.Matches("nanoleaf"); ok {
if s.bridges[sub] != nil {
s.cancels[sub]()
}
ctx, cancel := context.WithCancel(context.Background())
s.bridges[sub] = &bridge{
host: sub,
apiKey: command.APIKey,
panels: make([]*panel, 0, 64),
panelIDMap: make(map[uint16]string),
}
s.cancels[sub] = cancel
go func() {
for ctx.Err() == nil {
ctx2, cancel2 := context.WithCancel(ctx)
err := s.bridges[sub].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
}
}
}()
}
}
}
Loading…
Cancel
Save