diff --git a/README.md b/README.md index e6dd49c0..f5d2fcfd 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,16 @@ Use this command to provision a device: ####LoRa +LoRa devices should be provisioned using a specific command. +Parameters are the same except for the additional mandatory `--frequency-plan`: + +`$ arduino-cloud-cli device create-lora --name --frequency-plan --port --fqbn ` + The list of supported LoRa frequency plans can be retrieved with: `$ arduino-cloud-cli device list-frequency-plans` + ## Device commands Devices can be deleted using the device delete command. This command accepts two mutually exclusive flags: `--id` and `--tags`. Only one of them must be passed. When the `--id` is passed, the device having such ID gets deleted: diff --git a/binaries/getdeveui.arduino.samd.mkrwan1300.bin b/binaries/getdeveui.arduino.samd.mkrwan1300.bin new file mode 100755 index 00000000..f8864ad0 Binary files /dev/null and b/binaries/getdeveui.arduino.samd.mkrwan1300.bin differ diff --git a/binaries/getdeveui.arduino.samd.mkrwan1310.bin b/binaries/getdeveui.arduino.samd.mkrwan1310.bin new file mode 100755 index 00000000..7e31365b Binary files /dev/null and b/binaries/getdeveui.arduino.samd.mkrwan1310.bin differ diff --git a/cli/device/createlora.go b/cli/device/createlora.go new file mode 100644 index 00000000..f9c8227e --- /dev/null +++ b/cli/device/createlora.go @@ -0,0 +1,101 @@ +// 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 . + +package device + +import ( + "fmt" + "os" + + "github.com/arduino/arduino-cli/cli/errorcodes" + "github.com/arduino/arduino-cli/cli/feedback" + "github.com/arduino/arduino-cloud-cli/command/device" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var createLoraFlags struct { + port string + name string + fqbn string + frequencyPlan string +} + +func initCreateLoraCommand() *cobra.Command { + createLoraCommand := &cobra.Command{ + Use: "create-lora", + Short: "Create a LoRa device", + Long: "Create a LoRa device for Arduino IoT Cloud", + Run: runCreateLoraCommand, + } + createLoraCommand.Flags().StringVarP(&createLoraFlags.port, "port", "p", "", "Device port") + createLoraCommand.Flags().StringVarP(&createLoraFlags.name, "name", "n", "", "Device name") + createLoraCommand.Flags().StringVarP(&createLoraFlags.fqbn, "fqbn", "b", "", "Device fqbn") + createLoraCommand.Flags().StringVarP(&createLoraFlags.frequencyPlan, "frequency-plan", "f", "", + "ID of the LoRa frequency plan to use. Run the 'device list-frequency-plans' command to obtain a list of valid plans.") + createLoraCommand.MarkFlagRequired("name") + createLoraCommand.MarkFlagRequired("frequency-plan") + return createLoraCommand +} + +func runCreateLoraCommand(cmd *cobra.Command, args []string) { + logrus.Infof("Creating LoRa device with name %s", createLoraFlags.name) + + params := &device.CreateLoraParams{ + CreateParams: device.CreateParams{ + Name: createLoraFlags.name, + }, + FrequencyPlan: createLoraFlags.frequencyPlan, + } + if createLoraFlags.port != "" { + params.Port = &createLoraFlags.port + } + if createLoraFlags.fqbn != "" { + params.Fqbn = &createLoraFlags.fqbn + } + + dev, err := device.CreateLora(params) + if err != nil { + feedback.Errorf("Error during device create-lora: %v", err) + os.Exit(errorcodes.ErrGeneric) + } + + feedback.PrintResult(createLoraResult{dev}) +} + +type createLoraResult struct { + device *device.DeviceLoraInfo +} + +func (r createLoraResult) Data() interface{} { + return r.device +} + +func (r createLoraResult) String() string { + return fmt.Sprintf( + "name: %s\nid: %s\nboard: %s\nserial-number: %s\nfqbn: %s"+ + "\napp-eui: %s\napp-key: %s\neui: %s", + r.device.Name, + r.device.ID, + r.device.Board, + r.device.Serial, + r.device.FQBN, + r.device.AppEUI, + r.device.AppKey, + r.device.EUI, + ) +} diff --git a/cli/device/device.go b/cli/device/device.go index 988bff19..e389e974 100644 --- a/cli/device/device.go +++ b/cli/device/device.go @@ -35,6 +35,7 @@ func NewCommand() *cobra.Command { deviceCommand.AddCommand(tag.InitCreateTagsCommand()) deviceCommand.AddCommand(tag.InitDeleteTagsCommand()) deviceCommand.AddCommand(initListFrequencyPlansCommand()) + deviceCommand.AddCommand(initCreateLoraCommand()) return deviceCommand } diff --git a/command/device/createlora.go b/command/device/createlora.go new file mode 100644 index 00000000..4b8501b8 --- /dev/null +++ b/command/device/createlora.go @@ -0,0 +1,185 @@ +package device + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/arduino/arduino-cloud-cli/arduino/cli" + "github.com/arduino/arduino-cloud-cli/internal/config" + "github.com/arduino/arduino-cloud-cli/internal/iot" + iotclient "github.com/arduino/iot-client-go" + "github.com/sirupsen/logrus" + "go.bug.st/serial" +) + +const ( + deveuiUploadAttempts = 3 + deveuiUploadWait = 1000 + + serialEUIAttempts = 4 + serialEUIWait = 2000 + serialEUITimeout = 3500 + serialEUIBaudrate = 9600 + + // dev-eui is an IEEE EUI64 address, so it must have length of 8 bytes. + // It's retrieved as hexadecimal string, thus 16 chars are expected + deveuiLength = 16 +) + +// DeviceLoraInfo contains the most interesting +// parameters of an Arduino IoT Cloud LoRa device. +type DeviceLoraInfo struct { + DeviceInfo + AppEUI string `json:"app-eui"` + AppKey string `json:"app-key"` + EUI string `json:"eui"` +} + +// CreateLoRaParams contains the parameters needed +// to provision a LoRa device. +type CreateLoraParams struct { + CreateParams + FrequencyPlan string +} + +// CreateLora command is used to provision a new LoRa arduino device +// and to add it to Arduino IoT Cloud. +func CreateLora(params *CreateLoraParams) (*DeviceLoraInfo, error) { + comm, err := cli.NewCommander() + if err != nil { + return nil, err + } + + ports, err := comm.BoardList() + if err != nil { + return nil, err + } + board := boardFromPorts(ports, ¶ms.CreateParams) + if board == nil { + err = errors.New("no board found") + return nil, err + } + + bin, err := deveuiBinary(board.fqbn) + if err != nil { + return nil, fmt.Errorf("fqbn not supported for LoRa provisioning: %w", err) + } + + logrus.Infof("%s", "Uploading deveui sketch on the LoRa board") + errMsg := "Error while uploading the LoRa provisioning binary" + err = retry(deveuiUploadAttempts, deveuiUploadWait*time.Millisecond, errMsg, func() error { + return comm.UploadBin(board.fqbn, bin, board.port) + }) + if err != nil { + return nil, fmt.Errorf("failed to upload LoRa provisioning binary: %w", err) + } + + eui, err := extractEUI(board.port) + if err != nil { + return nil, 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 + } + + logrus.Info("Creating a new device on the cloud") + dev, err := iotClient.DeviceLoraCreate(params.Name, board.serial, board.dType, eui, params.FrequencyPlan) + if err != nil { + return nil, err + } + + devInfo, err := getDeviceLoraInfo(iotClient, dev) + if err != nil { + errDel := iotClient.DeviceDelete(dev.DeviceId) + if errDel != nil { // Oh no + return nil, fmt.Errorf( + "device was successfully provisioned and configured on IoT-API but " + + "now we can't fetch its information nor delete it - please check " + + "it on the web application.\n\nFetch error: " + err.Error() + + "\nDeletion error: " + errDel.Error(), + ) + } + return nil, fmt.Errorf("%s: %w", "cannot provision LoRa device", err) + } + return devInfo, nil +} + +// deveuiBinary gets the absolute path of the deveui binary corresponding to the +// provisioned board's fqbn. It is contained in the local binaries folder. +func deveuiBinary(fqbn string) (string, error) { + // Use local binaries until they are uploaded online + bin := filepath.Join("./binaries/", "getdeveui."+strings.ReplaceAll(fqbn, ":", ".")+".bin") + bin, err := filepath.Abs(bin) + if err != nil { + return "", fmt.Errorf("getting the deveui binary: %w", err) + } + if _, err := os.Stat(bin); os.IsNotExist(err) { + err = fmt.Errorf("%s: %w", "deveui binary not found", err) + return "", err + } + return bin, nil +} + +// extractEUI extracts the EUI from the provisioned lora board. +func extractEUI(port string) (string, error) { + var ser serial.Port + + logrus.Infof("%s\n", "Connecting to the board through serial port") + errMsg := "Error while connecting to the board" + err := retry(serialEUIAttempts, serialEUIWait*time.Millisecond, errMsg, func() error { + var err error + ser, err = serial.Open(port, &serial.Mode{BaudRate: serialEUIBaudrate}) + return err + }) + if err != nil { + return "", fmt.Errorf("failed to extract deveui from the board: %w", err) + } + + err = ser.SetReadTimeout(serialEUITimeout * time.Millisecond) + if err != nil { + return "", fmt.Errorf("setting serial read timeout: %w", err) + } + + buff := make([]byte, deveuiLength) + n, err := ser.Read(buff) + if err != nil { + return "", fmt.Errorf("reading from serial: %w", err) + } + + if n < deveuiLength { + return "", errors.New("cannot read eui from the device") + } + eui := string(buff) + return eui, nil +} + +func getDeviceLoraInfo(iotClient iot.Client, loraDev *iotclient.ArduinoLoradevicev1) (*DeviceLoraInfo, error) { + dev, err := iotClient.DeviceShow(loraDev.DeviceId) + if err != nil { + return nil, fmt.Errorf("cannot retrieve device from the cloud: %w", err) + } + + devInfo := &DeviceLoraInfo{ + DeviceInfo: DeviceInfo{ + Name: dev.Name, + ID: dev.Id, + Board: dev.Type, + Serial: dev.Serial, + FQBN: dev.Fqbn, + }, + AppEUI: loraDev.AppEui, + AppKey: loraDev.AppKey, + EUI: loraDev.Eui, + } + return devInfo, nil +} diff --git a/internal/iot/client.go b/internal/iot/client.go index 166ac783..b5092040 100644 --- a/internal/iot/client.go +++ b/internal/iot/client.go @@ -29,6 +29,7 @@ import ( // Client can be used to perform actions on Arduino IoT Cloud. type Client interface { DeviceCreate(fqbn, name, serial, devType string) (*iotclient.ArduinoDevicev2, error) + DeviceLoraCreate(name, serial, devType, eui, freq string) (*iotclient.ArduinoLoradevicev1, error) DeviceDelete(id string) error DeviceList(tags map[string]string) ([]iotclient.ArduinoDevicev2, error) DeviceShow(id string) (*iotclient.ArduinoDevicev2, error) @@ -84,6 +85,26 @@ func (cl *client) DeviceCreate(fqbn, name, serial, dType string) (*iotclient.Ard return &dev, nil } +// DeviceLoraCreate allows to create a new LoRa device on Arduino IoT Cloud. +// It returns the LoRa information about the newly created device, and an error. +func (cl *client) DeviceLoraCreate(name, serial, devType, eui, freq string) (*iotclient.ArduinoLoradevicev1, error) { + payload := iotclient.CreateLoraDevicesV1Payload{ + App: "defaultApp", + Eui: eui, + FrequencyPlan: freq, + Name: name, + Serial: serial, + Type: devType, + UserId: "me", + } + dev, _, err := cl.api.LoraDevicesV1Api.LoraDevicesV1Create(cl.ctx, payload) + if err != nil { + err = fmt.Errorf("creating lora device: %w", errorDetail(err)) + return nil, err + } + return &dev, nil +} + // DeviceDelete deletes the device corresponding to the passed ID // from Arduino IoT Cloud. func (cl *client) DeviceDelete(id string) error { diff --git a/internal/iot/mocks/Client.go b/internal/iot/mocks/Client.go index e6d7151b..07d9f769 100644 --- a/internal/iot/mocks/Client.go +++ b/internal/iot/mocks/Client.go @@ -180,6 +180,29 @@ func (_m *Client) DeviceList(tags map[string]string) ([]iot.ArduinoDevicev2, err return r0, r1 } +// DeviceLoraCreate provides a mock function with given fields: name, serial, devType, eui, freq +func (_m *Client) DeviceLoraCreate(name string, serial string, devType string, eui string, freq string) (*iot.ArduinoLoradevicev1, error) { + ret := _m.Called(name, serial, devType, eui, freq) + + var r0 *iot.ArduinoLoradevicev1 + if rf, ok := ret.Get(0).(func(string, string, string, string, string) *iot.ArduinoLoradevicev1); ok { + r0 = rf(name, serial, devType, eui, freq) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoLoradevicev1) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string, string, string) error); ok { + r1 = rf(name, serial, devType, eui, freq) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // DeviceOTA provides a mock function with given fields: id, file, expireMins func (_m *Client) DeviceOTA(id string, file *os.File, expireMins int) error { ret := _m.Called(id, file, expireMins)