diff --git a/README.md b/README.md index 7273cd97..bf5c5d06 100644 --- a/README.md +++ b/README.md @@ -132,3 +132,7 @@ Delete a dashboard with the following command: Extract a template from an existing dashboard. The template can be saved in two formats: json or yaml. The default format is yaml: `$ arduino-cloud-cli dashboard extract --id --outfile --format ` + +Create a dashboard: dashboards can be created only starting from a template. Supported dashboard template formats are JSON and YAML. The name parameter is optional. If it is provided then it overrides the name retrieved from the template. The `override` flag can be used to override the template `thing_id` placeholder with the actual ID of the thing to be used. + +`$ arduino-cloud-cli dashboard create --name --template --override =,=` \ No newline at end of file diff --git a/cli/dashboard/create.go b/cli/dashboard/create.go new file mode 100644 index 00000000..211dca70 --- /dev/null +++ b/cli/dashboard/create.go @@ -0,0 +1,92 @@ +// 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 dashboard + +import ( + "fmt" + "os" + "strings" + + "github.com/arduino/arduino-cli/cli/errorcodes" + "github.com/arduino/arduino-cli/cli/feedback" + "github.com/arduino/arduino-cloud-cli/command/dashboard" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var createFlags struct { + name string + template string + override map[string]string +} + +func initCreateCommand() *cobra.Command { + createCommand := &cobra.Command{ + Use: "create", + Short: "Create a dashboard from a template", + Long: "Create a dashboard from a template for Arduino IoT Cloud", + Run: runCreateCommand, + } + createCommand.Flags().StringVarP(&createFlags.name, "name", "n", "", "Dashboard name") + createCommand.Flags().StringVarP(&createFlags.template, "template", "t", "", + "File containing a dashboard template, JSON and YAML format are supported", + ) + createCommand.Flags().StringToStringVarP(&createFlags.override, "override", "o", nil, + "Map stating the items to be overridden. Ex: 'thing-0=xxxxxxxx,thing-1=yyyyyyyy'") + + createCommand.MarkFlagRequired("template") + return createCommand +} + +func runCreateCommand(cmd *cobra.Command, args []string) { + logrus.Infof("Creating dashboard from template %s\n", createFlags.template) + + params := &dashboard.CreateParams{ + Template: createFlags.template, + Override: createFlags.override, + } + if createFlags.name != "" { + params.Name = &createFlags.name + } + + dashboard, err := dashboard.Create(params) + if err != nil { + feedback.Errorf("Error during dashboard create: %v", err) + os.Exit(errorcodes.ErrGeneric) + } + + feedback.PrintResult(createResult{dashboard}) +} + +type createResult struct { + dashboard *dashboard.DashboardInfo +} + +func (r createResult) Data() interface{} { + return r.dashboard +} + +func (r createResult) String() string { + return fmt.Sprintf( + "name: %s\nid: %s\nupdated_at: %s\nwidgets: %s", + r.dashboard.Name, + r.dashboard.ID, + r.dashboard.UpdatedAt, + strings.Join(r.dashboard.Widgets, ", "), + ) +} diff --git a/cli/dashboard/dashboard.go b/cli/dashboard/dashboard.go index ae39236f..793f1e8a 100644 --- a/cli/dashboard/dashboard.go +++ b/cli/dashboard/dashboard.go @@ -28,6 +28,7 @@ func NewCommand() *cobra.Command { Long: "Dashboard commands.", } + dashboardCommand.AddCommand(initCreateCommand()) dashboardCommand.AddCommand(initListCommand()) dashboardCommand.AddCommand(initDeleteCommand()) dashboardCommand.AddCommand(initExtractCommand()) diff --git a/command/dashboard/create.go b/command/dashboard/create.go new file mode 100644 index 00000000..d92c5949 --- /dev/null +++ b/command/dashboard/create.go @@ -0,0 +1,66 @@ +// 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 dashboard + +import ( + "errors" + + "github.com/arduino/arduino-cloud-cli/internal/config" + "github.com/arduino/arduino-cloud-cli/internal/iot" + "github.com/arduino/arduino-cloud-cli/internal/template" +) + +// CreateParams contains the parameters needed to create a new dashboard. +type CreateParams struct { + Name *string // Name of the new dashboard + Override map[string]string // Template parameters to be overridden + Template string // Path of the template file +} + +// Create allows to create a new dashboard +func Create(params *CreateParams) (*DashboardInfo, error) { + conf, err := config.Retrieve() + if err != nil { + return nil, err + } + iotClient, err := iot.NewClient(conf.Client, conf.Secret) + if err != nil { + return nil, err + } + + dashboard, err := template.LoadDashboard(params.Template, params.Override, iotClient) + if err != nil { + return nil, err + } + + // Name passed as parameter has priority over name from template + if params.Name != nil { + dashboard.Name = *params.Name + } + // If name is not specified in the template, it should be passed as parameter + if dashboard.Name == "" { + return nil, errors.New("dashboard name not specified") + } + + newDashboard, err := iotClient.DashboardCreate(dashboard) + if err != nil { + return nil, err + } + + return getDashboardInfo(newDashboard), nil +} diff --git a/go.mod b/go.mod index 68e1a4f1..5fa4a83b 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/arduino/arduino-cli v0.0.0-20210607095659-16f41352eac3 github.com/arduino/go-paths-helper v1.6.1 github.com/arduino/iot-client-go v1.3.4-0.20210930122852-04551f4cb061 + github.com/gofrs/uuid v4.0.0+incompatible + github.com/google/go-cmp v0.5.6 github.com/howeyc/crc16 v0.0.0-20171223171357-2b2a61e366a6 github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5 github.com/sirupsen/logrus v1.8.1 diff --git a/go.sum b/go.sum index 2fdc7a11..6a61a403 100644 --- a/go.sum +++ b/go.sum @@ -137,6 +137,8 @@ github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9 github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -177,8 +179,9 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= diff --git a/internal/iot/client.go b/internal/iot/client.go index 54aac5b5..0b42326a 100644 --- a/internal/iot/client.go +++ b/internal/iot/client.go @@ -39,6 +39,7 @@ type Client interface { ThingDelete(id string) error ThingShow(id string) (*iotclient.ArduinoThing, error) ThingList(ids []string, device *string, props bool) ([]iotclient.ArduinoThing, error) + DashboardCreate(dashboard *iotclient.Dashboardv2) (*iotclient.ArduinoDashboardv2, error) DashboardShow(id string) (*iotclient.ArduinoDashboardv2, error) DashboardDelete(id string) error DashboardList() ([]iotclient.ArduinoDashboardv2, error) @@ -205,6 +206,15 @@ func (cl *client) ThingList(ids []string, device *string, props bool) ([]iotclie return things, nil } +// DashboardCreate adds a new dashboard on Arduino IoT Cloud. +func (cl *client) DashboardCreate(dashboard *iotclient.Dashboardv2) (*iotclient.ArduinoDashboardv2, error) { + newDashboard, _, err := cl.api.DashboardsV2Api.DashboardsV2Create(cl.ctx, *dashboard) + if err != nil { + return nil, fmt.Errorf("%s: %w", "adding new dashboard", errorDetail(err)) + } + return &newDashboard, nil +} + // DashboardShow allows to retrieve a specific dashboard, given its id, // from Arduino IoT Cloud. func (cl *client) DashboardShow(id string) (*iotclient.ArduinoDashboardv2, error) { diff --git a/internal/iot/mocks/Client.go b/internal/iot/mocks/Client.go new file mode 100644 index 00000000..09baa3a9 --- /dev/null +++ b/internal/iot/mocks/Client.go @@ -0,0 +1,315 @@ +// Code generated by mockery v0.0.0-dev. DO NOT EDIT. + +package mocks + +import ( + iot "github.com/arduino/iot-client-go" + mock "github.com/stretchr/testify/mock" + + os "os" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +// CertificateCreate provides a mock function with given fields: id, csr +func (_m *Client) CertificateCreate(id string, csr string) (*iot.ArduinoCompressedv2, error) { + ret := _m.Called(id, csr) + + var r0 *iot.ArduinoCompressedv2 + if rf, ok := ret.Get(0).(func(string, string) *iot.ArduinoCompressedv2); ok { + r0 = rf(id, csr) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoCompressedv2) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string) error); ok { + r1 = rf(id, csr) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DashboardCreate provides a mock function with given fields: dashboard +func (_m *Client) DashboardCreate(dashboard *iot.Dashboardv2) (*iot.ArduinoDashboardv2, error) { + ret := _m.Called(dashboard) + + var r0 *iot.ArduinoDashboardv2 + if rf, ok := ret.Get(0).(func(*iot.Dashboardv2) *iot.ArduinoDashboardv2); ok { + r0 = rf(dashboard) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoDashboardv2) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*iot.Dashboardv2) error); ok { + r1 = rf(dashboard) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DashboardDelete provides a mock function with given fields: id +func (_m *Client) DashboardDelete(id string) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DashboardList provides a mock function with given fields: +func (_m *Client) DashboardList() ([]iot.ArduinoDashboardv2, error) { + ret := _m.Called() + + var r0 []iot.ArduinoDashboardv2 + if rf, ok := ret.Get(0).(func() []iot.ArduinoDashboardv2); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]iot.ArduinoDashboardv2) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DashboardShow provides a mock function with given fields: id +func (_m *Client) DashboardShow(id string) (*iot.ArduinoDashboardv2, error) { + ret := _m.Called(id) + + var r0 *iot.ArduinoDashboardv2 + if rf, ok := ret.Get(0).(func(string) *iot.ArduinoDashboardv2); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoDashboardv2) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeviceCreate provides a mock function with given fields: fqbn, name, serial, devType +func (_m *Client) DeviceCreate(fqbn string, name string, serial string, devType string) (*iot.ArduinoDevicev2, error) { + ret := _m.Called(fqbn, name, serial, devType) + + var r0 *iot.ArduinoDevicev2 + if rf, ok := ret.Get(0).(func(string, string, string, string) *iot.ArduinoDevicev2); ok { + r0 = rf(fqbn, name, serial, devType) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoDevicev2) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string, string) error); ok { + r1 = rf(fqbn, name, serial, devType) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeviceDelete provides a mock function with given fields: id +func (_m *Client) DeviceDelete(id string) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeviceList provides a mock function with given fields: +func (_m *Client) DeviceList() ([]iot.ArduinoDevicev2, error) { + ret := _m.Called() + + var r0 []iot.ArduinoDevicev2 + if rf, ok := ret.Get(0).(func() []iot.ArduinoDevicev2); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]iot.ArduinoDevicev2) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } 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) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *os.File, int) error); ok { + r0 = rf(id, file, expireMins) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeviceShow provides a mock function with given fields: id +func (_m *Client) DeviceShow(id string) (*iot.ArduinoDevicev2, error) { + ret := _m.Called(id) + + var r0 *iot.ArduinoDevicev2 + if rf, ok := ret.Get(0).(func(string) *iot.ArduinoDevicev2); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoDevicev2) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ThingCreate provides a mock function with given fields: thing, force +func (_m *Client) ThingCreate(thing *iot.Thing, force bool) (*iot.ArduinoThing, error) { + ret := _m.Called(thing, force) + + var r0 *iot.ArduinoThing + if rf, ok := ret.Get(0).(func(*iot.Thing, bool) *iot.ArduinoThing); ok { + r0 = rf(thing, force) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoThing) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*iot.Thing, bool) error); ok { + r1 = rf(thing, force) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ThingDelete provides a mock function with given fields: id +func (_m *Client) ThingDelete(id string) error { + ret := _m.Called(id) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ThingList provides a mock function with given fields: ids, device, props +func (_m *Client) ThingList(ids []string, device *string, props bool) ([]iot.ArduinoThing, error) { + ret := _m.Called(ids, device, props) + + var r0 []iot.ArduinoThing + if rf, ok := ret.Get(0).(func([]string, *string, bool) []iot.ArduinoThing); ok { + r0 = rf(ids, device, props) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]iot.ArduinoThing) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func([]string, *string, bool) error); ok { + r1 = rf(ids, device, props) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ThingShow provides a mock function with given fields: id +func (_m *Client) ThingShow(id string) (*iot.ArduinoThing, error) { + ret := _m.Called(id) + + var r0 *iot.ArduinoThing + if rf, ok := ret.Get(0).(func(string) *iot.ArduinoThing); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*iot.ArduinoThing) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ThingUpdate provides a mock function with given fields: id, thing, force +func (_m *Client) ThingUpdate(id string, thing *iot.Thing, force bool) error { + ret := _m.Called(id, thing, force) + + var r0 error + if rf, ok := ret.Get(0).(func(string, *iot.Thing, bool) error); ok { + r0 = rf(id, thing, force) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/internal/template/dashboard.go b/internal/template/dashboard.go new file mode 100644 index 00000000..4e6f36aa --- /dev/null +++ b/internal/template/dashboard.go @@ -0,0 +1,79 @@ +// 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 template + +import ( + "encoding/json" + "fmt" + + "github.com/arduino/arduino-cloud-cli/internal/iot" +) + +type dashboardTemplate struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Widgets []widgetTemplate `json:"widgets,omitempty" yaml:"widgets,omitempty"` +} + +type widgetTemplate struct { + Height int64 `json:"height" yaml:"height"` + HeightMobile int64 `json:"height_mobile,omitempty" yaml:"height_mobile,omitempty"` + Id string `json:"id" yaml:"id"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Options map[string]interface{} `json:"options" yaml:"options"` + Type string `json:"type" yaml:"type"` + Variables []variableTemplate `json:"variables,omitempty" yaml:"variables,omitempty"` + Width int64 `json:"width" yaml:"width"` + WidthMobile int64 `json:"width_mobile,omitempty" yaml:"width_mobile,omitempty"` + X int64 `json:"x" yaml:"x"` + XMobile int64 `json:"x_mobile,omitempty" yaml:"x_mobile,omitempty"` + Y int64 `json:"y" yaml:"y"` + YMobile int64 `json:"y_mobile,omitempty" yaml:"y_mobile,omitempty"` +} + +type variableTemplate struct { + ThingID string `json:"thing_id" yaml:"thing_id"` + VariableName string `json:"variable_id" yaml:"variable_id"` + VariableID string +} + +// MarshalJSON satisfies the Marshaler interface from json package. +// With this, when a variableTemplate is marshaled, it only marshals +// its VariableID. In this way, a widgetTemplate can be +// marshaled and then unmarshaled into a iot.Widget struct. +// In the same way, a dashboardTemplate can now be converted +// into a iot.DashboardV2 leveraging the JSON marshal/unmarshal. +func (v *variableTemplate) MarshalJSON() ([]byte, error) { + return json.Marshal(v.VariableID) +} + +// getVariableID returns the id of a variable, given its name and its thing id. +// If the variable is not found, an error is returned. +func getVariableID(thingID string, variableName string, iotClient iot.Client) (string, error) { + thing, err := iotClient.ThingShow(thingID) + if err != nil { + return "", fmt.Errorf("getting variables of thing %s: %w", thingID, err) + } + + for _, v := range thing.Properties { + if v.Name == variableName { + return v.Id, nil + } + } + + return "", fmt.Errorf("thing with id %s doesn't have variable with name %s : %w", thingID, variableName, err) +} diff --git a/internal/template/extract.go b/internal/template/extract.go index 0da92c59..5ec6b02e 100644 --- a/internal/template/extract.go +++ b/internal/template/extract.go @@ -88,7 +88,6 @@ func FromDashboard(dashboard *iotclient.ArduinoDashboardv2) map[string]interface if len(w.Options) > 0 { widget["options"] = w.Options } - widgets = append(widgets, widget) } if len(widgets) > 0 { diff --git a/internal/template/load.go b/internal/template/load.go index b6a27df4..fd64a6d1 100644 --- a/internal/template/load.go +++ b/internal/template/load.go @@ -24,40 +24,42 @@ import ( "io/ioutil" "os" + "github.com/arduino/arduino-cloud-cli/internal/iot" iotclient "github.com/arduino/iot-client-go" + "github.com/gofrs/uuid" "gopkg.in/yaml.v3" ) -// loadTemplate loads a template file and puts it into a generic template -// of type map[string]interface{}. -// The input template should be in json or yaml format. -func loadTemplate(file string) (map[string]interface{}, error) { +// loadTemplate loads a template file and unmarshals it into whatever +// is pointed to by the template parameter. If template is nil or +// not a pointer, loadTemplate returns an error. +// file: path of a template file in json or yaml format. +func loadTemplate(file string, template interface{}) error { templateFile, err := os.Open(file) if err != nil { - return nil, err + return err } defer templateFile.Close() templateBytes, err := ioutil.ReadAll(templateFile) if err != nil { - return nil, err + return err } - template := make(map[string]interface{}) - // Extract template trying all the supported formats: json and yaml - if err = json.Unmarshal([]byte(templateBytes), &template); err != nil { - if err = yaml.Unmarshal([]byte(templateBytes), &template); err != nil { - return nil, errors.New("reading template file: template format is not valid") + if err = json.Unmarshal([]byte(templateBytes), template); err != nil { + if err = yaml.Unmarshal([]byte(templateBytes), template); err != nil { + return errors.New("reading template file: template format is not valid") } } - return template, nil + return nil } // LoadThing loads a thing from a thing template file. func LoadThing(file string) (*iotclient.Thing, error) { - template, err := loadTemplate(file) + var template map[string]interface{} + err := loadTemplate(file, &template) if err != nil { return nil, err } @@ -82,3 +84,55 @@ func LoadThing(file string) (*iotclient.Thing, error) { return thing, nil } + +// LoadDashboard loads a dashboard from a dashboard template file. +// It applies the thing overrides specified by the override parameter. +// It requires an iot.Client parameter to retrieve the actual variable id. +func LoadDashboard(file string, override map[string]string, iotClient iot.Client) (*iotclient.Dashboardv2, error) { + template := dashboardTemplate{} + err := loadTemplate(file, &template) + if err != nil { + return nil, err + } + + // Adapt the template to the dashboard struct + for i, widget := range template.Widgets { + // Generate and set a uuid for each widget + id, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("cannot create a uuid for new widget: %w", err) + } + widget.Id = id.String() + filterWidgetOptions(widget.Options) + // Even if the widget has no options, its field should exist + if widget.Options == nil { + widget.Options = make(map[string]interface{}) + } + // Set the correct variable id, given the thing id and the variable name + for j, variable := range widget.Variables { + // Check if thing name should be overridden + if id, ok := override[variable.ThingID]; ok { + variable.ThingID = id + } + variable.VariableID, err = getVariableID(variable.ThingID, variable.VariableName, iotClient) + if err != nil { + return nil, err + } + widget.Variables[j] = variable + } + template.Widgets[i] = widget + } + + // Convert template into dashboard structure exploiting json marshalling/unmarshalling + dashboard := &iotclient.Dashboardv2{} + t, err := json.Marshal(template) + if err != nil { + return nil, fmt.Errorf("%s: %w", "extracting template", err) + } + err = json.Unmarshal(t, &dashboard) + if err != nil { + return nil, fmt.Errorf("%s: %w", "creating dashboard structure from template", err) + } + + return dashboard, nil +} diff --git a/internal/template/load_test.go b/internal/template/load_test.go new file mode 100644 index 00000000..3790d09c --- /dev/null +++ b/internal/template/load_test.go @@ -0,0 +1,240 @@ +// 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 template + +import ( + "testing" + + "github.com/arduino/arduino-cloud-cli/internal/iot/mocks" + iotclient "github.com/arduino/iot-client-go" + "github.com/gofrs/uuid" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/mock" +) + +const ( + // Real IDs will be UUIDs v4 like this: 9231a50b-8680-4489-a465-2b769fc310cb + // Here we use these text strings to improve test errors readability + switchyID = "switchy-id" + relayID = "relay-id" + blinkSpeedID = "blink_speed-id" + + thingOverriddenID = "thing-overridden-id" + switchyOverriddenID = "switchy-overridden-id" +) + +var ( + dashboardTemplateTest = map[string]interface{}{ + "id": "home-security-alarm-dashboard", + "name": "Home Security Alarm", + "widgets": []interface{}{ + map[string]interface{}{ + "type": "Messenger", "name": "message_update", + "variables": []interface{}{map[string]interface{}{"thing_id": "home-security-alarm", "variable_id": "message_update"}}, + }, + map[string]interface{}{ + "type": "Switch", "name": "light_alarm", + "variables": []interface{}{map[string]interface{}{"thing_id": "home-security-alarm", "variable_id": "light_alarm"}}, + "options": map[string]interface{}{"showLabels": true}, + }, + }, + } + + dashboardDetailed = &iotclient.Dashboardv2{ + Name: "dashboard", + Widgets: []iotclient.Widget{ + {Name: "Switch-name", Height: 1, HeightMobile: 2, Width: 3, WidthMobile: 4, + X: 5, XMobile: 6, Y: 7, YMobile: 8, Options: map[string]interface{}{"showLabels": true}, + Type: "Switch", + }, + }, + } + + dashboardNoOptions = &iotclient.Dashboardv2{ + Name: "dashboard-no-options", + Widgets: []iotclient.Widget{ + {Name: "Switch-name", Height: 1, HeightMobile: 2, Width: 3, WidthMobile: 4, + X: 5, XMobile: 6, Y: 7, YMobile: 8, Options: map[string]interface{}{}, + Type: "Switch", + }, + }, + } + + dashboardWithVariable = &iotclient.Dashboardv2{ + Name: "dashboard-with-variable", + Widgets: []iotclient.Widget{ + {Name: "Switch-name", Height: 1, HeightMobile: 2, Width: 3, WidthMobile: 4, + X: 5, XMobile: 6, Y: 7, YMobile: 8, Options: map[string]interface{}{"showLabels": true}, Type: "Switch", + Variables: []string{switchyID}, + }, + }, + } + + dashboardVariableOverride = &iotclient.Dashboardv2{ + Name: "dashboard-with-variable", + Widgets: []iotclient.Widget{ + {Name: "Switch-name", Height: 1, HeightMobile: 2, Width: 3, WidthMobile: 4, + X: 5, XMobile: 6, Y: 7, YMobile: 8, Options: map[string]interface{}{"showLabels": true}, Type: "Switch", + Variables: []string{switchyOverriddenID}, + }, + }, + } + + dashboardTwoWidgets = &iotclient.Dashboardv2{ + Name: "dashboard-two-widgets", + Widgets: []iotclient.Widget{ + {Name: "blink_speed", Height: 7, Width: 8, + X: 7, Y: 5, Options: map[string]interface{}{"min": float64(0), "max": float64(5000)}, Type: "Slider", + Variables: []string{blinkSpeedID}, + }, + {Name: "relay_2", Height: 5, Width: 5, + X: 5, Y: 0, Options: map[string]interface{}{"showLabels": true}, Type: "Switch", + Variables: []string{relayID}, + }, + }, + } +) + +func TestLoadTemplate(t *testing.T) { + tests := []struct { + name string + file string + override map[string]string + want map[string]interface{} + }{ + + { + name: "yaml dashboard template", + file: "testdata/home-security-dashboard.yaml", + want: dashboardTemplateTest, + }, + + { + name: "json dashboard template", + file: "testdata/home-security-dashboard.json", + want: dashboardTemplateTest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got map[string]interface{} + err := loadTemplate(tt.file, &got) + if err != nil { + t.Errorf("%v", err) + } + if !cmp.Equal(got, tt.want) { + t.Errorf("Wrong template received, diff:\n%s", cmp.Diff(tt.want, got)) + } + }) + } +} + +func TestLoadDashboard(t *testing.T) { + mockClient := &mocks.Client{} + mockThingShow := func(thingID string) *iotclient.ArduinoThing { + if thingID == thingOverriddenID { + return &iotclient.ArduinoThing{ + Properties: []iotclient.ArduinoProperty{ + {Id: switchyOverriddenID, Name: "switchy"}, + }, + } + } + return &iotclient.ArduinoThing{ + Properties: []iotclient.ArduinoProperty{ + {Id: switchyID, Name: "switchy"}, + {Id: relayID, Name: "relay_2"}, + {Id: blinkSpeedID, Name: "blink_speed"}, + }, + } + } + mockClient.On("ThingShow", mock.AnythingOfType("string")).Return(mockThingShow, nil) + + tests := []struct { + name string + file string + override map[string]string + want *iotclient.Dashboardv2 + }{ + { + name: "dashboard detailed", + file: "testdata/dashboard-detailed.yaml", + override: nil, + want: dashboardDetailed, + }, + + { + name: "dashboard with wrong options to be filtered out", + file: "testdata/dashboard-wrong-options.yaml", + override: nil, + want: dashboardDetailed, + }, + + { + name: "dashboard without options, should have a not nil map", + file: "testdata/dashboard-no-options.yaml", + override: nil, + want: dashboardNoOptions, + }, + + { + name: "dashboard with variable, mocked variable id is concatenation of thing_id and variable_id", + file: "testdata/dashboard-with-variable.yaml", + override: nil, + want: dashboardWithVariable, + }, + + { + name: "dashboard with variable, thing is overridden", + file: "testdata/dashboard-with-variable.yaml", + override: map[string]string{"thing": thingOverriddenID}, + want: dashboardVariableOverride, + }, + + { + name: "dashboard with two widgets", + file: "testdata/dashboard-two-widgets.yaml", + override: nil, + want: dashboardTwoWidgets, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := LoadDashboard(tt.file, tt.override, mockClient) + if err != nil { + t.Errorf("%v", err) + } + + for i := range got.Widgets { + // check widget id generation + id := got.Widgets[i].Id + _, err := uuid.FromString(id) + if err != nil { + t.Errorf("Widget ID is not a valid UUID: %s", id) + } + // Remove generated id to be able to compare the widget with the expected one + got.Widgets[i].Id = "" + } + + if !cmp.Equal(got, tt.want) { + t.Errorf("Wrong template received, diff:\n%s", cmp.Diff(tt.want, got)) + } + }) + } +} diff --git a/internal/template/testdata/dashboard-detailed.yaml b/internal/template/testdata/dashboard-detailed.yaml new file mode 100644 index 00000000..077122f3 --- /dev/null +++ b/internal/template/testdata/dashboard-detailed.yaml @@ -0,0 +1,14 @@ +name: dashboard +widgets: + - height: 1 + height_mobile: 2 + name: Switch-name + options: + showLabels: true + type: Switch + width: 3 + width_mobile: 4 + x: 5 + x_mobile: 6 + "y": 7 + y_mobile: 8 diff --git a/internal/template/testdata/dashboard-no-options.yaml b/internal/template/testdata/dashboard-no-options.yaml new file mode 100644 index 00000000..39d2f283 --- /dev/null +++ b/internal/template/testdata/dashboard-no-options.yaml @@ -0,0 +1,12 @@ +name: dashboard-no-options +widgets: + - height: 1 + height_mobile: 2 + name: Switch-name + type: Switch + width: 3 + width_mobile: 4 + x: 5 + x_mobile: 6 + "y": 7 + y_mobile: 8 diff --git a/internal/template/testdata/dashboard-two-widgets.yaml b/internal/template/testdata/dashboard-two-widgets.yaml new file mode 100644 index 00000000..6b6027f5 --- /dev/null +++ b/internal/template/testdata/dashboard-two-widgets.yaml @@ -0,0 +1,26 @@ +id: dashboard-two-widgets +name: dashboard-two-widgets +widgets: + - type: Slider + name: blink_speed + width: 8 + height: 7 + x: 7 + y: 5 + variables: + - thing_id: remote-controlled-lights + variable_id: blink_speed + options: + min: 0 + max: 5000 + - type: Switch + name: relay_2 + width: 5 + height: 5 + x: 5 + y: 0 + variables: + - thing_id: remote-controlled-lights + variable_id: relay_2 + options: + showLabels: true \ No newline at end of file diff --git a/internal/template/testdata/dashboard-with-variable.yaml b/internal/template/testdata/dashboard-with-variable.yaml new file mode 100644 index 00000000..262f04d3 --- /dev/null +++ b/internal/template/testdata/dashboard-with-variable.yaml @@ -0,0 +1,17 @@ +name: dashboard-with-variable +widgets: + - height: 1 + height_mobile: 2 + name: Switch-name + options: + showLabels: true + variables: + - thing_id: thing + variable_id: switchy + type: Switch + width: 3 + width_mobile: 4 + x: 5 + x_mobile: 6 + "y": 7 + y_mobile: 8 diff --git a/internal/template/testdata/dashboard-wrong-options.yaml b/internal/template/testdata/dashboard-wrong-options.yaml new file mode 100644 index 00000000..222d5b01 --- /dev/null +++ b/internal/template/testdata/dashboard-wrong-options.yaml @@ -0,0 +1,17 @@ +name: dashboard +widgets: + - height: 1 + height_mobile: 2 + name: Switch-name + options: + showLabels: true + name: name-wrong + icon: wrong + section: section-wrong + type: Switch + width: 3 + width_mobile: 4 + x: 5 + x_mobile: 6 + "y": 7 + y_mobile: 8 diff --git a/internal/template/testdata/home-security-dashboard.json b/internal/template/testdata/home-security-dashboard.json new file mode 100644 index 00000000..7df2fcbe --- /dev/null +++ b/internal/template/testdata/home-security-dashboard.json @@ -0,0 +1,29 @@ +{ + "id": "home-security-alarm-dashboard", + "name": "Home Security Alarm", + "widgets": [ + { + "type": "Messenger", + "name": "message_update", + "variables": [ + { + "thing_id": "home-security-alarm", + "variable_id": "message_update" + } + ] + }, + { + "type": "Switch", + "name": "light_alarm", + "variables": [ + { + "thing_id": "home-security-alarm", + "variable_id": "light_alarm" + } + ], + "options": { + "showLabels": true + } + } + ] +} \ No newline at end of file diff --git a/internal/template/testdata/home-security-dashboard.yaml b/internal/template/testdata/home-security-dashboard.yaml new file mode 100644 index 00000000..ba38e851 --- /dev/null +++ b/internal/template/testdata/home-security-dashboard.yaml @@ -0,0 +1,15 @@ +id: home-security-alarm-dashboard +name: Home Security Alarm +widgets: + - type: Messenger + name: message_update + variables: + - thing_id: home-security-alarm + variable_id: message_update + - type: Switch + name: light_alarm + variables: + - thing_id: home-security-alarm + variable_id: light_alarm + options: + showLabels: true \ No newline at end of file