diff --git a/cmd/bridgetest/main.go b/cmd/bridgetest/main.go new file mode 100644 index 0000000..c1b2ec8 --- /dev/null +++ b/cmd/bridgetest/main.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") + } +} diff --git a/go.mod b/go.mod index 8a6b3d6..20a2922 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,13 @@ module git.aiterp.net/lucifer/new-server go 1.16 require ( - github.com/ClickHouse/clickhouse-go v1.4.5 // indirect - github.com/denisenkom/go-mssqldb v0.10.0 // indirect github.com/gin-gonic/gin v1.7.1 github.com/go-sql-driver/mysql v1.6.0 github.com/jmoiron/sqlx v1.3.4 github.com/lib/pq v1.10.2 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pressly/goose v2.7.0+incompatible - github.com/ziutek/mymysql v1.5.4 // indirect golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect ) diff --git a/go.sum b/go.sum index d4821b0..a8ed0d3 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,6 @@ -github.com/ClickHouse/clickhouse-go v1.4.5 h1:FfhyEnv6/BaWldyjgT2k4gDDmeNwJ9C4NbY/MXxJlXk= -github.com/ClickHouse/clickhouse-go v1.4.5/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= -github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4= -github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg= -github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/denisenkom/go-mssqldb v0.10.0 h1:QykgLZBorFE95+gO3u9esLd0BmbvpWp0/waNNZfHBM8= -github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.1 h1:qC89GU3p8TvKWMAVhEpmpB2CIb1hnqt2UdKZaP93mS8= @@ -20,32 +13,25 @@ github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD87 github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= -github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= -github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jmoiron/sqlx v1.3.4 h1:wv+0IJZfL5z0uZoUjlpKgHkgaFSYD+r9CfrXjEXsO7w= github.com/jmoiron/sqlx v1.3.4/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ= github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/lib/pq v1.2.0 h1:LXpIM/LZ5xGFhOpXAQUIMM1HdyqzVYM13zNdjCEEcA0= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= -github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U= github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -53,7 +39,6 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OH github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -68,11 +53,7 @@ github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs= -github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= @@ -80,8 +61,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/drivers/nanoleaf/bridge.go b/internal/drivers/nanoleaf/bridge.go new file mode 100644 index 0000000..73d2a7a --- /dev/null +++ b/internal/drivers/nanoleaf/bridge.go @@ -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) + } +} diff --git a/internal/drivers/nanoleaf/data.go b/internal/drivers/nanoleaf/data.go new file mode 100644 index 0000000..97ba26f --- /dev/null +++ b/internal/drivers/nanoleaf/data.go @@ -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" }}`) diff --git a/internal/drivers/nanoleaf/driver.go b/internal/drivers/nanoleaf/driver.go new file mode 100644 index 0000000..9c244b6 --- /dev/null +++ b/internal/drivers/nanoleaf/driver.go @@ -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 +} diff --git a/internal/drivers/nanoleaf/panel.go b/internal/drivers/nanoleaf/panel.go new file mode 100644 index 0000000..fc0b033 --- /dev/null +++ b/internal/drivers/nanoleaf/panel.go @@ -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 diff --git a/models/colorvalue.go b/models/colorvalue.go index bd86c1d..a11f516 100644 --- a/models/colorvalue.go +++ b/models/colorvalue.go @@ -2,14 +2,15 @@ package models import ( "fmt" - "regexp" + "math" "strconv" + "strings" ) type ColorValue struct { - Hue int `json:"h,omitempty"` - Saturation int `json:"s,omitempty"` - Kelvin int `json:"kelvin,omitempty"` + Hue float64 `json:"h,omitempty"` // 0..360 + Saturation float64 `json:"s,omitempty"` // 0..=1 + Kelvin int `json:"kelvin,omitempty"` } func (c *ColorValue) IsHueSat() bool { @@ -25,16 +26,17 @@ func (c *ColorValue) String() string { return fmt.Sprintf("kelvin:%d", c.Kelvin) } - return fmt.Sprintf("hsv:%d,%d", c.Hue, c.Saturation) + return fmt.Sprintf("hsv:%f,%f", c.Hue, c.Saturation) } -var kelvinRegex = regexp.MustCompile("kelvin:([0-9]+)") -var hsRegex = regexp.MustCompile("hs:([0-9]+),([0-9]+)") - func ParseColorValue(raw string) (ColorValue, error) { - if kelvinRegex.MatchString(raw) { - part := kelvinRegex.FindString(raw) - parsedPart, err := strconv.Atoi(part) + tokens := strings.SplitN(raw, ":", 2) + if len(tokens) != 2 { + return ColorValue{}, ErrBadInput + } + + if tokens[0] == "kelvin" { + parsedPart, err := strconv.Atoi(tokens[1]) if err != nil { return ColorValue{}, ErrBadInput } @@ -42,19 +44,19 @@ func ParseColorValue(raw string) (ColorValue, error) { return ColorValue{Kelvin: parsedPart}, nil } - if hsRegex.MatchString(raw) { - parts := kelvinRegex.FindAllString(raw, 2) + if tokens[0] == "hs" { + parts := strings.Split(tokens[1], ",") if len(parts) < 2 { return ColorValue{}, ErrUnknownColorFormat } - part1, err1 := strconv.Atoi(parts[0]) - part2, err2 := strconv.Atoi(parts[1]) + part1, err1 := strconv.ParseFloat(parts[0], 64) + part2, err2 := strconv.ParseFloat(parts[1], 64) if err1 != nil || err2 != nil { return ColorValue{}, ErrBadInput } - return ColorValue{Hue: part1, Saturation: part2}, nil + return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil } return ColorValue{}, ErrUnknownColorFormat diff --git a/models/device.go b/models/device.go index 907e904..affc24f 100644 --- a/models/device.go +++ b/models/device.go @@ -22,13 +22,13 @@ type Device struct { // DeviceState contains optional state values that // - Power: Whether the device is powered on // - Color: Color value, if a color setting can be set on the device -// - Intensity: e.g. brightness, from 0-255 +// - Intensity: e.g. brightness, range 0..=1 // - Temperature: e.g. for thermostats type DeviceState struct { Power bool `json:"power"` Color ColorValue `json:"color,omitempty"` - Intensity int `json:"intensity,omitempty"` - Temperature int `json:"temperature"` + Intensity float64 `json:"intensity,omitempty"` + Temperature float64 `json:"temperature"` } type NewDeviceState struct { diff --git a/models/driver.go b/models/driver.go index cff6a3b..5dd8253 100644 --- a/models/driver.go +++ b/models/driver.go @@ -12,6 +12,7 @@ type DriverProvider interface { type Driver interface { SearchBridge(ctx context.Context, address string, dryRun bool) ([]Bridge, error) SearchDevices(ctx context.Context, bridge Bridge, timeout time.Duration) ([]Device, error) - Consume(ctx context.Context, bridge Bridge, devices []Device, ch chan Event) (chan<- struct{}, error) + ListDevices(ctx context.Context, bridge Bridge) ([]Device, error) Publish(ctx context.Context, bridge Bridge, devices []Device) error + Run(ctx context.Context, bridge Bridge, ch chan<- Event) error } diff --git a/models/errors.go b/models/errors.go index abbcc0d..fa95fae 100644 --- a/models/errors.go +++ b/models/errors.go @@ -6,3 +6,8 @@ var ErrInvalidName = errors.New("invalid name") var ErrBadInput = errors.New("bad input") var ErrBadColor = errors.New("bad color") var ErrUnknownColorFormat = errors.New("unknown color format") + +var ErrMissingToken = errors.New("driver is missing authentication information") +var ErrIncorrectToken = errors.New("driver is not accepting authentication information") +var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)") +var ErrBridgeSearchFailed = errors.New("bridge search failed") diff --git a/models/eventhandler.go b/models/eventhandler.go index 8b1b82e..f233278 100644 --- a/models/eventhandler.go +++ b/models/eventhandler.go @@ -72,9 +72,9 @@ func (c *EventCondition) checkDevice(key string, device Device) bool { case "color": return c.matches(device.State.Color.String()) case "intensity": - return c.matches(strconv.Itoa(device.State.Intensity)) + return c.matches(strconv.FormatFloat(device.State.Intensity, 'f', -1, 64)) case "temperature": - return c.matches(strconv.Itoa(device.State.Temperature)) + return c.matches(strconv.FormatFloat(device.State.Temperature, 'f', -1, 64)) } return false