From e5d6bc61b1c398caeee6276a4a2672ba36909a36 Mon Sep 17 00:00:00 2001 From: Ingmar Stein Date: Wed, 28 Jun 2023 21:16:51 +0200 Subject: [PATCH 1/3] Add support for gen2 devices and beta versions Also: - Include device address and generation in log messages - Update to Go 1.21 --- .gitignore | 2 + README.md | 18 ++++-- go.mod | 12 +++- go.sum | 18 ++++-- main.go | 174 +++++++++++++++++++++++++++++++++++++++++++---------- 5 files changed, 179 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index 6a20b4d..43d8c85 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ # Built binaries bin/ + +.idea diff --git a/README.md b/README.md index ea031df..c2ba4d0 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,24 @@ Ensure you are on the same network as your Shelly devices. Then run the binary: ./shelly-bulk-update ``` -It will automatically discover all your Shelly devices using mDNS and attempt to update them if possible. +It will automatically discover all your Shelly devices using mDNS and attempt to update them to the latest stable +version if possible. Please note: * The initial discovery can take up to 1 minute. -* While updates are in progress and devices are restarting, you might see connection errors. Sometimes it takes a few minutes, please be patient :-) +* While updates are in progress and devices are restarting, you might see connection errors. Sometimes it takes a few + minutes, please be patient :-) -If any (or all) of your devices have authentication enabled, use the `--username` and `--password` flags to define your credentials: +If any (or all) of your devices have authentication enabled, use the `-username` and `-password` flags to define your +credentials: ```bash -./shelly-bulk-update --username admin --password MyPa$$w0rd +./shelly-bulk-update -username admin -password MyPa$$w0rd ``` + +To update your Shelly devices to the latest beta version, use `-stage=beta`. + +If you only want to update all Shelly devices of a specific device generation, use either `-gen=1` for +[generation 1](https://shelly-api-docs.shelly.cloud/gen1/#shelly-family-overview) or `-gen=2` for +[generation 2](https://shelly-api-docs.shelly.cloud/gen2/). For example, this can be used to update all second +generation devices to the latest beta version but keep first generation devices on the stable track. diff --git a/go.mod b/go.mod index 51eb175..4de0580 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,14 @@ module github.com/fermayo/shelly-bulk-update -go 1.16 +go 1.21 + +require github.com/grandcat/zeroconf v1.0.0 require ( - github.com/grandcat/zeroconf v1.0.0 - golang.org/x/sys v0.1.0 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/miekg/dns v1.1.56 // indirect + golang.org/x/mod v0.13.0 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/tools v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 2e71cb6..4487853 100644 --- a/go.sum +++ b/go.sum @@ -2,26 +2,32 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= -github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= +github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= +golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= +golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 25f31fb..c7c7055 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,8 @@ import ( "io" "log" "net/http" + "os" + "slices" "strings" "sync" "time" @@ -16,22 +18,44 @@ import ( ) const ( - OtaUrl = "http://%s/ota" - OtaCheckUrl = "http://%s/ota/check" + // https://shelly-api-docs.shelly.cloud/gen1/#ota + otaUrl = "http://%s/ota" + + // https://shelly-api-docs.shelly.cloud/gen1/#ota-check + otaCheckUrl = "http://%s/ota/check" + + // https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#shellycheckforupdate + checkForUpdateUrl = "http://%s/rpc/Shelly.CheckForUpdate" + + // https://shelly-api-docs.shelly.cloud/gen2/ComponentsAndServices/Shelly#shellyupdate + updateUrl = "http://%s/rpc/Shelly.Update?stage=%s" ) var ( scanTimeout = time.Second * 60 username string password string + updateStage string + genToUpdate int ) type ( + versionInfo struct { + Version string `json:"version"` + BuildId string `json:"build_id"` + } + + checkForUpdateResponse struct { + Stable versionInfo `json:"stable"` + Beta versionInfo `json:"beta"` + } + shellyUpdateStatusResponse struct { - Status string `json:"status"` - HasUpdate bool `json:"has_update"` - NewVersion string `json:"new_version"` - OldVersion string `json:"old_version"` + Status string `json:"status"` + HasUpdate bool `json:"has_update"` + NewVersion string `json:"new_version"` + OldVersion string `json:"old_version"` + BetaVersion string `json:"beta_version"` } shellyUpdateCheckResponse struct { @@ -68,10 +92,13 @@ func makeGetRequest(url string) ([]byte, error) { } func makeShellyUpdateRequest(hostname string, update bool) (*shellyUpdateStatusResponse, error) { - url := OtaUrl - + url := otaUrl if update { - url += "?update=1" + if updateStage == "beta" { + url += "?beta=1" + } else { + url += "?update=1" + } } body, err := makeGetRequest(fmt.Sprintf(url, hostname)) @@ -89,7 +116,7 @@ func makeShellyUpdateRequest(hostname string, update bool) (*shellyUpdateStatusR } func triggerShellyUpdateCheck(hostname string) (*shellyUpdateCheckResponse, error) { - body, err := makeGetRequest(fmt.Sprintf(OtaCheckUrl, hostname)) + body, err := makeGetRequest(fmt.Sprintf(otaCheckUrl, hostname)) if err != nil { return nil, err } @@ -103,6 +130,30 @@ func triggerShellyUpdateCheck(hostname string) (*shellyUpdateCheckResponse, erro return checkStatus, nil } +func makeGen2CheckForUpdateRequest(hostname string) (*checkForUpdateResponse, error) { + body, err := makeGetRequest(fmt.Sprintf(checkForUpdateUrl, hostname)) + if err != nil { + return nil, err + } + + var checkForUpdate *checkForUpdateResponse + err = json.Unmarshal(body, &checkForUpdate) + if err != nil { + return nil, err + } + + return checkForUpdate, nil +} + +func makeGen2UpdateRequest(hostname string, stage string) error { + _, err := makeGetRequest(fmt.Sprintf(updateUrl, hostname, stage)) + if err != nil { + return err + } + + return nil +} + func triggerShellyUpdate(hostname string) (*shellyUpdateStatusResponse, error) { return makeShellyUpdateRequest(hostname, true) } @@ -111,16 +162,13 @@ func checkShellyUpdateStatus(hostname string) (*shellyUpdateStatusResponse, erro return makeShellyUpdateRequest(hostname, false) } -func updateShelly(instance *zeroconf.ServiceEntry, wg *sync.WaitGroup) { - defer wg.Done() - - shellyAddress := instance.AddrIPv4[0].String() - +func updateShellyGen1(name, address string) { + prefix := fmt.Sprintf("[%s/%s/gen1]", name, address) // First, we trigger a check for updates - fmt.Printf("[%s] checking for updates...\n", instance.HostName) - _, err := triggerShellyUpdateCheck(shellyAddress) + fmt.Printf("%s checking for updates...\n", prefix) + _, err := triggerShellyUpdateCheck(address) if err != nil { - fmt.Printf("[%s] failed to check for updates: %s, aborting...\n", instance.Instance, err) + fmt.Printf("%s failed to check for updates: %s, aborting...\n", prefix, err) return } @@ -128,45 +176,91 @@ func updateShelly(instance *zeroconf.ServiceEntry, wg *sync.WaitGroup) { time.Sleep(time.Second * 5) // Then, we check if there are any updates available - updateStatus, err := checkShellyUpdateStatus(shellyAddress) + updateStatus, err := checkShellyUpdateStatus(address) if err != nil { - fmt.Printf("[%s] failed to query update status: %s, aborting...\n", instance.Instance, err) + fmt.Printf("%s failed to query update status: %s, aborting...\n", prefix, err) return } // If there's an update available, trigger the update - if updateStatus.HasUpdate { + if (updateStage == "stable" && updateStatus.HasUpdate) || + (updateStage == "beta" && updateStatus.OldVersion != updateStatus.BetaVersion) { + newVersion := updateStatus.NewVersion + if updateStage == "beta" { + newVersion = updateStatus.BetaVersion + } fmt.Printf( - "[%s] update available! (%s -> %s), updating...\n", - instance.HostName, updateStatus.OldVersion, - updateStatus.NewVersion, + "%s update available! (%s -> %s), updating...\n", + prefix, updateStatus.OldVersion, newVersion, ) - updateStatus, err := triggerShellyUpdate(shellyAddress) + updateStatus, err := triggerShellyUpdate(address) if err != nil { - fmt.Printf("[%s] failed to start update: %s, aborting...\n", instance.Instance, err) + fmt.Printf("%s failed to start update: %s, aborting...\n", prefix, err) return } for updateStatus.Status == "updating" { time.Sleep(time.Second * 5) - updateStatusCheck, err := checkShellyUpdateStatus(shellyAddress) + updateStatusCheck, err := checkShellyUpdateStatus(address) if err != nil { - fmt.Printf("[%s] failed to query update status: %s, retrying...\n", instance.Instance, err) + fmt.Printf("%s failed to query update status: %s, retrying...\n", prefix, err) } else { updateStatus = updateStatusCheck } } - fmt.Printf("[%s] device updated to %s!\n", instance.Instance, updateStatus.OldVersion) + fmt.Printf("%s device updated to %s!\n", prefix, updateStatus.OldVersion) } else { - fmt.Printf("[%s] already up to date (%s)\n", instance.Instance, updateStatus.OldVersion) + fmt.Printf("%s already up to date (%s)\n", prefix, updateStatus.OldVersion) + } +} + +func updateShellyGen2(name, address string) { + prefix := fmt.Sprintf("[%s/%s/gen2]", name, address) + // First, we trigger a check for updates + fmt.Printf("%s checking for updates...\n", prefix) + updates, err := makeGen2CheckForUpdateRequest(address) + if err != nil { + fmt.Printf("%s failed to check for updates: %s, aborting...\n", prefix, err) + return + } + + updateVersion := updates.Stable.Version + if updateStage == "beta" { + updateVersion = updates.Beta.Version + } + if updateVersion == "" { + fmt.Printf("%s already up to date\n", prefix) + return + } + + fmt.Printf("%s updating to version %s...\n", prefix, updateVersion) + err = makeGen2UpdateRequest(address, updateStage) + if err != nil { + fmt.Printf("%s failed to update: %s, aborting...\n", prefix, err) + return + } +} + +func updateShelly(name, address string, txtRecords []string, genToUpdate int) { + if slices.Contains(txtRecords, "gen=2") { + if genToUpdate == 2 || genToUpdate == 0 { + updateShellyGen2(name, address) + } + return + } + + if genToUpdate == 1 || genToUpdate == 0 { + updateShellyGen1(name, address) } } func main() { flag.StringVar(&username, "username", "admin", "username to use for authentication") flag.StringVar(&password, "password", "", "password to use for authentication") + flag.StringVar(&updateStage, "stage", "stable", "stable or beta") + flag.IntVar(&genToUpdate, "gen", 0, "device generation to update (default: all)") flag.Parse() @@ -174,6 +268,11 @@ func main() { fmt.Printf("Using basic authentication: %s:*******\n", username) } + if updateStage != "stable" && updateStage != "beta" { + flag.Usage() + os.Exit(2) + } + resolver, err := zeroconf.NewResolver(nil) if err != nil { log.Fatalln("Failed to initialize resolver:", err.Error()) @@ -184,9 +283,20 @@ func main() { go func(results <-chan *zeroconf.ServiceEntry) { fmt.Printf("[scanner] looking for Shelly devices using mDNS (%ds timeout)...\n", int(scanTimeout.Seconds())) for entry := range results { - if strings.HasPrefix(entry.Instance, "shelly") { + entry := entry + if strings.HasPrefix(strings.ToLower(entry.Instance), "shelly") { wg.Add(1) - go updateShelly(entry, &wg) + go func() { + address := entry.HostName + if len(entry.AddrIPv4) > 0 { + address = entry.AddrIPv4[0].String() + // not yet: + //} else if len(entry.AddrIPv6) > 0 { + // address = fmt.Sprintf("[%s]", entry.AddrIPv6[0].String()) + } + updateShelly(entry.Instance, address, entry.Text, genToUpdate) + wg.Done() + }() } } fmt.Println("[scanner] scanning process finished") From 9f18231ba8f6aaafced7e501665d1b0eb28f27e8 Mon Sep 17 00:00:00 2001 From: Ingmar Stein Date: Sun, 15 Oct 2023 21:07:13 +0200 Subject: [PATCH 2/3] Add GitHub workflow --- .github/workflows/go.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/go.yml diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..7975ff7 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,28 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Build + run: make + + - name: Test + run: go test -v ./... From 67e2972f441123c07f4e6c5844c5b8a81fe8bdc6 Mon Sep 17 00:00:00 2001 From: Ingmar Stein Date: Sun, 15 Oct 2023 21:07:52 +0200 Subject: [PATCH 3/3] Add Darwin-arm64 build for modern Macs --- .github/workflows/go.yml | 3 +++ Makefile | 5 ++++- main.go | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7975ff7..9c8d730 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -26,3 +26,6 @@ jobs: - name: Test run: go test -v ./... + + - name: Vet + run: go vet ./... diff --git a/Makefile b/Makefile index c900874..c334312 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ .PHONY: all -all: bin/shelly-bulk-update-Darwin-x86_64 bin/shelly-bulk-update-Linux-x86_64 bin/shelly-bulk-update-Windows-x86_64.exe +all: bin/shelly-bulk-update-Darwin-x86_64 bin/shelly-bulk-update-Darwin-arm64 bin/shelly-bulk-update-Linux-x86_64 bin/shelly-bulk-update-Windows-x86_64.exe bin/shelly-bulk-update-Darwin-x86_64: main.go GOOS=darwin GOARCH=amd64 go build -o $@ . +bin/shelly-bulk-update-Darwin-arm64: main.go + GOOS=darwin GOARCH=arm64 go build -o $@ . + bin/shelly-bulk-update-Linux-x86_64: main.go GOOS=linux GOARCH=amd64 go build -o $@ . diff --git a/main.go b/main.go index c7c7055..34bec6a 100644 --- a/main.go +++ b/main.go @@ -299,7 +299,6 @@ func main() { }() } } - fmt.Println("[scanner] scanning process finished") }(entries) ctx, cancel := context.WithTimeout(context.Background(), scanTimeout) @@ -310,5 +309,6 @@ func main() { } <-ctx.Done() + fmt.Println("[scanner] scanning process finished") wg.Wait() }