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.
 
 
 
 
 
 

462 lines
9.9 KiB

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
}
}
}