Skip to content

Commit af23c60

Browse files
author
Paolo Calao
authored
OTA on multiple devices (#58)
A new command ota mass-upload has been implemented. This command accepts two mutually exclusive flags to select devices: --device-ids and --tags It performs up to 10 concurrent OTA requests. It returns a struct containing information about the OTA results. It takes a mandatory --fqbn flag and uses it to validate the devices to be updated: if a device's fqbn doesn't match the passed one, it is returned as invalid result
1 parent 16536b0 commit af23c60

File tree

7 files changed

+419
-6
lines changed

7 files changed

+419
-6
lines changed

README.md

+8
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,14 @@ The default OTA upload should complete in 10 minutes. Use `--deferred` flag to e
156156

157157
`$ arduino-cloud-cli ota upload --device-id <deviceID> --file <sketch-file.ino.bin> --deferred`
158158

159+
It is also possible to perform a mass ota upload through a specific command.
160+
The fqbn is mandatory.
161+
To select the devices to update you can either provide a list of device ids or device tags.
162+
163+
`$ arduino-cloud-cli ota mass-upload --fqbn <deviceFQBN> --device-ids <deviceIDs> --file <sketch-file.ino.bin>`
164+
165+
`$ arduino-cloud-cli ota mass-upload --fqbn <deviceFQBN> --device-tags <key0>=<value0>,<key1>=<value1> --file <sketch-file.ino.bin>`
166+
159167
## Dashboard commands
160168

161169
Print a list of available dashboards and their widgets by using this command:

cli/ota/massupload.go

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// This file is part of arduino-cloud-cli.
2+
//
3+
// Copyright (C) 2021 ARDUINO SA (http://www.arduino.cc/)
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU Affero General Public License as published
7+
// by the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU Affero General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU Affero General Public License
16+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
package ota
19+
20+
import (
21+
"os"
22+
"sort"
23+
"strings"
24+
25+
"github.com/arduino/arduino-cli/cli/errorcodes"
26+
"github.com/arduino/arduino-cli/cli/feedback"
27+
"github.com/arduino/arduino-cli/table"
28+
"github.com/arduino/arduino-cloud-cli/command/ota"
29+
"github.com/sirupsen/logrus"
30+
"github.com/spf13/cobra"
31+
)
32+
33+
var massUploadFlags struct {
34+
deviceIDs []string
35+
tags map[string]string
36+
file string
37+
deferred bool
38+
fqbn string
39+
}
40+
41+
func initMassUploadCommand() *cobra.Command {
42+
massUploadCommand := &cobra.Command{
43+
Use: "mass-upload",
44+
Short: "Mass OTA upload",
45+
Long: "Mass OTA upload on devices of Arduino IoT Cloud",
46+
Run: runMassUploadCommand,
47+
}
48+
49+
massUploadCommand.Flags().StringSliceVarP(&massUploadFlags.deviceIDs, "device-ids", "d", nil,
50+
"Comma-separated list of device IDs to update")
51+
massUploadCommand.Flags().StringToStringVar(&massUploadFlags.tags, "device-tags", nil,
52+
"Comma-separated list of tags with format <key>=<value>.\n"+
53+
"Perform an OTA upload on all devices that match the provided tags.\n"+
54+
"Mutually exclusive with `--device-ids`.",
55+
)
56+
massUploadCommand.Flags().StringVarP(&massUploadFlags.file, "file", "", "", "Binary file (.bin) to be uploaded")
57+
massUploadCommand.Flags().BoolVar(&massUploadFlags.deferred, "deferred", false, "Perform a deferred OTA. It can take up to 1 week.")
58+
massUploadCommand.Flags().StringVarP(&massUploadFlags.fqbn, "fqbn", "b", "", "FQBN of the devices to update")
59+
60+
massUploadCommand.MarkFlagRequired("file")
61+
massUploadCommand.MarkFlagRequired("fqbn")
62+
return massUploadCommand
63+
}
64+
65+
func runMassUploadCommand(cmd *cobra.Command, args []string) {
66+
logrus.Infof("Uploading binary %s", massUploadFlags.file)
67+
68+
params := &ota.MassUploadParams{
69+
DeviceIDs: massUploadFlags.deviceIDs,
70+
Tags: massUploadFlags.tags,
71+
File: massUploadFlags.file,
72+
Deferred: massUploadFlags.deferred,
73+
FQBN: massUploadFlags.fqbn,
74+
}
75+
76+
resp, err := ota.MassUpload(params)
77+
if err != nil {
78+
feedback.Errorf("Error during ota upload: %v", err)
79+
os.Exit(errorcodes.ErrGeneric)
80+
}
81+
82+
// Put successful devices ahead
83+
sort.SliceStable(resp, func(i, j int) bool {
84+
return resp[i].Err == nil
85+
})
86+
87+
feedback.PrintResult(massUploadResult{resp})
88+
89+
var failed []string
90+
for _, r := range resp {
91+
if r.Err != nil {
92+
failed = append(failed, r.ID)
93+
}
94+
}
95+
if len(failed) == 0 {
96+
return
97+
}
98+
failStr := strings.Join(failed, ",")
99+
feedback.Printf(
100+
"You can try to perform the OTA again on the failed devices using the following command:\n"+
101+
"$ arduino-cloud-cli ota upload --file %s -d %s", params.File, failStr,
102+
)
103+
}
104+
105+
type massUploadResult struct {
106+
res []ota.Result
107+
}
108+
109+
func (r massUploadResult) Data() interface{} {
110+
return r.res
111+
}
112+
113+
func (r massUploadResult) String() string {
114+
if len(r.res) == 0 {
115+
return "No OTA done."
116+
}
117+
t := table.New()
118+
t.SetHeader("ID", "Result")
119+
for _, r := range r.res {
120+
outcome := "Success"
121+
if r.Err != nil {
122+
outcome = r.Err.Error()
123+
}
124+
125+
t.AddRow(
126+
r.ID,
127+
outcome,
128+
)
129+
}
130+
return t.Render()
131+
}

cli/ota/ota.go

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func NewCommand() *cobra.Command {
2929
}
3030

3131
otaCommand.AddCommand(initUploadCommand())
32+
otaCommand.AddCommand(initMassUploadCommand())
3233

3334
return otaCommand
3435
}

