Skip to content
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ The default OTA upload should complete in 10 minutes. Use `--deferred` flag to e

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

It is also possible to perform a mass ota upload through a specific command.
The fqbn is mandatory.
To select the devices to update you can either provide a list of device ids or device tags.

`$ arduino-cloud-cli ota mass-upload --fqbn <deviceFQBN> --device-ids <deviceIDs> --file <sketch-file.ino.bin>`

`$ arduino-cloud-cli ota mass-upload --fqbn <deviceFQBN> --device-tags <key0>=<value0>,<key1>=<value1> --file <sketch-file.ino.bin>`

## Dashboard commands

Print a list of available dashboards and their widgets by using this command:
Expand Down
131 changes: 131 additions & 0 deletions cli/ota/massupload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// This file is part of arduino-cloud-cli.
//
// Copyright (C) 2021 ARDUINO SA (http://www.arduino.cc/)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package ota

import (
"os"
"sort"
"strings"

"github.com/arduino/arduino-cli/cli/errorcodes"
"github.com/arduino/arduino-cli/cli/feedback"
"github.com/arduino/arduino-cli/table"
"github.com/arduino/arduino-cloud-cli/command/ota"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)

var massUploadFlags struct {
deviceIDs []string
tags map[string]string
file string
deferred bool
fqbn string
}

func initMassUploadCommand() *cobra.Command {
massUploadCommand := &cobra.Command{
Use: "mass-upload",
Short: "Mass OTA upload",
Long: "Mass OTA upload on devices of Arduino IoT Cloud",
Run: runMassUploadCommand,
}

massUploadCommand.Flags().StringSliceVarP(&massUploadFlags.deviceIDs, "device-ids", "d", nil,
"Comma-separated list of device IDs to update")
massUploadCommand.Flags().StringToStringVar(&massUploadFlags.tags, "device-tags", nil,
"Comma-separated list of tags with format <key>=<value>.\n"+
"Perform an OTA upload on all devices that match the provided tags.\n"+
"Mutually exclusive with `--device-ids`.",
)
massUploadCommand.Flags().StringVarP(&massUploadFlags.file, "file", "", "", "Binary file (.bin) to be uploaded")
massUploadCommand.Flags().BoolVar(&massUploadFlags.deferred, "deferred", false, "Perform a deferred OTA. It can take up to 1 week.")
massUploadCommand.Flags().StringVarP(&massUploadFlags.fqbn, "fqbn", "b", "", "FQBN of the devices to update")

massUploadCommand.MarkFlagRequired("file")
massUploadCommand.MarkFlagRequired("fqbn")
return massUploadCommand
}

func runMassUploadCommand(cmd *cobra.Command, args []string) {
logrus.Infof("Uploading binary %s", massUploadFlags.file)

params := &ota.MassUploadParams{
DeviceIDs: massUploadFlags.deviceIDs,
Tags: massUploadFlags.tags,
File: massUploadFlags.file,
Deferred: massUploadFlags.deferred,
FQBN: massUploadFlags.fqbn,
}

resp, err := ota.MassUpload(params)
if err != nil {
feedback.Errorf("Error during ota upload: %v", err)
os.Exit(errorcodes.ErrGeneric)
}

// Put successful devices ahead
sort.SliceStable(resp, func(i, j int) bool {
return resp[i].Err == nil
})

feedback.PrintResult(massUploadResult{resp})

var failed []string
for _, r := range resp {
if r.Err != nil {
failed = append(failed, r.ID)
}
}
if len(failed) == 0 {
return
}
failStr := strings.Join(failed, ",")
feedback.Printf(
"You can try to perform the OTA again on the failed devices using the following command:\n"+
"$ arduino-cloud-cli ota upload --file %s -d %s", params.File, failStr,
)
}

type massUploadResult struct {
res []ota.Result
}

func (r massUploadResult) Data() interface{} {
return r.res
}

func (r massUploadResult) String() string {
if len(r.res) == 0 {
return "No OTA done."
}
t := table.New()
t.SetHeader("ID", "Result")
for _, r := range r.res {
outcome := "Success"
if r.Err != nil {
outcome = r.Err.Error()
}

t.AddRow(
r.ID,
outcome,
)
}
return t.Render()
}
1 change: 1 addition & 0 deletions cli/ota/ota.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ func NewCommand() *cobra.Command {
}

otaCommand.AddCommand(initUploadCommand())
otaCommand.AddCommand(initMassUploadCommand())

