Gisle Aune
2 years ago
15 changed files with 1129 additions and 98 deletions
-
2bus.go
-
103cmd/bustest/main.go
-
60commands/device.go
-
9commands/state.go
-
12device/state.go
-
6errors.go
-
38events/button.go
-
20events/connection.go
-
82events/device.go
-
8internal/color/rgb.go
-
11internal/formattools/asterisks.go
-
462services/nanoleaf/client.go
-
231services/nanoleaf/data.go
-
55services/nanoleaf/discover.go
-
128services/nanoleaf/service.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) |
||||
|
} |
@ -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") |
@ -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 |
||||
|
} |
||||
|
} |
@ -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) |
|
||||
} |
|
@ -0,0 +1,11 @@ |
|||||
|
package formattools |
||||
|
|
||||
|
const firmament = "****************..." |
||||
|
|
||||
|
func Asterisks(s string) string { |
||||
|
if len(s) < 16 { |
||||
|
return firmament[:len(s)] |
||||
|
} else { |
||||
|
return firmament |
||||
|
} |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
} |
@ -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 |
@ -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 |
||||
|
} |
@ -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 |
||||
|
} |
||||
|
} |
||||
|
}() |
||||
|
} |
||||
|
} |
||||
|
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue