Merge branch 'asmodeus' of git.aiterp.net:lucifer/new-server into asmodeus

This commit is contained in:
Stian Fredrik Aune
2021-05-23 14:00:51 +02:00
12 changed files with 982 additions and 47 deletions
+411
View File
@@ -0,0 +1,411 @@
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
}
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)
}
}
+150
View File
@@ -0,0 +1,150 @@
package nanoleaf
import "encoding/binary"
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: "Triangle",
1: "Rhythm",
2: "Square",
3: "Control Square Master",
4: "Control Square Passive",
7: "Hexagon (Shapes)",
8: "Triangle (Shapes)",
9: "Mini Triangle (Shapes)",
12: "Shapes Controller",
}
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" }}`)
+162
View File
@@ -0,0 +1,162 @@
package nanoleaf
import (
"context"
"encoding/json"
"fmt"
"git.aiterp.net/lucifer/new-server/models"
"net/http"
"sync"
"time"
)
type Driver struct {
mu sync.Mutex
bridges []*bridge
}
// SearchBridge checks the bridge at the address. If it's not a dry-run, you must hold down the power button
// before calling this function and wait for the pattern.
func (d *Driver) SearchBridge(ctx context.Context, address string, dryRun bool) ([]models.Bridge, error) {
res, err := http.Get(fmt.Sprintf("http://%s/device_info", address))
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, models.ErrUnexpectedResponse
}
token := ""
if !dryRun {
req, err := http.NewRequest("POST", fmt.Sprintf("http://%s:16021/api/v1/new/", address), 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, models.ErrBridgeSearchFailed
}
tokenResponse := TokenResponse{}
err = json.NewDecoder(res.Body).Decode(&tokenResponse)
if err != nil {
return nil, err
}
token = tokenResponse.Token
}
return []models.Bridge{{
ID: -1,
Name: fmt.Sprintf("Nanoleaf Controller (MN: %s, SN: %s, HV: %s, FV: %s, BV: %s)",
deviceInfo.ModelNumber,
deviceInfo.SerialNumber,
deviceInfo.HardwareVersion,
deviceInfo.FirmwareVersion,
deviceInfo.BootloaderVersion,
),
Driver: models.DTNanoLeaf,
Address: address,
Token: token,
}}, nil
}
func (d *Driver) SearchDevices(ctx context.Context, bridge models.Bridge, timeout time.Duration) ([]models.Device, error) {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return nil, err
}
if timeout > time.Millisecond {
timeoutCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ctx = timeoutCtx
}
err = b.Refresh(ctx)
if err != nil {
return nil, err
}
return b.Devices(), nil
}
func (d *Driver) ListDevices(ctx context.Context, bridge models.Bridge) ([]models.Device, error) {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return nil, err
}
return b.Devices(), nil
}
func (d *Driver) Run(ctx context.Context, bridge models.Bridge, ch chan<- models.Event) error {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return err
}
return b.Run(ctx, bridge, ch)
}
func (d *Driver) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error {
b, err := d.ensureBridge(ctx, bridge)
if err != nil {
return err
}
b.Update(devices)
return nil
}
func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*bridge, error) {
d.mu.Lock()
for _, bridge := range d.bridges {
if bridge.host == info.Address {
d.mu.Unlock()
return bridge, nil
}
}
d.mu.Unlock()
bridge := &bridge{
host: info.Address,
apiKey: info.Token,
externalID: info.ID,
panelIDMap: make(map[uint16]int, 9),
}
// If this fails, then the authorization failed.
err := bridge.Refresh(ctx)
if err != nil {
return nil, err
}
// To avoid a potential duplicate, try looking for it again before inserting
d.mu.Lock()
for _, bridge := range d.bridges {
if bridge.host == info.Address {
d.mu.Unlock()
return bridge, nil
}
}
d.bridges = append(d.bridges, bridge)
d.mu.Unlock()
return bridge, nil
}
+68
View File
@@ -0,0 +1,68 @@
package nanoleaf
import (
"encoding/binary"
"time"
)
type panel struct {
ID uint16
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