return otaCommand
}
188 changes: 188 additions & 0 deletions command/ota/massupload.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// This file is part of arduino-cloud-cli.
//
// Copyright (C) 2021 ARDUINO SA (http://www.arduino.cc/)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.

package ota

import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"

"github.com/arduino/arduino-cloud-cli/internal/config"
"github.com/arduino/arduino-cloud-cli/internal/iot"
iotclient "github.com/arduino/iot-client-go"
)

const (
numConcurrentUploads = 10
)

// MassUploadParams contains the parameters needed to
// perform a Mass OTA upload.
type MassUploadParams struct {
DeviceIDs []string
Tags map[string]string
File string
Deferred bool
FQBN string
}

// Result of an ota upload on a device
type Result struct {
ID string
Err error
}

// MassUpload command is used to mass upload a firmware OTA,
// on devices of Arduino IoT Cloud.
func MassUpload(params *MassUploadParams) ([]Result, error) {
if params.DeviceIDs == nil && params.Tags == nil {
return nil, errors.New("provide either DeviceIDs or Tags")
} else if params.DeviceIDs != nil && params.Tags != nil {
return nil, errors.New("cannot use both DeviceIDs and Tags. only one of them should be not nil")
}

// Generate .ota file
otaDir, err := ioutil.TempDir("", "")
if err != nil {
return nil, fmt.Errorf("%s: %w", "cannot create temporary folder", err)
}
otaFile := filepath.Join(otaDir, "temp.ota")
defer os.RemoveAll(otaDir)

err = Generate(params.File, otaFile, params.FQBN)
if err != nil {
return nil, fmt.Errorf("%s: %w", "cannot generate .ota file", err)
}

conf, err := config.Retrieve()
if err != nil {
return nil, err
}
iotClient, err := iot.NewClient(conf.Client, conf.Secret)
if err != nil {
return nil, err
}

// Prepare the list of device-ids to update
d, err := idsGivenTags(iotClient, params.Tags)
if err != nil {
return nil, err
}
d = append(params.DeviceIDs, d...)
valid, invalid, err := validateDevices(iotClient, d, params.FQBN)
if err != nil {
return nil, fmt.Errorf("failed to validate devices: %w", err)
}
if len(valid) == 0 {
return invalid, nil
}

expiration := otaExpirationMins
if params.Deferred {
expiration = otaDeferredExpirationMins
}

res := run(iotClient, valid, otaFile, expiration)
res = append(res, invalid...)
return res, nil
}

func idsGivenTags(iotClient iot.Client, tags map[string]string) ([]string, error) {
if tags == nil {
return nil, nil
}
devs, err := iotClient.DeviceList(tags)
if err != nil {
return nil, fmt.Errorf("%s: %w", "cannot retrieve devices from cloud", err)
}
devices := make([]string, 0, len(devs))
for _, d := range devs {
devices = append(devices, d.Id)
}
return devices, nil
}

func validateDevices(iotClient iot.Client, ids []string, fqbn string) (valid []string, invalid []Result, err error) {
devs, err := iotClient.DeviceList(nil)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", "cannot retrieve devices from cloud", err)
}

for _, id := range ids {
var found *iotclient.ArduinoDevicev2
for _, d := range devs {
if d.Id == id {
found = &d
break
}
}
// Device not found on the cloud
if found == nil {
inv := Result{ID: id, Err: fmt.Errorf("not found")}
invalid = append(invalid, inv)
continue
}
// Device FQBN doesn't match the passed one
if found.Fqbn != fqbn {
inv := Result{ID: id, Err: fmt.Errorf("has FQBN '%s' instead of '%s'", found.Fqbn, fqbn)}
invalid = append(invalid, inv)
continue
}
valid = append(valid, id)
}
return valid, invalid, nil
}

func run(iotClient iot.Client, ids []string, otaFile string, expiration int) []Result {
type job struct {
id string
file *os.File
}
jobs := make(chan job, len(ids))

resCh := make(chan Result, len(ids))
results := make([]Result, 0, len(ids))

for _, id := range ids {
file, err := os.Open(otaFile)
if err != nil {
r := Result{ID: id, Err: fmt.Errorf("cannot open ota file")}
results = append(results, r)
continue
}
jobs <- job{id: id, file: file}
}
close(jobs)

for i := 0; i < numConcurrentUploads; i++ {
go func() {
for job := range jobs {
err := iotClient.DeviceOTA(job.id, job.file, expiration)
resCh <- Result{ID: job.id, Err: err}
}
}()
}

for range ids {
r := <-resCh
results = append(results, r)
}
return results
}
Loading