command/ota/massupload.go

+188
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// This file is part of arduino-cloud-cli.
2+
//
3+
// Copyright (C) 2021 ARDUINO SA (http://www.arduino.cc/)
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU Affero General Public License as published
7+
// by the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU Affero General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU Affero General Public License
16+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
18+
package ota
19+
20+
import (
21+
"errors"
22+
"fmt"
23+
"io/ioutil"
24+
"os"
25+
"path/filepath"
26+
27+
"github.com/arduino/arduino-cloud-cli/internal/config"
28+
"github.com/arduino/arduino-cloud-cli/internal/iot"
29+
iotclient "github.com/arduino/iot-client-go"
30+
)
31+
32+
const (
33+
numConcurrentUploads = 10
34+
)
35+
36+
// MassUploadParams contains the parameters needed to
37+
// perform a Mass OTA upload.
38+
type MassUploadParams struct {
39+
DeviceIDs []string
40+
Tags map[string]string
41+
File string
42+
Deferred bool
43+
FQBN string
44+
}
45+
46+
// Result of an ota upload on a device
47+
type Result struct {
48+
ID string
49+
Err error
50+
}
51+
52+
// MassUpload command is used to mass upload a firmware OTA,
53+
// on devices of Arduino IoT Cloud.
54+
func MassUpload(params *MassUploadParams) ([]Result, error) {
55+
if params.DeviceIDs == nil && params.Tags == nil {
56+
return nil, errors.New("provide either DeviceIDs or Tags")
57+
} else if params.DeviceIDs != nil && params.Tags != nil {
58+
return nil, errors.New("cannot use both DeviceIDs and Tags. only one of them should be not nil")
59+
}
60+
61+
// Generate .ota file
62+
otaDir, err := ioutil.TempDir("", "")
63+
if err != nil {
64+
return nil, fmt.Errorf("%s: %w", "cannot create temporary folder", err)
65+
}
66+
otaFile := filepath.Join(otaDir, "temp.ota")
67+
defer os.RemoveAll(otaDir)
68+
69+
err = Generate(params.File, otaFile, params.FQBN)
70+
if err != nil {
71+
return nil, fmt.Errorf("%s: %w", "cannot generate .ota file", err)
72+
}
73+
74+
conf, err := config.Retrieve()
75+
if err != nil {
76+
return nil, err
77+
}
78+
iotClient, err := iot.NewClient(conf.Client, conf.Secret)
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
// Prepare the list of device-ids to update
84+
d, err := idsGivenTags(iotClient, params.Tags)
85+
if err != nil {
86+
return nil, err
87+
}
88+
d = append(params.DeviceIDs, d...)
89+
valid, invalid, err := validateDevices(iotClient, d, params.FQBN)
90+
if err != nil {
91+
return nil, fmt.Errorf("failed to validate devices: %w", err)
92+
}
93+
if len(valid) == 0 {
94+
return invalid, nil
95+
}
96+
97+
expiration := otaExpirationMins
98+
if params.Deferred {
99+
expiration = otaDeferredExpirationMins
100+
}
101+
102+
res := run(iotClient, valid, otaFile, expiration)
103+
res = append(res, invalid...)
104+
return res, nil
105+
}
106+
107+
func idsGivenTags(iotClient iot.Client, tags map[string]string) ([]string, error) {
108+
if tags == nil {
109+
return nil, nil
110+
}
111+
devs, err := iotClient.DeviceList(tags)
112+
if err != nil {
113+
return nil, fmt.Errorf("%s: %w", "cannot retrieve devices from cloud", err)
114+
}
115+
devices := make([]string, 0, len(devs))
116+
for _, d := range devs {
117+
devices = append(devices, d.Id)
118+
}
119+
return devices, nil
120+
}
121+
122+
func validateDevices(iotClient iot.Client, ids []string, fqbn string) (valid []string, invalid []Result, err error) {
123+
devs, err := iotClient.DeviceList(nil)
124+
if err != nil {
125+
return nil, nil, fmt.Errorf("%s: %w", "cannot retrieve devices from cloud", err)
126+
}
127+
128+
for _, id := range ids {
129+
var found *iotclient.ArduinoDevicev2
130+
for _, d := range devs {
131+
if d.Id == id {
132+
found = &d
133+
break
134+
}
135+
}
136+
// Device not found on the cloud
137+
if found == nil {
138+
inv := Result{ID: id, Err: fmt.Errorf("not found")}
139+
invalid = append(invalid, inv)
140+
continue
141+
}
142+
// Device FQBN doesn't match the passed one
143+
if found.Fqbn != fqbn {
144+
inv := Result{ID: id, Err: fmt.Errorf("has FQBN '%s' instead of '%s'", found.Fqbn, fqbn)}
145+
invalid = append(invalid, inv)
146+
continue
147+
}
148+
valid = append(valid, id)
149+
}
150+
return valid, invalid, nil
151+
}
152+
153+
func run(iotClient iot.Client, ids []string, otaFile string, expiration int) []Result {
154+
type job struct {
155+
id string
156+
file *os.File
157+
}
158+
jobs := make(chan job, len(ids))
159+
160+
resCh := make(chan Result, len(ids))
161+
results := make([]Result, 0, len(ids))
162+
163+
for _, id := range ids {
164+
file, err := os.Open(otaFile)
165+
if err != nil {
166+
r := Result{ID: id, Err: fmt.Errorf("cannot open ota file")}
167+
results = append(results, r)
168+
continue
169+
}
170+
jobs <- job{id: id, file: file}
171+
}
172+
close(jobs)
173+
174+
for i := 0; i < numConcurrentUploads; i++ {
175+
go func() {
176+
for job := range jobs {
177+
err := iotClient.DeviceOTA(job.id, job.file, expiration)
178+
resCh <- Result{ID: job.id, Err: err}
179+
}
180+
}()
181+
}
182+
183+
for range ids {
184+
r := <-resCh
185+
results = append(results, r)
186+
}
187+
return results
188+
}

0 commit comments

Comments
 (0)