From 958b09e6598e041fed1040180dd90d06dd325e5b Mon Sep 17 00:00:00 2001 From: Stian Fredrik Aune Date: Fri, 30 Dec 2022 19:17:22 +0100 Subject: [PATCH] Basic IKEA tradfri light driver --- cmd/bustest/main.go | 9 +- commands/device.go | 5 +- go.mod | 31 ++++- go.sum | 85 +++++++++++++ services/tradfri/bridge.go | 246 ++++++++++++++++++++++++++++++++++++ services/tradfri/service.go | 118 +++++++++++++++++ 6 files changed, 484 insertions(+), 10 deletions(-) create mode 100644 services/tradfri/bridge.go create mode 100644 services/tradfri/service.go diff --git a/cmd/bustest/main.go b/cmd/bustest/main.go index eda00d8..d7d5e6c 100644 --- a/cmd/bustest/main.go +++ b/cmd/bustest/main.go @@ -2,10 +2,10 @@ package main import ( lucifer3 "git.aiterp.net/lucifer3/server" - "git.aiterp.net/lucifer3/server/commands" "git.aiterp.net/lucifer3/server/services" "git.aiterp.net/lucifer3/server/services/hue" "git.aiterp.net/lucifer3/server/services/nanoleaf" + "git.aiterp.net/lucifer3/server/services/tradfri" "time" ) @@ -20,12 +20,7 @@ func main() { bus.Join(services.NewEffectEnforcer(resolver, sceneMap)) bus.Join(nanoleaf.NewService()) bus.Join(hue.NewService()) - - bus.RunCommand(commands.ConnectDevice{ID: "hue:10.80.1.5", APIKey: "0-Ch5MKQtYnXrA3b8jvE4408mS3tHo9Vn57Zv8pt"}) + bus.Join(tradfri.NewService()) time.Sleep(time.Hour) } - -func p[T any](v T) *T { - return &v -} diff --git a/commands/device.go b/commands/device.go index 718d083..0fdea38 100644 --- a/commands/device.go +++ b/commands/device.go @@ -7,11 +7,12 @@ import ( ) type PairDevice struct { - ID string `json:"id"` + ID string `json:"id"` + APIKey string `json:"apiKey"` } func (c PairDevice) CommandDescription() string { - return fmt.Sprintf("PairDevice(%s)", c.ID) + return fmt.Sprintf("PairDevice(%s, %s)", c.ID, formattools.Asterisks(c.APIKey)) } func (c PairDevice) Matches(driver string) (sub string, ok bool) { diff --git a/go.mod b/go.mod index ca45253..b618808 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,33 @@ require github.com/lucasb-eyer/go-colorful v1.2.0 require github.com/gobwas/glob v0.2.3 -require golang.org/x/sync v0.1.0 // indirect +require golang.org/x/sync v0.1.0 + +require ( + github.com/bocajim/dtls v0.0.0-20190226153416-329cdc841d4f // indirect + github.com/dustin/go-coap v0.0.0-20170214053734-ddcc80675fa4 // indirect + github.com/eriklupander/dtls v0.0.0-20190304211642-b36018226359 // indirect + github.com/eriklupander/tradfri-go v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.4.7 // indirect + github.com/go-chi/chi v4.0.2+incompatible // indirect + github.com/golang/protobuf v1.3.1 // indirect + github.com/grpc-ecosystem/go-grpc-middleware v1.0.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect + github.com/magiconair/properties v1.8.0 // indirect + github.com/mitchellh/mapstructure v1.0.0 // indirect + github.com/pelletier/go-toml v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/sirupsen/logrus v1.9.0 // indirect + github.com/spf13/afero v1.1.2 // indirect + github.com/spf13/cast v1.2.0 // indirect + github.com/spf13/jwalterweatherman v1.0.0 // indirect + github.com/spf13/pflag v1.0.2 // indirect + github.com/spf13/viper v1.2.1 // indirect + golang.org/x/net v0.0.0-20190311183353-d8887717615a // indirect + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + golang.org/x/text v0.3.0 // indirect + google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 // indirect + google.golang.org/grpc v1.21.1 // indirect + gopkg.in/yaml.v2 v2.2.1 // indirect +) diff --git a/go.sum b/go.sum index fbf32fd..72d0bb7 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,91 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/bocajim/dtls v0.0.0-20190226153416-329cdc841d4f h1:5c9skqLuQJ2iyEhmR5xiFVBvAKmFfpYfoE72UqBRZds= +github.com/bocajim/dtls v0.0.0-20190226153416-329cdc841d4f/go.mod h1:htuSw7xe15DPFg5oJtcepjot00bvkhmXsx/b0yvFaKU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-coap v0.0.0-20170214053734-ddcc80675fa4 h1:+3t0PiNl/W3Cl4/+XmQ8gD1HQuw3ISaGHHmSBylNVJ8= +github.com/dustin/go-coap v0.0.0-20170214053734-ddcc80675fa4/go.mod h1:as2rZ2aojRzZF8bGx1bPAn1yi9ICG6LwkiPOj6PBtjc= +github.com/eriklupander/dtls v0.0.0-20190304211642-b36018226359 h1:GrRdzY4NkR4IGoip3PvJH1VYkzMQW6HGV9Bl48yq9js= +github.com/eriklupander/dtls v0.0.0-20190304211642-b36018226359/go.mod h1:9cQp/YAWpoevkrztrrOhFYyeHX8cOvhwHMiwg91o4Eo= +github.com/eriklupander/tradfri-go v1.0.1 h1:KXgaV5dB+F/c5dAi/Zh2A//mE8G7hYTWaDow166MrDg= +github.com/eriklupander/tradfri-go v1.0.1/go.mod h1:YfJVeg82Z9AHi/ZmkQgPc0LRAOqHojCxWlJBOxrMOLo= +github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= +github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0 h1:Iju5GlWwrvL6UBg4zJJt3btmonfrMlCDdsejg4CZE7c= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 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/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mitchellh/mapstructure v1.0.0 h1:vVpGvMXJPqSDh2VYHF7gsfQj8Ncx+Xw5Y1KHeTRY+7I= +github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= +github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= +github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.2 h1:Fy0orTDgHdbnzHcsOgfCN4LtHf0ec3wwtiwJqwvf3Gc= +github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.2.1 h1:bIcUwXqLseLF3BDAZduuNfekWG87ibtFxi59Bq+oI9M= +github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.21.1 h1:j6XxA85m/6txkUCHvzlV5f+HBNl/1r5cZ2A/3IEFOO8= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/services/tradfri/bridge.go b/services/tradfri/bridge.go new file mode 100644 index 0000000..1d62daf --- /dev/null +++ b/services/tradfri/bridge.go @@ -0,0 +1,246 @@ +package tradfri + +import ( + "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" + "github.com/eriklupander/tradfri-go/tradfri" + "math" + "math/rand" + "strings" + "sync" + "time" +) + +type Bridge struct { + mx sync.Mutex + client *tradfri.Client + + id string + newIP string + newCredentials string + + internalMap map[string]int + nameMap map[string]string + stateMap map[string]device.State +} + +func connect(ip, credentials string) (bridge *Bridge, retErr error) { + bridge = &Bridge{} + defer func() { + retErr = catchPanic() + }() + + if !strings.Contains(ip, ":") { + ip = ip + ":5684" + } + + parts := strings.Split(credentials, ":") + if len(parts) == 1 { + bridge.client = tradfri.NewTradfriClient(ip, "Client_identity", parts[0]) + + clientID := fmt.Sprintf("Lucifer4_%d", rand.Intn(10000)) + + token, err := bridge.client.AuthExchange(clientID) + if err != nil { + return nil, err + } + + bridge.newCredentials = clientID + ":" + token.Token + } else { + bridge.client = tradfri.NewTradfriClient(ip, parts[0], parts[1]) + + bridge.newCredentials = parts[0] + ":" + parts[1] + } + + bridge.newIP = ip + bridge.id = fmt.Sprintf("tradfri:%s", ip) + + return +} + +func (b *Bridge) listen(bus *lucifer3.EventBus) { + b.internalMap = make(map[string]int, 16) + b.nameMap = make(map[string]string, 16) + b.stateMap = make(map[string]device.State, 16) + + go func() { + defer b.mx.Unlock() + + for { + b.mx.Lock() + + devices, err := b.client.ListDevices() + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: b.id, + Error: "Unable to fetch IKEA devices", + }) + return + } + + for _, ikeaDevice := range devices { + id := fmt.Sprintf("%s:%d", b.id, ikeaDevice.DeviceId) + + lc := ikeaDevice.LightControl + if len(lc) == 0 { + continue + } + + currState := device.State{ + Power: gentools.Ptr(lc[0].Power > 0), + Intensity: gentools.Ptr(float64(lc[0].Dimmer) / 254), + Color: &color.Color{ + XY: &color.XY{ + X: float64(lc[0].CIE_1931_X) / 65535, + Y: float64(lc[0].CIE_1931_Y) / 65535, + }, + }, + } + + if len(b.nameMap[id]) == 0 { + b.internalMap[id] = ikeaDevice.DeviceId + b.nameMap[id] = ikeaDevice.Name + b.stateMap[id] = currState + + bus.RunEvent(events.DeviceReady{ID: id}) + bus.RunEvent(events.HardwareMetadata{ID: id}) + bus.RunEvent(events.HardwareState{ + ID: id, + InternalName: ikeaDevice.Name, + SupportFlags: defaultSF, + ColorFlags: defaultCF, + State: b.stateMap[id], + }) + } + + b.refreshDevice(id, bus) + } + + b.mx.Unlock() + time.Sleep(10 * time.Second) + } + }() +} + +func (b *Bridge) writeState(id string, state device.State, bus *lucifer3.EventBus) { + b.stateMap[id] = state + b.refreshDevice(id, bus) +} + +func (b *Bridge) refreshDevice(id string, bus *lucifer3.EventBus) { + ikeaDevice, err := b.client.GetDevice(b.internalMap[id]) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: id, + Error: "Unable to fetch IKEA device", + }) + return + } + + changed := false + + if len(ikeaDevice.LightControl) > 0 { + ikeaState := ikeaDevice.LightControl[0] + luciferState := b.stateMap[id] + + currPower := ikeaState.Power > 0 + currIntensity := float64(ikeaState.Dimmer) / 254 + currX := float64(ikeaState.CIE_1931_X) / 65535 + currY := float64(ikeaState.CIE_1931_Y) / 65535 + + if luciferState.Intensity != nil { + newIntensity := *luciferState.Intensity + diffIntensity := math.Abs(newIntensity - currIntensity) + + if diffIntensity >= 0.01 { + changed = true + + intensityInt := int(math.Round(newIntensity * 254)) + + _, err := b.client.PutDeviceDimming(ikeaDevice.DeviceId, intensityInt) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: id, + Error: "Failed to update intensity state", + }) + return + } + } + } + + if luciferState.Color != nil && luciferState.Color.XY != nil { + newX := luciferState.Color.XY.X + newY := luciferState.Color.XY.Y + diffX := math.Abs(newX - currX) + diffY := math.Abs(newY - currY) + + if diffX >= 0.0001 || diffY >= 0.0001 { + changed = true + + xInt := int(math.Round(newX * 65535)) + yInt := int(math.Round(newY * 65535)) + + _, err := b.client.PutDeviceColor(ikeaDevice.DeviceId, xInt, yInt) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: id, + Error: "Failed to update color state", + }) + return + } + } + } + + if luciferState.Power != nil { + newPower := *luciferState.Power + diffPower := newPower != currPower + + if diffPower { + changed = true + + powerInt := 0 + if newPower { + powerInt = 1 + } + + _, err := b.client.PutDevicePower(ikeaDevice.DeviceId, powerInt) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: id, + Error: "Failed to update power state", + }) + return + } + } + } + + if changed { + bus.RunEvent(events.HardwareState{ + ID: id, + InternalName: ikeaDevice.Name, + SupportFlags: defaultSF, + ColorFlags: defaultCF, + State: luciferState, + }) + } + } +} + +const defaultSF = device.SFlagPower | device.SFlagIntensity | device.SFlagColor +const defaultCF = device.CFlagXY + +func catchPanic(mutexes ...sync.Locker) (retErr error) { + if err, ok := recover().(error); ok { + retErr = err + } + + for _, mx := range mutexes { + mx.Unlock() + } + + return +} diff --git a/services/tradfri/service.go b/services/tradfri/service.go new file mode 100644 index 0000000..7d36fb8 --- /dev/null +++ b/services/tradfri/service.go @@ -0,0 +1,118 @@ +package tradfri + +import ( + "fmt" + lucifer3 "git.aiterp.net/lucifer3/server" + "git.aiterp.net/lucifer3/server/commands" + "git.aiterp.net/lucifer3/server/events" + "github.com/sirupsen/logrus" + "io" + "strings" + "sync" +) + +func NewService() lucifer3.ActiveService { + logrus.StandardLogger().Out = io.Discard + logrus.StandardLogger().ExitFunc = func(i int) { + panic(fmt.Sprintf("tradfri: exit code %d", i)) + } + + return &service{ + bridges: make(map[string]*Bridge, 4), + } +} + +type service struct { + mu sync.Mutex + + bridges map[string]*Bridge +} + +func (s *service) Active() bool { + return true +} + +func (s *service) HandleEvent(*lucifer3.EventBus, lucifer3.Event) { + // NOP +} + +func (s *service) HandleCommand(bus *lucifer3.EventBus, command lucifer3.Command) { + defer s.mu.Unlock() + s.mu.Lock() + + switch command := command.(type) { + case commands.PairDevice: + if sub, ok := command.Matches("tradfri"); ok { + bridge, err := connect(sub, command.APIKey) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: command.ID, + Error: err.Error(), + }) + return + } + + s.bridges[bridge.id] = bridge + + bus.RunEvent(events.DeviceAccepted{ + ID: bridge.id, + APIKey: bridge.newCredentials, + }) + } + case commands.SearchDevices: + bus.RunEvent(events.DeviceFailed{ + ID: command.ID, + Error: "Please follow instructions in IKEA app; they will appear here after pairing.", + }) + case commands.SetState: + if _, ok := command.Matches("tradfri"); ok { + bridge, ok := s.findBridge(command.ID) + if !ok { + return + } + + bridge.writeState(command.ID, command.State, bus) + } + case commands.SetStateBatch: + for id, state := range command { + if strings.HasPrefix(id, "tradfri") { + bridge, ok := s.findBridge(id) + if !ok { + return + } + + bridge.writeState(id, state, bus) + } + } + case commands.ConnectDevice: + if sub, ok := command.Matches("tradfri"); ok { + if s.bridges[command.ID] == nil { + bridge, err := connect(sub, command.APIKey) + if err != nil { + bus.RunEvent(events.DeviceFailed{ + ID: command.ID, + Error: err.Error(), + }) + return + } + + s.bridges[command.ID] = bridge + } + + bus.RunEvent(events.DeviceConnected{Prefix: s.bridges[command.ID].id}) + s.bridges[command.ID].listen(bus) + } + } +} + +func (s *service) findBridge(id string) (*Bridge, bool) { + bits := strings.Split(id, ":") + for i := 2; i < len(bits); i++ { + prefix := strings.Join(bits[0:i], ":") + if s.bridges[prefix] != nil { + return s.bridges[prefix], true + } + } + + return nil, false +}