Gisle Aune
4 years ago
12 changed files with 982 additions and 47 deletions
-
157cmd/bridgetest/main.go
-
4go.mod
-
25go.sum
-
411internal/drivers/nanoleaf/bridge.go
-
150internal/drivers/nanoleaf/data.go
-
162internal/drivers/nanoleaf/driver.go
-
68internal/drivers/nanoleaf/panel.go
-
34models/colorvalue.go
-
6models/device.go
-
3models/driver.go
-
5models/errors.go
-
4models/eventhandler.go
@ -0,0 +1,157 @@ |
|||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"bufio" |
||||
|
"context" |
||||
|
"flag" |
||||
|
"fmt" |
||||
|
"git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf" |
||||
|
"git.aiterp.net/lucifer/new-server/models" |
||||
|
"log" |
||||
|
"os" |
||||
|
"sort" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"time" |
||||
|
) |
||||
|
|
||||
|
var flagDriver = flag.String("driver", "Nanoleaf", "The bridge driver to use") |
||||
|
var flagAddress = flag.String("address", "127.0.0.1", "The bridge's address") |
||||
|
var flagToken = flag.String("token", "", "The bridge's access token / api key / login") |
||||
|
var flagPair = flag.Bool("pair", false, "Try to pair with the bridge.") |
||||
|
var flagSearch = flag.Bool("search", false, "Search for devices first.") |
||||
|
var flagSearchTimeout = flag.Duration("search-timeout", time.Second*3, "Timeout for device search.") |
||||
|
|
||||
|
func main() { |
||||
|
flag.Parse() |
||||
|
|
||||
|
// TODO: Select driver
|
||||
|
driver := nanoleaf.Driver{} |
||||
|
|
||||
|
// Find bridge
|
||||
|
bridges, err := driver.SearchBridge(context.Background(), *flagAddress, !*flagPair) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Failed to search bridge:", err) |
||||
|
} |
||||
|
if len(bridges) == 0 { |
||||
|
log.Fatalln("No bridges found") |
||||
|
} |
||||
|
bridge := bridges[0] |
||||
|
if !*flagPair { |
||||
|
bridge.Token = *flagToken |
||||
|
} else { |
||||
|
log.Println("New token:", bridge.Token) |
||||
|
} |
||||
|
|
||||
|
// List devices
|
||||
|
var devices []models.Device |
||||
|
if *flagSearch { |
||||
|
devices, err = driver.SearchDevices(context.Background(), bridge, *flagSearchTimeout) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Failed to search devices:", err) |
||||
|
} |
||||
|
} else { |
||||
|
devices, err = driver.ListDevices(context.Background(), bridge) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Failed to list devices:", err) |
||||
|
} |
||||
|
} |
||||
|
for i := range devices { |
||||
|
devices[i].ID = i + 1 |
||||
|
} |
||||
|
|
||||
|
_ = driver.Publish(context.Background(), bridge, devices) |
||||
|
|
||||
|
ch := make(chan models.Event) |
||||
|
go func() { |
||||
|
err := driver.Run(context.Background(), bridge, ch) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Run bridge stopped:", err) |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
go func() { |
||||
|
reader := bufio.NewReader(os.Stdin) |
||||
|
|
||||
|
_, _ = fmt.Fprintln(os.Stderr, "Format: [id1,id2,...] [on|off] [color] [intensity]") |
||||
|
for _, device := range devices { |
||||
|
_, _ = fmt.Fprintf(os.Stderr, "Device: %d - %s %+v\n", device.ID, device.InternalID, device.Capabilities) |
||||
|
} |
||||
|
_, _ = fmt.Fprintln(os.Stderr, "Format: [id1,id2,...] [on|off] [color]") |
||||
|
|
||||
|
for { |
||||
|
text, _ := reader.ReadString('\n') |
||||
|
text = strings.Trim(text, "\t \r\n") |
||||
|
|
||||
|
tokens := strings.Split(text, " ") |
||||
|
if len(tokens) < 4 { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
color, err := models.ParseColorValue(tokens[2]) |
||||
|
if err != nil { |
||||
|
_, _ = fmt.Fprintln(os.Stderr, "Invalid color:", err) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
intensity, _ := strconv.ParseFloat(tokens[3], 64) |
||||
|
|
||||
|
power := strings.ToLower(tokens[1]) == "on" |
||||
|
|
||||
|
idsStr := strings.Split(tokens[0], ",") |
||||
|
ids := make([]int, 0, len(idsStr)) |
||||
|
for _, idStr := range idsStr { |
||||
|
if idStr == "*" { |
||||
|
ids = append(ids[:0], -1) |
||||
|
break |
||||
|
} |
||||
|
|
||||
|
id, err := strconv.Atoi(idStr) |
||||
|
if err != nil { |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
ids = append(ids, id) |
||||
|
} |
||||
|
|
||||
|
updatedDevices := devices[:0:0] |
||||
|
for _, device := range devices { |
||||
|
for _, id := range ids { |
||||
|
if id == -1 || id == device.ID { |
||||
|
if (color.IsKelvin() && device.HasCapability(models.DCColorKelvin)) || (color.IsHueSat() && device.HasCapability(models.DCColorHS)) { |
||||
|
device.State.Color = color |
||||
|
if device.HasCapability(models.DCPower) { |
||||
|
device.State.Power = power |
||||
|
} |
||||
|
if device.HasCapability(models.DCIntensity) { |
||||
|
device.State.Intensity = intensity |
||||
|
} |
||||
|
updatedDevices = append(updatedDevices, device) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if len(updatedDevices) > 0 { |
||||
|
err := driver.Publish(context.Background(), bridge, updatedDevices) |
||||
|
if err != nil { |
||||
|
log.Fatalln("Publish to bridge failed:", err) |
||||
|
return |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}() |
||||
|
|
||||
|
for event := range ch { |
||||
|
_, _ = fmt.Fprintf(os.Stderr, "Event %s", event.Name) |
||||
|
keys := make([]string, 0, 8) |
||||
|
for key := range event.Payload { |
||||
|
keys = append(keys, key) |
||||
|
} |
||||
|
sort.Strings(keys) |
||||
|
for _, key := range keys { |
||||
|
_, _ = fmt.Fprintf(os.Stderr, " %s=%#+v", key, event.Payload[key]) |
||||
|
} |
||||
|
_, _ = fmt.Fprint(os.Stderr, "\n") |
||||
|
} |
||||
|
} |
@ -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) |
||||
|
} |
||||
|
} |
@ -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" }}`) |
@ -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 |
||||
|
} |
@ -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 |
Write
Preview
Loading…
Cancel
Save
Reference in new issue