diff --git a/bus.go b/bus.go index 17c1d78..d1a4fa3 100644 --- a/bus.go +++ b/bus.go @@ -96,7 +96,7 @@ func (b *EventBus) send(message serviceMessage) { listener.mu.Unlock() } - for i := range deleteList { + for _, i := range deleteList { b.listeners = append(b.listeners[:i], b.listeners[i+1:]...) } diff --git a/cmd/bustest/main.go b/cmd/bustest/main.go index 4fc2447..400e8f8 100644 --- a/cmd/bustest/main.go +++ b/cmd/bustest/main.go @@ -1,16 +1,14 @@ package main import ( - "fmt" lucifer3 "git.aiterp.net/lucifer3/server" "git.aiterp.net/lucifer3/server/commands" "git.aiterp.net/lucifer3/server/device" "git.aiterp.net/lucifer3/server/effects" "git.aiterp.net/lucifer3/server/events" "git.aiterp.net/lucifer3/server/internal/color" - "git.aiterp.net/lucifer3/server/internal/gentools" "git.aiterp.net/lucifer3/server/services" - "log" + "git.aiterp.net/lucifer3/server/services/nanoleaf" "time" ) @@ -23,76 +21,47 @@ func main() { bus.JoinPrivileged(resolver) bus.JoinPrivileged(sceneMap) bus.Join(services.NewEffectEnforcer(resolver, sceneMap)) + bus.Join(nanoleaf.NewService()) - bus.RunEvent(events.Connected{Prefix: "nanoleaf:10.80.1.11"}) - - numbers := []int{5, 2, 3, 1, 4} - for i, id := range []string{"e28c", "67db", "f744", "d057", "73c1"} { - bus.RunEvent(events.HardwareState{ - ID: "nanoleaf:10.80.1.11:" + id, - InternalName: fmt.Sprintf("Hexagon %d", numbers[i]), - SupportFlags: device.SFlagPower | device.SFlagColor | device.SFlagIntensity, - ColorFlags: device.CFlagRGB, - State: device.State{}, - }) - } - - bus.RunCommand(commands.ReplaceScene{ - Match: "lucifer:name:Hex*", - SceneName: "Evening", - }) - - bus.RunCommand(commands.AddAlias{ - Match: "nanoleaf:10.80.1.{11,7,16,5}:*", - Alias: "lucifer:tag:Magic Lamps", - }) + bus.JoinCallback(func(event lucifer3.Event) bool { + switch event.(type) { + case events.DevicesReady: + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:2d0c", Alias: "lucifer:name:Hex 5"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:542f", Alias: "lucifer:name:Hex 4"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:e760", Alias: "lucifer:name:Hex 3"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:207a", Alias: "lucifer:name:Hex 2"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:df9a", Alias: "lucifer:name:Hex 1"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:cdd5", Alias: "lucifer:name:Hex 6"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:4597", Alias: "lucifer:name:Hex 7"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:82cb", Alias: "lucifer:name:Hex 8"}) + bus.RunCommand(commands.AddAlias{Match: "nanoleaf:10.80.1.14:09fd", Alias: "lucifer:name:Hex 9"}) + bus.RunCommand(commands.Assign{ + Match: "nanoleaf:10.80.1.14:*", + Effect: effects.Gradient{ + States: []device.State{ + {Power: p(true), Intensity: p(0.3), Color: p(color.MustParse("xy:0.22,0.18"))}, + {Power: p(true), Intensity: p(0.5), Color: p(color.MustParse("xy:0.22,0.18"))}, + }, + Interpolate: true, + AnimationMS: 1000, + Reverse: false, + }, + }) - for i, id := range []string{"40e5", "dead", "beef", "cafe", "1337"} { - bus.RunEvent(events.HardwareState{ - ID: "nanoleaf:10.80.1.11:" + id, - InternalName: fmt.Sprintf("Hexagon %d", 6+i), - SupportFlags: device.SFlagPower | device.SFlagColor | device.SFlagIntensity, - ColorFlags: device.CFlagRGB, - State: device.State{}, - }) - } + return false + } - bus.RunEvent(events.ExternalEvent{ - Kind: "weather", - Values: map[string]string{ - "location": "Brekstad", - "temperature_celsius": "21.00", - "precipitation_mm": "3.21", - }, + return true }) - c1 := gentools.Ptr(color.MustParse("rgb:#ff0000")) - c2 := gentools.Ptr(color.MustParse("rgb:#00ff00")) - bus.RunCommand(commands.Assign{ - Match: "**:Hexagon *", - Effect: effects.Gradient{ - States: []device.State{ - { - Power: gentools.Ptr(true), - Color: c1, - Intensity: gentools.Ptr(1.0), - }, - { - Power: gentools.Ptr(true), - Color: c2, - Intensity: gentools.Ptr(0.7), - }, - }, - AnimationMS: 1000, - Interpolate: true, - }, + bus.RunCommand(commands.ConnectDevice{ + ID: "nanoleaf:10.80.1.14", + APIKey: "", }) - log.Println("Search \"**:Hexagon {1,5,6}\"") - for _, dev := range resolver.Resolve("lucifer:name:Hexagon {1,5,6}") { - log.Println("- ID:", dev.ID) - log.Println(" Aliases:", dev.Aliases) - } + time.Sleep(time.Hour) +} - time.Sleep(time.Second * 15) +func p[T any](v T) *T { + return &v } diff --git a/commands/device.go b/commands/device.go new file mode 100644 index 0000000..718d083 --- /dev/null +++ b/commands/device.go @@ -0,0 +1,60 @@ +package commands + +import ( + "fmt" + "git.aiterp.net/lucifer3/server/internal/formattools" + "strings" +) + +type PairDevice struct { + ID string `json:"id"` +} + +func (c PairDevice) CommandDescription() string { + return fmt.Sprintf("PairDevice(%s)", c.ID) +} + +func (c PairDevice) Matches(driver string) (sub string, ok bool) { + split := strings.SplitN(c.ID, ":", 2) + if split[0] != driver { + return "", false + } + + return split[1], true +} + +type ConnectDevice struct { + ID string `json:"id"` + APIKey string `json:"apiKey"` +} + +func (c ConnectDevice) Matches(driver string) (sub string, ok bool) { + split := strings.SplitN(c.ID, ":", 2) + if split[0] != driver { + return "", false + } + + return split[1], true +} + +func (c ConnectDevice) CommandDescription() string { + return fmt.Sprintf("ConnectDevice(%s, %s)", c.ID, formattools.Asterisks(c.APIKey)) +} + +type SearchDevices struct { + ID string `json:"id"` + Hint string `json:"hint"` +} + +func (c SearchDevices) Matches(driver string) (string, bool) { + split := strings.SplitN(c.ID, ":", 2) + if split[0] != driver { + return "", false + } + + return split[1], true +} + +func (c SearchDevices) CommandDescription() string { + return fmt.Sprintf("SearchDevices(%s, %#+v)", c.ID, c.Hint) +} diff --git a/commands/state.go b/commands/state.go index 1c304f7..487499c 100644 --- a/commands/state.go +++ b/commands/state.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "git.aiterp.net/lucifer3/server/device" + "strings" ) type SetState struct { @@ -10,6 +11,14 @@ type SetState struct { State device.State } +func (c SetState) Matches(driver string) (sub string, ok bool) { + if strings.HasPrefix(c.ID, driver) && strings.HasPrefix(c.ID[len(driver):], ":") { + return strings.SplitN(c.ID[len(driver)+1:], ":", 2)[0], true + } else { + return "", false + } +} + func (c SetState) CommandDescription() string { return fmt.Sprintf("SetState(%s, %s)", c.ID, c.State) } diff --git a/device/state.go b/device/state.go index 16bf575..5269b2b 100644 --- a/device/state.go +++ b/device/state.go @@ -17,16 +17,20 @@ type State struct { func (s State) String() string { parts := make([]string, 0, 4) if s.Power != nil { - parts = append(parts, fmt.Sprintf("power:%t", *s.Power)) + if *s.Power { + parts = append(parts, "on") + } else { + parts = append(parts, "off") + } } if s.Temperature != nil { - parts = append(parts, fmt.Sprintf("temperature:%f", *s.Temperature)) + parts = append(parts, fmt.Sprintf("%f°C", *s.Temperature)) } if s.Intensity != nil { - parts = append(parts, fmt.Sprintf("intensity:%.2f", *s.Intensity)) + parts = append(parts, fmt.Sprintf("%.1f%%", *s.Intensity*100)) } if s.Color != nil { - parts = append(parts, fmt.Sprintf("color:%s", s.Color.String())) + parts = append(parts, s.Color.String()) } return fmt.Sprint("(", strings.Join(parts, ", "), ")") diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..990ac04 --- /dev/null +++ b/errors.go @@ -0,0 +1,6 @@ +package lucifer3 + +import "errors" + +var ErrUnexpectedResponse = errors.New("unexpected response from device") +var ErrIncorrectToken = errors.New("api token not accepted by device") diff --git a/events/button.go b/events/button.go new file mode 100644 index 0000000..7543edb --- /dev/null +++ b/events/button.go @@ -0,0 +1,38 @@ +package events + +import "fmt" + +type ButtonPressed struct { + ID string `json:"id"` + SwipedID string `json:"swipedId,omitempty"` + Name string `json:"name"` +} + +func (e ButtonPressed) EventDescription() string { + if e.SwipedID != "" { + return fmt.Sprintf("ButtonPressed(name:%s, swipe:%s->%s)", e.Name, e.SwipedID, e.ID) + } else { + return fmt.Sprintf("ButtonPressed(name:%s, id:%s)", e.Name, e.ID) + } +} + +func (e ButtonPressed) TriggerKind() string { + return "ButtonPressed:" + e.Name +} + +func (e ButtonPressed) TriggerValue(key string) (string, bool) { + switch key { + case "name": + return e.Name, true + case "id": + return e.ID, true + case "swipedFromId", "swipedId": + if e.SwipedID != "" { + return e.SwipedID, true + } else { + return "", false + } + default: + return "", false + } +} diff --git a/events/connection.go b/events/connection.go deleted file mode 100644 index 4f5d4e2..0000000 --- a/events/connection.go +++ /dev/null @@ -1,20 +0,0 @@ -package events - -import "fmt" - -type Connected struct { - Prefix string `json:"prefix"` -} - -func (e Connected) EventDescription() string { - return fmt.Sprintf("Connect(prefix:%s)", e.Prefix) -} - -type Disconnected struct { - Prefix string `json:"prefix"` - Reason string `json:"reason"` -} - -func (e Disconnected) EventDescription() string { - return fmt.Sprintf("Disconnected(prefix:%s, reason:%s)", e.Prefix, e.Reason) -} diff --git a/events/device.go b/events/device.go index 5d07a73..9d8f582 100644 --- a/events/device.go +++ b/events/device.go @@ -5,6 +5,23 @@ import ( "git.aiterp.net/lucifer3/server/device" ) +type DeviceConnected struct { + Prefix string `json:"prefix"` +} + +func (e DeviceConnected) EventDescription() string { + return fmt.Sprintf("DeviceConnected(prefix:%s)", e.Prefix) +} + +type DeviceDisconnected struct { + Prefix string `json:"prefix"` + Reason string `json:"reason"` +} + +func (e DeviceDisconnected) EventDescription() string { + return fmt.Sprintf("DeviceDisconnected(prefix:%s, reason:%s)", e.Prefix, e.Reason) +} + type HardwareState struct { ID string `json:"internalId"` InternalName string `json:"internalName"` @@ -14,16 +31,69 @@ type HardwareState struct { State device.State `json:"state"` } -func (d HardwareState) EventDescription() string { +func (e HardwareState) EventDescription() string { return fmt.Sprintf("HardwareState(id:%s, iname:%#+v, sflags:%s, cflags:%s, buttons:%v, state:%s)", - d.ID, d.InternalName, d.SupportFlags, d.ColorFlags, d.Buttons, d.State, + e.ID, e.InternalName, e.SupportFlags, e.ColorFlags, e.Buttons, e.State, ) } -type Unreachable struct { - DeviceID string `json:"deviceId"` +type HardwareMetadata struct { + ID string `json:"id"` + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + O int `json:"o,omitempty"` + ShapeType string `json:"shapeType,omitempty"` + Icon string `json:"icon,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + FirmwareVersion string `json:"firmwareVersion,omitempty"` +} + +func (e HardwareMetadata) EventDescription() string { + return fmt.Sprintf("HardwareMetadata(id:%s, icon:%s, ...)", e.ID, e.Icon) +} + +// DevicesReady is triggered to indicate that all hardware states have been pushed to the bus ahead of this event. +type DevicesReady struct { + ID string `json:"id"` +} + +func (d DevicesReady) EventDescription() string { + return fmt.Sprintf("DevicesReady(id:%s)", d.ID) +} + +type DevicesUnreachable struct { + ID string `json:"id"` +} + +func (d DevicesUnreachable) EventDescription() string { + return fmt.Sprintf("DevicesUnreachable(id:%s)", d.ID) +} + +type DeviceFailed struct { + ID string `json:"id"` + Error string `json:"error"` +} + +func (e DeviceFailed) EventDescription() string { + return fmt.Sprintf("DeviceFailed(id:%s, err:%s)", e.ID, e.Error) +} + +type DeviceAvailable struct { + ID string + Name string +} + +func (e DeviceAvailable) EventDescription() string { + return fmt.Sprintf("DeviceAvailable(id:%s, name:%s)") +} + +type DeviceAccepted struct { + ID string `json:"id"` + APIKey string `json:"apiKey"` + Extras map[string]string `json:"extras"` } -func (d Unreachable) EventDescription() string { - return fmt.Sprintf("Unreachable(id:%s)", d.DeviceID) +func (e DeviceAccepted) EventDescription() string { + // TODO: Use formattools.Asterisks + return fmt.Sprintf("DeviceAccepted(id:%s, apiKey:%s)", e.ID, e.APIKey) } diff --git a/internal/color/rgb.go b/internal/color/rgb.go index aa4bb72..5c04ef5 100644 --- a/internal/color/rgb.go +++ b/internal/color/rgb.go @@ -35,3 +35,11 @@ func (rgb RGB) ToXY() XY { Y: y / (x + y + z), } } + +func (rgb RGB) Normalize() (RGB, float64) { + hue, sat, value := colorful.Color{R: rgb.Red, G: rgb.Green, B: rgb.Blue}.Hsv() + hs := HueSat{Hue: hue, Sat: sat} + newRGB := hs.ToRGB() + + return newRGB, value +} diff --git a/internal/formattools/asterisks.go b/internal/formattools/asterisks.go new file mode 100644 index 0000000..b4c2211 --- /dev/null +++ b/internal/formattools/asterisks.go @@ -0,0 +1,11 @@ +package formattools + +const firmament = "****************..." + +func Asterisks(s string) string { + if len(s) < 16 { + return firmament[:len(s)] + } else { + return firmament + } +} diff --git a/services/nanoleaf/client.go b/services/nanoleaf/client.go new file mode 100644 index 0000000..3b843c7 --- /dev/null +++ b/services/nanoleaf/client.go @@ -0,0 +1,462 @@ +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 + } + } +} diff --git a/services/nanoleaf/data.go b/services/nanoleaf/data.go new file mode 100644 index 0000000..3c04e9e --- /dev/null +++ b/services/nanoleaf/data.go @@ -0,0 +1,231 @@ +package nanoleaf + +import ( + "encoding/binary" + "time" +) + +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: "Legacy Triangle", + 1: "Rhythm", + 2: "Square", + 3: "Control Square Master", + 4: "Control Square Passive", + 7: "Hexagon", + 8: "Triangle", + 9: "Mini Triangle", + 12: "Shapes Controller", +} + +var shapeIconMap = map[int]string{ + 0: "triangle", + 1: "rhythm", + 2: "Square", + 3: "square", + 4: "square", + 7: "hexagon", + 8: "triangle", + 9: "triangle-small", + 12: "hexagon", +} + +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" }}`) + +type panel struct { + ID uint16 + FullID string + + On bool + Intensity float64 + 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/services/nanoleaf/discover.go b/services/nanoleaf/discover.go new file mode 100644 index 0000000..2466d17 --- /dev/null +++ b/services/nanoleaf/discover.go @@ -0,0 +1,55 @@ +package nanoleaf + +import ( + "context" + "encoding/json" + "errors" + "fmt" + lucifer3 "git.aiterp.net/lucifer3/server" + "net/http" +) + +func Discover(ctx context.Context, ip string, register bool) (string, *DeviceInfo, error) { + res, err := http.Get(fmt.Sprintf("http://%s/device_info", ip)) + 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, lucifer3.ErrUnexpectedResponse + } + + token := "" + if register { + req, err := http.NewRequest("POST", fmt.Sprintf("http://%s:16021/api/v1/new/", ip), 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, errors.New(res.Status) + } + + tokenResponse := TokenResponse{} + err = json.NewDecoder(res.Body).Decode(&tokenResponse) + if err != nil { + return "", nil, err + } + + token = tokenResponse.Token + } + + return token, &deviceInfo, nil +} diff --git a/services/nanoleaf/service.go b/services/nanoleaf/service.go new file mode 100644 index 0000000..12cad71 --- /dev/null +++ b/services/nanoleaf/service.go @@ -0,0 +1,128 @@ +package nanoleaf + +import ( + "context" + "fmt" + lucifer3 "git.aiterp.net/lucifer3/server" + "git.aiterp.net/lucifer3/server/commands" + "git.aiterp.net/lucifer3/server/events" + "strings" + "time" +) + +func NewService() lucifer3.ActiveService { + return &service{ + bridges: make(map[string]*bridge), + cancels: make(map[string]context.CancelFunc), + } +} + +type service struct { + bridges map[string]*bridge + cancels map[string]context.CancelFunc +} + +func (s *service) Active() bool { + return true +} + +func (s *service) HandleEvent(*lucifer3.EventBus, lucifer3.Event) {} + +func (s *service) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command) { + switch command := command.(type) { + case commands.SetState: + if sub, ok := command.Matches("nanoleaf"); ok && s.bridges[sub] != nil { + s.bridges[sub].Update(command.ID, command.State) + } + + case commands.SearchDevices: + if sub, ok := command.Matches("nanoleaf"); ok { + if s.bridges[sub] != nil { + go func(bridge *bridge) { + changed, err := bridge.Refresh(context.Background()) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: command.ID, + Error: fmt.Sprintf("Search failed: %s", err), + }) + return + } + if changed { + for _, event := range bridge.HardwareEvents() { + bus.RunEvent(event) + } + } + }(s.bridges[sub]) + } + } + + case commands.PairDevice: + if address, ok := command.Matches("nanoleaf"); ok { + go func() { + timeout, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + + apiKey, info, err := Discover(timeout, address, true) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: command.ID, + Error: fmt.Sprintf("Pairing failed: %s", err), + }) + return + } + + bus.RunEvent(events.DeviceAccepted{ + ID: command.ID, + APIKey: apiKey, + Extras: nil, + }) + bus.RunEvent(events.HardwareMetadata{ + ID: command.ID, + Icon: "bridge", + SerialNumber: info.SerialNumber, + FirmwareVersion: strings.Join([]string{ + info.FirmwareVersion, info.BootloaderVersion, info.HardwareVersion, + }, "; "), + }) + }() + } + + case commands.ConnectDevice: + if sub, ok := command.Matches("nanoleaf"); ok { + if s.bridges[sub] != nil { + s.cancels[sub]() + } + + ctx, cancel := context.WithCancel(context.Background()) + + s.bridges[sub] = &bridge{ + host: sub, + apiKey: command.APIKey, + panels: make([]*panel, 0, 64), + panelIDMap: make(map[uint16]string), + } + s.cancels[sub] = cancel + + go func() { + for ctx.Err() == nil { + ctx2, cancel2 := context.WithCancel(ctx) + + err := s.bridges[sub].Run(ctx2, bus) + cancel2() + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: command.ID, + Error: fmt.Sprintf("Run failed: %s", err), + }) + } + + select { + case <-time.After(time.Second * 5): + case <-ctx.Done(): + return + } + } + }() + } + } +}