From 95ec918ed1a9cf232b0aabb9f923fe44b8a893af Mon Sep 17 00:00:00 2001 From: Gisle Aune Date: Mon, 7 Jun 2021 19:46:02 +0200 Subject: [PATCH] add lifx driver. It works with the bulb, but the code is a bit smelly. --- app/config/driver.go | 2 + cmd/bridgetest/main.go | 45 +++- internal/drivers/lifx/bridge.go | 267 +++++++++++++++++++ internal/drivers/lifx/client.go | 178 +++++++++++++ internal/drivers/lifx/driver.go | 177 +++++++++++++ internal/drivers/lifx/packet.go | 191 ++++++++++++++ internal/drivers/lifx/payloads.go | 417 ++++++++++++++++++++++++++++++ internal/drivers/lifx/products.go | 73 ++++++ internal/drivers/lifx/state.go | 87 +++++++ models/bridge.go | 1 + models/colorvalue.go | 22 +- models/device.go | 11 +- models/errors.go | 7 + 13 files changed, 1465 insertions(+), 13 deletions(-) create mode 100644 internal/drivers/lifx/bridge.go create mode 100644 internal/drivers/lifx/client.go create mode 100644 internal/drivers/lifx/driver.go create mode 100644 internal/drivers/lifx/packet.go create mode 100644 internal/drivers/lifx/payloads.go create mode 100644 internal/drivers/lifx/products.go create mode 100644 internal/drivers/lifx/state.go diff --git a/app/config/driver.go b/app/config/driver.go index ad95ab3..99625ee 100644 --- a/app/config/driver.go +++ b/app/config/driver.go @@ -3,6 +3,7 @@ package config import ( "git.aiterp.net/lucifer/new-server/internal/drivers" "git.aiterp.net/lucifer/new-server/internal/drivers/hue" + "git.aiterp.net/lucifer/new-server/internal/drivers/lifx" "git.aiterp.net/lucifer/new-server/internal/drivers/nanoleaf" "git.aiterp.net/lucifer/new-server/models" "sync" @@ -19,6 +20,7 @@ func DriverProvider() models.DriverProvider { dp = drivers.DriverMap{ models.DTNanoLeaf: &nanoleaf.Driver{}, models.DTHue: &hue.Driver{}, + models.DTLIFX: &lifx.Driver{}, } } diff --git a/cmd/bridgetest/main.go b/cmd/bridgetest/main.go index 82c7e1a..ffe29d1 100644 --- a/cmd/bridgetest/main.go +++ b/cmd/bridgetest/main.go @@ -47,6 +47,15 @@ func main() { log.Println("New token:", bridge.Token) } + ch := config.EventChannel + go func() { + err := driver.Run(context.Background(), bridge, ch) + if err != nil { + log.Fatalln("Run bridge stopped:", err) + } + }() + time.Sleep(time.Second) + // List devices var devices []models.Device if *flagSearch { @@ -60,18 +69,14 @@ func main() { log.Fatalln("Failed to list devices:", err) } } + + idMap := make(map[string]int) + nextId := len(devices) + 1 for i := range devices { devices[i].ID = i + 1 + idMap[devices[i].InternalID] = i + 1 } - ch := config.EventChannel - 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) @@ -85,6 +90,30 @@ func main() { text, _ := reader.ReadString('\n') text = strings.Trim(text, "\t  \r\n") + if text == "search" { + _, _ = driver.SearchDevices(context.Background(), bridge, time.Second) + } + if text == "list" || text == "search" || text == "json" { + devices, err = driver.ListDevices(context.Background(), bridge) + if err != nil { + log.Fatalln("Failed to list devices:", err) + } + + for i, device := range devices { + if extId, ok := idMap[device.InternalID]; ok { + devices[i].ID = extId + } else { + idMap[device.InternalID] = nextId + devices[i].ID = nextId + + nextId += 1 + } + + if text != "json" { + _, _ = fmt.Fprintf(os.Stderr, "Device: %d - %s %+v\n", device.ID, device.InternalID, device.Capabilities) + } + } + } if text == "json" { j, _ := json.MarshalIndent(devices, "", " ") fmt.Println(string(j)) diff --git a/internal/drivers/lifx/bridge.go b/internal/drivers/lifx/bridge.go new file mode 100644 index 0000000..6331cd7 --- /dev/null +++ b/internal/drivers/lifx/bridge.go @@ -0,0 +1,267 @@ +package lifx + +import ( + "context" + "git.aiterp.net/lucifer/new-server/models" + "log" + "sync" + "time" +) + +type Bridge struct { + mu sync.Mutex + externalID int + ip string + states []*State + client *Client +} + +func (b *Bridge) StartSearch() error { + c := b.getClient() + if c == nil { + return models.ErrBridgeRunningRequired + } + + _, err := c.Send("", &GetService{}) + return err +} + +func (b *Bridge) Publish(devices []models.Device) { + b.mu.Lock() + for _, device := range devices { + state, _ := b.ensureState(device.InternalID) + + state.deviceState = new(models.DeviceState) + *state.deviceState = device.State + + b.checkAndUpdateState(state) + + state.externalId = device.ID + } + b.mu.Unlock() +} + +func (b *Bridge) Devices() []models.Device { + devices := make([]models.Device, 0, 8) + + b.mu.Lock() + for _, state := range b.states { + if state.lightState == nil || state.firmware == nil || state.version == nil { + continue + } + + deviceState := models.DeviceState{} + if state.deviceState != nil { + deviceState = *state.deviceState + } + + device := models.Device{ + ID: state.externalId, + BridgeID: b.externalID, + InternalID: state.target, + Icon: "lightbulb", + Name: state.lightState.Label, + Capabilities: []models.DeviceCapability{models.DCPower, models.DCIntensity}, + DriverProperties: make(map[string]interface{}), + State: deviceState, + } + + device.DriverProperties["lifxVendorId"] = state.version.Vendor + device.DriverProperties["lifxProductId"] = state.version.Product + device.DriverProperties["lifxFirmwareMajor"] = state.firmware.Major + device.DriverProperties["lifxFirmwareMinor"] = state.firmware.Minor + device.DriverProperties["lifxFirmwareBuild"] = state.firmware.BuildTime + + product := findProduct(state.version.Vendor, state.version.Product) + if product != nil { + device.DriverProperties["productName"] = product.Name + + if product.Features.Color { + device.Capabilities = append(device.Capabilities, models.DCColorHS) + } + if len(product.Features.TemperatureRange) >= 2 { + device.Capabilities = append(device.Capabilities, models.DCColorHSK, models.DCColorKelvin) + device.DriverProperties["minKelvin"] = product.Features.TemperatureRange[0] + device.DriverProperties["maxKelvin"] = product.Features.TemperatureRange[1] + } + + for _, upgrade := range product.Upgrades { + if state.firmware.Major > upgrade.Major || (state.firmware.Major >= upgrade.Major && state.firmware.Minor >= upgrade.Minor) { + if upgrade.Features.TemperatureRange != nil { + device.DriverProperties["minKelvin"] = upgrade.Features.TemperatureRange[0] + device.DriverProperties["maxKelvin"] = upgrade.Features.TemperatureRange[1] + } + } + } + } + + devices = append(devices, device) + } + b.mu.Unlock() + + return devices +} + +func (b *Bridge) Run(ctx context.Context, debug bool) error { + client, err := createClient(ctx, b.ip, debug) + if err != nil { + return err + } + + b.mu.Lock() + b.client = client + b.mu.Unlock() + defer func() { + b.mu.Lock() + if b.client == client { + b.client = nil + } + b.mu.Unlock() + }() + + lastSearchTime := time.Now() + lastServiceTime := time.Time{} + _, err = client.Send("", &GetService{}) + if err != nil { + return err + } + + for { + target, seq, payload, err := client.Recv(time.Millisecond * 200) + if err == models.ErrInvalidPacketSize || err == models.ErrPayloadTooShort || err == models.ErrUnrecognizedPacketType { + log.Println("LIFX udp socket received something weird:", err) + } else if err != nil && err != models.ErrReadTimeout { + if ctx.Err() != nil { + return ctx.Err() + } + + return err + } + + if payload != nil { + b.mu.Lock() + state, _ := b.ensureState(target) + b.mu.Unlock() + + switch p := payload.(type) { + case *StateService: + if p.Service == 1 { + // Throw these messages to the wind. UDP errors will eventually be caught. + + // Get the version only if it's missing. It should never change anyway. + b.mu.Lock() + if state.version == nil { + _, _ = client.Send(target, &GetVersion{}) + } + b.mu.Unlock() + + _, _ = client.Send(target, &GetHostFirmware{}) + + lastServiceTime = time.Now() + } + case *LightState: + b.mu.Lock() + state.lightState = p + state.lightStateTime = time.Now() + + if state.deviceState == nil { + state.deviceState = &models.DeviceState{ + Power: p.On, + Color: models.ColorValue{ + Hue: p.Hue, + Saturation: p.Sat, + Kelvin: p.Kelvin, + }, + Intensity: p.Bri, + } + } + + b.checkAndUpdateState(state) + b.mu.Unlock() + case *StateHostFirmware: + b.mu.Lock() + state.firmware = p + b.mu.Unlock() + case *StateVersion: + b.mu.Lock() + state.version = p + b.mu.Unlock() + case *Acknowledgement: + b.mu.Lock() + state.handleAck(seq) + b.mu.Unlock() + } + } + + b.mu.Lock() + for _, state := range b.states { + if time.Since(state.lightStateTime) > time.Second*10 && time.Since(state.requestTime) > time.Second*3 { + state.requestTime = time.Now() + _, _ = client.Send(state.target, &GetColor{}) + } else if len(state.acksPending) > 0 && time.Since(state.updateTime) > time.Second { + state.requestTime = time.Now() + b.checkAndUpdateState(state) + } + + if time.Since(state.discoveredTime) > time.Second*10 { + if state.firmware == nil { + _, _ = client.Send(state.target, &GetHostFirmware{}) + } + if state.version == nil { + _, _ = client.Send(state.target, &GetVersion{}) + } + } + } + b.mu.Unlock() + + // + if time.Since(lastServiceTime) > time.Second*30 && time.Since(lastSearchTime) > time.Second*3 { + lastSearchTime = time.Now() + _, err = client.Send("", &GetService{}) + if err != nil { + return err + } + } + } +} + +func (b *Bridge) checkAndUpdateState(state *State) { + state.acksPending = state.acksPending[:0] + + updatePayloads := state.generateUpdate() + for _, updatePayload := range updatePayloads { + seq, err := b.client.Send(state.target, updatePayload) + if err != nil { + log.Println("Error sending updates to", state.externalId, state.target, "err:", err) + continue + } + + state.updateTime = time.Now() + state.acksPending = append(state.acksPending, seq) + } +} + +func (b *Bridge) ensureState(target string) (*State, bool) { + for _, state := range b.states { + if state.target == target { + return state, false + } + } + + state := &State{ + target: target, + discoveredTime: time.Now(), + } + + b.states = append(b.states, state) + + return state, true +} + +func (b *Bridge) getClient() *Client { + b.mu.Lock() + client := b.client + b.mu.Unlock() + + return client +} diff --git a/internal/drivers/lifx/client.go b/internal/drivers/lifx/client.go new file mode 100644 index 0000000..4d203a1 --- /dev/null +++ b/internal/drivers/lifx/client.go @@ -0,0 +1,178 @@ +package lifx + +import ( + "context" + "errors" + "git.aiterp.net/lucifer/new-server/models" + "log" + "math/rand" + "net" + "sync" + "time" +) + +type Client struct { + mu sync.Mutex + seq uint8 + buf []byte + lastN int + + conn *net.UDPConn + source uint32 + debug bool + + addrMap map[string]*net.UDPAddr +} + +// Send sends the payload to the hardware addr specified. If it cannot find a mapped +// IP address from a past message, it will be broadcast and may be slow to use. If the +// address is left blank, the package is marked as tagged and broadcast. +// +// ErrInvalidAddress can be returned by this, and indicates a badly configured device. +// It should probably be logged. +func (c *Client) Send(addr string, payload Payload) (seq uint8, err error) { + c.mu.Lock() + seq = c.seq + c.seq += 1 + c.mu.Unlock() + + packet := createPacket() + packet.SetSource(c.source) + packet.SetSequence(seq) + + sendAddr := &net.UDPAddr{IP: net.IPv4(255, 255, 255, 255), Port: 56700} + if addr == "" { + packet.SetTagged(true) + + if c.debug { + log.Println("Broadcasting", payload, "seq", seq) + } + } else { + err = packet.SetTarget(addr) + if err != nil { + return + } + + c.mu.Lock() + if udpAddr, ok := c.addrMap[addr]; ok { + sendAddr = udpAddr + } + c.mu.Unlock() + + if c.debug { + log.Println("Sending", payload, "to", addr, "seq", seq) + } + } + + packet.SetPayload(payload) + + _, err = c.conn.WriteToUDP(packet, sendAddr) + if err != nil { + return + } + + return +} + +// LastPacket gets the last read packet. This data is valid until the next call to Recv. The Packet may +// not be valid, however! +func (c *Client) LastPacket() Packet { + if c.lastN < 36 { + return nil + } + + return c.buf[:c.lastN] +} + +// Recv reads a message from the UDP socket. The data returned is decoded and will always be valid. +// +// However, these should be handled specifically: +// - ErrPayloadTooShort, ErrInvalidPacketSize: Garbage was received, please ignore. +// - ErrUnrecognizedPacketType: Log these and see what's up +// - ErrReadTimeout: The connection gets a 50ms read deadline. It should be used to do other things than wait +func (c *Client) Recv(timeout time.Duration) (target string, seq uint8, payload Payload, err error) { + if c.buf == nil { + c.buf = make([]byte, 2048) + } + + if timeout > 0 { + err = c.conn.SetReadDeadline(time.Now().Add(timeout)) + } else { + err = c.conn.SetReadDeadline(time.Time{}) + } + if err != nil { + return + } + + n, addr, err := c.conn.ReadFromUDP(c.buf) + if n > 0 { + c.lastN = n + } + if err != nil { + if netErr, ok := err.(*net.OpError); ok && netErr.Timeout() { + err = models.ErrReadTimeout + return + } + + return + } + + packet := Packet(c.buf[:n]) + if n < 2 || packet.Size() != n && packet.Protocol() != 1024 { + err = models.ErrInvalidAddress + return + } + + seq = packet.Sequence() + target = packet.Target().String() + + payload, err = packet.Payload() + if err != nil { + return + } + + // Learn the IP address from state service or ack messages. + if service, ok := payload.(*StateService); ok && service.Service == 1 { + c.mu.Lock() + if c.addrMap == nil { + c.addrMap = make(map[string]*net.UDPAddr) + } + c.addrMap[packet.Target().String()] = addr + c.mu.Unlock() + } + + if c.debug { + log.Println("Received", payload, "from", target, "seq", seq) + } + + return +} + +// createClient creates a client that will last as long as the context. +func createClient(ctx context.Context, bindAddr string, debug bool) (*Client, error) { + addr := net.ParseIP(bindAddr) + if addr == nil { + return nil, errors.New("invalid addr") + } + + source := uint32(rand.Uint64()) + if source < 2 { + source = 2 + } + + conn, err := net.ListenUDP("udp", &net.UDPAddr{ + IP: addr, + Port: 0, + Zone: "", + }) + if err != nil { + return nil, err + } + + go func() { + <-ctx.Done() + _ = conn.Close() + }() + + return &Client{conn: conn, source: source, debug: debug}, nil +} diff --git a/internal/drivers/lifx/driver.go b/internal/drivers/lifx/driver.go new file mode 100644 index 0000000..f1fa86f --- /dev/null +++ b/internal/drivers/lifx/driver.go @@ -0,0 +1,177 @@ +package lifx + +import ( + "context" + "fmt" + "git.aiterp.net/lucifer/new-server/models" + "net" + "sync" + "time" +) + +type Driver struct { + mu sync.Mutex + bridges []*Bridge +} + +func (d *Driver) SearchBridge(ctx context.Context, address string, _ bool) ([]models.Bridge, error) { + if address == "" { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + bridges := make([]models.Bridge, 0, len(ifaces)) + for _, iface := range ifaces { + if iface.Name == "lo" { + continue + } + + addrs, err := iface.Addrs() + if err != nil || len(addrs) == 0 { + continue + } + + for _, addr := range addrs { + bridges = append(bridges, models.Bridge{ + ID: -1, + Name: fmt.Sprintf("%s (%s)", iface.Name, addr), + Driver: models.DTLIFX, + Address: addr.String(), + }) + } + } + + return bridges, nil + } + + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + _, err := createClient(ctx2, address, false) + if err != nil { + return nil, err + } + + return []models.Bridge{{ + ID: 0, + Name: "Your network card", + Driver: models.DTLIFX, + Address: address, + 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 + } + + before, err := d.ListDevices(ctx, bridge) + if err != nil { + return nil, err + } + + err = b.StartSearch() + if err != nil { + return nil, err + } + + if timeout < time.Second/10 { + timeout = time.Second / 10 + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(timeout): + } + + after, err := d.ListDevices(ctx, bridge) + if err != nil { + return nil, err + } + + intersection := make([]models.Device, 0, 4) + for _, device := range after { + found := false + for _, device2 := range before { + if device2.InternalID == device.InternalID { + found = true + break + } + } + + if !found { + intersection = append(intersection, device) + } + } + + return intersection, 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) Publish(ctx context.Context, bridge models.Bridge, devices []models.Device) error { + b, err := d.ensureBridge(ctx, bridge) + if err != nil { + return err + } + + b.Publish(devices) + + return 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.Token == "debug") +} + +func (d *Driver) ensureBridge(ctx context.Context, info models.Bridge) (*Bridge, error) { + d.mu.Lock() + for _, bridge := range d.bridges { + if bridge.ip == info.Address { + d.mu.Unlock() + return bridge, nil + } + } + d.mu.Unlock() + + bridge := &Bridge{ + ip: info.Address, + externalID: info.ID, + } + + // Try to create a short-lived client to make sure it works. + ctx2, cancel := context.WithCancel(ctx) + defer cancel() + _, err := createClient(ctx2, info.Address, info.Token == "debug") + 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.ip == 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/lifx/packet.go b/internal/drivers/lifx/packet.go new file mode 100644 index 0000000..365aa1c --- /dev/null +++ b/internal/drivers/lifx/packet.go @@ -0,0 +1,191 @@ +package lifx + +import ( + "encoding/binary" + "fmt" + "git.aiterp.net/lucifer/new-server/models" + "log" + "net" +) + +func createPacket() Packet { + p := make(Packet, 36) + p.Reset() + + return p +} + +type Packet []byte + +func (p *Packet) Reset() { + p.ensureHeader() + copy(*p, blankHeader) +} + +func (p *Packet) Size() int { + return int(binary.LittleEndian.Uint16((*p)[0:2])) +} + +func (p *Packet) Protocol() int { + return int(binary.LittleEndian.Uint16((*p)[2:3]) & 0b00001111_11111111) +} + +func (p *Packet) SetTagged(v bool) { + if v { + (*p)[3] |= uint8(0b00100000) + } else { + (*p)[3] &= ^uint8(0b00100000) + } +} + +func (p *Packet) Source() uint32 { + return binary.LittleEndian.Uint32((*p)[4:8]) +} + +func (p *Packet) Target() net.HardwareAddr { + return net.HardwareAddr((*p)[8:14]) +} + +func (p *Packet) SetSource(v uint32) { + binary.LittleEndian.PutUint32((*p)[4:8], v) +} + +func (p *Packet) CopyTargetFrom(other Packet) { + copy((*p)[8:14], other[8:14]) +} + +func (p *Packet) SetTarget(v string) error { + addr, err := net.ParseMAC(v) + if err != nil { + return err + } + if len(addr) != 6 { + return models.ErrInvalidAddress + } + + copy((*p)[8:], addr) + return nil +} + +func (p *Packet) SetResRequired(v bool) { + if v { + (*p)[22] |= uint8(0b00000001) + } else { + (*p)[22] &= ^uint8(0b00000001) + } +} + +func (p *Packet) SetAckRequired(v bool) { + if v { + (*p)[22] |= uint8(0b00000010) + } else { + (*p)[22] &= ^uint8(0b00000010) + } +} + +func (p *Packet) Sequence() uint8 { + return (*p)[23] +} + +func (p *Packet) SetSequence(v uint8) { + (*p)[23] = v +} + +func (p *Packet) PacketType() uint16 { + return binary.LittleEndian.Uint16((*p)[32:34]) +} + +func (p *Packet) SetRawPayload(packetType uint16, data []byte) { + p.ensureSize(36 + len(data)) + copy((*p)[36:], data) + binary.LittleEndian.PutUint16(*p, uint16(36+len(data))) + binary.LittleEndian.PutUint16((*p)[32:34], packetType) +} + +func (p *Packet) Payload() (res Payload, err error) { + switch p.PacketType() { + case 2: + res = &GetService{} + case 3: + res = &StateService{} + err = res.Decode((*p)[36:]) + case 14: + res = &GetHostFirmware{} + case 15: + res = &StateHostFirmware{} + err = res.Decode((*p)[36:]) + case 32: + res = &GetVersion{} + case 33: + res = &StateVersion{} + err = res.Decode((*p)[36:]) + case 45: + res = &Acknowledgement{} + case 101: + res = &GetColor{} + case 102: + res = &SetColor{} + err = res.Decode((*p)[36:]) + case 107: + res = &LightState{} + err = res.Decode((*p)[36:]) + case 117: + res = &SetLightPower{} + err = res.Decode((*p)[36:]) + default: + err = models.ErrUnrecognizedPacketType + } + + if err != nil { + res = nil + } + + if res != nil && res.PacketType() != p.PacketType() { + log.Println("BUG Incorrect packet type used in Packet.Payload") + log.Println("BUG Payload Type:", p.PacketType()) + log.Println("BUG Panic Packet Type:", res.PacketType()) + log.Println("BUG Panic Payload:", res.String()) + } + + return +} + +func (p *Packet) SetPayload(payload Payload) { + ackRequired, resRequired := payload.Flags() + + p.SetRawPayload(payload.PacketType(), payload.Encode()) + p.SetAckRequired(ackRequired) + p.SetResRequired(resRequired) +} + +func (p *Packet) ensureHeader() { + p.ensureSize(36) +} + +func (p *Packet) ensureSize(size int) { + if len(*p) < size { + newData := make([]byte, size) + copy(newData, *p) + *p = newData + } +} + +type Payload interface { + fmt.Stringer + Decode(data []byte) error + Encode() []byte + PacketType() uint16 + Flags() (ackRequired bool, resRequired bool) +} + +var blankHeader = []byte{ + 0x24, 0x00, 0x00, 0x14, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, +} diff --git a/internal/drivers/lifx/payloads.go b/internal/drivers/lifx/payloads.go new file mode 100644 index 0000000..5e33a2a --- /dev/null +++ b/internal/drivers/lifx/payloads.go @@ -0,0 +1,417 @@ +package lifx + +import ( + "encoding/binary" + "fmt" + "git.aiterp.net/lucifer/new-server/models" + "math" + "time" +) + +type GetService struct{} + +func (p *GetService) Decode([]byte) error { + return nil +} + +func (p *GetService) Encode() []byte { + return []byte{} +} + +func (p *GetService) PacketType() uint16 { + return 2 +} + +func (p *GetService) Flags() (ackRequired bool, resRequired bool) { + return false, true +} + +func (p *GetService) String() string { + return "GetService()" +} + +type GetVersion struct{} + +func (p *GetVersion) Decode([]byte) error { + return nil +} + +func (p *GetVersion) Encode() []byte { + return []byte{} +} + +func (p *GetVersion) PacketType() uint16 { + return 32 +} + +func (p *GetVersion) Flags() (ackRequired bool, resRequired bool) { + return false, true +} + +func (p *GetVersion) String() string { + return "GetVersion()" +} + +type GetHostFirmware struct{} + +func (p *GetHostFirmware) Decode([]byte) error { + return nil +} + +func (p *GetHostFirmware) Encode() []byte { + return []byte{} +} + +func (p *GetHostFirmware) PacketType() uint16 { + return 14 +} + +func (p *GetHostFirmware) Flags() (ackRequired bool, resRequired bool) { + return false, true +} + +func (p *GetHostFirmware) String() string { + return "GetHostFirmware()" +} + +type StateHostFirmware struct { + BuildTime time.Time + Major uint16 + Minor uint16 +} + +func (p *StateHostFirmware) Decode(data []byte) error { + if len(data) < 20 { + return models.ErrPayloadTooShort + } + + ts := int64(binary.LittleEndian.Uint64(data[0:8])) + + p.BuildTime = time.Unix(ts/1000000000, ts%1000000000) + p.Major = binary.LittleEndian.Uint16(data[16:18]) + p.Minor = binary.LittleEndian.Uint16(data[18:20]) + + return nil +} + +func (p *StateHostFirmware) Encode() []byte { + var data [20]byte + + binary.LittleEndian.PutUint64(data[0:8], uint64(p.BuildTime.UnixNano())) + binary.LittleEndian.PutUint16(data[16:18], p.Major) + binary.LittleEndian.PutUint16(data[18:20], p.Minor) + + return data[:] +} + +func (p *StateHostFirmware) PacketType() uint16 { + return 15 +} + +func (p *StateHostFirmware) Flags() (ackRequired bool, resRequired bool) { + return false, false +} + +func (p *StateHostFirmware) String() string { + return fmt.Sprintf("StateHostFirmware(major=%d, minor=%d, build=%s)", p.Major, p.Minor, p.BuildTime.Format(time.RFC3339)) +} + +type StateVersion struct { + Vendor uint32 + Product uint32 +} + +func (p *StateVersion) Decode(data []byte) error { + if len(data) < 8 { + return models.ErrPayloadTooShort + } + + p.Vendor = binary.LittleEndian.Uint32(data[0:4]) + p.Product = binary.LittleEndian.Uint32(data[4:8]) + + return nil +} + +func (p *StateVersion) Encode() []byte { + var data [12]byte + + binary.LittleEndian.PutUint32(data[0:4], p.Vendor) + binary.LittleEndian.PutUint32(data[4:8], p.Product) + + return data[:] +} + +func (p *StateVersion) PacketType() uint16 { + return 33 +} + +func (p *StateVersion) Flags() (ackRequired bool, resRequired bool) { + return false, false +} + +func (p *StateVersion) String() string { + return fmt.Sprintf("StateVersion(vendor=%d, product=%d)", p.Vendor, p.Product) +} + +type GetColor struct{} + +func (p *GetColor) Decode([]byte) error { + return nil +} + +func (p *GetColor) Encode() []byte { + return []byte{} +} + +func (p *GetColor) PacketType() uint16 { + return 101 +} + +func (p *GetColor) Flags() (ackRequired bool, resRequired bool) { + return false, true +} + +func (p *GetColor) String() string { + return "GetColor()" +} + +type Acknowledgement struct{} + +func (p *Acknowledgement) Decode([]byte) error { + return nil +} + +func (p *Acknowledgement) Encode() []byte { + return []byte{} +} + +func (p *Acknowledgement) PacketType() uint16 { + return 45 +} + +func (p *Acknowledgement) Flags() (ackRequired bool, resRequired bool) { + return false, true +} + +func (p *Acknowledgement) String() string { + return "Acknowledgement()" +} + +type SetColor struct { + Hue float64 + Sat float64 + Bri float64 + Kelvin int + TransitionTime time.Duration +} + +func (p *SetColor) Decode(data []byte) error { + if len(data) < 13 { + return models.ErrPayloadTooShort + } + + hue := binary.LittleEndian.Uint16(data[1:3]) + sat := binary.LittleEndian.Uint16(data[3:5]) + bri := binary.LittleEndian.Uint16(data[5:7]) + kelvin := binary.LittleEndian.Uint16(data[7:9]) + transitionMs := binary.LittleEndian.Uint32(data[9:13]) + + p.Hue = float64(hue) / (65536 / 360) + p.Sat = float64(sat) / 65535 + p.Bri = float64(bri) / 65535 + p.Kelvin = int(kelvin) + p.TransitionTime = time.Duration(transitionMs) * time.Millisecond + + return nil +} + +func (p *SetColor) Encode() []byte { + var data [13]byte + + data[0] = 0x00 // reserved + binary.LittleEndian.PutUint16(data[1:3], uint16(math.Mod(p.Hue, 360)*(65536/360))) + binary.LittleEndian.PutUint16(data[3:5], uint16(p.Sat*65535)) + binary.LittleEndian.PutUint16(data[5:7], uint16(p.Bri*65535)) + binary.LittleEndian.PutUint16(data[7:9], uint16(p.Kelvin)) + binary.LittleEndian.PutUint32(data[9:13], uint32(p.TransitionTime.Milliseconds())) + + return data[:] +} + +func (p *SetColor) PacketType() uint16 { + return 102 +} + +func (p *SetColor) Flags() (ackRequired bool, resRequired bool) { + return true, false +} + +func (p *SetColor) String() string { + return fmt.Sprintf("SetColor(hsvk={%f, %f, %f, %d}, ttime=%s)", + p.Hue, + p.Sat, + p.Bri, + p.Kelvin, + p.TransitionTime, + ) +} + +type SetLightPower struct { + On bool + TransitionTime time.Duration +} + +func (p *SetLightPower) Decode(data []byte) error { + if len(data) < 6 { + return models.ErrPayloadTooShort + } + + level := binary.LittleEndian.Uint16(data[0:2]) + transitionMs := binary.LittleEndian.Uint32(data[2:6]) + + p.On = level > 0 + p.TransitionTime = time.Duration(transitionMs) * time.Millisecond + + return nil +} + +func (p *SetLightPower) Encode() []byte { + var data [6]byte + + if p.On { + data[0], data[1] = 0xff, 0xff + } else { + data[0], data[1] = 0x00, 0x00 + } + binary.LittleEndian.PutUint32(data[2:6], uint32(p.TransitionTime.Milliseconds())) + + return data[:] +} + +func (p *SetLightPower) PacketType() uint16 { + return 117 +} + +func (p *SetLightPower) Flags() (ackRequired bool, resRequired bool) { + return true, false +} + +func (p *SetLightPower) String() string { + return fmt.Sprintf("SetLightPower(on=%t, ttime=%s)", + p.On, + p.TransitionTime, + ) +} + +type StateService struct { + Service int + Port int +} + +func (p *StateService) String() string { + return fmt.Sprintf("StateService(service=%d, port=%d)", p.Service, p.Port) +} + +func (p *StateService) Decode(data []byte) error { + if len(data) < 5 { + return models.ErrPayloadTooShort + } + + p.Service = int(data[0]) + p.Port = int(binary.LittleEndian.Uint32(data[1:5])) + + return nil +} + +func (p *StateService) Encode() []byte { + var data [5]byte + data[0] = uint8(p.Service) + binary.LittleEndian.PutUint32(data[1:5], uint32(p.Port)) + + return data[:] +} + +func (p *StateService) PacketType() uint16 { + return 3 +} + +func (p *StateService) Flags() (ackRequired bool, resRequired bool) { + return false, false +} + +type LightState struct { + Hue float64 + Sat float64 + Bri float64 + Kelvin int + On bool + Label string +} + +func (p *LightState) String() string { + return fmt.Sprintf("LightState(hsvk=(%f, %f, %f, %d), on=%t, label=%+v)", + p.Hue, p.Sat, p.Bri, p.Kelvin, + p.On, p.Label, + ) +} + +func (p *LightState) Decode(data []byte) error { + if len(data) < 52 { + return models.ErrPayloadTooShort + } + + hue := binary.LittleEndian.Uint16(data[0:2]) + sat := binary.LittleEndian.Uint16(data[2:4]) + bri := binary.LittleEndian.Uint16(data[4:6]) + kelvin := binary.LittleEndian.Uint16(data[6:8]) + power := binary.LittleEndian.Uint16(data[10:12]) + + p.Hue = float64(hue) / (65536 / 360) + p.Sat = float64(sat) / 65535 + p.Bri = float64(bri) / 65535 + p.Kelvin = int(kelvin) + p.On = power > 32767 + + p.Label = "" + for i, v := range data[14:46] { + if v == 0 { + p.Label = string(data[14 : 14+i]) + break + } + } + + return nil +} + +func (p *LightState) Encode() []byte { + var data [52]byte + + power := uint16(0) + if p.On { + power = 0xffff + } + + binary.LittleEndian.PutUint16(data[0:2], uint16(math.Mod(p.Hue, 360)*(65536/360))) + binary.LittleEndian.PutUint16(data[2:4], uint16(p.Sat*65535)) + binary.LittleEndian.PutUint16(data[4:6], uint16(p.Bri*65535)) + binary.LittleEndian.PutUint16(data[6:8], uint16(p.Kelvin)) + binary.LittleEndian.PutUint16(data[10:12], power) + + label := p.Label + labelBytes := []byte(label) + for len(labelBytes) > 31 { + label = label[:len(label)-1] + labelBytes = []byte(label) + } + copy(data[14:], labelBytes) + + return data[:] +} + +func (p *LightState) PacketType() uint16 { + return 107 +} + +func (p *LightState) Flags() (ackRequired bool, resRequired bool) { + return false, false +} diff --git a/internal/drivers/lifx/products.go b/internal/drivers/lifx/products.go new file mode 100644 index 0000000..ba679d9 --- /dev/null +++ b/internal/drivers/lifx/products.go @@ -0,0 +1,73 @@ +package lifx + +import "encoding/json" + +type ProductMap struct { + Vid uint32 `json:"vid"` + Name string `json:"name"` + Defaults struct { + Hev bool `json:"hev"` + Color bool `json:"color"` + Chain bool `json:"chain"` + Matrix bool `json:"matrix"` + Relays bool `json:"relays"` + Buttons bool `json:"buttons"` + Infrared bool `json:"infrared"` + Multizone bool `json:"multizone"` + TemperatureRange []int `json:"temperature_range"` + ExtendedMultiZone bool `json:"extended_multizone"` + } `json:"defaults"` + Products []ProductMapEntry `json:"products"` +} + +type ProductMapEntry struct { + Pid uint32 `json:"pid"` + Name string `json:"name"` + Features struct { + Color bool `json:"color"` + Chain bool `json:"chain"` + Matrix bool `json:"matrix"` + Infrared bool `json:"infrared"` + MultiZone bool `json:"multizone"` + TemperatureRange []int `json:"temperature_range,omitempty"` + MinExtMzFirmware int `json:"min_ext_mz_firmware,omitempty"` + MinExtMzFirmwareComponents []int `json:"min_ext_mz_firmware_components,omitempty"` + Relays bool `json:"relays,omitempty"` + Buttons bool `json:"buttons,omitempty"` + Hev bool `json:"hev,omitempty"` + } `json:"features"` + Upgrades []struct { + Major uint16 `json:"major"` + Minor uint16 `json:"minor"` + Features struct { + TemperatureRange []int `json:"temperature_range,omitempty"` + ExtendedMultizone *bool `json:"extended_multizone,omitempty"` + } `json:"features"` + } `json:"upgrades"` +} + +var productMap ProductMap + +func findProduct(vendorId uint32, productId uint32) *ProductMapEntry { + if vendorId != productMap.Vid { + return nil + } + + for _, entry := range productMap.Products { + if entry.Pid == productId { + return &entry + } + } + + return nil +} + +func init() { + // Source: https://github.com/LIFX/products + // Commit: 2734460e132dd7ed32c3c0132345a758d47ff80b + + err := json.Unmarshal([]byte(`{"vid":1,"name":"LIFX","defaults":{"hev":false,"color":false,"chain":false,"matrix":false,"relays":false,"buttons":false,"infrared":false,"multizone":false,"temperature_range":null,"extended_multizone":false},"products":[{"pid":1,"name":"LIFX Original 1000","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":3,"name":"LIFX Color 650","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":10,"name":"LIFX White 800 (Low Voltage)","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2700,6500]},"upgrades":[]},{"pid":11,"name":"LIFX White 800 (High Voltage)","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2700,6500]},"upgrades":[]},{"pid":15,"name":"LIFX Color 1000","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":18,"name":"LIFX White 900 BR30 (Low Voltage)","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":19,"name":"LIFX White 900 BR30 (High Voltage)","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":20,"name":"LIFX Color 1000 BR30","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":22,"name":"LIFX Color 1000","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":27,"name":"LIFX A19","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":28,"name":"LIFX BR30","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":29,"name":"LIFX A19 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":30,"name":"LIFX BR30 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":31,"name":"LIFX Z","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":true,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":32,"name":"LIFX Z","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":true,"temperature_range":[2500,9000],"min_ext_mz_firmware":1532997580,"min_ext_mz_firmware_components":[2,77]},"upgrades":[{"major":2,"minor":77,"features":{"extended_multizone":true}},{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":36,"name":"LIFX Downlight","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":37,"name":"LIFX Downlight","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":38,"name":"LIFX Beam","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":true,"temperature_range":[2500,9000],"min_ext_mz_firmware":1532997580,"min_ext_mz_firmware_components":[2,77]},"upgrades":[{"major":2,"minor":77,"features":{"extended_multizone":true}},{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":39,"name":"LIFX Downlight White to Warm","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":40,"name":"LIFX Downlight","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":43,"name":"LIFX A19","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":44,"name":"LIFX BR30","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":45,"name":"LIFX A19 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":46,"name":"LIFX BR30 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[{"major":2,"minor":80,"features":{"temperature_range":[1500,9000]}}]},{"pid":49,"name":"LIFX Mini Color","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":50,"name":"LIFX Mini White to Warm","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,6500]},"upgrades":[{"major":3,"minor":70,"features":{"temperature_range":[1500,9000]}}]},{"pid":51,"name":"LIFX Mini White","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2700,2700]},"upgrades":[]},{"pid":52,"name":"LIFX GU10","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":53,"name":"LIFX GU10","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":55,"name":"LIFX Tile","features":{"color":true,"chain":true,"matrix":true,"infrared":false,"multizone":false,"temperature_range":[2500,9000]},"upgrades":[]},{"pid":57,"name":"LIFX Candle","features":{"color":true,"chain":false,"matrix":true,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":59,"name":"LIFX Mini Color","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":60,"name":"LIFX Mini White to Warm","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,6500]},"upgrades":[{"major":3,"minor":70,"features":{"temperature_range":[1500,9000]}}]},{"pid":61,"name":"LIFX Mini White","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2700,2700]},"upgrades":[]},{"pid":62,"name":"LIFX A19","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":63,"name":"LIFX BR30","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":64,"name":"LIFX A19 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":65,"name":"LIFX BR30 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":66,"name":"LIFX Mini White","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2700,2700]},"upgrades":[]},{"pid":68,"name":"LIFX Candle","features":{"color":false,"chain":false,"matrix":true,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":70,"name":"LIFX Switch","features":{"color":false,"relays":true,"chain":false,"matrix":false,"buttons":true,"infrared":false,"multizone":false},"upgrades":[]},{"pid":71,"name":"LIFX Switch","features":{"color":false,"relays":true,"chain":false,"matrix":false,"buttons":true,"infrared":false,"multizone":false},"upgrades":[]},{"pid":81,"name":"LIFX Candle White to Warm","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2200,6500]},"upgrades":[]},{"pid":82,"name":"LIFX Filament Clear","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2100,2100]},"upgrades":[]},{"pid":85,"name":"LIFX Filament Amber","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2000,2000]},"upgrades":[]},{"pid":87,"name":"LIFX Mini White","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2700,2700]},"upgrades":[]},{"pid":88,"name":"LIFX Mini White","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2700,2700]},"upgrades":[]},{"pid":89,"name":"LIFX Switch","features":{"color":false,"relays":true,"chain":false,"matrix":false,"buttons":true,"infrared":false,"multizone":false},"upgrades":[]},{"pid":90,"name":"LIFX Clean","features":{"hev":true,"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":91,"name":"LIFX Color","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":92,"name":"LIFX Color","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":93,"name":"LIFX A19 US","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":94,"name":"LIFX BR30","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":96,"name":"LIFX Candle White to Warm","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2200,6500]},"upgrades":[]},{"pid":97,"name":"LIFX A19","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":98,"name":"LIFX BR30","features":{"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":99,"name":"LIFX Clean","features":{"hev":true,"color":true,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":100,"name":"LIFX Filament Clear","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2100,2100]},"upgrades":[]},{"pid":101,"name":"LIFX Filament Amber","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[2000,2000]},"upgrades":[]},{"pid":109,"name":"LIFX A19 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":110,"name":"LIFX BR30 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":111,"name":"LIFX A19 Night Vision","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":112,"name":"LIFX BR30 Night Vision Intl","features":{"color":true,"chain":false,"matrix":false,"infrared":true,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":113,"name":"LIFX Mini WW US","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]},{"pid":114,"name":"LIFX Mini WW Intl","features":{"color":false,"chain":false,"matrix":false,"infrared":false,"multizone":false,"temperature_range":[1500,9000]},"upgrades":[]}]}`), &productMap) + if err != nil { + panic(err) + } +} diff --git a/internal/drivers/lifx/state.go b/internal/drivers/lifx/state.go new file mode 100644 index 0000000..864c8c9 --- /dev/null +++ b/internal/drivers/lifx/state.go @@ -0,0 +1,87 @@ +package lifx + +import ( + "git.aiterp.net/lucifer/new-server/models" + "math" + "time" +) + +type State struct { + target string + externalId int + lightState *LightState + firmware *StateHostFirmware + version *StateVersion + + deviceState *models.DeviceState + discoveredTime time.Time + lightStateTime time.Time + requestTime time.Time + updateTime time.Time + acksPending []uint8 +} + +func (s *State) generateUpdate() []Payload { + if s.deviceState == nil || s.lightState == nil { + return nil + } + + results := make([]Payload, 0, 0) + if s.deviceState.Power != s.lightState.On { + results = append(results, &SetLightPower{On: s.deviceState.Power, TransitionTime: time.Millisecond * 100}) + } + + if !s.deviceState.Power { + return results + } + + c := s.deviceState.Color + l := s.lightState + di := s.deviceState.Intensity + k := c.Kelvin + if k == 0 { + k = 4000 + } + if !equalish(c.Hue, l.Hue) || !equalish(c.Saturation, l.Sat) || !equalish(di, l.Bri) || k != l.Kelvin { + results = append(results, &SetColor{ + Hue: c.Hue, + Sat: c.Saturation, + Bri: di, + Kelvin: k, + TransitionTime: time.Millisecond * 150, + }) + } + + return results +} + +func (s *State) handleAck(seq uint8) { + for i, pendingAck := range s.acksPending { + if pendingAck == seq { + s.acksPending = append(s.acksPending[:i], s.acksPending[i+1:]...) + break + } + } + + if len(s.acksPending) == 0 { + s.lightStateTime = time.Now() + + prevLabel := "" + if s.lightState != nil { + prevLabel = s.lightState.Label + } + + s.lightState = &LightState{ + Hue: s.deviceState.Color.Hue, + Sat: s.deviceState.Color.Saturation, + Bri: s.deviceState.Intensity, + Kelvin: s.deviceState.Color.Kelvin, + On: s.deviceState.Power, + Label: prevLabel, + } + } +} + +func equalish(a, b float64) bool { + return math.Abs(a-b) < 0.005 +} diff --git a/models/bridge.go b/models/bridge.go index 2bf1773..d14f776 100644 --- a/models/bridge.go +++ b/models/bridge.go @@ -22,6 +22,7 @@ type DriverKind string var ( DTHue DriverKind = "Hue" DTNanoLeaf DriverKind = "Nanoleaf" + DTLIFX DriverKind = "LIFX" ) var ValidDriverKinds = []DriverKind{ diff --git a/models/colorvalue.go b/models/colorvalue.go index a11f516..a9c5627 100644 --- a/models/colorvalue.go +++ b/models/colorvalue.go @@ -35,7 +35,7 @@ func ParseColorValue(raw string) (ColorValue, error) { return ColorValue{}, ErrBadInput } - if tokens[0] == "kelvin" { + if tokens[0] == "kelvin" || tokens[0] == "k" { parsedPart, err := strconv.Atoi(tokens[1]) if err != nil { return ColorValue{}, ErrBadInput @@ -59,5 +59,25 @@ func ParseColorValue(raw string) (ColorValue, error) { return ColorValue{Hue: math.Mod(part1, 360), Saturation: math.Min(math.Max(part2, 0), 1)}, nil } + if tokens[0] == "hsk" { + parts := strings.Split(tokens[1], ",") + if len(parts) < 3 { + return ColorValue{}, ErrUnknownColorFormat + } + + part1, err1 := strconv.ParseFloat(parts[0], 64) + part2, err2 := strconv.ParseFloat(parts[1], 64) + part3, err3 := strconv.Atoi(parts[2]) + if err1 != nil || err2 != nil || err3 != nil { + return ColorValue{}, ErrBadInput + } + + return ColorValue{ + Hue: math.Mod(part1, 360), + Saturation: math.Min(math.Max(part2, 0), 1), + Kelvin: part3, + }, nil + } + return ColorValue{}, ErrUnknownColorFormat } diff --git a/models/device.go b/models/device.go index e75f891..6e27834 100644 --- a/models/device.go +++ b/models/device.go @@ -50,6 +50,7 @@ type DeviceRepository interface { var ( DCPower DeviceCapability = "Power" DCColorHS DeviceCapability = "ColorHS" + DCColorHSK DeviceCapability = "ColorHSK" DCColorKelvin DeviceCapability = "ColorKelvin" DCButtons DeviceCapability = "Buttons" DCPresence DeviceCapability = "Presence" @@ -89,10 +90,12 @@ func (d *Device) Validate() error { return nil } -func (d *Device) HasCapability(capacity DeviceCapability) bool { +func (d *Device) HasCapability(capabilities ...DeviceCapability) bool { for _, c := range d.Capabilities { - if c == capacity { - return true + for _, c2 := range capabilities { + if c == c2 { + return true + } } } @@ -110,7 +113,7 @@ func (d *Device) SetState(newState NewDeviceState) error { return err } - if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) { + if (parsed.IsKelvin() && d.HasCapability(DCColorKelvin, DCColorHSK)) || (parsed.IsHueSat() && d.HasCapability(DCColorHS)) { d.State.Color = parsed } } diff --git a/models/errors.go b/models/errors.go index 608e188..8566d9a 100644 --- a/models/errors.go +++ b/models/errors.go @@ -15,3 +15,10 @@ var ErrIncorrectToken = errors.New("driver is not accepting authentication infor var ErrUnexpectedResponse = errors.New("driver api returned unexpected response (wrong driver selected?)") var ErrBridgeSearchFailed = errors.New("bridge search failed") var ErrAddressOnlyDryRunnable = errors.New("this address may only be used for a dry run") + +var ErrInvalidAddress = errors.New("invalid mac address") +var ErrPayloadTooShort = errors.New("payload too short") +var ErrInvalidPacketSize = errors.New("invalid packet size") +var ErrReadTimeout = errors.New("read timeout") +var ErrUnrecognizedPacketType = errors.New("packet type not recognized") +var ErrBridgeRunningRequired = errors.New("this operation cannot be performed when bridge is not running")