diff --git a/Taskfile.yml b/Taskfile.yml index d97c88a0..10ef00c7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -8,7 +8,7 @@ vars: GOLANGCI_LINT_VERSION: v2.4.0 GOIMPORTS_VERSION: v0.29.0 DPRINT_VERSION: 0.48.0 - EXAMPLE_VERSION: "0.5.0" + EXAMPLE_VERSION: "0.5.1" RUNNER_VERSION: "0.5.0" VERSION: # if version is not passed we hack the semver by encoding the commit as pre-release sh: echo "${VERSION:-0.0.0-$(git rev-parse --short HEAD)}" diff --git a/cmd/arduino-app-cli/app/app.go b/cmd/arduino-app-cli/app/app.go index d8aae2fb..fca105b8 100644 --- a/cmd/arduino-app-cli/app/app.go +++ b/cmd/arduino-app-cli/app/app.go @@ -50,5 +50,5 @@ func Load(idOrPath string) (app.ArduinoApp, error) { return app.ArduinoApp{}, fmt.Errorf("invalid app path: %s", idOrPath) } - return app.Load(id.ToPath().String()) + return app.Load(id.ToPath()) } diff --git a/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go b/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go index edcd721d..22cbc58b 100644 --- a/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go +++ b/cmd/arduino-app-cli/internal/servicelocator/servicelocator.go @@ -42,11 +42,11 @@ func Init(cfg config.Configuration) { var ( GetBricksIndex = sync.OnceValue(func() *bricksindex.BricksIndex { - return f.Must(bricksindex.GenerateBricksIndexFromFile(GetStaticStore().GetAssetsFolder())) + return f.Must(bricksindex.Load(GetStaticStore().GetAssetsFolder())) }) GetModelsIndex = sync.OnceValue(func() *modelsindex.ModelsIndex { - return f.Must(modelsindex.GenerateModelsIndexFromFile(GetStaticStore().GetAssetsFolder())) + return f.Must(modelsindex.Load(GetStaticStore().GetAssetsFolder())) }) GetProvisioner = sync.OnceValue(func() *orchestrator.Provision { diff --git a/debian/arduino-app-cli/etc/needrestart/conf.d/arduino-adbd.conf b/debian/arduino-app-cli/etc/needrestart/conf.d/arduino-adbd.conf new file mode 100644 index 00000000..1ffff052 --- /dev/null +++ b/debian/arduino-app-cli/etc/needrestart/conf.d/arduino-adbd.conf @@ -0,0 +1,3 @@ +# exclude adbd from service that need to be restarted. +$nrconf{override_rc}{qr(^adbd\.service$)} = 0; + diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index f2b3a999..77721c82 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1147,6 +1147,15 @@ components: $ref: '#/components/schemas/ErrorResponse' description: Precondition Failed schemas: + AIModel: + properties: + description: + type: string + id: + type: string + name: + type: string + type: object AIModelItem: properties: brick_ids: @@ -1183,7 +1192,7 @@ components: properties: bricks: items: - $ref: '#/components/schemas/BrickInstance' + $ref: '#/components/schemas/BrickInstanceListItem' nullable: true type: array type: object @@ -1195,6 +1204,8 @@ components: type: string name: type: string + require_model: + type: boolean required: - id - name @@ -1310,6 +1321,11 @@ components: $ref: '#/components/schemas/CodeExample' nullable: true type: array + compatible_models: + items: + $ref: '#/components/schemas/AIModel' + nullable: true + type: array description: type: string id: @@ -1318,6 +1334,8 @@ components: type: string readme: type: string + require_model: + type: boolean status: type: string used_by_apps: @@ -1331,6 +1349,38 @@ components: type: object type: object BrickInstance: + properties: + author: + type: string + category: + type: string + compatible_models: + items: + $ref: '#/components/schemas/AIModel' + nullable: true + type: array + config_variables: + items: + $ref: '#/components/schemas/BrickConfigVariable' + type: array + id: + type: string + model: + type: string + name: + type: string + require_model: + type: boolean + status: + type: string + variables: + additionalProperties: + type: string + description: 'Deprecated: use config_variables instead. This field is kept + for backward compatibility.' + type: object + type: object + BrickInstanceListItem: properties: author: type: string @@ -1346,6 +1396,8 @@ components: type: string name: type: string + require_model: + type: boolean status: type: string variables: @@ -1365,13 +1417,10 @@ components: type: string id: type: string - models: - items: - type: string - nullable: true - type: array name: type: string + require_model: + type: boolean status: type: string type: object diff --git a/internal/api/handlers/app_delete.go b/internal/api/handlers/app_delete.go index 6503d124..99d72cf1 100644 --- a/internal/api/handlers/app_delete.go +++ b/internal/api/handlers/app_delete.go @@ -42,7 +42,7 @@ func HandleAppDelete( return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) diff --git a/internal/api/handlers/app_details.go b/internal/api/handlers/app_details.go index 72c74bdd..1795ae42 100644 --- a/internal/api/handlers/app_details.go +++ b/internal/api/handlers/app_details.go @@ -45,7 +45,7 @@ func HandleAppDetails( return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) @@ -81,7 +81,7 @@ func HandleAppDetailsEdits( render.EncodeResponse(w, http.StatusPreconditionFailed, models.ErrorResponse{Details: "invalid id"}) return } - appToEdit, err := app.Load(id.ToPath().String()) + appToEdit, err := app.Load(id.ToPath()) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) diff --git a/internal/api/handlers/app_logs.go b/internal/api/handlers/app_logs.go index 2cf44c87..0fece050 100644 --- a/internal/api/handlers/app_logs.go +++ b/internal/api/handlers/app_logs.go @@ -43,7 +43,7 @@ func HandleAppLogs( return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) diff --git a/internal/api/handlers/app_ports.go b/internal/api/handlers/app_ports.go index 8671cd7e..c405c3a3 100644 --- a/internal/api/handlers/app_ports.go +++ b/internal/api/handlers/app_ports.go @@ -47,7 +47,7 @@ func HandleAppPorts( return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) diff --git a/internal/api/handlers/app_sketch_libs.go b/internal/api/handlers/app_sketch_libs.go index 43969572..7e280a2f 100644 --- a/internal/api/handlers/app_sketch_libs.go +++ b/internal/api/handlers/app_sketch_libs.go @@ -36,7 +36,7 @@ func HandleSketchAddLibrary(idProvider *app.IDProvider) http.HandlerFunc { render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "cannot alter examples"}) return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) // Get query param addDeps (default false) addDeps, _ := strconv.ParseBool(r.URL.Query().Get("add_deps")) @@ -78,7 +78,7 @@ func HandleSketchRemoveLibrary(idProvider *app.IDProvider) http.HandlerFunc { render.EncodeResponse(w, http.StatusBadRequest, models.ErrorResponse{Details: "cannot alter examples"}) return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) return @@ -114,7 +114,7 @@ func HandleSketchListLibraries(idProvider *app.IDProvider) http.HandlerFunc { render.EncodeResponse(w, http.StatusPreconditionFailed, models.ErrorResponse{Details: "invalid id"}) return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) return diff --git a/internal/api/handlers/app_start.go b/internal/api/handlers/app_start.go index 0293aff0..bfd8a034 100644 --- a/internal/api/handlers/app_start.go +++ b/internal/api/handlers/app_start.go @@ -47,7 +47,7 @@ func HandleAppStart( return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) diff --git a/internal/api/handlers/app_stop.go b/internal/api/handlers/app_stop.go index f5a9979c..a013ef9e 100644 --- a/internal/api/handlers/app_stop.go +++ b/internal/api/handlers/app_stop.go @@ -38,7 +38,7 @@ func HandleAppStop( return } - app, err := app.Load(id.ToPath().String()) + app, err := app.Load(id.ToPath()) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", id.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) diff --git a/internal/api/handlers/bricks.go b/internal/api/handlers/bricks.go index 9ac76632..f3d22c6d 100644 --- a/internal/api/handlers/bricks.go +++ b/internal/api/handlers/bricks.go @@ -55,7 +55,7 @@ func HandleAppBrickInstancesList( } appPath := appId.ToPath() - app, err := app.Load(appPath.String()) + app, err := app.Load(appPath) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", appId.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) @@ -85,7 +85,7 @@ func HandleAppBrickInstanceDetails( } appPath := appId.ToPath() - app, err := app.Load(appPath.String()) + app, err := app.Load(appPath) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", appId.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) @@ -120,7 +120,7 @@ func HandleBrickCreate( } appPath := appId.ToPath() - app, err := app.Load(appPath.String()) + app, err := app.Load(appPath) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", appId.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) @@ -190,7 +190,7 @@ func HandleBrickUpdates( } appPath := appId.ToPath() - app, err := app.Load(appPath.String()) + app, err := app.Load(appPath) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", appId.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) @@ -236,7 +236,7 @@ func HandleBrickDelete( } appPath := appId.ToPath() - app, err := app.Load(appPath.String()) + app, err := app.Load(appPath) if err != nil { slog.Error("Unable to parse the app.yaml", slog.String("error", err.Error()), slog.String("path", appId.String())) render.EncodeResponse(w, http.StatusInternalServerError, models.ErrorResponse{Details: "unable to find the app"}) diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index f6094430..25472727 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -1,6 +1,6 @@ // Package client provides primitives to interact with the openapi HTTP API. // -// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.0 DO NOT EDIT. +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. package client import ( @@ -41,6 +41,13 @@ const ( StarsDesc ListLibrariesParamsSort = "stars_desc" ) +// AIModel defines model for AIModel. +type AIModel struct { + Description *string `json:"description,omitempty"` + Id *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` +} + // AIModelItem defines model for AIModelItem. type AIModelItem struct { BrickIds *[]string `json:"brick_ids"` @@ -59,14 +66,15 @@ type AIModelsListResult struct { // AppBrickInstancesResult defines model for AppBrickInstancesResult. type AppBrickInstancesResult struct { - Bricks *[]BrickInstance `json:"bricks"` + Bricks *[]BrickInstanceListItem `json:"bricks"` } // AppDetailedBrick defines model for AppDetailedBrick. type AppDetailedBrick struct { - Category *string `json:"category,omitempty"` - Id string `json:"id"` - Name string `json:"name"` + Category *string `json:"category,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + RequireModel *bool `json:"require_model,omitempty"` } // AppDetailedInfo defines model for AppDetailedInfo. @@ -135,27 +143,46 @@ type BrickCreateUpdateRequest struct { // BrickDetailsResult defines model for BrickDetailsResult. type BrickDetailsResult struct { - ApiDocsPath *string `json:"api_docs_path,omitempty"` - Author *string `json:"author,omitempty"` - Category *string `json:"category,omitempty"` - CodeExamples *[]CodeExample `json:"code_examples"` - Description *string `json:"description,omitempty"` - Id *string `json:"id,omitempty"` - Name *string `json:"name,omitempty"` - Readme *string `json:"readme,omitempty"` - Status *string `json:"status,omitempty"` - UsedByApps *[]AppReference `json:"used_by_apps"` - Variables *map[string]BrickVariable `json:"variables,omitempty"` + ApiDocsPath *string `json:"api_docs_path,omitempty"` + Author *string `json:"author,omitempty"` + Category *string `json:"category,omitempty"` + CodeExamples *[]CodeExample `json:"code_examples"` + CompatibleModels *[]AIModel `json:"compatible_models"` + Description *string `json:"description,omitempty"` + Id *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Readme *string `json:"readme,omitempty"` + RequireModel *bool `json:"require_model,omitempty"` + Status *string `json:"status,omitempty"` + UsedByApps *[]AppReference `json:"used_by_apps"` + Variables *map[string]BrickVariable `json:"variables,omitempty"` } // BrickInstance defines model for BrickInstance. type BrickInstance struct { + Author *string `json:"author,omitempty"` + Category *string `json:"category,omitempty"` + CompatibleModels *[]AIModel `json:"compatible_models"` + ConfigVariables *[]BrickConfigVariable `json:"config_variables,omitempty"` + Id *string `json:"id,omitempty"` + Model *string `json:"model,omitempty"` + Name *string `json:"name,omitempty"` + RequireModel *bool `json:"require_model,omitempty"` + Status *string `json:"status,omitempty"` + + // Variables Deprecated: use config_variables instead. This field is kept for backward compatibility. + Variables *map[string]string `json:"variables,omitempty"` +} + +// BrickInstanceListItem defines model for BrickInstanceListItem. +type BrickInstanceListItem struct { Author *string `json:"author,omitempty"` Category *string `json:"category,omitempty"` ConfigVariables *[]BrickConfigVariable `json:"config_variables,omitempty"` Id *string `json:"id,omitempty"` Model *string `json:"model,omitempty"` Name *string `json:"name,omitempty"` + RequireModel *bool `json:"require_model,omitempty"` Status *string `json:"status,omitempty"` // Variables Deprecated: use config_variables instead. This field is kept for backward compatibility. @@ -164,13 +191,13 @@ type BrickInstance struct { // BrickListItem defines model for BrickListItem. type BrickListItem struct { - Author *string `json:"author,omitempty"` - Category *string `json:"category,omitempty"` - Description *string `json:"description,omitempty"` - Id *string `json:"id,omitempty"` - Models *[]string `json:"models"` - Name *string `json:"name,omitempty"` - Status *string `json:"status,omitempty"` + Author *string `json:"author,omitempty"` + Category *string `json:"category,omitempty"` + Description *string `json:"description,omitempty"` + Id *string `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + RequireModel *bool `json:"require_model,omitempty"` + Status *string `json:"status,omitempty"` } // BrickListResult defines model for BrickListResult. diff --git a/internal/e2e/daemon/app_test.go b/internal/e2e/daemon/app_test.go index 1de8ff64..b62b882b 100644 --- a/internal/e2e/daemon/app_test.go +++ b/internal/e2e/daemon/app_test.go @@ -783,9 +783,10 @@ func TestAppDetails(t *testing.T) { require.Len(t, *detailsResp.JSON200.Bricks, 1) require.Equal(t, client.AppDetailedBrick{ - Id: ImageClassifactionBrickID, - Name: "Image Classification", - Category: f.Ptr("video"), + Id: ImageClassifactionBrickID, + Name: "Image Classification", + Category: f.Ptr("video"), + RequireModel: f.Ptr(true), }, (*detailsResp.JSON200.Bricks)[0], ) diff --git a/internal/e2e/daemon/brick_test.go b/internal/e2e/daemon/brick_test.go index fa1cab40..5d709bb3 100644 --- a/internal/e2e/daemon/brick_test.go +++ b/internal/e2e/daemon/brick_test.go @@ -72,7 +72,7 @@ func TestBricksList(t *testing.T) { require.NoError(t, err) staticStore := store.NewStaticStore(paths.New("testdata", "assets", cfg.RunnerVersion).String()) - brickIndex, err := bricksindex.GenerateBricksIndexFromFile(staticStore.GetAssetsFolder()) + brickIndex, err := bricksindex.Load(staticStore.GetAssetsFolder()) require.NoError(t, err) // Compare the response with the bricks index @@ -83,6 +83,7 @@ func TestBricksList(t *testing.T) { require.Equal(t, bIdx.Description, *brick.Description) require.Equal(t, "Arduino", *brick.Author) require.Equal(t, "installed", *brick.Status) + require.Equal(t, bIdx.RequireModel, *brick.RequireModel) } } @@ -115,6 +116,17 @@ func TestBricksDetails(t *testing.T) { }, } + expectedModelLiteInfo := []client.AIModel{ + { + Id: f.Ptr("mobilenet-image-classification"), + Name: f.Ptr("General purpose image classification"), + Description: f.Ptr("General purpose image classification model based on MobileNetV2. This model is trained on the ImageNet dataset and can classify images into 1000 categories."), + }, + { + Id: f.Ptr("person-classification"), + Name: f.Ptr("Person classification"), + Description: f.Ptr("Person classification model based on WakeVision dataset. This model is trained to classify images into two categories: person and not-person."), + }} response, err := httpClient.GetBrickDetailsWithResponse(t.Context(), validBrickID, func(ctx context.Context, req *http.Request) error { return nil }) require.NoError(t, err) require.Equal(t, http.StatusOK, response.StatusCode(), "status code should be 200 ok") @@ -133,5 +145,7 @@ func TestBricksDetails(t *testing.T) { require.NotEmpty(t, *response.JSON200.Readme) require.NotNil(t, response.JSON200.UsedByApps, "UsedByApps should not be nil") require.Equal(t, expectedUsedByApps, *(response.JSON200.UsedByApps)) + require.NotNil(t, response.JSON200.CompatibleModels, "Models should not be nil") + require.Equal(t, expectedModelLiteInfo, *(response.JSON200.CompatibleModels)) }) } diff --git a/internal/e2e/daemon/instance_bricks_test.go b/internal/e2e/daemon/bricks_instance_test.go similarity index 91% rename from internal/e2e/daemon/instance_bricks_test.go rename to internal/e2e/daemon/bricks_instance_test.go index c210a9e6..1b8ee7c5 100644 --- a/internal/e2e/daemon/instance_bricks_test.go +++ b/internal/e2e/daemon/bricks_instance_test.go @@ -51,6 +51,18 @@ var ( Value: f.Ptr("/models/ootb/ei/mobilenet-v2-224px.eim"), }, } + + expectedModelInfo = []client.AIModel{ + { + Id: f.Ptr("mobilenet-image-classification"), + Name: f.Ptr("General purpose image classification"), + Description: f.Ptr("General purpose image classification model based on MobileNetV2. This model is trained on the ImageNet dataset and can classify images into 1000 categories."), + }, + { + Id: f.Ptr("person-classification"), + Name: f.Ptr("Person classification"), + Description: f.Ptr("Person classification model based on WakeVision dataset. This model is trained to classify images into two categories: person and not-person."), + }} ) func setupTestApp(t *testing.T) (*client.CreateAppResp, *client.ClientWithResponses) { @@ -78,7 +90,6 @@ func setupTestApp(t *testing.T) (*client.CreateAppResp, *client.ClientWithRespon ) require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode()) - return createResp, httpClient } @@ -86,11 +97,20 @@ func TestGetAppBrickInstances(t *testing.T) { var actualBody models.ErrorResponse createResp, httpClient := setupTestApp(t) t.Run("GetAppBrickInstances_Success", func(t *testing.T) { + expectedVariables := map[string]string{ + "CUSTOM_MODEL_PATH": "/home/arduino/.arduino-bricks/ei-models", + "EI_CLASSIFICATION_MODEL": "/models/ootb/ei/mobilenet-v2-224px.eim", + } + brickInstances, err := httpClient.GetAppBrickInstancesWithResponse(t.Context(), *createResp.JSON201.Id, func(ctx context.Context, req *http.Request) error { return nil }) require.NoError(t, err) require.Len(t, *brickInstances.JSON200.Bricks, 1) require.Equal(t, ImageClassifactionBrickID, *(*brickInstances.JSON200.Bricks)[0].Id) require.Equal(t, expectedConfigVariables, *(*brickInstances.JSON200.Bricks)[0].ConfigVariables) + require.Equal(t, "Arduino", *(*brickInstances.JSON200.Bricks)[0].Author) + require.Equal(t, "video", *(*brickInstances.JSON200.Bricks)[0].Category) + require.True(t, *(*brickInstances.JSON200.Bricks)[0].RequireModel) + require.Equal(t, expectedVariables, *(*brickInstances.JSON200.Bricks)[0].Variables) }) @@ -135,6 +155,20 @@ func TestGetAppBrickInstanceById(t *testing.T) { require.NotEmpty(t, brickInstance.JSON200) require.Equal(t, ImageClassifactionBrickID, *brickInstance.JSON200.Id) require.Equal(t, expectedConfigVariables, (*brickInstance.JSON200.ConfigVariables)) + require.NotNil(t, brickInstance.JSON200.CompatibleModels) + require.Equal(t, expectedModelInfo, *(brickInstance.JSON200.CompatibleModels)) + }) + t.Run("GetAppBrickInstanceByBrickIDWithCompatibleModels_Success", func(t *testing.T) { + brickInstance, err := httpClient.GetAppBrickInstanceByBrickIDWithResponse( + t.Context(), + *createResp.JSON201.Id, + ImageClassifactionBrickID, + func(ctx context.Context, req *http.Request) error { return nil }) + require.NoError(t, err) + require.NotEmpty(t, brickInstance.JSON200) + require.Equal(t, ImageClassifactionBrickID, *brickInstance.JSON200.Id) + require.NotNil(t, brickInstance.JSON200.CompatibleModels) + require.Equal(t, expectedModelInfo, *(brickInstance.JSON200.CompatibleModels)) }) t.Run("GetAppBrickInstanceByBrickID_InvalidAppID_Fails", func(t *testing.T) { diff --git a/internal/orchestrator/app/app.go b/internal/orchestrator/app/app.go index c40c9009..813ca64c 100644 --- a/internal/orchestrator/app/app.go +++ b/internal/orchestrator/app/app.go @@ -37,26 +37,25 @@ type ArduinoApp struct { // Load creates an App instance by reading all the files composing an app and grouping them // by file type. -func Load(appPath string) (ArduinoApp, error) { - path := paths.New(appPath) - if path == nil { +func Load(appPath *paths.Path) (ArduinoApp, error) { + if appPath == nil { return ArduinoApp{}, errors.New("empty app path") } - exist, err := path.IsDirCheck() + exist, err := appPath.IsDirCheck() if err != nil { return ArduinoApp{}, fmt.Errorf("app path is not valid: %w", err) } if !exist { - return ArduinoApp{}, fmt.Errorf("app path must be a directory: %s", path) + return ArduinoApp{}, fmt.Errorf("app path must be a directory: %s", appPath) } - path, err = path.Abs() + appPath, err = appPath.Abs() if err != nil { return ArduinoApp{}, fmt.Errorf("cannot get absolute path for app: %w", err) } app := ArduinoApp{ - FullPath: path, + FullPath: appPath, Descriptor: AppDescriptor{}, } @@ -71,13 +70,13 @@ func Load(appPath string) (ArduinoApp, error) { return ArduinoApp{}, errors.New("descriptor app.yaml file missing from app") } - if path.Join("python", "main.py").Exist() { - app.MainPythonFile = path.Join("python", "main.py") + if appPath.Join("python", "main.py").Exist() { + app.MainPythonFile = appPath.Join("python", "main.py") } - if path.Join("sketch", "sketch.ino").Exist() { + if appPath.Join("sketch", "sketch.ino").Exist() { // TODO: check sketch casing? - app.MainSketchPath = path.Join("sketch") + app.MainSketchPath = appPath.Join("sketch") } if app.MainPythonFile == nil && app.MainSketchPath == nil { diff --git a/internal/orchestrator/app/app_test.go b/internal/orchestrator/app/app_test.go index 47e3f53f..db9cc048 100644 --- a/internal/orchestrator/app/app_test.go +++ b/internal/orchestrator/app/app_test.go @@ -25,27 +25,34 @@ import ( ) func TestLoad(t *testing.T) { + t.Run("it fails if the app path is nil", func(t *testing.T) { + app, err := Load(nil) + assert.Error(t, err) + assert.Empty(t, app) + assert.Contains(t, err.Error(), "empty app path") + }) + t.Run("it fails if the app path is empty", func(t *testing.T) { - app, err := Load("") + app, err := Load(paths.New("")) assert.Error(t, err) assert.Empty(t, app) assert.Contains(t, err.Error(), "empty app path") }) t.Run("it fails if the app path exist but it's a file", func(t *testing.T) { - _, err := Load("testdata/app.yaml") + _, err := Load(paths.New("testdata/app.yaml")) assert.Error(t, err) assert.Contains(t, err.Error(), "app path must be a directory") }) t.Run("it fails if the app path does not exist", func(t *testing.T) { - _, err := Load("testdata/this-folder-does-not-exist") + _, err := Load(paths.New("testdata/this-folder-does-not-exist")) assert.Error(t, err) assert.Contains(t, err.Error(), "app path is not valid") }) t.Run("it loads an app correctly", func(t *testing.T) { - app, err := Load("testdata/AppSimple") + app, err := Load(paths.New("testdata/AppSimple")) assert.NoError(t, err) assert.NotEmpty(t, app) @@ -61,7 +68,7 @@ func TestMissingDescriptor(t *testing.T) { appFolderPath := paths.New("testdata", "MissingDescriptor") // Load app - app, err := Load(appFolderPath.String()) + app, err := Load(appFolderPath) assert.Error(t, err) assert.ErrorContains(t, err, "descriptor app.yaml file missing from app") assert.Empty(t, app) @@ -71,7 +78,7 @@ func TestMissingMains(t *testing.T) { appFolderPath := paths.New("testdata", "MissingMains") // Load app - app, err := Load(appFolderPath.String()) + app, err := Load(appFolderPath) assert.Error(t, err) assert.ErrorContains(t, err, "main python file and sketch file missing from app") assert.Empty(t, app) diff --git a/internal/orchestrator/app/generator/app_template/python/main.py b/internal/orchestrator/app/generator/app_template/python/main.py index 9ef98b13..fb56fb23 100644 --- a/internal/orchestrator/app/generator/app_template/python/main.py +++ b/internal/orchestrator/app/generator/app_template/python/main.py @@ -1,6 +1,15 @@ -def main(): - print("Hello World!") +import time +from arduino.app_utils import App -if __name__ == "__main__": - main() +print("Hello world!") + + +def loop(): + """This function is called repeatedly by the App framework.""" + # You can replace this with any code you want your App to run repeatedly. + time.sleep(10) + + +# See: https://docs.arduino.cc/software/app-lab/tutorials/getting-started/#app-run +App.run(user_loop=loop) diff --git a/internal/orchestrator/app/generator/testdata/app-all.golden/python/main.py b/internal/orchestrator/app/generator/testdata/app-all.golden/python/main.py index 9ef98b13..fb56fb23 100644 --- a/internal/orchestrator/app/generator/testdata/app-all.golden/python/main.py +++ b/internal/orchestrator/app/generator/testdata/app-all.golden/python/main.py @@ -1,6 +1,15 @@ -def main(): - print("Hello World!") +import time +from arduino.app_utils import App -if __name__ == "__main__": - main() +print("Hello world!") + + +def loop(): + """This function is called repeatedly by the App framework.""" + # You can replace this with any code you want your App to run repeatedly. + time.sleep(10) + + +# See: https://docs.arduino.cc/software/app-lab/tutorials/getting-started/#app-run +App.run(user_loop=loop) diff --git a/internal/orchestrator/app/generator/testdata/app-no-sketch.golden/python/main.py b/internal/orchestrator/app/generator/testdata/app-no-sketch.golden/python/main.py index 9ef98b13..fb56fb23 100644 --- a/internal/orchestrator/app/generator/testdata/app-no-sketch.golden/python/main.py +++ b/internal/orchestrator/app/generator/testdata/app-no-sketch.golden/python/main.py @@ -1,6 +1,15 @@ -def main(): - print("Hello World!") +import time +from arduino.app_utils import App -if __name__ == "__main__": - main() +print("Hello world!") + + +def loop(): + """This function is called repeatedly by the App framework.""" + # You can replace this with any code you want your App to run repeatedly. + time.sleep(10) + + +# See: https://docs.arduino.cc/software/app-lab/tutorials/getting-started/#app-run +App.run(user_loop=loop) diff --git a/internal/orchestrator/app/parser_test.go b/internal/orchestrator/app/parser_test.go index 123d38f4..fe8c8f59 100644 --- a/internal/orchestrator/app/parser_test.go +++ b/internal/orchestrator/app/parser_test.go @@ -115,7 +115,7 @@ bricks: err = os.WriteFile(appYaml.String(), []byte(appDescriptor), 0600) require.NoError(t, err) - app, err := Load(tempDir) + app, err := Load(paths.New(tempDir)) require.NoError(t, err) require.Equal(t, "Test App", app.Name) require.Equal(t, 1, len(app.Descriptor.Bricks)) diff --git a/internal/orchestrator/app_status.go b/internal/orchestrator/app_status.go index 481e240f..7e9c32fc 100644 --- a/internal/orchestrator/app_status.go +++ b/internal/orchestrator/app_status.go @@ -97,7 +97,7 @@ func parseDockerStatusEvent(ctx context.Context, cfg config.Configuration, docke } // FIXME: create an helper function to transform an app.ArduinoApp into an ortchestrator.AppInfo - app, err := app.Load(appStatus.AppPath.String()) + app, err := app.Load(appStatus.AppPath) if err != nil { slog.Warn("error loading app", "appPath", appStatus.AppPath.String(), "error", err) return AppInfo{}, err diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index 691249d0..85e6e601 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -58,22 +58,20 @@ func (s *Service) List() (BrickListResult, error) { res := BrickListResult{Bricks: make([]BrickListItem, len(s.bricksIndex.Bricks))} for i, brick := range s.bricksIndex.Bricks { res.Bricks[i] = BrickListItem{ - ID: brick.ID, - Name: brick.Name, - Author: "Arduino", // TODO: for now we only support our bricks - Description: brick.Description, - Category: brick.Category, - Status: "installed", - Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) string { - return m.ID - }), + ID: brick.ID, + Name: brick.Name, + Author: "Arduino", // TODO: for now we only support our bricks + Description: brick.Description, + Category: brick.Category, + Status: "installed", + RequireModel: brick.RequireModel, } } return res, nil } func (s *Service) AppBrickInstancesList(a *app.ArduinoApp) (AppBrickInstancesResult, error) { - res := AppBrickInstancesResult{BrickInstances: make([]BrickInstance, len(a.Descriptor.Bricks))} + res := AppBrickInstancesResult{BrickInstances: make([]BrickInstanceListItem, len(a.Descriptor.Bricks))} for i, brickInstance := range a.Descriptor.Bricks { brick, found := s.bricksIndex.FindBrickByID(brickInstance.ID) if !found { @@ -82,12 +80,13 @@ func (s *Service) AppBrickInstancesList(a *app.ArduinoApp) (AppBrickInstancesRes variablesMap, configVariables := getBrickConfigDetails(brick, brickInstance.Variables) - res.BrickInstances[i] = BrickInstance{ + res.BrickInstances[i] = BrickInstanceListItem{ ID: brick.ID, Name: brick.Name, Author: "Arduino", // TODO: for now we only support our bricks Category: brick.Category, Status: "installed", + RequireModel: brick.RequireModel, ModelID: brickInstance.Model, // TODO: in case is not set by the user, should we return the default model? Variables: variablesMap, // TODO: do we want to show also the default value of not explicitly set variables? ConfigVariables: configVariables, @@ -121,9 +120,17 @@ func (s *Service) AppBrickInstanceDetails(a *app.ArduinoApp, brickID string) (Br Author: "Arduino", // TODO: for now we only support our bricks Category: brick.Category, Status: "installed", // For now every Arduino brick are installed + RequireModel: brick.RequireModel, Variables: variables, ConfigVariables: configVariables, ModelID: modelID, + CompatibleModels: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) AIModel { + return AIModel{ + ID: m.ID, + Name: m.Name, + Description: m.ModuleDescription, + } + }), }, nil } @@ -193,19 +200,26 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, if err != nil { return BrickDetailsResult{}, fmt.Errorf("unable to get used by apps: %w", err) } - return BrickDetailsResult{ ID: id, Name: brick.Name, Author: "Arduino", // TODO: for now we only support our bricks Description: brick.Description, Category: brick.Category, + RequireModel: brick.RequireModel, Status: "installed", // For now every Arduino brick are installed Variables: variables, Readme: readme, ApiDocsPath: apiDocsPath, CodeExamples: codeExamples, UsedByApps: usedByApps, + CompatibleModels: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) AIModel { + return AIModel{ + ID: m.ID, + Name: m.Name, + Description: m.ModuleDescription, + } + }), }, nil } @@ -237,7 +251,7 @@ func getUsedByApps( } for _, file := range appPaths { - app, err := app.Load(file.String()) + app, err := app.Load(file) if err != nil { // we are not considering the broken apps slog.Warn("unable to parse app.yaml, skipping", "path", file.String(), "error", err.Error()) diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go index c14cebfd..266d41ef 100644 --- a/internal/orchestrator/bricks/bricks_test.go +++ b/internal/orchestrator/bricks/bricks_test.go @@ -16,6 +16,8 @@ package bricks import ( + "os" + "path/filepath" "testing" "github.com/arduino/go-paths-helper" @@ -24,15 +26,18 @@ import ( "github.com/arduino/arduino-app-cli/internal/orchestrator/app" "github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex" + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" + "github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex" + "github.com/arduino/arduino-app-cli/internal/store" ) func TestBrickCreate(t *testing.T) { - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata")) + bricksIndex, err := bricksindex.Load(paths.New("testdata")) require.Nil(t, err) brickService := NewService(nil, bricksIndex, nil) t.Run("fails if brick id does not exist", func(t *testing.T) { - err = brickService.BrickCreate(BrickCreateUpdateRequest{ID: "not-existing-id"}, f.Must(app.Load("testdata/dummy-app"))) + err = brickService.BrickCreate(BrickCreateUpdateRequest{ID: "not-existing-id"}, f.Must(app.Load(paths.New("testdata/dummy-app")))) require.Error(t, err) require.Equal(t, "brick \"not-existing-id\" not found", err.Error()) }) @@ -41,7 +46,7 @@ func TestBrickCreate(t *testing.T) { req := BrickCreateUpdateRequest{ID: "arduino:arduino_cloud", Variables: map[string]string{ "NON_EXISTING_VARIABLE": "some-value", }} - err = brickService.BrickCreate(req, f.Must(app.Load("testdata/dummy-app"))) + err = brickService.BrickCreate(req, f.Must(app.Load(paths.New("testdata/dummy-app")))) require.Error(t, err) require.Equal(t, "variable \"NON_EXISTING_VARIABLE\" does not exist on brick \"arduino:arduino_cloud\"", err.Error()) }) @@ -51,7 +56,7 @@ func TestBrickCreate(t *testing.T) { "ARDUINO_DEVICE_ID": "", "ARDUINO_SECRET": "a-secret-a", }} - err = brickService.BrickCreate(req, f.Must(app.Load("testdata/dummy-app"))) + err = brickService.BrickCreate(req, f.Must(app.Load(paths.New("testdata/dummy-app")))) require.Error(t, err) require.Equal(t, "required variable \"ARDUINO_DEVICE_ID\" cannot be empty", err.Error()) }) @@ -65,10 +70,10 @@ func TestBrickCreate(t *testing.T) { req := BrickCreateUpdateRequest{ID: "arduino:arduino_cloud", Variables: map[string]string{ "ARDUINO_SECRET": "a-secret-a", }} - err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String()))) + err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp))) require.NoError(t, err) - after, err := app.Load(tempDummyApp.String()) + after, err := app.Load(tempDummyApp) require.Nil(t, err) require.Len(t, after.Descriptor.Bricks, 1) require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID) @@ -83,9 +88,9 @@ func TestBrickCreate(t *testing.T) { require.Nil(t, paths.New("testdata/dummy-app").CopyDirTo(tempDummyApp)) req := BrickCreateUpdateRequest{ID: "arduino:dbstorage_sqlstore"} - err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String()))) + err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp))) require.Nil(t, err) - after, err := app.Load(tempDummyApp.String()) + after, err := app.Load(tempDummyApp) require.Nil(t, err) require.Len(t, after.Descriptor.Bricks, 2) require.Equal(t, "arduino:dbstorage_sqlstore", after.Descriptor.Bricks[1].ID) @@ -97,7 +102,7 @@ func TestBrickCreate(t *testing.T) { require.Nil(t, err) err = paths.New("testdata/dummy-app").CopyDirTo(tempDummyApp) require.Nil(t, err) - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata")) + bricksIndex, err := bricksindex.Load(paths.New("testdata")) require.Nil(t, err) brickService := NewService(nil, bricksIndex, nil) @@ -111,10 +116,10 @@ func TestBrickCreate(t *testing.T) { }, } - err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp.String()))) + err = brickService.BrickCreate(req, f.Must(app.Load(tempDummyApp))) require.Nil(t, err) - after, err := app.Load(tempDummyApp.String()) + after, err := app.Load(tempDummyApp) require.Nil(t, err) require.Len(t, after.Descriptor.Bricks, 1) require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID) @@ -124,18 +129,18 @@ func TestBrickCreate(t *testing.T) { } func TestUpdateBrick(t *testing.T) { - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata")) + bricksIndex, err := bricksindex.Load(paths.New("testdata")) require.Nil(t, err) brickService := NewService(nil, bricksIndex, nil) t.Run("fails if brick id does not exist into brick index", func(t *testing.T) { - err = brickService.BrickUpdate(BrickCreateUpdateRequest{ID: "not-existing-id"}, f.Must(app.Load("testdata/dummy-app"))) + err = brickService.BrickUpdate(BrickCreateUpdateRequest{ID: "not-existing-id"}, f.Must(app.Load(paths.New("testdata/dummy-app")))) require.Error(t, err) require.Equal(t, "brick \"not-existing-id\" not found into the brick index", err.Error()) }) t.Run("fails if brick is present into the index but not in the app ", func(t *testing.T) { - err = brickService.BrickUpdate(BrickCreateUpdateRequest{ID: "arduino:dbstorage_sqlstore"}, f.Must(app.Load("testdata/dummy-app"))) + err = brickService.BrickUpdate(BrickCreateUpdateRequest{ID: "arduino:dbstorage_sqlstore"}, f.Must(app.Load(paths.New("testdata/dummy-app")))) require.Error(t, err) require.Equal(t, "brick \"arduino:dbstorage_sqlstore\" not found into the bricks of the app", err.Error()) }) @@ -144,7 +149,7 @@ func TestUpdateBrick(t *testing.T) { req := BrickCreateUpdateRequest{ID: "arduino:arduino_cloud", Variables: map[string]string{ "NON_EXISTING_VARIABLE": "some-value", }} - err = brickService.BrickUpdate(req, f.Must(app.Load("testdata/dummy-app"))) + err = brickService.BrickUpdate(req, f.Must(app.Load(paths.New("testdata/dummy-app")))) require.Error(t, err) require.Equal(t, "variable \"NON_EXISTING_VARIABLE\" does not exist on brick \"arduino:arduino_cloud\"", err.Error()) }) @@ -155,7 +160,7 @@ func TestUpdateBrick(t *testing.T) { "ARDUINO_DEVICE_ID": "", "ARDUINO_SECRET": "a-secret-a", }} - err = brickService.BrickUpdate(req, f.Must(app.Load("testdata/dummy-app"))) + err = brickService.BrickUpdate(req, f.Must(app.Load(paths.New("testdata/dummy-app")))) require.Error(t, err) require.Equal(t, "required variable \"ARDUINO_DEVICE_ID\" cannot be empty", err.Error()) }) @@ -169,10 +174,10 @@ func TestUpdateBrick(t *testing.T) { req := BrickCreateUpdateRequest{ID: "arduino:arduino_cloud", Variables: map[string]string{ "ARDUINO_SECRET": "a-secret-a", }} - err = brickService.BrickUpdate(req, f.Must(app.Load(tempDummyApp.String()))) + err = brickService.BrickUpdate(req, f.Must(app.Load(tempDummyApp))) require.NoError(t, err) - after, err := app.Load(tempDummyApp.String()) + after, err := app.Load(tempDummyApp) require.Nil(t, err) require.Len(t, after.Descriptor.Bricks, 1) require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID) @@ -184,7 +189,7 @@ func TestUpdateBrick(t *testing.T) { tempDummyApp := paths.New("testdata/dummy-app.temp") require.Nil(t, tempDummyApp.RemoveAll()) require.Nil(t, paths.New("testdata/dummy-app").CopyDirTo(tempDummyApp)) - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata")) + bricksIndex, err := bricksindex.Load(paths.New("testdata")) require.Nil(t, err) brickService := NewService(nil, bricksIndex, nil) @@ -198,10 +203,10 @@ func TestUpdateBrick(t *testing.T) { }, } - err = brickService.BrickUpdate(req, f.Must(app.Load(tempDummyApp.String()))) + err = brickService.BrickUpdate(req, f.Must(app.Load(tempDummyApp))) require.Nil(t, err) - after, err := app.Load(tempDummyApp.String()) + after, err := app.Load(tempDummyApp) require.Nil(t, err) require.Len(t, after.Descriptor.Bricks, 1) require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID) @@ -213,7 +218,7 @@ func TestUpdateBrick(t *testing.T) { tempDummyApp := paths.New("testdata/dummy-app-for-update.temp") require.Nil(t, tempDummyApp.RemoveAll()) require.Nil(t, paths.New("testdata/dummy-app-for-update").CopyDirTo(tempDummyApp)) - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New("testdata")) + bricksIndex, err := bricksindex.Load(paths.New("testdata")) require.Nil(t, err) brickService := NewService(nil, bricksIndex, nil) @@ -226,10 +231,10 @@ func TestUpdateBrick(t *testing.T) { }, } - err = brickService.BrickUpdate(req, f.Must(app.Load(tempDummyApp.String()))) + err = brickService.BrickUpdate(req, f.Must(app.Load(tempDummyApp))) require.Nil(t, err) - after, err := app.Load(tempDummyApp.String()) + after, err := app.Load(tempDummyApp) require.Nil(t, err) require.Len(t, after.Descriptor.Bricks, 1) require.Equal(t, "arduino:arduino_cloud", after.Descriptor.Bricks[0].ID) @@ -318,3 +323,512 @@ func TestGetBrickInstanceVariableDetails(t *testing.T) { }) } } + +func TestBricksDetails(t *testing.T) { + tmpDir := t.TempDir() + appsDir := filepath.Join(tmpDir, "ArduinoApps") + dataDir := filepath.Join(tmpDir, "Data") + assetsDir := filepath.Join(dataDir, "assets") + + require.NoError(t, os.MkdirAll(appsDir, 0755)) + require.NoError(t, os.MkdirAll(assetsDir, 0755)) + + t.Setenv("ARDUINO_APP_CLI__APPS_DIR", appsDir) + t.Setenv("ARDUINO_APP_CLI__DATA_DIR", dataDir) + + cfg, err := config.NewFromEnv() + require.NoError(t, err) + + for _, brick := range []string{"object_detection", "weather_forecast", "one_model_brick"} { + createFakeBrickAssets(t, assetsDir, brick) + } + createFakeApp(t, appsDir) + + bIndex := &bricksindex.BricksIndex{ + Bricks: []bricksindex.Brick{ + { + ID: "arduino:object_detection", + Name: "Object Detection", + Category: "video", + ModelName: "yolox-object-detection", // Default model + Variables: []bricksindex.BrickVariable{ + {Name: "EI_OBJ_DETECTION_MODEL", DefaultValue: "default_path", Description: "path to the model file"}, + {Name: "CUSTOM_MODEL_PATH", DefaultValue: "/home/arduino/.arduino-bricks/ei-models", Description: "path to the custom model directory"}, + }, + }, + { + ID: "arduino:weather_forecast", + Name: "Weather Forecast", + Category: "miscellaneous", + ModelName: "", + }, + { + ID: "arduino:one_model_brick", + Name: "one model brick", + Category: "video", + ModelName: "face-detection", // Default model + Variables: []bricksindex.BrickVariable{}, + }, + }, + } + mIndex := &modelsindex.ModelsIndex{ + Models: []modelsindex.AIModel{ + + { + ID: "yolox-object-detection", + Name: "General purpose object detection - YoloX", + ModuleDescription: "General purpose object detection...", + Bricks: []string{"arduino:object_detection", "arduino:video_object_detection"}, + }, + { + ID: "face-detection", + Name: "Lightweight-Face-Detection", + Bricks: []string{"arduino:object_detection", "arduino:video_object_detection", "arduino:one_model_brick"}, + }, + }} + + svc := &Service{ + bricksIndex: bIndex, + modelsIndex: mIndex, + staticStore: store.NewStaticStore(assetsDir), + } + idProvider := app.NewAppIDProvider(cfg) + + t.Run("Brick Not Found", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:non_existing", idProvider, cfg) + require.Error(t, err) + require.Equal(t, ErrBrickNotFound, err) + require.Empty(t, res.ID) + }) + + t.Run("Success - Full Details - multiple models", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:object_detection", idProvider, cfg) + require.NoError(t, err) + + require.Equal(t, "arduino:object_detection", res.ID) + require.Equal(t, "Object Detection", res.Name) + require.Equal(t, "Arduino", res.Author) + require.Equal(t, "installed", res.Status) + require.Contains(t, res.Variables, "EI_OBJ_DETECTION_MODEL") + require.Equal(t, "default_path", res.Variables["EI_OBJ_DETECTION_MODEL"].DefaultValue) + require.Equal(t, "# Documentation", res.Readme) + require.Contains(t, res.ApiDocsPath, filepath.Join("arduino", "app_bricks", "object_detection", "API.md")) + require.Len(t, res.CodeExamples, 1) + require.Contains(t, res.CodeExamples[0].Path, "blink.ino") + require.Len(t, res.UsedByApps, 1) + require.Equal(t, "My App", res.UsedByApps[0].Name) + require.NotEmpty(t, res.UsedByApps[0].ID) + require.Len(t, res.CompatibleModels, 2) + require.Equal(t, "yolox-object-detection", res.CompatibleModels[0].ID) + require.Equal(t, "General purpose object detection - YoloX", res.CompatibleModels[0].Name) + require.Equal(t, "General purpose object detection...", res.CompatibleModels[0].Description) + require.Equal(t, "face-detection", res.CompatibleModels[1].ID) + require.Equal(t, "Lightweight-Face-Detection", res.CompatibleModels[1].Name) + require.Equal(t, "", res.CompatibleModels[1].Description) + }) + + t.Run("Success - Full Details - no models", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:weather_forecast", idProvider, cfg) + require.NoError(t, err) + + require.Equal(t, "arduino:weather_forecast", res.ID) + require.Equal(t, "Weather Forecast", res.Name) + require.Equal(t, "Arduino", res.Author) + require.Equal(t, "installed", res.Status) + require.Empty(t, res.Variables) + require.Equal(t, "# Documentation", res.Readme) + require.Contains(t, res.ApiDocsPath, filepath.Join("arduino", "app_bricks", "weather_forecast", "API.md")) + require.Len(t, res.CodeExamples, 1) + require.Contains(t, res.CodeExamples[0].Path, "blink.ino") + require.Len(t, res.UsedByApps, 1) + require.Equal(t, "My App", res.UsedByApps[0].Name) + require.NotEmpty(t, res.UsedByApps[0].ID) + require.Len(t, res.CompatibleModels, 0) + }) + + t.Run("Success - Full Details - one model", func(t *testing.T) { + res, err := svc.BricksDetails("arduino:one_model_brick", idProvider, cfg) + require.NoError(t, err) + + require.Equal(t, "arduino:one_model_brick", res.ID) + require.Equal(t, "one model brick", res.Name) + require.Len(t, res.CompatibleModels, 1) + require.Equal(t, "face-detection", res.CompatibleModels[0].ID) + require.Equal(t, "Lightweight-Face-Detection", res.CompatibleModels[0].Name) + require.Equal(t, "", res.CompatibleModels[0].Description) + }) +} + +func createFakeBrickAssets(t *testing.T, assetsDir, brick string) { + t.Helper() + + brickDocDir := filepath.Join(assetsDir, "docs", "arduino", brick) + require.NoError(t, os.MkdirAll(brickDocDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(brickDocDir, "README.md"), + []byte("# Documentation"), 0600)) + + brickExDir := filepath.Join(assetsDir, "examples", "arduino", brick) + require.NoError(t, os.MkdirAll(brickExDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(brickExDir, "blink.ino"), + []byte("void setup() {}"), 0600)) +} + +func createFakeApp(t *testing.T, appsDir string) { + t.Helper() + myAppDir := filepath.Join(appsDir, "MyApp") + require.NoError(t, os.MkdirAll(myAppDir, 0755)) + + appYamlContent := ` +name: My App +bricks: + - arduino:object_detection: + - arduino:weather_forecast: +` + require.NoError(t, os.WriteFile(filepath.Join(myAppDir, "app.yaml"), []byte(appYamlContent), 0600)) + pythonDir := filepath.Join(myAppDir, "python") + require.NoError(t, os.MkdirAll(pythonDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(pythonDir, "main.py"), []byte("print('hello')"), 0600)) +} + +func TestAppBrickInstanceModelsDetails(t *testing.T) { + + bIndex := &bricksindex.BricksIndex{ + Bricks: []bricksindex.Brick{ + { + ID: "arduino:object_detection", + Name: "Object Detection", + Category: "video", + ModelName: "yolox-object-detection", // Default model + Variables: []bricksindex.BrickVariable{ + {Name: "EI_OBJ_DETECTION_MODEL", DefaultValue: "default_path", Description: "path to the model file"}, + {Name: "CUSTOM_MODEL_PATH", DefaultValue: "/home/arduino/.arduino-bricks/ei-models", Description: "path to the custom model directory"}, + }, + RequireModel: true, + }, + { + ID: "arduino:weather_forecast", + Name: "Weather Forecast", + Category: "miscellaneous", + ModelName: "", + RequireModel: false, + }, + }, + } + + mIndex := &modelsindex.ModelsIndex{ + Models: []modelsindex.AIModel{ + + { + ID: "yolox-object-detection", + Name: "General purpose object detection - YoloX", + ModuleDescription: "General purpose object detection...", + Bricks: []string{"arduino:object_detection", "arduino:video_object_detection"}, + }, + { + ID: "face-detection", + Name: "Lightweight-Face-Detection", + Bricks: []string{"arduino:object_detection", "arduino:video_object_detection"}, + }, + }} + + svc := &Service{ + bricksIndex: bIndex, + modelsIndex: mIndex, + } + + tests := []struct { + name string + app *app.ArduinoApp + brickID string + expectedError string + validate func(*testing.T, BrickInstance) + }{ + { + name: "Brick not found in global Index", + brickID: "arduino:non_existent_brick", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{Bricks: []app.Brick{}}, + }, + expectedError: "brick not found", + }, + { + name: "Brick found in Index but not added to App", + brickID: "arduino:object_detection", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + {ID: "arduino:weather_forecast"}, + }, + }, + }, + expectedError: "brick arduino:object_detection not added in the app", + }, + { + name: "Success - Standard Brick without Model", + brickID: "arduino:weather_forecast", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + {ID: "arduino:weather_forecast"}, + }, + }, + }, + validate: func(t *testing.T, res BrickInstance) { + require.Equal(t, "arduino:weather_forecast", res.ID) + require.Equal(t, "Weather Forecast", res.Name) + require.Equal(t, "installed", res.Status) + require.Empty(t, res.ModelID) + require.Empty(t, res.CompatibleModels) + require.False(t, res.RequireModel) + }, + }, + { + name: "Success - Brick with Default Model", + brickID: "arduino:object_detection", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + { + ID: "arduino:object_detection", + }, + }, + }, + }, + validate: func(t *testing.T, res BrickInstance) { + require.Equal(t, "arduino:object_detection", res.ID) + require.Equal(t, "yolox-object-detection", res.ModelID) + require.Len(t, res.CompatibleModels, 2) + require.Equal(t, "yolox-object-detection", res.CompatibleModels[0].ID) + require.Equal(t, "face-detection", res.CompatibleModels[1].ID) + require.True(t, res.RequireModel) + }, + }, + { + name: "Success - Brick with Overridden Model in App", + brickID: "arduino:object_detection", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + { + ID: "arduino:object_detection", + Model: "face-detection", + }, + }, + }, + }, + validate: func(t *testing.T, res BrickInstance) { + require.Equal(t, "arduino:object_detection", res.ID) + require.Equal(t, "face-detection", res.ModelID) + require.Len(t, res.CompatibleModels, 2) + require.Equal(t, "yolox-object-detection", res.CompatibleModels[0].ID) + require.Equal(t, "face-detection", res.CompatibleModels[1].ID) + require.True(t, res.RequireModel) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := svc.AppBrickInstanceDetails(tt.app, tt.brickID) + + if tt.expectedError != "" { + require.Error(t, err) + require.Equal(t, err.Error(), tt.expectedError) + return + } + + require.NoError(t, err) + if tt.validate != nil { + tt.validate(t, result) + } + }) + } +} + +func TestAppBrickInstancesList(t *testing.T) { + + bIndex := &bricksindex.BricksIndex{ + Bricks: []bricksindex.Brick{ + { + ID: "arduino:weather_forecast", + Name: "Weather Forecast", + Category: "miscellaneous", + RequireModel: false, + Variables: []bricksindex.BrickVariable{}, + }, + { + ID: "arduino:object_detection", + Name: "Object Detection", + Category: "video", + ModelName: "yolox-object-detection", + RequireModel: true, + Variables: []bricksindex.BrickVariable{ + {Name: "CUSTOM_MODEL_PATH", DefaultValue: "/home/arduino/.arduino-bricks/ei-models", Description: "path to the custom model directory"}, + {Name: "EI_OBJ_DETECTION_MODEL", DefaultValue: "/models/ootb/ei/yolo-x-nano.eim", Description: "path to the model file"}, + }, + }, + { + ID: "arduino:audio_classification", + Name: "Audio Classification", + Category: "audio", + ModelName: "glass-breaking", + RequireModel: true, + Variables: []bricksindex.BrickVariable{ + {Name: "CUSTOM_MODEL_PATH", DefaultValue: "/home/arduino/.arduino-bricks/ei-models"}, + {Name: "EI_AUDIO_CLASSIFICATION_MODEL", DefaultValue: "/models/ootb/ei/glass-breaking.eim"}, + }, + }, + { + ID: "arduino:streamlit_ui", + Name: "WebUI - Streamlit", + Category: "ui", + RequireModel: false, + Ports: []string{"7000", "8000"}, + }, + }, + } + + svc := &Service{ + bricksIndex: bIndex, + modelsIndex: &modelsindex.ModelsIndex{}, + } + + tests := []struct { + name string + app *app.ArduinoApp + expectedError string + validate func(*testing.T, AppBrickInstancesResult) + }{ + { + name: "Error - Brick not found in Index", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + {ID: "arduino:non_existent_brick"}, + }, + }, + }, + expectedError: "brick not found with id arduino:non_existent_brick", + }, + { + name: "Success - Empty App", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{}, + }, + }, + validate: func(t *testing.T, res AppBrickInstancesResult) { + require.Empty(t, res.BrickInstances) + }, + }, + { + name: "Success - Simple Brick", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + {ID: "arduino:weather_forecast"}, + }, + }, + }, + validate: func(t *testing.T, res AppBrickInstancesResult) { + require.Len(t, res.BrickInstances, 1) + brick := res.BrickInstances[0] + + require.Equal(t, "arduino:weather_forecast", brick.ID) + require.Equal(t, "Weather Forecast", brick.Name) + require.Equal(t, "miscellaneous", brick.Category) + require.Equal(t, "installed", brick.Status) + require.Equal(t, "Arduino", brick.Author) + require.False(t, brick.RequireModel) + require.Empty(t, brick.ModelID) + }, + }, + { + name: "Success - Brick with Model Configured", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + { + ID: "arduino:object_detection", + Model: "face-detection", // default model overridden + Variables: map[string]string{ + "CUSTOM_MODEL_PATH": "/custom/path", + }, + }, + }, + }, + }, + validate: func(t *testing.T, res AppBrickInstancesResult) { + require.Len(t, res.BrickInstances, 1) + brick := res.BrickInstances[0] + + require.Equal(t, "arduino:object_detection", brick.ID) + require.Equal(t, "video", brick.Category) + require.True(t, brick.RequireModel) + require.Equal(t, "face-detection", brick.ModelID) + + foundCustom := false + for _, v := range brick.ConfigVariables { + if v.Name == "CUSTOM_MODEL_PATH" { + require.Equal(t, "/custom/path", v.Value) + foundCustom = true + } + } + require.True(t, foundCustom, "Variable CUSTOM_MODEL_PATH should be present and overridden") + }, + }, + { + name: "Success - Multiple Bricks", + app: &app.ArduinoApp{ + Descriptor: app.AppDescriptor{ + Bricks: []app.Brick{ + {ID: "arduino:streamlit_ui"}, + {ID: "arduino:audio_classification", Model: "glass-breaking"}, + }, + }, + }, + validate: func(t *testing.T, res AppBrickInstancesResult) { + require.Len(t, res.BrickInstances, 2) + + // Brick 1: Streamlit UI + b1 := res.BrickInstances[0] + require.Equal(t, "arduino:streamlit_ui", b1.ID) + require.Equal(t, "WebUI - Streamlit", b1.Name) + require.Equal(t, "Arduino", b1.Author) + require.Equal(t, "ui", b1.Category) + require.Equal(t, "installed", b1.Status) + require.Equal(t, "", b1.ModelID) + require.Empty(t, b1.Variables) + require.Empty(t, b1.ConfigVariables) + require.False(t, b1.RequireModel) + + // Brick 2: Audio Classification + b2 := res.BrickInstances[1] + require.Equal(t, "arduino:audio_classification", b2.ID) + require.Equal(t, "audio", b2.Category) + require.True(t, b2.RequireModel) + require.Equal(t, "glass-breaking", b2.ModelID) + require.Equal(t, 2, len(b2.ConfigVariables)) + require.Equal(t, "/home/arduino/.arduino-bricks/ei-models", b2.ConfigVariables[0].Value) + require.Equal(t, "/models/ootb/ei/glass-breaking.eim", b2.ConfigVariables[1].Value) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := svc.AppBrickInstancesList(tt.app) + + if tt.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.expectedError) + return + } + + require.NoError(t, err) + if tt.validate != nil { + tt.validate(t, result) + } + }) + } +} diff --git a/internal/orchestrator/bricks/testdata/bricks-list.yaml b/internal/orchestrator/bricks/testdata/bricks-list.yaml index 8e3114d6..e38eeeb4 100644 --- a/internal/orchestrator/bricks/testdata/bricks-list.yaml +++ b/internal/orchestrator/bricks/testdata/bricks-list.yaml @@ -4,7 +4,6 @@ bricks: description: Connects to Arduino Cloud require_container: false require_model: false - require_devices: false mount_devices_into_container: false ports: [] category: null @@ -19,7 +18,11 @@ bricks: local database. require_container: false require_model: false - require_devices: false mount_devices_into_container: false ports: [] category: storage +- id: arduino:brick-with-require-model + name: A brick with required model + description: "Brick with required model" + require_model: true + model_name: mobilenet-image-classification \ No newline at end of file diff --git a/internal/orchestrator/bricks/types.go b/internal/orchestrator/bricks/types.go index 868c563a..bd63bd57 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -20,20 +20,19 @@ type BrickListResult struct { } type BrickListItem struct { - ID string `json:"id"` - Name string `json:"name"` - Author string `json:"author"` - Description string `json:"description"` - Category string `json:"category"` - Status string `json:"status"` - Models []string `json:"models"` + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author"` + Description string `json:"description"` + Category string `json:"category"` + Status string `json:"status"` + RequireModel bool `json:"require_model"` } type AppBrickInstancesResult struct { - BrickInstances []BrickInstance `json:"bricks"` + BrickInstances []BrickInstanceListItem `json:"bricks"` } - -type BrickInstance struct { +type BrickInstanceListItem struct { ID string `json:"id"` Name string `json:"name"` Author string `json:"author"` @@ -41,9 +40,27 @@ type BrickInstance struct { Status string `json:"status"` Variables map[string]string `json:"variables,omitempty" description:"Deprecated: use config_variables instead. This field is kept for backward compatibility."` ConfigVariables []BrickConfigVariable `json:"config_variables,omitempty"` + RequireModel bool `json:"require_model"` ModelID string `json:"model,omitempty"` } +type BrickInstance struct { + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author"` + Category string `json:"category"` + Status string `json:"status"` + Variables map[string]string `json:"variables,omitempty" description:"Deprecated: use config_variables instead. This field is kept for backward compatibility."` + ConfigVariables []BrickConfigVariable `json:"config_variables,omitempty"` + RequireModel bool `json:"require_model"` + ModelID string `json:"model,omitempty"` + CompatibleModels []AIModel `json:"compatible_models"` +} +type AIModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} type BrickConfigVariable struct { Name string `json:"name"` Value string `json:"value"` @@ -67,15 +84,17 @@ type AppReference struct { } type BrickDetailsResult struct { - ID string `json:"id"` - Name string `json:"name"` - Author string `json:"author"` - Description string `json:"description"` - Category string `json:"category"` - Status string `json:"status"` - Variables map[string]BrickVariable `json:"variables,omitempty"` - Readme string `json:"readme"` - ApiDocsPath string `json:"api_docs_path"` - CodeExamples []CodeExample `json:"code_examples"` - UsedByApps []AppReference `json:"used_by_apps"` + ID string `json:"id"` + Name string `json:"name"` + Author string `json:"author"` + Description string `json:"description"` + Category string `json:"category"` + Status string `json:"status"` + RequireModel bool `json:"require_model"` + Variables map[string]BrickVariable `json:"variables,omitempty"` + Readme string `json:"readme"` + ApiDocsPath string `json:"api_docs_path"` + CodeExamples []CodeExample `json:"code_examples"` + UsedByApps []AppReference `json:"used_by_apps"` + CompatibleModels []AIModel `json:"compatible_models"` } diff --git a/internal/orchestrator/bricksindex/bricks_index.go b/internal/orchestrator/bricksindex/bricks_index.go index e4b774f4..9e52f1c6 100644 --- a/internal/orchestrator/bricksindex/bricks_index.go +++ b/internal/orchestrator/bricksindex/bricks_index.go @@ -91,7 +91,7 @@ func unmarshalBricksIndex(content io.Reader) (*BricksIndex, error) { return &index, nil } -func GenerateBricksIndexFromFile(dir *paths.Path) (*BricksIndex, error) { +func Load(dir *paths.Path) (*BricksIndex, error) { content, err := dir.Join("bricks-list.yaml").Open() if err != nil { return nil, err diff --git a/internal/orchestrator/bricksindex/bricks_index_test.go b/internal/orchestrator/bricksindex/bricks_index_test.go index 5b2a1546..b8d28c07 100644 --- a/internal/orchestrator/bricksindex/bricks_index_test.go +++ b/internal/orchestrator/bricksindex/bricks_index_test.go @@ -16,171 +16,197 @@ package bricksindex import ( + "os" "testing" - yaml "github.com/goccy/go-yaml" + "github.com/arduino/go-paths-helper" "github.com/stretchr/testify/require" ) -func TestBricksIndex(t *testing.T) { - x := `bricks: -- id: arduino:image_classification - name: Image Classification - description: "Brick for image classification using a pre-trained model. It processes\ - \ images and returns the predicted class label and confidence score.\nBrick is\ - \ designed to work with pre-trained models provided by framework or with custom\ - \ image classification models trained on Edge Impulse platform. \n" - require_container: true - require_model: true - ports: [] - model_name: mobilenet-image-classification - variables: - - name: CUSTOM_MODEL_PATH - default_value: /opt/models/ei/ - description: path to the custom model directory - - name: EI_CLASSIFICATION_MODEL - default_value: /models/ootb/ei/mobilenet-v2-224px.eim - description: path to the model file -- id: arduino:camera_scanner - name: Camera Scanner - description: Scans a camera for barcodes and QR codes - require_container: false - require_model: false - ports: [] -- id: arduino:streamlit_ui - name: Streamlit UI - description: A simplified user interface based on Streamlit and Python. - require_container: false - require_model: false - ports: - - 7000 -- id: arduino:keyword_spotter - name: Keyword Spotter - description: 'Brick for keyword spotting using a pre-trained model. It processes - audio input to detect specific keywords or phrases. +func TestGenerateBricksIndexFromFile(t *testing.T) { + index, err := Load(paths.New("testdata")) + require.NoError(t, err) + + // Check if ports are correctly set + bWebUI, found := index.FindBrickByID("arduino:web_ui") + require.True(t, found) + require.Equal(t, []string{"7000"}, bWebUI.Ports) + + // Check if variables are correctly set + bIC, found := index.FindBrickByID("arduino:image_classification") + require.True(t, found) + require.Equal(t, "Image Classification", bIC.Name) + require.Equal(t, "mobilenet-image-classification", bIC.ModelName) + require.Len(t, bIC.Variables, 2) + require.Equal(t, "CUSTOM_MODEL_PATH", bIC.Variables[0].Name) + require.Equal(t, "/opt/models/ei/", bIC.Variables[0].DefaultValue) + require.Equal(t, "path to the custom model directory", bIC.Variables[0].Description) + require.Equal(t, "EI_CLASSIFICATION_MODEL", bIC.Variables[1].Name) + require.Equal(t, "/models/ootb/ei/mobilenet-v2-224px.eim", bIC.Variables[1].DefaultValue) + require.Equal(t, "path to the model file", bIC.Variables[1].Description) + require.False(t, bIC.Variables[0].IsRequired()) + require.False(t, bIC.Variables[1].IsRequired()) + + bRequireModel, found := index.FindBrickByID("arduino:model_required") + require.True(t, found) + require.True(t, bRequireModel.RequireModel) + + bDb, found := index.FindBrickByID("arduino:dbstorage_tsstore") + require.True(t, found) + require.False(t, bDb.RequireModel) - Brick is designed to work with pre-trained models provided by framework or with - custom audio classification models trained on Edge Impulse platform. + bNoRequireModel, found := index.FindBrickByID("arduino:missing-model-require") + require.True(t, found) + require.False(t, bNoRequireModel.RequireModel) +} - ' +func TestBricksIndexYAMLFormats(t *testing.T) { + testCases := []struct { + name string + yamlContent string + expectedError string + expectedBricks []Brick + }{ + { + // TODO: add a validator fo the bricks-list to validate the field + name: "missing bricks field does not cuase error", + yamlContent: `other_field: value`, + expectedBricks: nil, + }, + { + name: "bad YAML format invalid indentation", + yamlContent: `bricks: + - id: arduino:test_brick + name: Test Brick + description: A test brick`, + expectedError: "found character '\t' that cannot start any token", + }, + { + name: "empty bricks", + yamlContent: `bricks: []`, + expectedBricks: []Brick{}, + }, + { + name: "bad YAML format unclosed quotes", + yamlContent: `bricks: +- id: "arduino:test_brick + name: Test Brick + description: A test brick`, + expectedError: "could not find end character of double-quoted text", + }, + { + name: "bad YAML format missing colon", + yamlContent: `bricks: +- id arduino:test_brick + name: Test Brick`, + expectedError: "unexpected key name", + }, + { + name: "bad YAML format invalid syntax", + yamlContent: `bricks: +- id: arduino:test_brick + name: Test Brick + description: A test brick + ports: [7000,`, + expectedError: "sequence end token ']' not found", + }, + { + name: "bad YAML format tab characters", + yamlContent: "bricks:\n\t- id: arduino:test_brick\n\t name: Test Brick", + expectedError: "found character '\t' that cannot start any token", + }, + { + name: "simple brick", + yamlContent: `bricks: +- id: arduino:simple_brick + name: Test Brick + description: A test brick +`, + expectedBricks: []Brick{ + { + ID: "arduino:simple_brick", + Name: "Test Brick", + Description: "A test brick", + Category: "", + RequiresDisplay: "", + RequireContainer: false, + RequireModel: false, + RequiredDevices: nil, + Variables: nil, + Ports: nil, + ModelName: "", + MountDevicesIntoContainer: false, + }, + }, + }, + { + name: "valid YAML with complex variables", + yamlContent: `bricks: +- id: arduino:complex_brick + name: Complex Brick + description: A complex test brick + category: storage require_container: true require_model: true - ports: [] - model_name: keyword-spotting-hello-world - variables: - - name: CUSTOM_MODEL_PATH - default_value: /opt/models/ei/ - description: path to the custom model directory - - name: EI_KEYWORK_SPOTTING_MODEL - default_value: /models/ootb/ei/keyword-spotting-hello-world.eim - description: path to the model file -- id: arduino:mqtt - name: MQTT Connector - description: MQTT connector module. Acts as a client for receiving and publishing - messages to an MQTT broker. - require_container: false - require_model: false - ports: [] -- id: arduino:web_ui - name: Web UI - description: A user interface based on HTML and JavaScript that can rely on additional - APIs and a WebSocket exposed by a web server. - require_container: false - require_model: false + mount_devices_into_container: true + model_name: a-complex-model + required_devices: + - camera ports: - 7000 -- id: arduino:dbstorage_tsstore - name: Database Storage - Time Series Store - description: Simplified time series database storage layer for Arduino sensor samples - built on top of InfluxDB. - require_container: true - require_model: false - ports: [] - variables: - - name: APP_HOME - default_value: . - - name: DB_PASSWORD - default_value: Arduino15 - description: Database password - - name: DB_USERNAME - default_value: admin - description: Edge Impulse project API key - - name: INFLUXDB_ADMIN_TOKEN - default_value: 392edbf2-b8a2-481f-979d-3f188b2c05f0 - description: InfluxDB admin token -- id: arduino:dbstorage_sqlstore - name: Database Storage - SQLStore - description: Simplified database storage layer for Arduino sensor data using SQLite - local database. - require_container: false - require_model: false - ports: [] -- id: arduino:object_detection - name: Object Detection - description: "Brick for object detection using a pre-trained model. It processes\ - \ images and returns the predicted class label, bounding-boxes and confidence\ - \ score.\nBrick is designed to work with pre-trained models provided by framework\ - \ or with custom object detection models trained on Edge Impulse platform. \n" - require_container: true - require_model: true - ports: [] - model_name: yolox-object-detection - variables: - - name: CUSTOM_MODEL_PATH - default_value: /opt/models/ei/ - description: path to the custom model directory - - name: EI_OBJ_DETECTION_MODEL - default_value: /models/ootb/ei/yolo-x-nano.eim - description: path to the model file -- id: arduino:weather_forecast - name: Weather Forecast - description: Online weather forecast module for Arduino using open-meteo.com geolocation - and weather APIs. Requires an internet connection. - require_container: false - require_model: false - ports: [] -- id: arduino:visual_anomaly_detection - name: Visual Anomaly Detection - description: "Brick for visual anomaly detection using a pre-trained model. It processes\ - \ images and returns detected anomalies and bounding-boxes.\nBrick is designed\ - \ to work with pre-trained models provided by framework or with custom object\ - \ detection models trained on Edge Impulse platform. \n" - require_container: true - require_model: true - ports: [] - model_name: concreate-crack-anomaly-detection + - 8080 variables: - - name: CUSTOM_MODEL_PATH - default_value: /opt/models/ei/ - description: path to the custom model directory - - name: EI_V_ANOMALY_DETECTION_MODEL - default_value: /models/ootb/ei/concrete-crack-anomaly-detection.eim - description: path to the model file -` - - var index BricksIndex - err := yaml.Unmarshal([]byte(x), &index) - require.NoError(t, err) - require.Len(t, index.Bricks, 11) + - name: REQUIRED_VAR + default_value: "" + description: A required variable + - name: OPTIONAL_VAR + default_value: "default_value" + description: An optional variable`, + expectedBricks: []Brick{ + { + ID: "arduino:complex_brick", + Name: "Complex Brick", + Description: "A complex test brick", + Category: "storage", + RequiresDisplay: "", + RequireContainer: true, + RequireModel: true, + RequiredDevices: []string{"camera"}, + MountDevicesIntoContainer: true, + Variables: []BrickVariable{ + { + Name: "REQUIRED_VAR", + DefaultValue: "", + Description: "A required variable", + }, + { + Name: "OPTIONAL_VAR", + DefaultValue: "default_value", + Description: "An optional variable", + }, + }, + Ports: []string{"7000", "8080"}, + ModelName: "a-complex-model", + }, + }, + }, + } - // Check if ports are correctly set - b, found := index.FindBrickByID("arduino:web_ui") - require.True(t, found) - require.Equal(t, []string{"7000"}, b.Ports) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tempDir := t.TempDir() + brickIndex := paths.New(tempDir, "bricks-list.yaml") + err := os.WriteFile(brickIndex.String(), []byte(tc.yamlContent), 0600) + require.NoError(t, err) - // Check if variables are correctly set - b, found = index.FindBrickByID("arduino:image_classification") - require.True(t, found) - require.Equal(t, "Image Classification", b.Name) - require.Equal(t, "mobilenet-image-classification", b.ModelName) - require.True(t, b.RequireModel) - require.Len(t, b.Variables, 2) - require.Equal(t, "CUSTOM_MODEL_PATH", b.Variables[0].Name) - require.Equal(t, "/opt/models/ei/", b.Variables[0].DefaultValue) - require.Equal(t, "path to the custom model directory", b.Variables[0].Description) - require.Equal(t, "EI_CLASSIFICATION_MODEL", b.Variables[1].Name) - require.Equal(t, "/models/ootb/ei/mobilenet-v2-224px.eim", b.Variables[1].DefaultValue) - require.Equal(t, "path to the model file", b.Variables[1].Description) - require.False(t, b.Variables[0].IsRequired()) - require.False(t, b.Variables[1].IsRequired()) + index, err := Load(paths.New(tempDir)) + if tc.expectedError != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tc.expectedError) + } else { + require.NoError(t, err) + require.Equal(t, index.Bricks, tc.expectedBricks, "bricsk mistmatch") + } + }) + } } diff --git a/internal/orchestrator/bricksindex/testdata/bricks-list.yaml b/internal/orchestrator/bricksindex/testdata/bricks-list.yaml new file mode 100644 index 00000000..c494df17 --- /dev/null +++ b/internal/orchestrator/bricksindex/testdata/bricks-list.yaml @@ -0,0 +1,142 @@ +bricks: +- id: arduino:image_classification + name: Image Classification + description: "Brick for image classification using a pre-trained model. It processes\ + \ images and returns the predicted class label and confidence score.\nBrick is\ + \ designed to work with pre-trained models provided by framework or with custom\ + \ image classification models trained on Edge Impulse platform. \n" + require_container: true + require_model: true + ports: [] + model_name: mobilenet-image-classification + variables: + - name: CUSTOM_MODEL_PATH + default_value: /opt/models/ei/ + description: path to the custom model directory + - name: EI_CLASSIFICATION_MODEL + default_value: /models/ootb/ei/mobilenet-v2-224px.eim + description: path to the model file +- id: arduino:camera_scanner + name: Camera Scanner + description: Scans a camera for barcodes and QR codes + require_container: false + require_model: false + ports: [] +- id: arduino:streamlit_ui + name: Streamlit UI + description: A simplified user interface based on Streamlit and Python. + require_container: false + require_model: false + ports: + - 7000 +- id: arduino:keyword_spotter + name: Keyword Spotter + description: 'Brick for keyword spotting using a pre-trained model. It processes + audio input to detect specific keywords or phrases. + + Brick is designed to work with pre-trained models provided by framework or with + custom audio classification models trained on Edge Impulse platform. + + ' + require_container: true + require_model: true + ports: [] + model_name: keyword-spotting-hello-world + variables: + - name: CUSTOM_MODEL_PATH + default_value: /opt/models/ei/ + description: path to the custom model directory + - name: EI_KEYWORK_SPOTTING_MODEL + default_value: /models/ootb/ei/keyword-spotting-hello-world.eim + description: path to the model file +- id: arduino:mqtt + name: MQTT Connector + description: MQTT connector module. Acts as a client for receiving and publishing + messages to an MQTT broker. + require_container: false + require_model: false + ports: [] +- id: arduino:web_ui + name: Web UI + description: A user interface based on HTML and JavaScript that can rely on additional + APIs and a WebSocket exposed by a web server. + require_container: false + require_model: false + ports: + - 7000 +- id: arduino:dbstorage_tsstore + name: Database Storage - Time Series Store + description: Simplified time series database storage layer for Arduino sensor samples + built on top of InfluxDB. + require_container: true + require_model: false + ports: [] + variables: + - name: APP_HOME + default_value: . + - name: DB_PASSWORD + default_value: Arduino15 + description: Database password + - name: DB_USERNAME + default_value: admin + description: Edge Impulse project API key + - name: INFLUXDB_ADMIN_TOKEN + default_value: 392edbf2-b8a2-481f-979d-3f188b2c05f0 + description: InfluxDB admin token +- id: arduino:dbstorage_sqlstore + name: Database Storage - SQLStore + description: Simplified database storage layer for Arduino sensor data using SQLite + local database. + require_container: false + require_model: false + ports: [] +- id: arduino:object_detection + name: Object Detection + description: "Brick for object detection using a pre-trained model. It processes\ + \ images and returns the predicted class label, bounding-boxes and confidence\ + \ score.\nBrick is designed to work with pre-trained models provided by framework\ + \ or with custom object detection models trained on Edge Impulse platform. \n" + require_container: true + require_model: true + ports: [] + model_name: yolox-object-detection + variables: + - name: CUSTOM_MODEL_PATH + default_value: /opt/models/ei/ + description: path to the custom model directory + - name: EI_OBJ_DETECTION_MODEL + default_value: /models/ootb/ei/yolo-x-nano.eim + description: path to the model file +- id: arduino:weather_forecast + name: Weather Forecast + description: Online weather forecast module for Arduino using open-meteo.com geolocation + and weather APIs. Requires an internet connection. + require_container: false + require_model: false + ports: [] +- id: arduino:visual_anomaly_detection + name: Visual Anomaly Detection + description: "Brick for visual anomaly detection using a pre-trained model. It processes\ + \ images and returns detected anomalies and bounding-boxes.\nBrick is designed\ + \ to work with pre-trained models provided by framework or with custom object\ + \ detection models trained on Edge Impulse platform. \n" + require_container: true + require_model: true + ports: [] + model_name: concreate-crack-anomaly-detection + variables: + - name: CUSTOM_MODEL_PATH + default_value: /opt/models/ei/ + description: path to the custom model directory + - name: EI_V_ANOMALY_DETECTION_MODEL + default_value: /models/ootb/ei/concrete-crack-anomaly-detection.eim + description: path to the model file +- id: arduino:missing-model-require + name: Camera Scanner + description: Scans a camera for barcodes and QR codes + require_container: false + ports: [] +- id: arduino:model_required + name: Model Required Brick + description: A brick that requires a model + require_model: true diff --git a/internal/orchestrator/helpers.go b/internal/orchestrator/helpers.go index 5a637b07..3b94b654 100644 --- a/internal/orchestrator/helpers.go +++ b/internal/orchestrator/helpers.go @@ -193,7 +193,7 @@ func getRunningApp( if idx == -1 { return nil, nil } - app, err := app.Load(apps[idx].AppPath.String()) + app, err := app.Load(apps[idx].AppPath) if err != nil { return nil, fmt.Errorf("failed to load running app: %w", err) } diff --git a/internal/orchestrator/modelsindex/models_index.go b/internal/orchestrator/modelsindex/models_index.go index e18797f1..c24eb203 100644 --- a/internal/orchestrator/modelsindex/models_index.go +++ b/internal/orchestrator/modelsindex/models_index.go @@ -54,26 +54,26 @@ type AIModel struct { } type ModelsIndex struct { - models []AIModel + Models []AIModel } func (m *ModelsIndex) GetModels() []AIModel { - return m.models + return m.Models } func (m *ModelsIndex) GetModelByID(id string) (*AIModel, bool) { - idx := slices.IndexFunc(m.models, func(v AIModel) bool { return v.ID == id }) + idx := slices.IndexFunc(m.Models, func(v AIModel) bool { return v.ID == id }) if idx == -1 { return nil, false } - return &m.models[idx], true + return &m.Models[idx], true } func (m *ModelsIndex) GetModelsByBrick(brick string) []AIModel { var matches []AIModel - for i := range m.models { - if len(m.models[i].Bricks) > 0 && slices.Contains(m.models[i].Bricks, brick) { - matches = append(matches, m.models[i]) + for i := range m.Models { + if len(m.Models[i].Bricks) > 0 && slices.Contains(m.Models[i].Bricks, brick) { + matches = append(matches, m.Models[i]) } } if len(matches) == 0 { @@ -84,7 +84,7 @@ func (m *ModelsIndex) GetModelsByBrick(brick string) []AIModel { func (m *ModelsIndex) GetModelsByBricks(bricks []string) []AIModel { var matchingModels []AIModel - for _, model := range m.models { + for _, model := range m.Models { for _, modelBrick := range model.Bricks { if slices.Contains(bricks, modelBrick) { matchingModels = append(matchingModels, model) @@ -95,7 +95,7 @@ func (m *ModelsIndex) GetModelsByBricks(bricks []string) []AIModel { return matchingModels } -func GenerateModelsIndexFromFile(dir *paths.Path) (*ModelsIndex, error) { +func Load(dir *paths.Path) (*ModelsIndex, error) { content, err := dir.Join("models-list.yaml").ReadFile() if err != nil { return nil, err @@ -113,5 +113,5 @@ func GenerateModelsIndexFromFile(dir *paths.Path) (*ModelsIndex, error) { models[i] = model } } - return &ModelsIndex{models: models}, nil + return &ModelsIndex{Models: models}, nil } diff --git a/internal/orchestrator/modelsindex/modelsindex_test.go b/internal/orchestrator/modelsindex/modelsindex_test.go index 53ffb585..7f760390 100644 --- a/internal/orchestrator/modelsindex/modelsindex_test.go +++ b/internal/orchestrator/modelsindex/modelsindex_test.go @@ -9,7 +9,7 @@ import ( ) func TestModelsIndex(t *testing.T) { - modelsIndex, err := GenerateModelsIndexFromFile(paths.New("testdata")) + modelsIndex, err := Load(paths.New("testdata")) require.NoError(t, err) require.NotNil(t, modelsIndex) @@ -40,7 +40,7 @@ func TestModelsIndex(t *testing.T) { t.Run("it fails if model-list.yaml does not exist", func(t *testing.T) { nonExistentPath := paths.New("nonexistentdir") - modelsIndex, err := GenerateModelsIndexFromFile(nonExistentPath) + modelsIndex, err := Load(nonExistentPath) assert.Error(t, err) assert.Nil(t, modelsIndex) }) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 8eedff6f..841e31f1 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -388,16 +388,6 @@ func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp, ctx, cancel := context.WithCancel(ctx) defer cancel() - appStatus, err := getAppStatus(ctx, docker, app) - if err != nil { - yield(StreamMessage{error: err}) - return - } - if appStatus.Status != StatusStarting && appStatus.Status != StatusRunning { - yield(StreamMessage{data: fmt.Sprintf("app %q is not running", app.Name)}) - return - } - if !yield(StreamMessage{data: fmt.Sprintf("Stopping app %q", app.Name)}) { return } @@ -413,7 +403,16 @@ func stopAppWithCmd(ctx context.Context, docker command.Cli, app app.ArduinoApp, }) if app.MainSketchPath != nil { - // TODO: check that the app sketch is running before attempting to stop it. + // Before stopping the microcontroller we want to make sure that the app was running. + appStatus, err := getAppStatus(ctx, docker, app) + if err != nil { + yield(StreamMessage{error: err}) + return + } + if appStatus.Status != StatusStarting && appStatus.Status != StatusRunning { + yield(StreamMessage{data: fmt.Sprintf("app %q is not running", app.Name)}) + return + } if err := micro.Disable(); err != nil { yield(StreamMessage{error: err}) @@ -614,7 +613,7 @@ func ListApps( } for _, file := range appPaths { - app, err := app.Load(file.String()) + app, err := app.Load(file) if err != nil { result.BrokenApps = append(result.BrokenApps, BrokenAppInfo{ Name: file.Base(), @@ -673,9 +672,10 @@ type AppDetailedInfo struct { } type AppDetailedBrick struct { - ID string `json:"id" required:"true"` - Name string `json:"name" required:"true"` - Category string `json:"category,omitempty"` + ID string `json:"id" required:"true"` + Name string `json:"name" required:"true"` + Category string `json:"category,omitempty"` + RequireModel bool `json:"require_model"` } func AppDetails( @@ -738,6 +738,7 @@ func AppDetails( } res.Name = bi.Name res.Category = bi.Category + res.RequireModel = bi.RequireModel return res }), }, nil @@ -956,7 +957,7 @@ func GetDefaultApp(cfg config.Configuration) (*app.ArduinoApp, error) { return nil, nil } - app, err := app.Load(string(defaultAppPath)) + app, err := app.Load(paths.New(string(defaultAppPath))) if err != nil { // If the app is not valid, we remove the file slog.Warn("default app is not valid", slog.String("path", string(defaultAppPath)), slog.String("error", err.Error())) diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 223b4705..ab42d287 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -90,7 +90,7 @@ func TestCloneApp(t *testing.T) { }) // The app.yaml will have the name set to the new-name - clonedApp := f.Must(app.Load(appDir.String())) + clonedApp := f.Must(app.Load(appDir)) require.Equal(t, "new-name", clonedApp.Name) }) t.Run("with icon", func(t *testing.T) { @@ -108,7 +108,7 @@ func TestCloneApp(t *testing.T) { }) // The app.yaml will have the icon set to 🦄 - clonedApp := f.Must(app.Load(appDir.String())) + clonedApp := f.Must(app.Load(appDir)) require.Equal(t, "with-icon", clonedApp.Name) require.Equal(t, "🦄", clonedApp.Descriptor.Icon) }) @@ -164,7 +164,7 @@ func TestEditApp(t *testing.T) { appDir := cfg.AppsDir().Join("app-default") t.Run("previously not default", func(t *testing.T) { - app := f.Must(app.Load(appDir.String())) + app := f.Must(app.Load(appDir)) previousDefaultApp, err := GetDefaultApp(cfg) require.NoError(t, err) @@ -178,7 +178,7 @@ func TestEditApp(t *testing.T) { require.True(t, appDir.EquivalentTo(currentDefaultApp.FullPath)) }) t.Run("previously default", func(t *testing.T) { - app := f.Must(app.Load(appDir.String())) + app := f.Must(app.Load(appDir)) err := SetDefaultApp(&app, cfg) require.NoError(t, err) @@ -200,12 +200,12 @@ func TestEditApp(t *testing.T) { _, err := CreateApp(t.Context(), CreateAppRequest{Name: originalAppName}, idProvider, cfg) require.NoError(t, err) appDir := cfg.AppsDir().Join(originalAppName) - userApp := f.Must(app.Load(appDir.String())) + userApp := f.Must(app.Load(appDir)) originalPath := userApp.FullPath err = EditApp(AppEditRequest{Name: f.Ptr("new-name")}, &userApp, cfg) require.NoError(t, err) - editedApp, err := app.Load(cfg.AppsDir().Join("new-name").String()) + editedApp, err := app.Load(cfg.AppsDir().Join("new-name")) require.NoError(t, err) require.Equal(t, "new-name", editedApp.Name) require.True(t, originalPath.NotExist()) // The original app directory should be removed after renaming @@ -215,7 +215,7 @@ func TestEditApp(t *testing.T) { _, err := CreateApp(t.Context(), CreateAppRequest{Name: existingAppName}, idProvider, cfg) require.NoError(t, err) appDir := cfg.AppsDir().Join(existingAppName) - existingApp := f.Must(app.Load(appDir.String())) + existingApp := f.Must(app.Load(appDir)) err = EditApp(AppEditRequest{Name: f.Ptr(existingAppName)}, &existingApp, cfg) require.ErrorIs(t, err, ErrAppAlreadyExists) @@ -227,14 +227,14 @@ func TestEditApp(t *testing.T) { _, err := CreateApp(t.Context(), CreateAppRequest{Name: commonAppName}, idProvider, cfg) require.NoError(t, err) commonAppDir := cfg.AppsDir().Join(commonAppName) - commonApp := f.Must(app.Load(commonAppDir.String())) + commonApp := f.Must(app.Load(commonAppDir)) err = EditApp(AppEditRequest{ Icon: f.Ptr("💻"), Description: f.Ptr("new desc"), }, &commonApp, cfg) require.NoError(t, err) - editedApp := f.Must(app.Load(commonAppDir.String())) + editedApp := f.Must(app.Load(commonAppDir)) require.Equal(t, "new desc", editedApp.Descriptor.Description) require.Equal(t, "💻", editedApp.Descriptor.Icon) }) @@ -427,7 +427,7 @@ func TestGetAppEnvironmentVariablesWithDefaults(t *testing.T) { require.NoError(t, err) appId := createApp(t, "app1", false, idProvider, cfg) - appDesc, err := app.Load(appId.ToPath().String()) + appDesc, err := app.Load(appId.ToPath()) require.NoError(t, err) appDesc.Descriptor.Bricks = []app.Brick{ { @@ -447,7 +447,6 @@ bricks: \ or with custom object detection models trained on Edge Impulse platform. \n" require_container: true require_model: true - require_devices: false ports: [] category: video model_name: yolox-object-detection @@ -461,7 +460,7 @@ bricks: `) err = cfg.AssetsDir().Join("bricks-list.yaml").WriteFile(bricksIndexContent) require.NoError(t, err) - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(cfg.AssetsDir()) + bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) assert.NoError(t, err) modelsIndexContent := []byte(` @@ -483,7 +482,7 @@ models: `) err = cfg.AssetsDir().Join("models-list.yaml").WriteFile(modelsIndexContent) require.NoError(t, err) - modelIndex, err := modelsindex.GenerateModelsIndexFromFile(cfg.AssetsDir()) + modelIndex, err := modelsindex.Load(cfg.AssetsDir()) require.NoError(t, err) env := getAppEnvironmentVariables(appDesc, bricksIndex, modelIndex) @@ -512,7 +511,7 @@ func TestGetAppEnvironmentVariablesWithCustomModelOverrides(t *testing.T) { require.NoError(t, err) appId := createApp(t, "app1", false, idProvider, cfg) - appDesc, err := app.Load(appId.ToPath().String()) + appDesc, err := app.Load(appId.ToPath()) require.NoError(t, err) appDesc.Descriptor.Bricks = []app.Brick{ { @@ -533,7 +532,6 @@ bricks: \ or with custom object detection models trained on Edge Impulse platform. \n" require_container: true require_model: true - require_devices: false category: video model_name: yolox-object-detection variables: @@ -546,7 +544,7 @@ bricks: `) err = cfg.AssetsDir().Join("bricks-list.yaml").WriteFile(bricksIndexContent) require.NoError(t, err) - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(cfg.AssetsDir()) + bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) assert.NoError(t, err) modelsIndexContent := []byte(` @@ -568,7 +566,7 @@ models: `) err = cfg.AssetsDir().Join("models-list.yaml").WriteFile(modelsIndexContent) require.NoError(t, err) - modelIndex, err := modelsindex.GenerateModelsIndexFromFile(cfg.AssetsDir()) + modelIndex, err := modelsindex.Load(cfg.AssetsDir()) require.NoError(t, err) env := getAppEnvironmentVariables(appDesc, bricksIndex, modelIndex) diff --git a/internal/orchestrator/provision.go b/internal/orchestrator/provision.go index 393fbc8c..c5ab52b6 100644 --- a/internal/orchestrator/provision.go +++ b/internal/orchestrator/provision.go @@ -257,7 +257,7 @@ func generateMainComposeFile( } // 4. Retrieve the required devices that we have to mount - slog.Debug("Brick config", slog.Bool("require_devices", idxBrick.MountDevicesIntoContainer), slog.Any("ports", ports), slog.Any("required_devices", idxBrick.RequiredDevices)) + slog.Debug("Brick config", slog.Bool("mount_devices_into_container", idxBrick.MountDevicesIntoContainer), slog.Any("ports", ports), slog.Any("required_devices", idxBrick.RequiredDevices)) if idxBrick.MountDevicesIntoContainer { servicesThatRequireDevices = slices.AppendSeq(servicesThatRequireDevices, maps.Keys(svcs)) } diff --git a/internal/orchestrator/provision_test.go b/internal/orchestrator/provision_test.go index b2ca0bb0..1bd7aa65 100644 --- a/internal/orchestrator/provision_test.go +++ b/internal/orchestrator/provision_test.go @@ -104,7 +104,7 @@ bricks: require.NoError(t, err) // Override brick index with custom test content - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(cfg.AssetsDir()) + bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) require.Nil(t, err, "Failed to load bricks index with custom content") br, ok := bricksIndex.FindBrickByID("arduino:video_object_detection") @@ -301,7 +301,7 @@ bricks: err := cfg.AssetsDir().Join("bricks-list.yaml").WriteFile(bricksIndexContent) require.NoError(t, err) - bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(cfg.AssetsDir()) + bricksIndex, err := bricksindex.Load(cfg.AssetsDir()) require.Nil(t, err, "Failed to load bricks index with custom content") br, ok := bricksIndex.FindBrickByID("arduino:dbstorage_tsstore") require.True(t, ok, "Brick arduino:dbstorage_tsstore should exist in the index") diff --git a/pkg/board/board.go b/pkg/board/board.go index 3dac4af2..b8a1ec1c 100644 --- a/pkg/board/board.go +++ b/pkg/board/board.go @@ -192,6 +192,8 @@ func FromFQBN(ctx context.Context, fqbn string) ([]Board, error) { if name, err := GetCustomName(ctx, conn); err == nil { customName = name } + } else { + slog.Warn("failed to get custom name", "serial", serial, "error", err) } boards = append(boards, Board{ diff --git a/pkg/board/remote/adb/adb.go b/pkg/board/remote/adb/adb.go index eb401305..277aaad3 100644 --- a/pkg/board/remote/adb/adb.go +++ b/pkg/board/remote/adb/adb.go @@ -46,14 +46,50 @@ type ADBConnection struct { // Ensures ADBConnection implements the RemoteConn interface at compile time. var _ remote.RemoteConn = (*ADBConnection)(nil) +var ( + // ErrNotFound is returned when the ADB device is not found. + ErrNotFound = fmt.Errorf("ADB device not found") + // ErrDeviceOffline is returned when the ADB device is not reachable. + // This usually requires a restart of the adbd server daemon on the device. + ErrDeviceOffline = fmt.Errorf("ADB device is offline") +) + +// FromSerial creates an ADBConnection from a device serial number. +// returns an error NotFoundErr if the device is not found, and DeviceOfflineErr if the device is offline. func FromSerial(serial string, adbPath string) (*ADBConnection, error) { if adbPath == "" { adbPath = FindAdbPath() } + isConnected := func(serial, adbPath string) (bool, error) { + cmd, err := paths.NewProcess(nil, adbPath, "-s", serial, "get-state") + if err != nil { + return false, fmt.Errorf("failed to create ADB command: %w", err) + } + + output, err := cmd.RunAndCaptureCombinedOutput(context.TODO()) + if err != nil { + slog.Error("unable to connect to ADB device", "error", err, "output", string(output), "serial", serial) + if bytes.Contains(output, []byte("device offline")) { + return false, ErrDeviceOffline + } else if bytes.Contains(output, []byte("not found")) { + return false, ErrNotFound + } + return false, fmt.Errorf("failed to get ADB device state: %w: %s", err, output) + } + + return string(bytes.TrimSpace(output)) == "device", nil + } + + if connected, err := isConnected(serial, adbPath); err != nil { + return nil, err + } else if !connected { + return nil, fmt.Errorf("device %s is not connected", serial) + } + return &ADBConnection{ - host: serial, adbPath: adbPath, + host: serial, }, nil } @@ -65,8 +101,8 @@ func FromHost(host string, adbPath string) (*ADBConnection, error) { if err != nil { return nil, err } - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("failed to connect to ADB host %s: %w", host, err) + if out, err := cmd.RunAndCaptureCombinedOutput(context.TODO()); err != nil { + return nil, fmt.Errorf("failed to connect to ADB host %s: %w: %s", host, err, out) } return FromSerial(host, adbPath) }