You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

455 lines
9.9 KiB

package nanoleaf
import (
"bytes"
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/internal/color"
"git.aiterp.net/lucifer/new-server/internal/lerrors"
"git.aiterp.net/lucifer/new-server/models"
"github.com/lucasb-eyer/go-colorful"
"io"
"io/ioutil"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
type bridge struct {
mu sync.Mutex
externalID int
host string
apiKey string
panels []*panel
panelIDMap map[uint16]int
}
func (b *bridge) Devices() []models.Device {
results := make([]models.Device, 0, len(b.panels))
for i, panel := range b.panels {
// Find normalized RGB and intensity
red := float64(panel.ColorRGBA[0]) / 255.0
green := float64(panel.ColorRGBA[1]) / 255.0
blue := float64(panel.ColorRGBA[2]) / 255.0
hue, sat, value := colorful.Color{R: red, G: green, B: blue}.Hsv()
rgb := colorful.Hsv(hue, sat, 1)
shapeType, shapeTypeOK := shapeTypeMap[panel.ShapeType]
if !shapeTypeOK {
shapeType = "NanoLeaf"
}
shapeIcon, shapeIconOK := shapeIconMap[panel.ShapeType]
if !shapeIconOK {
shapeIcon = "lightbulb"
}
results = append(results, models.Device{
ID: -1,
BridgeID: b.externalID,
InternalID: strconv.Itoa(int(panel.ID)),
Icon: shapeIcon,
Name: fmt.Sprintf("%s %d", shapeType, i+1),
Capabilities: []models.DeviceCapability{
models.DCPower,
models.DCColorHS,
models.DCIntensity,
models.DCButtons,
},
ButtonNames: []string{"Touch"},
DriverProperties: map[string]interface{}{
"x": panel.X,
"y": panel.Y,
"o": panel.O,
"shapeType": shapeTypeMap[panel.ShapeType],
"shapeWidth": shapeWidthMap[panel.ShapeType],
},
UserProperties: nil,
State: models.DeviceState{
Power: panel.ColorRGBA[3] == 0,
Color: color.Color{RGB: &color.RGB{
Red: rgb.R,
Green: rgb.G,
Blue: rgb.B,
}},
Intensity: value,
Temperature: 0,
},
Tags: nil,
})
}
return results
}
func (b *bridge) Refresh(ctx context.Context) error {
overview, err := b.Overview(ctx)
if err != nil {
return err
}
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
}
}
b.panels = append(b.panels, &panel{
ID: panelInfo.PanelID,
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 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, lerrors.ErrUnexpectedResponse
case 401:
return nil, lerrors.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(devices []models.Device) {
b.mu.Lock()
defer b.mu.Unlock()
for _, device := range devices {
id, err := strconv.Atoi(device.InternalID)
if err != nil {
continue
}
b.panelIDMap[uint16(id)] = device.ID
for _, panel := range b.panels {
if panel.ID == uint16(id) {
if device.State.Power {
rgbColor, ok := device.State.Color.ToRGB()
if !ok {
newColor := [4]byte{255, 255, 255, 255}
if newColor != panel.ColorRGBA {
panel.update(newColor, time.Now().Add(time.Millisecond*220))
}
continue
}
rgb := rgbColor.RGB.AtIntensity(device.State.Intensity)
red := byte(rgb.Red * 255.9)
green := byte(rgb.Green * 255.9)
blue := byte(rgb.Blue * 255.9)
newColor := [4]byte{red, green, blue, 255}
if newColor != panel.ColorRGBA {
panel.update(newColor, time.Now().Add(time.Millisecond*220))
}
} else {
panel.update([4]byte{0, 0, 0, 0}, time.Now())
}
break
}
}
}
}
func (b *bridge) Run(ctx context.Context, info models.Bridge, ch chan<- models.Event) error {
err := b.updateEffect(ctx)
if err != nil {
return err
}
conn, err := net.DialUDP("udp", nil, &net.UDPAddr{
IP: net.ParseIP(info.Address),
Port: 60222,
})
if err != nil {
return err
}
defer conn.Close()
// Notify connections and disconnections
ch <- models.BridgeConnectedEvent(info)
defer func() {
ch <- models.BridgeDisconnectedEvent(info)
}()
// 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, info.Address, info.Token, ch)
go func() {
ticker := time.NewTicker(time.Second * 5)
hadErrors := false
for {
select {
case <-ticker.C:
case <-ctx2.Done():
}
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 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 lerrors.ErrUnexpectedResponse
}
b.mu.Lock()
for _, panel := range b.panels {
panel.Stale = true
}
b.mu.Unlock()
return nil
}
func (b *bridge) runTouchListener(ctx context.Context, host, apiKey string, ch chan<- models.Event) {
cooldownID := 0
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", host, 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()
externalID, hasExternalID := b.panelIDMap[message.PanelID(i)]
swipedFromID, hasSwipedFromID := b.panelIDMap[message.SwipedFromPanelID(i)]
b.mu.Unlock()
if !hasExternalID || (externalID == cooldownID && time.Now().Before(cooldownUntil)) {
continue
}
event := models.Event{
Name: models.ENButtonPressed,
Payload: map[string]string{
"buttonIndex": "0",
"buttonName": "Touch",
"deviceId": strconv.Itoa(externalID),
},
}
if hasSwipedFromID {
event.Payload["swipedFromDeviceId"] = strconv.Itoa(swipedFromID)
}
ch <- event
cooldownID = externalID
cooldownUntil = time.Now().Add(time.Second)
}
}
}
teardownAndRetry:
if touchListener != nil {
_ = touchListener.Close()
}
if reqCloser != nil {
_ = reqCloser.Close()
reqCloser = nil
}
if ctx.Err() != nil {
break
}
time.Sleep(time.Second * 3)
}
}