diff --git a/README.md b/README.md index b564509..f1fdb7d 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,12 @@ shellyctl is an unofficial command line client for the [Shelly Gen2 API](https:/ ## Features * mDNS discovery of shelly devices on the local network. +* Bluetooth Low Energy (BLE) discovery of shelly devices for RPC, monitoring, and initial setup. +* Command line interface for documented APIs. * prometheus metrics endpoint with the status of known devices. ## Maturity -This library is currently in active development (as of December 2023). It has meaningful gaps in testing and functionality. At this stage there is no guarantee of backwards compatibility. Once the project reaches a stable state, I will begin crafting releases with semantic versioning. +This library is currently in active development (as of January 2024). It has meaningful gaps in testing and functionality. At this stage there is no guarantee of backwards compatibility. Once the project reaches a stable state, I will begin tagging releases with semantic versioning. ## Usage ``` @@ -77,9 +79,99 @@ Global Flags: --log-level string threshold for outputing logs: trace, debug, info, warn, error, fatal, panic (default "warn") ``` +### RPC Command-line + +#### Example +``` +$ shellyctl switch set-config --ble-device=D4:D4:DA:09:2E:B6 --auto-off=true --auto-off-delay=60 + +Response to Switch.SetConfig command: + Restart Required: false + +$ shellyctl switch set --ble-device=D4:D4:DA:09:2E:B6 --on=true + +Response to Switch.Set command: + Was On: true +``` + +#### Menu Heirarchy +- `ble` + - `get-config` ([BLE.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/BLE/#blegetconfig)) + - `get-status` ([BLE.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/BLE/#blegetstatus)) + - `set-config` ([BLE.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/BLE/#blesetconfig)) +- `cloud` + - `get-config` ([Cloud.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cloud/#cloudgetconfig)) + - `get-status` ([Cloud.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cloud/#cloudgetstatus)) + - `set-config` ([Cloud.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cloud/#cloudsetconfig)) +- `cover` + - `calibrate` ([Cover.Calibrate](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#covercalibrate)) + - `close` ([Cover.Close](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#coverclose)) + - `get-config` ([Cover.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#covergetconfig)) + - `get-status` ([Cover.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#covergetstatus)) + - `go-to-position` ([Cover.GoToPosition](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#covergotoposition)) + - `open` ([Cover.Open](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#coveropen)) + - `reset-counters` ([Cover.ResetCounters](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#coverresetcounters)) + - `set-config` ([Cover.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#coversetconfig)) + - `stop` ([Cover.Stop](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Cover/#coverstop)) +- input + - `check-expression` ([Input.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Input#inputcheckexpression)) + - `get-config` ([Input.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Input#inputsetconfig)) + - `get-status` ([Input.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Input#inputgetstatus)) + - `set-config` ([Input.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Input#inputsetconfig)) +- `light` + - `get-config` ([Light.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Light#lightgetconfig)) + - `get-status` ([Light.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Light#lightgetstatus)) + - `set` ([Light.Set](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Light#lightset)) + - `set-config` ([Light.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Light#lightsetconfig)) + - `toggle` ([Light.Toggle](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Light#lighttoggle)) +- `mqtt` + - `get-config` ([MQTT.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Mqtt#mqttgetconfig)) + - `get-status` ([MQTT.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Mqtt#mqttgetstatus)) + - `set-config` ([MQTT.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Mqtt#mqttsetconfig)) +- `schedule` + - `delete` ([Schedule.Delete](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Schedule#scheduledelete)) + - `delete-all` ([Schedule.DeleteAll](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Schedule#scheduledeleteall)) +- `shelly` + - `check-for-update` ([Shelly.CheckForUpdate](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#shellycheckforupdate)) + - `get-config` ([Shelly.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#shellygetconfig)) + - `get-status` ([Shelly.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#shellygetstatus)) + - `reboot` ([Shelly.Reboot](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#shellyreboot)) + - `set-auth` ([Shelly.SetAuth](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#shellysetauth)) +- `switch` + - `get-config` ([Switch.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#switchgetconfig)) + - `get-status` ([Switch.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#switchgetstatusg)) + - `set` ([Switch.Set](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#switchset)) + - `set-config` ([Switch.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#switchsetconfig)) + - `toggle` ([Switch.Toggle](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Switch#switchtoggle)) +- `sys` + - `get-config` ([Sys.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Sys#sysgetconfig)) + - `get-status` ([Sys.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Sys#sysgetstatus)) + - `set-config` ([Sys.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Sys#syssetconfig)) +- `wifi` + - `get-config` ([WiFi.GetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/WiFi#wifigetconfig)) + - `get-status` ([WiFi.GetStatus](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/WiFi#wifigetstatus)) + - `set-config` ([WiFi.SetConfig](https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/WiFi#wifisetconfig)) + + +### Device Initial Setup +By default Shelly devices can be configured with RPCs over Bluetooth Low Energy (BLE) channel. The initial configuration is therefore just a matter of configuring network connectivity, optionally disabling BLE, and optionally setting authentication. +``` +$ shellyctl wifi set-config --sta-enable=true --sta-ssid=INTERNET --sta-pass=password --ble-device=AA:BB:CC:DD:EE:FF + +Response to Wifi.SetConfig command: + Restart Required: false + +$ shellyctl ble set-config --enable=false --host=192.168.1.62 + +Response to BLE.SetConfig command: + Restart Required: true + +$ shellyctl shelly reboot --host=192.168.1.62 + +Response to Shelly.Reboot command: +``` + ## TODO -* BLE Support -* Device Provisioning * Device Backup & Restore / Support for configuration as code style provisioning. * MQTT / WebSocket support diff --git a/cmd/gen.go b/cmd/gen.go index 000ab29..b25cac6 100644 --- a/cmd/gen.go +++ b/cmd/gen.go @@ -212,7 +212,7 @@ func init() { } baggage.Discoverer = discovery.NewDiscoverer(dOpts...) - if err := discoveryAddHosts(ctx, baggage.Discoverer); err != nil { + if err := discoveryAddDevices(ctx, baggage.Discoverer); err != nil { l.Fatal().Err(err).Msg("adding devices") } return childRun(cmd, args) diff --git a/cmd/helper_discovery.go b/cmd/helper_discovery.go index 1412d9e..ba00929 100644 --- a/cmd/helper_discovery.go +++ b/cmd/helper_discovery.go @@ -16,6 +16,8 @@ import ( var ( hosts []string mdnsSearch bool + bleSearch bool + bleDevices []string mdnsInterface string mdnsZone string mdnsService string @@ -40,6 +42,18 @@ func discoveryFlags(f *pflag.FlagSet, withTTL bool) { false, "if true, devices will be discovered via mDNS") + f.BoolVar( + &bleSearch, + "ble-search", + false, + "if true, devices will be discovered via Bluetooth Low-Energy") + + f.StringArrayVar( + &bleDevices, + "ble-device", + []string{}, + "MAC address of a single bluetooth low-energy device. May be specified multiple times to work with multiple devices.") + f.StringVar( &mdnsInterface, "mdns-interface", @@ -96,7 +110,7 @@ func discoveryFlags(f *pflag.FlagSet, withTTL bool) { } func discoveryOptionsFromFlags() (opts []discovery.DiscovererOption, err error) { - if len(hosts) == 0 && !mdnsSearch { + if len(hosts) == 0 && len(bleDevices) == 0 && !mdnsSearch && !bleSearch { return nil, errors.New("no hosts and or discovery (mDNS)") } if mdnsInterface != "" { @@ -125,12 +139,27 @@ func discoveryOptionsFromFlags() (opts []discovery.DiscovererOption, err error) return opts, err } -func discoveryAddHosts(ctx context.Context, d *discovery.Discoverer) error { +func discoveryAddDevices(ctx context.Context, d *discovery.Discoverer) error { l := log.Ctx(ctx) var wg sync.WaitGroup concurrencyLimit := make(chan struct{}, discoveryConcurrency) defer close(concurrencyLimit) defer wg.Wait() + if len(bleDevices) > 0 { + select { + case concurrencyLimit <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + wg.Add(1) + go func() { + defer func() { + wg.Done() + <-concurrencyLimit + }() + discoveryAddBLEDevices(ctx, d) + }() + } for _, h := range hosts { // This chan send will block if the we exceed discoveryConcurrency. select { @@ -155,3 +184,21 @@ func discoveryAddHosts(ctx context.Context, d *discovery.Discoverer) error { } return nil } + +func discoveryAddBLEDevices(ctx context.Context, d *discovery.Discoverer) error { + l := log.Ctx(ctx) + for _, mac := range bleDevices { + if err := ctx.Err(); err != nil { + return err + } + _, err := d.AddBLE(ctx, mac) + if err == nil { + continue + } + if !skipFailedHosts { + l.Fatal().Err(err).Msg("adding device") + } + l.Warn().Err(err).Msg("adding device; continuing because `skip-failed-hosts=true`") + } + return nil +} diff --git a/cmd/prometheus.go b/cmd/prometheus.go index 4786603..e6d4f87 100644 --- a/cmd/prometheus.go +++ b/cmd/prometheus.go @@ -56,7 +56,7 @@ var prometheusCmd = &cobra.Command{ l.Fatal().Err(err).Msg("parsing flags") } disc := discovery.NewDiscoverer(dOpts...) - if err := discoveryAddHosts(ctx, disc); err != nil { + if err := discoveryAddDevices(ctx, disc); err != nil { l.Fatal().Err(err).Msg("adding devices") } diff --git a/go.mod b/go.mod index fcdcfe3..2a69591 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.21.3 require ( github.com/go-logr/zerologr v1.2.3 github.com/hashicorp/mdns v1.0.5 - github.com/jcodybaker/go-shelly v0.0.0-20240120015947-a59697f0c673 + github.com/jcodybaker/go-shelly v0.0.0-20240120171830-b7e86393c146 github.com/mongoose-os/mos v0.0.0-20230313140341-b44964e63a92 github.com/rs/zerolog v1.31.0 github.com/spf13/cobra v1.8.0 @@ -24,8 +24,11 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/eclipse/paho.mqtt.golang v1.4.3 // indirect + github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.3.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-cmp v0.6.0 // indirect @@ -43,6 +46,7 @@ require ( github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/miekg/dns v1.1.41 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muka/go-bluetooth v0.0.0-20221213043340-85dc80edc4e1 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.18.0 // indirect @@ -51,6 +55,8 @@ require ( github.com/prometheus/procfs v0.12.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/saltosystems/winrt-go v0.0.0-20230921082907-2ab5b7d431e1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect @@ -58,6 +64,7 @@ require ( github.com/stoewer/go-strcase v1.3.0 // indirect github.com/stretchr/testify v1.8.4 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tinygo-org/cbgo v0.0.4 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect @@ -79,4 +86,5 @@ require ( gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect + tinygo.org/x/bluetooth v0.8.0 // indirect ) diff --git a/go.sum b/go.sum index cd4769d..0878717 100644 --- a/go.sum +++ b/go.sum @@ -318,6 +318,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.1-0.20181010231311-3f9d52f7176a/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= @@ -357,6 +359,8 @@ github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zerologr v1.2.3 h1:up5N9vcH9Xck3jJkXzgyOxozT14R47IyDODz8LM1KSs= github.com/go-logr/zerologr v1.2.3/go.mod h1:BxwGo7y5zgSHYR1BjbnHPyF/5ZjVKfKxAZANVu6E8Ho= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.0.0-20160704185906-46af16f9f7b1/go.mod h1:+35s3my2LFTysnkMfxsJBAMHj/DoqoB9knIWoYG/Vk0= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -371,9 +375,12 @@ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/godbus/dbus v0.0.0-20151105175453-c7fdd8b5cd55/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= github.com/godbus/dbus v0.0.0-20180201030542-885f9cc04c9c/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw= +github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e h1:BWhy2j3IXJhjCbC68FptL43tDKIq8FladmaTs3Xs7Z8= github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU= github.com/gogo/googleapis v1.4.0/go.mod h1:5YRNX2z1oM5gXdAkurHa942MDgEJyk02w4OecKY87+c= @@ -546,6 +553,10 @@ github.com/jcodybaker/go-shelly v0.0.0-20240120015912-3d2139385d8e h1:UhLvZBBhaJ github.com/jcodybaker/go-shelly v0.0.0-20240120015912-3d2139385d8e/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= github.com/jcodybaker/go-shelly v0.0.0-20240120015947-a59697f0c673 h1:wEMttgOsrBOChft1jd+bRpmiUcn8gG5uhCi6xVv+s20= github.com/jcodybaker/go-shelly v0.0.0-20240120015947-a59697f0c673/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= +github.com/jcodybaker/go-shelly v0.0.0-20240120161836-e56e1d68c7a2 h1:HH+f7tAGeILbgaoLv4AVsAGEn/2LAmAePQaYNkJ5eY0= +github.com/jcodybaker/go-shelly v0.0.0-20240120161836-e56e1d68c7a2/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= +github.com/jcodybaker/go-shelly v0.0.0-20240120171830-b7e86393c146 h1:GH5Q8UnmFuSz7lcaOVZxBlCR+P12CYm3ytIdsUZfnrY= +github.com/jcodybaker/go-shelly v0.0.0-20240120171830-b7e86393c146/go.mod h1:EfKnkqHSomR+wV7AoVgv6wU+kz1Xm4RSaEKaWMKWgWg= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20160803190731-bd40a432e4c7/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= @@ -642,6 +653,8 @@ github.com/mongoose-os/mos v0.0.0-20230313140341-b44964e63a92 h1:uk50uLxsSRtEMKM github.com/mongoose-os/mos v0.0.0-20230313140341-b44964e63a92/go.mod h1:02cswnce2ybKkrJdLrXLnAxkWaqtWcIKpNnl69Dr5xc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= +github.com/muka/go-bluetooth v0.0.0-20221213043340-85dc80edc4e1 h1:BuVRHr4HHJbk1DHyWkArJ7E8J/VA8ncCr/VLnQFazBo= +github.com/muka/go-bluetooth v0.0.0-20221213043340-85dc80edc4e1/go.mod h1:dMCjicU6vRBk34dqOmIZm0aod6gUwZXOXzBROqGous0= github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -691,6 +704,7 @@ github.com/opencontainers/runtime-tools v0.0.0-20181011054405-1d69bd0f9c39/go.mo github.com/opencontainers/selinux v1.6.0/go.mod h1:VVGKuOLlE7v4PJyT6h7mNWvq1rzqiriPsEqVhc+svHE= github.com/opencontainers/selinux v1.8.0/go.mod h1:RScLhm78qiWa2gbVCcGkC7tCGdgk3ogry1nUQF8Evvo= github.com/opencontainers/selinux v1.8.2/go.mod h1:MUIHuUEvKB1wtJjQdOyYRgOnLD2xAPP8dBsCoU0KuF8= +github.com/paypal/gatt v0.0.0-20151011220935-4ae819d591cf/go.mod h1:+AwQL2mK3Pd3S+TUwg0tYQjid0q1txyNUJuuSmz8Kdk= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= @@ -756,6 +770,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/saltosystems/winrt-go v0.0.0-20230921082907-2ab5b7d431e1 h1:L2YoWezgwpAZ2SEKjXk6yLnwOkM3u7mXq/mKuJeEpFM= +github.com/saltosystems/winrt-go v0.0.0-20230921082907-2ab5b7d431e1/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U= github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo= @@ -767,9 +783,12 @@ github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjM github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skratchdot/open-golang v0.0.0-20160302144031-75fb7ed4208c/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -821,12 +840,15 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/suapapa/go_eddystone v1.3.1/go.mod h1:bXC11TfJOS+3g3q/Uzd7FKd5g62STQEfeEIhcKe4Qy8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/syndtr/gocapability v0.0.0-20170704070218-db04d3cc01c8/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/syndtr/gocapability v0.0.0-20180916011248-d98352740cb2/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ23RP/odRBOTVjwp2cDyi6I= +github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= +github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= @@ -1125,6 +1147,7 @@ golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211015200801-69063c4bb744 h1:KzbpndAYEM+4oHRp9JmB2ewj0NHHxO3Z0g7Gus2O1kk= golang.org/x/sys v0.0.0-20211015200801-69063c4bb744/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1207,6 +1230,7 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20200916195026-c9a70fc28ce3/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20200925191224-5d1fdd8fa346/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= @@ -1473,3 +1497,5 @@ sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +tinygo.org/x/bluetooth v0.8.0 h1:WmuRebsODcUUIlGhesyuNRIAEIUCErhKlrZ9K9aimdI= +tinygo.org/x/bluetooth v0.8.0/go.mod h1:cfsVc0/nGo3nzi6+CeQaXb+anNlmEnSABkKsxer8OAE= diff --git a/pkg/discovery/ble.go b/pkg/discovery/ble.go new file mode 100644 index 0000000..0e66958 --- /dev/null +++ b/pkg/discovery/ble.go @@ -0,0 +1,304 @@ +package discovery + +import ( + "context" + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/mongoose-os/mos/common/mgrpc" + "github.com/mongoose-os/mos/common/mgrpc/codec" + "github.com/mongoose-os/mos/common/mgrpc/frame" + "github.com/rs/zerolog/log" + "tinygo.org/x/bluetooth" +) + +var ( + // https://github.com/mongoose-os-libs/rpc-gatts + mongooseGATTServiceID bluetooth.UUID + frameDataCharacteristic bluetooth.UUID + frameControlTxCharacteristic bluetooth.UUID + frameControlRxCharacteristic bluetooth.UUID + + bleMGRPCID int64 +) + +func init() { + var err error + mongooseGATTServiceID, err = bluetooth.ParseUUID("5f6d4f53-5f52-5043-5f53-56435f49445f") + if err != nil { + panic(fmt.Sprintf("parsing BLE service UUID: %v", err)) + } + frameDataCharacteristic, err = bluetooth.ParseUUID("5f6d4f53-5f52-5043-5f64-6174615f5f5f") + if err != nil { + panic(fmt.Sprintf("parsing BLE service UUID: %v", err)) + } + frameControlTxCharacteristic, err = bluetooth.ParseUUID("5f6d4f53-5f52-5043-5f74-785f63746c5f") + if err != nil { + panic(fmt.Sprintf("parsing BLE service UUID: %v", err)) + } + frameControlRxCharacteristic, err = bluetooth.ParseUUID("5f6d4f53-5f52-5043-5f72-785f63746c5f") + if err != nil { + panic(fmt.Sprintf("parsing BLE service UUID: %v", err)) + } + initialID, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt32)) + if err != nil { + panic(fmt.Sprintf("initializing mGRPC ID: %v", err)) + } + bleMGRPCID = initialID.Int64() +} + +func (d *Discoverer) SearchBLE(ctx context.Context) ([]*Device, error) { + if err := d.enableBLEAdapter(); err != nil { + return nil, err + } + var wg sync.WaitGroup + defer wg.Wait() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + ll := log.Ctx(ctx).With().Str("component", "discovery").Str("subcomponent", "ble").Logger() + + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + if err := d.bleAdapter.StopScan(); err != nil { + ll.Err(err).Msg("stopping BLE scan") + } + }() + var devices []*Device + err := d.bleAdapter.Scan(func(a *bluetooth.Adapter, sr bluetooth.ScanResult) { + ll := ll.With(). + Str("ble_address", sr.Address.String()). + Str("ble_local_name", sr.LocalName()).Logger() + if !sr.AdvertisementPayload.HasServiceUUID(mongooseGATTServiceID) { + ll.Debug().Msg("found non-shelly device") + return + } + ll.Info().Msg("found device") + dev, err := d.AddBLE(ctx, sr.Address.String()) + if err != nil { + ll.Err(err).Msg("adding BLE device") + } + devices = append(devices, dev) + }) + return devices, err +} + +func (d *Discoverer) AddBLE(ctx context.Context, mac string) (*Device, error) { + if err := d.enableBLEAdapter(); err != nil { + return nil, err + } + return d.addDevice(&Device{ + MACAddr: mac, + ble: &BLEDevice{ + bleAdapter: d.bleAdapter, + }, + }), nil +} + +type BLEDevice struct { + lock sync.Mutex + bleAdapter *bluetooth.Adapter + device *bluetooth.Device + service bluetooth.DeviceService + frameChar bluetooth.DeviceCharacteristic + txChar bluetooth.DeviceCharacteristic + rxChar bluetooth.DeviceCharacteristic +} + +var _ mgrpc.MgRPC = &BLEDevice{} + +func (b *BLEDevice) open(ctx context.Context, mac string) error { + ll := log.Ctx(ctx).With().Str("component", "discovery").Str("subcomponent", "ble").Logger() + device, err := b.searchForBLEDevice(ctx, mac, 30*time.Second) + if err != nil { + return fmt.Errorf("connecting to BLE device: %w", err) + } + if device == nil { + return errors.New("failed to find device") + } + services, err := device.DiscoverServices([]bluetooth.UUID{mongooseGATTServiceID}) + if err != nil { + return fmt.Errorf("discovering BLE services: %w", err) + } + if len(services) == 0 { + return errors.New("device is BLE RPC service") + } + b.lock.Lock() + defer b.lock.Unlock() + b.service = services[0] + + ll.Debug().Str("service", b.service.String()).Msg("found service") + chars, err := b.service.DiscoverCharacteristics(nil) + if err != nil { + return fmt.Errorf("reading characteristics: %w", err) + } + + for _, c := range chars { + ll.Debug().Str("service", b.service.String()). + Str("characteristic", c.String()). + Msg("found characteristic") + if c.UUID() == frameDataCharacteristic { + b.frameChar = c + } + if c.UUID() == frameControlRxCharacteristic { + b.rxChar = c + } + if c.UUID() == frameControlTxCharacteristic { + b.txChar = c + } + } + + if b.frameChar.UUID() == (bluetooth.UUID{}) { + return errors.New("BLE RPC service is missing data characteristic") + } + if b.txChar.UUID() == (bluetooth.UUID{}) { + return errors.New("BLE RPC service is missing tx characteristic") + } + if b.rxChar.UUID() == (bluetooth.UUID{}) { + return errors.New("BLE RPC service is missing rx characteristic") + } + return nil +} + +func (b *BLEDevice) searchForBLEDevice(ctx context.Context, mac string, timeout time.Duration) (device *bluetooth.Device, err error) { + var wg sync.WaitGroup + defer wg.Wait() + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ll := log.Ctx(ctx).With().Str("component", "discovery").Str("subcomponent", "ble").Logger() + + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + if err := b.bleAdapter.StopScan(); err != nil { + ll.Err(err).Msg("stopping BLE scan") + } + }() + + err = b.bleAdapter.Scan(func(a *bluetooth.Adapter, sr bluetooth.ScanResult) { + if !strings.EqualFold(sr.Address.String(), mac) { + return + } + ll.Info(). + Str("ble_address", sr.Address.String()). + Str("ble_local_name", sr.LocalName()). + Msg("found device") + var err error + device, err = b.bleAdapter.Connect(sr.Address, bluetooth.ConnectionParams{}) + if err != nil { + ll.Err(err).Msg("connecting to bluetooth device") + } + cancel() + }) + return device, err +} + +func (b *BLEDevice) Call( + ctx context.Context, dst string, cmd *frame.Command, getCreds mgrpc.GetCredsCallback, +) (*frame.Response, error) { + ll := log.Ctx(ctx).With(). + Str("component", "discovery"). + Str("subcomponent", "ble"). + Str("method", cmd.Cmd).Logger() + cmd.ID = atomic.AddInt64(&bleMGRPCID, 1) + reqFrame := frame.NewRequestFrame("shellyctl", "", "", cmd, false) + reqFrameBytes, err := json.Marshal(reqFrame) + if err != nil { + return nil, fmt.Errorf("encoding command: %w", err) + } + reqFrameLen := make([]byte, 4) + binary.BigEndian.PutUint32(reqFrameLen, uint32(len(reqFrameBytes))) + ll.Debug().Hex("command length", reqFrameLen).Msg("encoding command length") + if _, err := b.txChar.WriteWithoutResponse(reqFrameLen); err != nil { + return nil, fmt.Errorf("writing tx length: %w", err) + } + err = b.rxChar.EnableNotifications(func(buf []byte) { + ll.Debug().Int("response length", len(buf)).Msg("got response length") + }) + ll.Debug().Str("characteristic", b.rxChar.String()).Msg("enable notifications") + if err != nil { + return nil, fmt.Errorf("enabling notifications: %w", err) + } + ll.Debug().Str("characteristic", b.txChar.String()).Msg("sent tx length") + if _, err := b.frameChar.WriteWithoutResponse(reqFrameBytes); err != nil { + return nil, fmt.Errorf("writing frame: %w", err) + } + mtu, err := b.frameChar.GetMTU() + if err != nil { + return nil, fmt.Errorf("getting mtu: %w", err) + } + ll.Debug().Str("characteristic", b.frameChar.String()). + Str("frame", string(reqFrameBytes)). + Uint16("mtu", mtu). + Msg("sent frame") + t := time.NewTicker(250 * time.Millisecond) + respFrameLenRaw := make([]byte, 4) + var respFrameLen uint32 + for { + select { + case <-t.C: + case <-ctx.Done(): + return nil, errors.New("nope") + } + _, err := b.rxChar.Read(respFrameLenRaw) + if err != nil { + ll.Err(err).Msg("reading response length") + continue + } + respFrameLen = binary.BigEndian.Uint32(respFrameLenRaw) + ll.Debug().Uint32("response_length", respFrameLen).Hex("response_len", respFrameLenRaw).Msg("got response length") + break + } + respBuf := make([]byte, respFrameLen) + for readBytes := 0; readBytes < int(respFrameLen); { + n, err := b.frameChar.Read(respBuf[readBytes:]) + if err != nil { + ll.Err(err).Msg("reading response") + continue + } + readBytes += n + ll.Debug().Str("resp", string(respBuf[0:readBytes])).Msg("got partial message") + } + ll.Info().Str("resp", string(respBuf)).Msg("message is complete") + respFrame := &frame.Frame{} + if err = json.Unmarshal(respBuf, &respFrame); err != nil { + return nil, fmt.Errorf("parsing response message: %w", err) + } + resp := frame.NewResponseFromFrame(respFrame) + return resp, nil +} + +func (b *BLEDevice) AddHandler(method string, handler mgrpc.Handler) { +} + +func (b *BLEDevice) Disconnect(ctx context.Context) error { + b.lock.Lock() + defer b.lock.Unlock() + device := b.device + b.device = nil + if device == nil { + return nil + } + return device.Disconnect() +} + +func (b *BLEDevice) IsConnected() bool { + b.lock.Lock() + defer b.lock.Unlock() + return b.device != nil +} + +func (b *BLEDevice) SetCodecOptions(opts *codec.Options) error { + return nil +} diff --git a/pkg/discovery/device.go b/pkg/discovery/device.go index 0094543..d0cdbca 100644 --- a/pkg/discovery/device.go +++ b/pkg/discovery/device.go @@ -3,6 +3,7 @@ package discovery import ( "context" "fmt" + "net/url" "time" "github.com/jcodybaker/go-shelly" @@ -11,16 +12,23 @@ import ( // Device describes one shelly device. type Device struct { - URI string + uri string MACAddr string Specs shelly.DeviceSpecs lastSeen time.Time source discoverySource + ble *BLEDevice } // Open creates an mongoose rpc channel to the device. func (d *Device) Open(ctx context.Context) (mgrpc.MgRPC, error) { - c, err := mgrpc.New(ctx, d.URI, mgrpc.UseHTTPPost()) + if d.ble != nil { + if err := d.ble.open(ctx, d.MACAddr); err != nil { + return nil, err + } + return d.ble, nil + } + c, err := mgrpc.New(ctx, d.uri, mgrpc.UseHTTPPost()) if err != nil { return nil, fmt.Errorf("establishing rpc channel: %w", err) } @@ -45,3 +53,10 @@ func (d *Device) resolveSpecs(ctx context.Context) error { d.MACAddr = resp.MAC return nil } + +func (d *Device) Instance() string { + if d.ble != nil { + return (&url.URL{Scheme: "ble", Host: d.MACAddr}).String() + } + return d.uri +} diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go index 2c63c97..c11bf93 100644 --- a/pkg/discovery/discovery.go +++ b/pkg/discovery/discovery.go @@ -4,13 +4,15 @@ import ( "context" "errors" "fmt" - "net" "net/url" "strings" "sync" "time" "github.com/hashicorp/mdns" + "github.com/rs/zerolog/log" + "golang.org/x/sync/errgroup" + "tinygo.org/x/bluetooth" ) const ( @@ -25,43 +27,34 @@ const ( func NewDiscoverer(opts ...DiscovererOption) *Discoverer { d := &Discoverer{ - deviceTTL: DefaultDeviceTTL, - now: time.Now, - knownDevices: make(map[string]*Device), - mdnsZone: DefaultMDNSZone, - mdnsService: DefaultMDNSService, - searchTimeout: DefaultMDNSSearchTimeout, - concurrency: DefaultConcurrency, - mdnsQueryFunc: mdns.Query, + knownDevices: make(map[string]*Device), + options: &options{ + bleAdapter: bluetooth.DefaultAdapter, + now: time.Now, + deviceTTL: DefaultDeviceTTL, + mdnsZone: DefaultMDNSZone, + mdnsService: DefaultMDNSService, + searchTimeout: DefaultMDNSSearchTimeout, + concurrency: DefaultConcurrency, + mdnsQueryFunc: mdns.Query, + }, } for _, o := range opts { o(d) } + d.enableBLEAdapter = sync.OnceValue[error](func() error { + log.Logger.Debug().Msg("enabling BLE adapter") + return d.bleAdapter.Enable() + }) return d } // Discoverer finds shelly gen 2 devices and provides basic metadata. type Discoverer struct { + *options knownDevices map[string]*Device - mdnsInterface *net.Interface - mdnsZone string - mdnsService string - mdnsEnabled bool - - searchTimeout time.Duration - concurrency int - - preferIPVersion string - - mdnsQueryFunc func(*mdns.QueryParam) error - now func() time.Time - lock sync.Mutex - - // deviceTTL is relevant for long-lived commands (like prometheus metrics server) when - // mixed with mDNS or other ephemeral discovery. - deviceTTL time.Duration } // AddDeviceByAddress attempts to parse a user-provided URI and add the device. @@ -98,13 +91,18 @@ func (d *Discoverer) AddDeviceByAddress(ctx context.Context, addr string, opts . } dev := &Device{ - URI: u.String(), + uri: u.String(), source: sourceManual, } if err = dev.resolveSpecs(ctx); err != nil { return nil, err } dev.lastSeen = d.now() + dev = d.addDevice(dev, opts...) + return dev, nil +} + +func (d *Discoverer) addDevice(dev *Device, opts ...DeviceOption) *Device { for _, o := range opts { o(dev) } @@ -112,10 +110,10 @@ func (d *Discoverer) AddDeviceByAddress(ctx context.Context, addr string, opts . defer d.lock.Unlock() if existingDev, ok := d.knownDevices[dev.MACAddr]; ok { existingDev.lastSeen = dev.lastSeen - return existingDev, nil + return existingDev } d.knownDevices[dev.MACAddr] = dev - return dev, nil + return dev } // AllDevices returns all known devices. @@ -128,3 +126,31 @@ func (d *Discoverer) AllDevices() []*Device { } return out } + +func (d *Discoverer) Search(ctx context.Context) ([]*Device, error) { + if !d.bleSearchEnabled && !d.mdnsSearchEnabled { + return nil, nil + } + var l sync.Mutex + var allDevs []*Device + eg, ctx := errgroup.WithContext(ctx) + if d.bleSearchEnabled { + eg.Go(func() error { + devs, err := d.SearchBLE(ctx) + l.Lock() + defer l.Unlock() + allDevs = append(allDevs, devs...) + return err + }) + } + if d.mdnsSearchEnabled { + eg.Go(func() error { + devs, err := d.SearchMDNS(ctx) + l.Lock() + defer l.Unlock() + allDevs = append(allDevs, devs...) + return err + }) + } + return allDevs, eg.Wait() +} diff --git a/pkg/discovery/mdns.go b/pkg/discovery/mdns.go index 4c47f43..3f3e06a 100644 --- a/pkg/discovery/mdns.go +++ b/pkg/discovery/mdns.go @@ -17,9 +17,9 @@ const ( mdnsSearchBuffer = 50 ) -// MDNSSearch finds new devices via mDNS. -func (d *Discoverer) MDNSSearch(ctx context.Context) ([]*Device, error) { - if !d.mdnsEnabled { +// SearchMDNS finds new devices via mDNS. +func (d *Discoverer) SearchMDNS(ctx context.Context) ([]*Device, error) { + if !d.mdnsSearchEnabled { return nil, nil } c := make(chan *mdns.ServiceEntry, mdnsSearchBuffer) diff --git a/pkg/discovery/mdns_test.go b/pkg/discovery/mdns_test.go index bf1425d..740bc76 100644 --- a/pkg/discovery/mdns_test.go +++ b/pkg/discovery/mdns_test.go @@ -68,7 +68,7 @@ func TestDiscovererMDNSSearch(t *testing.T) { d := NewDiscoverer( func(d *Discoverer) { d.mdnsQueryFunc = queryFunc }, ) - devs, err := d.MDNSSearch(ctx) + devs, err := d.SearchMDNS(ctx) require.NoError(t, err) assert.Len(t, devs, 1) } diff --git a/pkg/discovery/options.go b/pkg/discovery/options.go index 554cdb7..4b709d0 100644 --- a/pkg/discovery/options.go +++ b/pkg/discovery/options.go @@ -3,8 +3,34 @@ package discovery import ( "net" "time" + + "github.com/hashicorp/mdns" + "tinygo.org/x/bluetooth" ) +type options struct { + bleAdapter *bluetooth.Adapter + enableBLEAdapter func() error + bleSearchEnabled bool + now func() time.Time + + mdnsInterface *net.Interface + mdnsZone string + mdnsService string + mdnsSearchEnabled bool + + searchTimeout time.Duration + concurrency int + + // deviceTTL is relevant for long-lived commands (like prometheus metrics server) when + // mixed with mDNS or other ephemeral discovery. + deviceTTL time.Duration + + preferIPVersion string + + mdnsQueryFunc func(*mdns.QueryParam) error +} + // DiscovererOption provides optional parameters for the Discoverer. type DiscovererOption func(*Discoverer) @@ -62,7 +88,14 @@ func WithDeviceTTL(ttl time.Duration) DiscovererOption { // WithMDNSSearchEnabled allows enabling or disabling mDNS discovery. func WithMDNSSearchEnabled(enabled bool) DiscovererOption { return func(d *Discoverer) { - d.mdnsEnabled = enabled + d.mdnsSearchEnabled = enabled + } +} + +// WithBLEAdapter configures a BLE adapter for use in discovery. +func WithBLEAdapter(ble *bluetooth.Adapter) DiscovererOption { + return func(d *Discoverer) { + d.bleAdapter = ble } } diff --git a/pkg/discovery/test_harness.go b/pkg/discovery/test_harness.go index 295b751..b512c7d 100644 --- a/pkg/discovery/test_harness.go +++ b/pkg/discovery/test_harness.go @@ -64,7 +64,7 @@ func (td *TestDiscoverer) NewTestDevice(t *testing.T, add bool) *TestDevice { u, err := url.Parse(d.s.URL) require.NoError(t, err) u.Path = "/rpc" - d.URI = u.String() + d.uri = u.String() t.Cleanup(d.Shutdown) if add { td.knownDevices[d.MACAddr] = d.Device diff --git a/pkg/gencobra/gencobra.go b/pkg/gencobra/gencobra.go index ab9133b..13e62d4 100644 --- a/pkg/gencobra/gencobra.go +++ b/pkg/gencobra/gencobra.go @@ -53,12 +53,12 @@ func RequestToCmd(req shelly.RPCRequestBody, baggage *Baggage) (*cobra.Command, return err } - if _, err := baggage.Discoverer.MDNSSearch(ctx); err != nil { + if _, err := baggage.Discoverer.Search(ctx); err != nil { return err } for _, d := range baggage.Discoverer.AllDevices() { - ll := ll.With().Str("instance", d.URI).Logger() + ll := ll.With().Str("instance", d.Instance()).Logger() conn, err := d.Open(ctx) if err != nil { return err @@ -186,6 +186,14 @@ func newFlagReader(f *pflag.FlagSet, method string) fieldFunc { var b bool b, err = f.GetBool(flagName) fieldValue.Set(reflect.ValueOf(&b)) + case reflect.TypeOf(float64(0)): + var i float64 + i, err = f.GetFloat64(flagName) + fieldValue.SetFloat(float64(i)) + case reflect.TypeOf((*float64)(nil)): + var i float64 + i, err = f.GetFloat64(flagName) + fieldValue.Set(reflect.ValueOf(&i)) case reflect.TypeOf(int(0)): var i int i, err = f.GetInt(flagName) diff --git a/pkg/promserver/server.go b/pkg/promserver/server.go index 2b14431..15f691c 100644 --- a/pkg/promserver/server.go +++ b/pkg/promserver/server.go @@ -256,7 +256,7 @@ func (s *Server) Describe(ch chan<- *prometheus.Desc) { // Collect implements prometheus.Collector. func (s *Server) Collect(ch chan<- prometheus.Metric) { l := log.Ctx(s.ctx) - if _, err := s.discoverer.MDNSSearch(s.ctx); err != nil { + if _, err := s.discoverer.SearchMDNS(s.ctx); err != nil { l.Err(err).Msg("finding new mdns devices") } var wg sync.WaitGroup @@ -286,7 +286,7 @@ func (s *Server) Collect(ch chan<- prometheus.Metric) { func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan<- prometheus.Metric) { l := log.Ctx(ctx).With(). Str("mac", d.MACAddr). - Str("uri", d.URI). + Str("uri", d.Instance()). Logger() c, err := d.Open(s.ctx) if err != nil { @@ -334,7 +334,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.switchOutputOnDesc, prometheus.GaugeValue, ptrBoolToFloat64(sws.Output), - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -351,7 +351,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.totalEnergyWattHoursDesc, prometheus.CounterValue, sws.AEnergy.Total, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -369,7 +369,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.totalReturnedEnergyWattHoursDesc, prometheus.CounterValue, sws.RetAEnergy.Total, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -387,7 +387,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.temperatureCelsiusDesc, prometheus.GaugeValue, *sws.Temperature.C, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -405,7 +405,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.temperatureFahrenheitDesc, prometheus.GaugeValue, *sws.Temperature.F, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -423,7 +423,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.networkFrequencyHertzDesc, prometheus.GaugeValue, *sws.Freq, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -441,7 +441,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.powerFactorDesc, prometheus.GaugeValue, *sws.PF, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -459,7 +459,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.voltageDesc, prometheus.GaugeValue, *sws.Voltage, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -477,7 +477,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.currentAmperesDesc, prometheus.GaugeValue, *sws.Current, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -495,7 +495,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.instantaneousActivePowerWattsDesc, prometheus.GaugeValue, *sws.APower, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -535,7 +535,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.componentErrorDesc, prometheus.GaugeValue, eValue, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -568,7 +568,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.coverPositionDesc, prometheus.GaugeValue, currentPos, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -584,7 +584,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.coverPositionControlEnabled, prometheus.GaugeValue, ptrBoolToFloat64(cs.PosControl), - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -605,7 +605,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.coverStateDesc, prometheus.GaugeValue, stateActive, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -624,7 +624,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.totalEnergyWattHoursDesc, prometheus.CounterValue, cs.AEnergy.Total, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -642,7 +642,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.temperatureCelsiusDesc, prometheus.GaugeValue, *cs.Temperature.C, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -660,7 +660,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.temperatureFahrenheitDesc, prometheus.GaugeValue, *cs.Temperature.F, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -678,7 +678,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.networkFrequencyHertzDesc, prometheus.GaugeValue, *cs.Freq, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -696,7 +696,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.powerFactorDesc, prometheus.GaugeValue, *cs.PF, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -714,7 +714,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.voltageDesc, prometheus.GaugeValue, *cs.Voltage, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -732,7 +732,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.currentAmperesDesc, prometheus.GaugeValue, *cs.Current, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -750,7 +750,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.instantaneousActivePowerWattsDesc, prometheus.GaugeValue, *cs.APower, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -790,7 +790,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.componentErrorDesc, prometheus.GaugeValue, eValue, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -821,7 +821,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.inputEnabledDesc, prometheus.GaugeValue, ptrBoolToFloat64(ic.Enable), - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -839,7 +839,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.inputStateOnDesc, prometheus.GaugeValue, ptrBoolToFloat64(is.State), - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -857,7 +857,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.inputPercentDesc, prometheus.GaugeValue, *is.Percent, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -874,7 +874,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.inputXPercentDesc, prometheus.GaugeValue, *is.XPercent, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, @@ -913,7 +913,7 @@ func (s *Server) collectDevice(ctx context.Context, d *discovery.Device, ch chan s.componentErrorDesc, prometheus.GaugeValue, eValue, - d.URI, + d.Instance(), d.MACAddr, deviceName, componentName, diff --git a/pkg/promserver/server_test.go b/pkg/promserver/server_test.go index dbd52d6..3ab4041 100644 --- a/pkg/promserver/server_test.go +++ b/pkg/promserver/server_test.go @@ -745,7 +745,7 @@ shelly_status_voltage{component="switch",component_name="Lift Pump",device_name= require.NoError(t, err) t.Log(string(body)) - expect := strings.NewReplacer("$INSTANCE_DEVICE_1", d1.URI, "$MAC_DEVICE_1", d1.MACAddr).Replace(tc.expect) + expect := strings.NewReplacer("$INSTANCE_DEVICE_1", d1.Instance(), "$MAC_DEVICE_1", d1.MACAddr).Replace(tc.expect) require.NoError(t, testutil.ScrapeAndCompare(metricserver.URL, bytes.NewBufferString(expect))) }) }