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.
417 lines
9.0 KiB
417 lines
9.0 KiB
package nanoleaf
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"git.aiterp.net/lucifer/new-server/models"
|
|
"github.com/lucasb-eyer/go-colorful"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"math"
|
|
"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 {
|
|
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.LinearRgb(red, green, blue).Hsv()
|
|
|
|
results = append(results, models.Device{
|
|
ID: -1,
|
|
BridgeID: b.externalID,
|
|
InternalID: strconv.Itoa(int(panel.ID)),
|
|
Icon: "hexagon",
|
|
Name: fmt.Sprintf("Hexagon %d", i),
|
|
Capabilities: []models.DeviceCapability{
|
|
models.DCPower,
|
|
models.DCColorHS,
|
|
models.DCIntensity,
|
|
models.DCButtons,
|
|
},
|
|
ButtonNames: []string{"Touch"},
|
|
DriverProperties: map[string]string{
|
|
"x": strconv.Itoa(panel.X),
|
|
"y": strconv.Itoa(panel.Y),
|
|
"o": strconv.Itoa(panel.O),
|
|
"shapeType": shapeTypeMap[panel.ShapeType],
|
|
"shapeWidth": strconv.Itoa(shapeWidthMap[panel.ShapeType]),
|
|
},
|
|
UserProperties: nil,
|
|
State: models.DeviceState{
|
|
Power: panel.ColorRGBA[3] == 0,
|
|
Color: models.ColorValue{
|
|
Hue: math.Mod(hue, 360),
|
|
Saturation: sat,
|
|
},
|
|
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 panelInfo.PanelID == 0 {
|
|
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, models.ErrUnexpectedResponse
|
|
case 401:
|
|
return nil, models.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 {
|
|
color := colorful.Hsv(device.State.Color.Hue, device.State.Color.Saturation, device.State.Intensity)
|
|
|
|
red, green, blue := color.RGB255()
|
|
newColor := [4]byte{red, green, blue, 255}
|
|
if newColor != panel.ColorRGBA {
|
|
panel.update(newColor, time.Now().Add(time.Millisecond*120))
|
|
}
|
|
} 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.Event{Name: "BridgeConnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
|
defer func() {
|
|
ch <- models.Event{Name: "BridgeDisconnected", Payload: map[string]string{"bridgeId": strconv.Itoa(info.ID)}}
|
|
}()
|
|
|
|
// 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)
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
case <-ctx2.Done():
|
|
}
|
|
|
|
err := b.updateEffect(ctx)
|
|
if err != nil {
|
|
log.Println("Failed to update effects:", err, "This error is non-fatal, and it will be retried shortly.")
|
|
}
|
|
}
|
|
}()
|
|
|
|
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 models.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: "ButtonPressed",
|
|
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)
|
|
}
|
|
}
|