diff --git a/README.md b/README.md index 3c633f1a..46d339d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,38 @@ # iot-cloud-cli -The iot-cloud-cli is a virtual device for Arduino IoT Cloud for testing. +iot-cloud-cli is a command line interface that allows to exploit the features of Arduino IoT Cloud. As of now, it is possible to provision a device and to simulate a device to be connected to the cloud using MQTT for thoubleshooting purpose. + +### Requirements + +This is all you need to use iot-cloud-cli for device **provisioning**: + * A client ID and a secret ID, retrievable from the [cloud](https://create.arduino.cc/iot/integrations) by creating a new API key + * arduino-cli in daemon mode, use the command 'arduino-cli daemon' + +This is all you need to use iot-cloud-cli as a **virtual device**: + * A "Generic ESP8266 Module" or "Generic ESP32 Module" device in IoT Cloud (requires a Maker plan) + * A thing with a `counter` property connected to the "Generic ESP8266/ESP32 Module" device + + +## Set a configuration + +iot-cloud-cli should be configured before being used. In particular a client ID and the corresponding secret ID should be set. +You can retrieve them from the [cloud](https://create.arduino.cc/iot/integrations) by creating a new API key. + +Once you have the IDs, call this command with your parameters: + +`$ iot-cloud-cli config -c -s ` + +## Device provisioning + +When provisioning a device, you can optionally explicit the port which the device is connected to and its fqbn. If they are not given, then the first device found is provisioned. +Use this command to provision a device: + +`$ iot-cloud-cli device create --name --port --fqbn ` + + +## Use iot-cloud-cli as a virtual device + +The iot-cloud-cli can be used as a virtual device for Arduino IoT Cloud for testing. ``` $ iot-cloud-cli ping -u "" -p "" -t > @@ -10,15 +42,9 @@ $ iot-cloud-cli ping -u "" -p "" -t > Property value sent successfully 87 ``` -## Requirements - -This is all you need to use iot-cloud-cli: - * A "Generic ESP8266 Module" device in IoT Cloud (requires a Maker plan) - * A thing with a `counter` property connected to the "Generic ESP8266 Module" device - -## How to set up the device and thing in IoT Cloud +### How to set up the device and thing in IoT Cloud -### Device +#### Device * Visit https://create.arduino.cc/iot/devices and select "Add device". * Select "Set up a 3rd party device". @@ -27,7 +53,7 @@ This is all you need to use iot-cloud-cli: * Pick a nice and friendly device name. * Save the "Device ID" and "Secret Key" is a safe place, because you will not be able to see them anymore. -### Thing ID +#### Thing ID * Visit https://create.arduino.cc/iot/things and select "Create Thing". * Select "Add Variable". @@ -35,13 +61,13 @@ This is all you need to use iot-cloud-cli: * Press the "Add Variable" button to confirm. * Copy the "Thing ID" from the bottom right of the page. -### Connect the device and the thing +#### Connect the device and the thing You should connect the new device to the new thing. -### Testing +#### Testing -#### Connect to the PROD environment +##### Connect to the PROD environment ```shell $ iot-cloud-cli ping -u "" -p "" -t > @@ -57,7 +83,7 @@ Property value sent successfully 87 If you visit https://create.arduino.cc/iot/devices the "Generic ESP8266 Module" device status should be "Online". -#### Connect to the DEV environment +##### Connect to the DEV environment The DEV environment is using a different broker, so you need to add the option `--host`: diff --git a/arduino/grpc/compile.go b/arduino/grpc/compile.go index 6ff04e54..8f8b4fa7 100644 --- a/arduino/grpc/compile.go +++ b/arduino/grpc/compile.go @@ -39,7 +39,7 @@ func (c compileHandler) UploadBin(fqbn, bin, port string) error { // Wait for the upload to complete for { - resp, err := stream.Recv() + _, err := stream.Recv() if err != nil { if err == io.EOF { break @@ -47,10 +47,6 @@ func (c compileHandler) UploadBin(fqbn, bin, port string) error { err = fmt.Errorf("%s: %w", "errors during upload", err) return err } - if resp.ErrStream != nil { - err = fmt.Errorf("%s: %w", "errors during upload", err) - return err - } } return nil diff --git a/binaries/arduino.mbed_nano.nanorp2040connect.elf b/binaries/arduino.mbed_nano.nanorp2040connect.elf new file mode 100755 index 00000000..5a445b78 Binary files /dev/null and b/binaries/arduino.mbed_nano.nanorp2040connect.elf differ diff --git a/binaries/arduino.mbed_portenta.envie_m7.bin b/binaries/arduino.mbed_portenta.envie_m7.bin new file mode 100755 index 00000000..17663d4b Binary files /dev/null and b/binaries/arduino.mbed_portenta.envie_m7.bin differ diff --git a/binaries/arduino.samd.mkr1000.bin b/binaries/arduino.samd.mkr1000.bin new file mode 100755 index 00000000..9eb8d6ed Binary files /dev/null and b/binaries/arduino.samd.mkr1000.bin differ diff --git a/binaries/arduino.samd.mkrgsm1400.bin b/binaries/arduino.samd.mkrgsm1400.bin new file mode 100755 index 00000000..c7f8c6e9 Binary files /dev/null and b/binaries/arduino.samd.mkrgsm1400.bin differ diff --git a/binaries/arduino.samd.mkrnb1500.bin b/binaries/arduino.samd.mkrnb1500.bin new file mode 100755 index 00000000..95df1f4d Binary files /dev/null and b/binaries/arduino.samd.mkrnb1500.bin differ diff --git a/binaries/arduino.samd.mkrwifi1010.bin b/binaries/arduino.samd.mkrwifi1010.bin new file mode 100755 index 00000000..3c3cb6b5 Binary files /dev/null and b/binaries/arduino.samd.mkrwifi1010.bin differ diff --git a/binaries/arduino.samd.nano_33_iot.bin b/binaries/arduino.samd.nano_33_iot.bin new file mode 100755 index 00000000..50c2881c Binary files /dev/null and b/binaries/arduino.samd.nano_33_iot.bin differ diff --git a/cli/device/create.go b/cli/device/create.go new file mode 100644 index 00000000..3d4f2015 --- /dev/null +++ b/cli/device/create.go @@ -0,0 +1,46 @@ +package device + +import ( + "fmt" + + "github.com/bcmi-labs/iot-cloud-cli/command/device" + "github.com/spf13/cobra" +) + +var createFlags struct { + port string + name string + fqbn string +} + +func initCreateCommand() *cobra.Command { + createCommand := &cobra.Command{ + Use: "create", + Short: "Create a device", + Long: "Create a device for Arduino IoT Cloud", + RunE: runCreateCommand, + } + createCommand.Flags().StringVarP(&createFlags.port, "port", "p", "", "Device port") + createCommand.Flags().StringVarP(&createFlags.name, "name", "n", "", "Device name") + createCommand.Flags().StringVarP(&createFlags.fqbn, "fqbn", "b", "", "Device fqbn") + createCommand.MarkFlagRequired("name") + return createCommand +} + +func runCreateCommand(cmd *cobra.Command, args []string) error { + fmt.Printf("Creating device with name %s\n", createFlags.name) + + params := &device.CreateParams{ + Port: createFlags.port, + Name: createFlags.name, + Fqbn: createFlags.fqbn, + } + + devID, err := device.Create(params) + if err != nil { + return err + } + + fmt.Printf("IoT Cloud device created with ID: %s\n", devID) + return nil +} diff --git a/cli/device/device.go b/cli/device/device.go new file mode 100644 index 00000000..a9088dab --- /dev/null +++ b/cli/device/device.go @@ -0,0 +1,17 @@ +package device + +import ( + "github.com/spf13/cobra" +) + +func NewCommand() *cobra.Command { + deviceCommand := &cobra.Command{ + Use: "device", + Short: "Device commands.", + Long: "Device commands.", + } + + deviceCommand.AddCommand(initCreateCommand()) + + return deviceCommand +} diff --git a/cli/root.go b/cli/root.go index d8c46e58..e045cb3c 100644 --- a/cli/root.go +++ b/cli/root.go @@ -5,6 +5,7 @@ import ( "os" "github.com/bcmi-labs/iot-cloud-cli/cli/config" + "github.com/bcmi-labs/iot-cloud-cli/cli/device" "github.com/bcmi-labs/iot-cloud-cli/cli/ping" "github.com/spf13/cobra" ) @@ -13,6 +14,7 @@ func Execute() { rootCmd := &cobra.Command{} rootCmd.AddCommand(ping.NewCommand()) rootCmd.AddCommand(config.NewCommand()) + rootCmd.AddCommand(device.NewCommand()) if err := rootCmd.Execute(); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/command/device/create.go b/command/device/create.go new file mode 100644 index 00000000..187b8c7c --- /dev/null +++ b/command/device/create.go @@ -0,0 +1,127 @@ +package device + +import ( + "errors" + "fmt" + "strings" + + rpc "github.com/arduino/arduino-cli/rpc/cc/arduino/cli/commands/v1" + "github.com/bcmi-labs/iot-cloud-cli/arduino/grpc" + "github.com/bcmi-labs/iot-cloud-cli/internal/config" + "github.com/bcmi-labs/iot-cloud-cli/internal/iot" +) + +// CreateParams contains the paramters needed +// to find the device to be provisioned. +// If Port is an empty string, then each serial port is analyzed. +// If Fqbn is an empty string, then the first device found gets selected. +type CreateParams struct { + Port string + Name string + Fqbn string +} + +type device struct { + fqbn string + serial string + dType string + port string +} + +// Create command is used to provision a new arduino device +// and to add it to the arduino iot cloud. +func Create(params *CreateParams) (string, error) { + rpcComm, rpcClose, err := grpc.NewClient() + if err != nil { + return "", err + } + defer rpcClose() + + ports, err := rpcComm.BoardList() + if err != nil { + return "", err + } + dev := deviceFromPorts(ports, params) + if dev == nil { + err = errors.New("no device found") + return "", err + } + + conf, err := config.Retrieve() + if err != nil { + return "", err + } + iotClient, err := iot.NewClient(conf.Client, conf.Secret) + if err != nil { + return "", err + } + + fmt.Println("Creating a new device on the cloud") + devID, err := iotClient.AddDevice(dev.fqbn, params.Name, dev.serial, dev.dType) + if err != nil { + return "", err + } + + prov := &provision{ + Commander: rpcComm, + Client: iotClient, + dev: dev, + id: devID} + err = prov.run() + if err != nil { + // TODO: delete the device on iot cloud + err = fmt.Errorf("%s: %w", "cannot provision device", err) + return "", err + } + + return devID, nil +} + +// deviceFromPorts returns a board that matches all the criteria +// passed in. If no criteria are passed, it returns the first device found. +func deviceFromPorts(ports []*rpc.DetectedPort, params *CreateParams) *device { + for _, port := range ports { + if portFilter(port, params) { + continue + } + board := boardFilter(port.Boards, params) + if board != nil { + t := strings.Split(board.Fqbn, ":")[2] + dev := &device{board.Fqbn, port.SerialNumber, t, port.Address} + return dev + } + } + + return nil +} + +// portFilter filters out the given port if the port parameter is not an empty string +// and if they do not match. +// It returns: +// true -> to skip the port +// false -> to keep the port +func portFilter(port *rpc.DetectedPort, params *CreateParams) bool { + if len(port.Boards) == 0 { + return true + } + if params.Port != "" && params.Port != port.Address { + return true + } + return false +} + +// boardFilter looks for a board which has the same fqbn passed as parameter. +// It returns: +// - a board if it is found. +// - nil if no board matching the fqbn parameter is found. +func boardFilter(boards []*rpc.BoardListItem, params *CreateParams) (board *rpc.BoardListItem) { + if params.Fqbn == "" { + return boards[0] + } + for _, b := range boards { + if b.Fqbn == params.Fqbn { + return b + } + } + return +} diff --git a/command/device/provision.go b/command/device/provision.go new file mode 100644 index 00000000..53a06af5 --- /dev/null +++ b/command/device/provision.go @@ -0,0 +1,261 @@ +package device + +import ( + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/bcmi-labs/iot-cloud-cli/arduino" + "github.com/bcmi-labs/iot-cloud-cli/internal/iot" + "github.com/bcmi-labs/iot-cloud-cli/internal/serial" +) + +type provision struct { + arduino.Commander + iot.Client + ser *serial.Serial + dev *device + id string +} + +type binFile struct { + Bin string `json:"bin"` + Filename string `json:"filename"` + Fqbn string `json:"fqbn"` + Name string `json:"name"` + Sha256 string `json:"sha256"` +} + +func (p provision) run() error { + bin, err := downloadProvisioningFile(p.dev.fqbn) + if err != nil { + return err + } + + fmt.Printf("\n%s\n", "Uploading provisioning sketch on the device") + time.Sleep(500 * time.Millisecond) + // Try to upload the provisioning sketch + errMsg := "Error while uploading the provisioning sketch: " + err = retry(5, time.Millisecond*1000, errMsg, func() error { + //serialutils.Reset(dev.port, true, nil) + return p.UploadBin(p.dev.fqbn, bin, p.dev.port) + }) + if err != nil { + return err + } + + fmt.Printf("\n%s\n", "Connecting to the device through serial port") + // Try to connect to device through the serial port + time.Sleep(1500 * time.Millisecond) + p.ser = serial.NewSerial() + errMsg = "Error while connecting to the device: " + err = retry(5, time.Millisecond*1000, errMsg, func() error { + return p.ser.Connect(p.dev.port) + }) + if err != nil { + return err + } + defer p.ser.Close() + fmt.Printf("%s\n\n", "Connected to device") + + // Send configuration commands to the device + err = p.configDev() + if err != nil { + return err + } + + fmt.Printf("%s\n\n", "Device provisioning successful") + return nil +} + +func (p provision) configDev() error { + fmt.Println("Receiving the certificate") + csr, err := p.ser.SendReceive(serial.CSR, []byte(p.id)) + if err != nil { + return err + } + cert, err := p.AddCertificate(p.id, string(csr)) + if err != nil { + return err + } + + fmt.Println("Requesting begin storage") + err = p.ser.Send(serial.BeginStorage, nil) + if err != nil { + return err + } + + s := strconv.Itoa(cert.NotBefore.Year()) + fmt.Println("Sending year: ", s) + err = p.ser.Send(serial.SetYear, []byte(s)) + if err != nil { + return err + } + + s = fmt.Sprintf("%02d", int(cert.NotBefore.Month())) + fmt.Println("Sending month: ", s) + err = p.ser.Send(serial.SetMonth, []byte(s)) + if err != nil { + return err + } + + s = fmt.Sprintf("%02d", cert.NotBefore.Day()) + fmt.Println("Sending day: ", s) + err = p.ser.Send(serial.SetDay, []byte(s)) + if err != nil { + return err + } + + s = fmt.Sprintf("%02d", cert.NotBefore.Hour()) + fmt.Println("Sending hour: ", s) + err = p.ser.Send(serial.SetHour, []byte(s)) + if err != nil { + return err + } + + s = strconv.Itoa(31) + fmt.Println("Sending validity: ", s) + err = p.ser.Send(serial.SetValidity, []byte(s)) + if err != nil { + return err + } + + fmt.Println("Sending certificate serial") + b, err := hex.DecodeString(cert.Serial) + if err != nil { + err = fmt.Errorf("%s: %w", "decoding certificate serial", err) + return err + } + err = p.ser.Send(serial.SetCertSerial, b) + if err != nil { + return err + } + + fmt.Println("Sending certificate authority key") + b, err = hex.DecodeString(cert.AuthorityKeyIdentifier) + if err != nil { + err = fmt.Errorf("%s: %w", "decoding certificate authority key id", err) + return err + } + err = p.ser.Send(serial.SetAuthKey, b) + if err != nil { + return err + } + + fmt.Println("Sending certificate signature") + b, err = hex.DecodeString(cert.SignatureAsn1X + cert.SignatureAsn1Y) + if err != nil { + err = fmt.Errorf("%s: %w", "decoding certificate signature", err) + return err + } + err = p.ser.Send(serial.SetSignature, b) + if err != nil { + return err + } + + time.Sleep(time.Second) + fmt.Println("Requesting end storage") + err = p.ser.Send(serial.EndStorage, nil) + if err != nil { + return err + } + + time.Sleep(2 * time.Second) + fmt.Println("Requesting certificate reconstruction") + err = p.ser.Send(serial.ReconstructCert, nil) + if err != nil { + return err + } + + return nil +} + +func downloadProvisioningFile(fqbn string) (string, error) { + // Use local binaries until they are uploaded online + bin := filepath.Join("./binaries/", strings.ReplaceAll(fqbn, ":", ".")+".bin") + bin, err := filepath.Abs(bin) + if err != nil { + return "", err + } + if _, err := os.Stat(bin); err == nil { + return bin, nil + } + + elf := filepath.Join("./binaries/", strings.ReplaceAll(fqbn, ":", ".")+".elf") + elf, err = filepath.Abs(elf) + if err != nil { + return "", err + } + if _, err := os.Stat(elf); os.IsNotExist(err) { + err = fmt.Errorf("%s: %w", "fqbn not supported", err) + return "", err + } + return elf, nil + + // TODO: upload binaries on some arduino page and enable this flow + //url := "https://api2.arduino.cc/iot/v2/binaries/provisioning?fqbn=" + fqbn + //path, _ := filepath.Abs("./provisioning.bin") + + //cl := http.Client{ + //Timeout: time.Second * 3, // Timeout after 2 seconds + //} + + //req, err := http.NewRequest(http.MethodGet, url, nil) + //if err != nil { + //err = fmt.Errorf("%s: %w", "request provisioning binary", err) + //return "", err + //} + //res, err := cl.Do(req) + //if err != nil { + //err = fmt.Errorf("%s: %w", "request provisioning binary", err) + //return "", err + //} + + //if res.Body != nil { + //defer res.Body.Close() + //} + + //body, err := ioutil.ReadAll(res.Body) + //if err != nil { + //err = fmt.Errorf("%s: %w", "read provisioning request body", err) + //return "", err + //} + + //bin := binFile{} + //err = json.Unmarshal(body, &bin) + //if err != nil { + //err = fmt.Errorf("%s: %w", "unmarshal provisioning binary", err) + //return "", err + //} + + //bytes, err := base64.StdEncoding.DecodeString(bin.Bin) + //if err != nil { + //err = fmt.Errorf("%s: %w", "decoding provisioning binary", err) + //return "", err + //} + + //err = ioutil.WriteFile(path, bytes, 0666) + //if err != nil { + //err = fmt.Errorf("%s: %w", "downloading provisioning binary", err) + //return "", err + //} + + //return path, nil +} + +func retry(tries int, sleep time.Duration, errMsg string, fun func() error) error { + var err error + for n := 0; n < tries; n++ { + err = fun() + if err == nil { + break + } + fmt.Println(errMsg, err.Error(), "\nTrying again...") + time.Sleep(sleep) + } + return err +}