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.
448 lines
9.7 KiB
448 lines
9.7 KiB
package nanoleaf
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
lucifer3 "git.aiterp.net/lucifer3/server"
|
|
"git.aiterp.net/lucifer3/server/commands"
|
|
"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 results
|
|
}
|
|
|
|
func (b *bridge) Refresh(ctx context.Context) ([]string, error) {
|
|
overview, err := b.Overview(ctx)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
|
|
newIDs := make([]string, 0, 0)
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
idBytes := [2]byte{0, 0}
|
|
binary.BigEndian.PutUint16(idBytes[:], panelInfo.PanelID)
|
|
hexID := hex.EncodeToString(idBytes[:])
|
|
fullID := fmt.Sprintf("nanoleaf:%s:%s", b.host, hexID)
|
|
|
|
newIDs = append(newIDs, fullID)
|
|
|
|
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 newIDs, 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) {
|
|
transitionTime := time.Now().Add(time.Millisecond * 255)
|
|
|
|
b.mu.Lock()
|
|
for _, panel := range b.panels {
|
|
if panel.FullID == id {
|
|
panel.apply(change, transitionTime)
|
|
break
|
|
}
|
|
}
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
func (b *bridge) UpdateBatch(batch commands.SetStateBatch) {
|
|
transitionTime := time.Now().Add(time.Millisecond * 255)
|
|
|
|
b.mu.Lock()
|
|
for _, panel := range b.panels {
|
|
if change, ok := batch[panel.FullID]; ok {
|
|
panel.apply(change, transitionTime)
|
|
}
|
|
}
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
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),
|
|
})
|
|
|
|
newIDs, 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 _, id := range newIDs {
|
|
bus.RunEvent(events.DeviceReady{ID: id})
|
|
}
|
|
bus.RunEvent(events.DeviceReady{ID: fmt.Sprintf("nanoleaf:%s", b.host)})
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|