From 93c65e1e2d6045d4c6536e33a03d13da1e479c2e Mon Sep 17 00:00:00 2001 From: Luca Rinaldi Date: Mon, 24 Nov 2025 11:46:42 +0100 Subject: [PATCH 01/11] feat: update examples to 0.5.1 (#100) * feat: update examples to 0.5.1 * fixup! feat: update examples to 0.5.1 --- Taskfile.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)}" From 7e2e6ba5ed92ad7f0ffbb42aa5a323a5275ef3af Mon Sep 17 00:00:00 2001 From: Luca Rinaldi Date: Tue, 25 Nov 2025 16:09:07 +0100 Subject: [PATCH 02/11] fix(pkg/board): handle adb connection error (#98) * fix(pkg/board): handle adb connection error * add a warning * don't expose the function * rename errors * fixup! rename errors * fixup! don't expose the function * implement code review suggestions * nit --- pkg/board/board.go | 2 ++ pkg/board/remote/adb/adb.go | 42 ++++++++++++++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 3 deletions(-) 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) } From 8d4eb51dfa9c1aaecf41cd94d8e45a2e8dd15798 Mon Sep 17 00:00:00 2001 From: mirkoCrobu <214636120+mirkoCrobu@users.noreply.github.com> Date: Tue, 25 Nov 2025 17:40:02 +0100 Subject: [PATCH 03/11] [API] Show compatible models in the brick list/details (#94) * remove models field from brick list * add lite model information to brickDetails endpoint * fix test * fix test end2end * refactoring * rename struct * fix tests * add unit test for brick details --- internal/api/docs/openapi.yaml | 19 +- internal/e2e/client/client.gen.go | 23 ++- internal/e2e/daemon/brick_test.go | 13 ++ internal/orchestrator/bricks/bricks.go | 11 +- internal/orchestrator/bricks/bricks_test.go | 171 ++++++++++++++++++ internal/orchestrator/bricks/types.go | 20 +- .../orchestrator/modelsindex/models_index.go | 18 +- 7 files changed, 242 insertions(+), 33 deletions(-) diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index f2b3a999..0b767d6f 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: @@ -1314,6 +1323,11 @@ components: type: string id: type: string + models: + items: + $ref: '#/components/schemas/AIModel' + nullable: true + type: array name: type: string readme: @@ -1365,11 +1379,6 @@ components: type: string id: type: string - models: - items: - type: string - nullable: true - type: array name: type: string status: diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index f6094430..2325f388 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"` @@ -141,6 +148,7 @@ type BrickDetailsResult struct { CodeExamples *[]CodeExample `json:"code_examples"` Description *string `json:"description,omitempty"` Id *string `json:"id,omitempty"` + Models *[]AIModel `json:"models"` Name *string `json:"name,omitempty"` Readme *string `json:"readme,omitempty"` Status *string `json:"status,omitempty"` @@ -164,13 +172,12 @@ 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"` + Status *string `json:"status,omitempty"` } // BrickListResult defines model for BrickListResult. diff --git a/internal/e2e/daemon/brick_test.go b/internal/e2e/daemon/brick_test.go index fa1cab40..488a670a 100644 --- a/internal/e2e/daemon/brick_test.go +++ b/internal/e2e/daemon/brick_test.go @@ -115,6 +115,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 +144,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.Models, "Models should not be nil") + require.Equal(t, expectedModelLiteInfo, *(response.JSON200.Models)) }) } diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index 691249d0..bcc4f016 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -64,9 +64,6 @@ func (s *Service) List() (BrickListResult, error) { Description: brick.Description, Category: brick.Category, Status: "installed", - Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) string { - return m.ID - }), } } return res, nil @@ -193,7 +190,6 @@ 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, @@ -206,6 +202,13 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, ApiDocsPath: apiDocsPath, CodeExamples: codeExamples, UsedByApps: usedByApps, + Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) AIModel { + return AIModel{ + ID: m.ID, + Name: m.Name, + Description: m.ModuleDescription, + } + }), }, nil } diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go index c14cebfd..8f378e20 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,6 +26,9 @@ 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) { @@ -318,3 +323,169 @@ 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.Models, 2) + require.Equal(t, "yolox-object-detection", res.Models[0].ID) + require.Equal(t, "General purpose object detection - YoloX", res.Models[0].Name) + require.Equal(t, "General purpose object detection...", res.Models[0].Description) + require.Equal(t, "face-detection", res.Models[1].ID) + require.Equal(t, "Lightweight-Face-Detection", res.Models[1].Name) + require.Equal(t, "", res.Models[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.Models, 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.Models, 1) + require.Equal(t, "face-detection", res.Models[0].ID) + require.Equal(t, "Lightweight-Face-Detection", res.Models[0].Name) + require.Equal(t, "", res.Models[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)) +} diff --git a/internal/orchestrator/bricks/types.go b/internal/orchestrator/bricks/types.go index 868c563a..f27b0652 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -20,13 +20,12 @@ 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"` } type AppBrickInstancesResult struct { @@ -78,4 +77,11 @@ type BrickDetailsResult struct { ApiDocsPath string `json:"api_docs_path"` CodeExamples []CodeExample `json:"code_examples"` UsedByApps []AppReference `json:"used_by_apps"` + Models []AIModel `json:"models"` +} + +type AIModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` } diff --git a/internal/orchestrator/modelsindex/models_index.go b/internal/orchestrator/modelsindex/models_index.go index e18797f1..c9a47347 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) @@ -113,5 +113,5 @@ func GenerateModelsIndexFromFile(dir *paths.Path) (*ModelsIndex, error) { models[i] = model } } - return &ModelsIndex{models: models}, nil + return &ModelsIndex{Models: models}, nil } From 3d8b8ea911db31b198c3b49c61d02a1f8e3a4d9b Mon Sep 17 00:00:00 2001 From: mirkoCrobu <214636120+mirkoCrobu@users.noreply.github.com> Date: Wed, 26 Nov 2025 10:47:12 +0100 Subject: [PATCH 04/11] [API] Add compatible models in brickinstance details (#99) * add compatible models in brickinstance details * fix test e2e * fix test e2e * add unit tests * fix tests * remove omitempty from field compatible_modules * update field name for brick details endpoint --- internal/api/docs/openapi.yaml | 15 +- internal/e2e/client/client.gen.go | 39 ++-- internal/e2e/daemon/brick_test.go | 4 +- ...bricks_test.go => bricks_instance_test.go} | 27 ++- internal/orchestrator/bricks/bricks.go | 9 +- internal/orchestrator/bricks/bricks_test.go | 174 ++++++++++++++++-- internal/orchestrator/bricks/types.go | 52 +++--- 7 files changed, 254 insertions(+), 66 deletions(-) rename internal/e2e/daemon/{instance_bricks_test.go => bricks_instance_test.go} (93%) diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index 0b767d6f..f50969e6 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1319,15 +1319,15 @@ components: $ref: '#/components/schemas/CodeExample' nullable: true type: array - description: - type: string - id: - type: string - models: + compatible_models: items: $ref: '#/components/schemas/AIModel' nullable: true type: array + description: + type: string + id: + type: string name: type: string readme: @@ -1350,6 +1350,11 @@ components: type: string category: type: string + compatible_models: + items: + $ref: '#/components/schemas/AIModel' + nullable: true + type: array config_variables: items: $ref: '#/components/schemas/BrickConfigVariable' diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index 2325f388..1f3e6bbd 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -142,29 +142,30 @@ 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"` - Models *[]AIModel `json:"models"` - 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"` + 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"` - ConfigVariables *[]BrickConfigVariable `json:"config_variables,omitempty"` - Id *string `json:"id,omitempty"` - Model *string `json:"model,omitempty"` - Name *string `json:"name,omitempty"` - Status *string `json:"status,omitempty"` + 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"` + 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"` diff --git a/internal/e2e/daemon/brick_test.go b/internal/e2e/daemon/brick_test.go index 488a670a..b5f2b6d8 100644 --- a/internal/e2e/daemon/brick_test.go +++ b/internal/e2e/daemon/brick_test.go @@ -144,7 +144,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.Models, "Models should not be nil") - require.Equal(t, expectedModelLiteInfo, *(response.JSON200.Models)) + 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 93% rename from internal/e2e/daemon/instance_bricks_test.go rename to internal/e2e/daemon/bricks_instance_test.go index c210a9e6..0a6375bc 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 } @@ -135,6 +146,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/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index bcc4f016..3b722b3d 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -121,6 +121,13 @@ func (s *Service) AppBrickInstanceDetails(a *app.ArduinoApp, brickID string) (Br 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 } @@ -202,7 +209,7 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, ApiDocsPath: apiDocsPath, CodeExamples: codeExamples, UsedByApps: usedByApps, - Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) AIModel { + CompatibleModels: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) AIModel { return AIModel{ ID: m.ID, Name: m.Name, diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go index 8f378e20..f9804b25 100644 --- a/internal/orchestrator/bricks/bricks_test.go +++ b/internal/orchestrator/bricks/bricks_test.go @@ -418,13 +418,13 @@ func TestBricksDetails(t *testing.T) { 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.Models, 2) - require.Equal(t, "yolox-object-detection", res.Models[0].ID) - require.Equal(t, "General purpose object detection - YoloX", res.Models[0].Name) - require.Equal(t, "General purpose object detection...", res.Models[0].Description) - require.Equal(t, "face-detection", res.Models[1].ID) - require.Equal(t, "Lightweight-Face-Detection", res.Models[1].Name) - require.Equal(t, "", res.Models[1].Description) + 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) { @@ -443,7 +443,7 @@ func TestBricksDetails(t *testing.T) { 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.Models, 0) + require.Len(t, res.CompatibleModels, 0) }) t.Run("Success - Full Details - one model", func(t *testing.T) { @@ -452,10 +452,10 @@ func TestBricksDetails(t *testing.T) { require.Equal(t, "arduino:one_model_brick", res.ID) require.Equal(t, "one model brick", res.Name) - require.Len(t, res.Models, 1) - require.Equal(t, "face-detection", res.Models[0].ID) - require.Equal(t, "Lightweight-Face-Detection", res.Models[0].Name) - require.Equal(t, "", res.Models[0].Description) + 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) }) } @@ -489,3 +489,153 @@ bricks: 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"}, + }, + }, + { + ID: "arduino:weather_forecast", + Name: "Weather Forecast", + Category: "miscellaneous", + ModelName: "", + }, + }, + } + + 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) + }, + }, + { + 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) + }, + }, + { + 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) + }, + }, + } + + 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) + } + }) + } +} diff --git a/internal/orchestrator/bricks/types.go b/internal/orchestrator/bricks/types.go index f27b0652..782ec2f2 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -33,16 +33,22 @@ type AppBrickInstancesResult struct { } 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"` - ModelID string `json:"model,omitempty"` + 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"` + 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"` @@ -66,22 +72,16 @@ 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"` - Models []AIModel `json:"models"` -} - -type AIModel struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` + 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"` + CompatibleModels []AIModel `json:"compatible_models"` } From 1861a8fdacc8e347e79d923af123618ae5e702c1 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 26 Nov 2025 12:12:04 +0100 Subject: [PATCH 05/11] refact: use the `Load` function uniformely in all the yaml loaders (#107) * feat(tests): add bricks index test and YAML data for bricks * refactor: rename index generation functions to Load for consistency * use paths.path as argument in app.load * use dir instead of path in app.Load --------- Co-authored-by: Luca Rinaldi --- cmd/arduino-app-cli/app/app.go | 2 +- .../internal/servicelocator/servicelocator.go | 4 +- internal/api/handlers/app_delete.go | 2 +- internal/api/handlers/app_details.go | 4 +- internal/api/handlers/app_logs.go | 2 +- internal/api/handlers/app_ports.go | 2 +- internal/api/handlers/app_sketch_libs.go | 6 +- internal/api/handlers/app_start.go | 2 +- internal/api/handlers/app_stop.go | 2 +- internal/api/handlers/bricks.go | 10 +- internal/e2e/daemon/brick_test.go | 2 +- internal/orchestrator/app/app.go | 21 +- internal/orchestrator/app/app_test.go | 19 +- internal/orchestrator/app/parser_test.go | 2 +- internal/orchestrator/app_status.go | 2 +- internal/orchestrator/bricks/bricks.go | 2 +- internal/orchestrator/bricks/bricks_test.go | 48 +-- .../orchestrator/bricksindex/bricks_index.go | 2 +- .../bricksindex/bricks_index_test.go | 296 +++++++++--------- .../bricksindex/testdata/bricks-list.yaml | 133 ++++++++ internal/orchestrator/helpers.go | 2 +- .../orchestrator/modelsindex/models_index.go | 2 +- .../modelsindex/modelsindex_test.go | 4 +- internal/orchestrator/orchestrator.go | 4 +- internal/orchestrator/orchestrator_test.go | 30 +- internal/orchestrator/provision_test.go | 4 +- 26 files changed, 382 insertions(+), 227 deletions(-) create mode 100644 internal/orchestrator/bricksindex/testdata/bricks-list.yaml 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/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/daemon/brick_test.go b/internal/e2e/daemon/brick_test.go index b5f2b6d8..02736884 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 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/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 3b722b3d..cc9128c7 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -247,7 +247,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 f9804b25..4fee6fde 100644 --- a/internal/orchestrator/bricks/bricks_test.go +++ b/internal/orchestrator/bricks/bricks_test.go @@ -32,12 +32,12 @@ import ( ) 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()) }) @@ -46,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()) }) @@ -56,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()) }) @@ -70,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) @@ -88,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) @@ -102,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) @@ -116,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) @@ -129,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()) }) @@ -149,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()) }) @@ -160,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()) }) @@ -174,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) @@ -189,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) @@ -203,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) @@ -218,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) @@ -231,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) 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..8b02c8e0 100644 --- a/internal/orchestrator/bricksindex/bricks_index_test.go +++ b/internal/orchestrator/bricksindex/bricks_index_test.go @@ -16,152 +16,16 @@ 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. - - 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 -` - - var index BricksIndex - err := yaml.Unmarshal([]byte(x), &index) +func TestGenerateBricksIndexFromFile(t *testing.T) { + index, err := Load(paths.New("testdata")) require.NoError(t, err) - require.Len(t, index.Bricks, 11) // Check if ports are correctly set b, found := index.FindBrickByID("arduino:web_ui") @@ -184,3 +48,155 @@ func TestBricksIndex(t *testing.T) { require.False(t, b.Variables[0].IsRequired()) require.False(t, b.Variables[1].IsRequired()) } + +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 + require_devices: false + mount_devices_into_container: true + model_name: a-complex-model + required_devices: + - camera + ports: + - 7000 + - 8080 + variables: + - 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", + }, + }, + }, + } + + 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) + + 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..0bd036f8 --- /dev/null +++ b/internal/orchestrator/bricksindex/testdata/bricks-list.yaml @@ -0,0 +1,133 @@ +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 \ No newline at end of file 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 c9a47347..c24eb203 100644 --- a/internal/orchestrator/modelsindex/models_index.go +++ b/internal/orchestrator/modelsindex/models_index.go @@ -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 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..1a61145c 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -614,7 +614,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(), @@ -956,7 +956,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..d6c6a4dc 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{ { @@ -461,7 +461,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 +483,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 +512,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{ { @@ -546,7 +546,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 +568,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_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") From d7d175357e6d4e70ee72a6525a6af568547ac9da Mon Sep 17 00:00:00 2001 From: Giulio Date: Wed, 26 Nov 2025 17:49:58 +0100 Subject: [PATCH 06/11] Add require_model to the brick list (#93) * brick - add require on list and details * require_model for the app bricks * fixing yaml ser * list app brick detalis * add tests on reqmodel * rename to RequireModel * TestBricksList test * TestAppBrickInstanceDetails * fix * update tests * add RequireModel to test * add test IC * remove models * Update internal/orchestrator/bricksindex/testdata/bricks-list.yaml Co-authored-by: Davide * Update internal/orchestrator/bricksindex/testdata/bricks-list.yaml Co-authored-by: Davide * Update internal/orchestrator/bricks/testdata/bricks-list.yaml Co-authored-by: Davide * revert --------- Co-authored-by: Davide --- internal/api/docs/openapi.yaml | 8 ++++ internal/e2e/client/client.gen.go | 22 ++++++---- internal/e2e/daemon/app_test.go | 7 ++-- internal/e2e/daemon/brick_test.go | 1 + internal/orchestrator/bricks/bricks.go | 16 +++++--- internal/orchestrator/bricks/bricks_test.go | 13 ++++-- .../bricks/testdata/bricks-list.yaml | 5 +++ internal/orchestrator/bricks/types.go | 15 ++++--- .../bricksindex/bricks_index_test.go | 41 ++++++++++++------- .../bricksindex/testdata/bricks-list.yaml | 11 ++++- internal/orchestrator/orchestrator.go | 8 ++-- 11 files changed, 100 insertions(+), 47 deletions(-) diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index f50969e6..f32c9a76 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1204,6 +1204,8 @@ components: type: string name: type: string + require_model: + type: boolean required: - id - name @@ -1332,6 +1334,8 @@ components: type: string readme: type: string + require_model: + type: boolean status: type: string used_by_apps: @@ -1365,6 +1369,8 @@ components: type: string name: type: string + require_model: + type: boolean status: type: string variables: @@ -1386,6 +1392,8 @@ components: type: string name: type: string + require_model: + type: boolean status: type: string type: object diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index 1f3e6bbd..496680c9 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -71,9 +71,10 @@ type AppBrickInstancesResult struct { // 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. @@ -151,6 +152,7 @@ type BrickDetailsResult struct { 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"` @@ -165,6 +167,7 @@ type BrickInstance struct { 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. @@ -173,12 +176,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"` - 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 02736884..5d709bb3 100644 --- a/internal/e2e/daemon/brick_test.go +++ b/internal/e2e/daemon/brick_test.go @@ -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) } } diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index cc9128c7..df49c1ec 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -58,12 +58,13 @@ 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", + 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 @@ -85,6 +86,7 @@ func (s *Service) AppBrickInstancesList(a *app.ArduinoApp) (AppBrickInstancesRes 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, @@ -118,6 +120,7 @@ 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, @@ -203,6 +206,7 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider, 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, diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go index 4fee6fde..0077a5b2 100644 --- a/internal/orchestrator/bricks/bricks_test.go +++ b/internal/orchestrator/bricks/bricks_test.go @@ -503,12 +503,14 @@ func TestAppBrickInstanceModelsDetails(t *testing.T) { {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: "", + ID: "arduino:weather_forecast", + Name: "Weather Forecast", + Category: "miscellaneous", + ModelName: "", + RequireModel: false, }, }, } @@ -577,6 +579,7 @@ func TestAppBrickInstanceModelsDetails(t *testing.T) { require.Equal(t, "installed", res.Status) require.Empty(t, res.ModelID) require.Empty(t, res.CompatibleModels) + require.False(t, res.RequireModel) }, }, { @@ -597,6 +600,7 @@ func TestAppBrickInstanceModelsDetails(t *testing.T) { 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) }, }, { @@ -618,6 +622,7 @@ func TestAppBrickInstanceModelsDetails(t *testing.T) { 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) }, }, } diff --git a/internal/orchestrator/bricks/testdata/bricks-list.yaml b/internal/orchestrator/bricks/testdata/bricks-list.yaml index 8e3114d6..9a8c68c4 100644 --- a/internal/orchestrator/bricks/testdata/bricks-list.yaml +++ b/internal/orchestrator/bricks/testdata/bricks-list.yaml @@ -23,3 +23,8 @@ bricks: 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 782ec2f2..1fca898f 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -20,12 +20,13 @@ 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"` + 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 { @@ -40,6 +41,7 @@ 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"` CompatibleModels []AIModel `json:"compatible_models"` } @@ -78,6 +80,7 @@ type BrickDetailsResult struct { 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"` diff --git a/internal/orchestrator/bricksindex/bricks_index_test.go b/internal/orchestrator/bricksindex/bricks_index_test.go index 8b02c8e0..b8bf7c0a 100644 --- a/internal/orchestrator/bricksindex/bricks_index_test.go +++ b/internal/orchestrator/bricksindex/bricks_index_test.go @@ -28,25 +28,36 @@ func TestGenerateBricksIndexFromFile(t *testing.T) { require.NoError(t, err) // Check if ports are correctly set - b, found := index.FindBrickByID("arduino:web_ui") + bWebUI, found := index.FindBrickByID("arduino:web_ui") require.True(t, found) - require.Equal(t, []string{"7000"}, b.Ports) + require.Equal(t, []string{"7000"}, bWebUI.Ports) // Check if variables are correctly set - b, found = index.FindBrickByID("arduino:image_classification") + bIC, 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()) + 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) + + bNoRequireModel, found := index.FindBrickByID("arduino:missing-model-require") + require.True(t, found) + require.False(t, bNoRequireModel.RequireModel) } func TestBricksIndexYAMLFormats(t *testing.T) { diff --git a/internal/orchestrator/bricksindex/testdata/bricks-list.yaml b/internal/orchestrator/bricksindex/testdata/bricks-list.yaml index 0bd036f8..c494df17 100644 --- a/internal/orchestrator/bricksindex/testdata/bricks-list.yaml +++ b/internal/orchestrator/bricksindex/testdata/bricks-list.yaml @@ -130,4 +130,13 @@ bricks: 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 \ No newline at end of file + 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/orchestrator.go b/internal/orchestrator/orchestrator.go index 1a61145c..74851180 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -673,9 +673,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 +739,7 @@ func AppDetails( } res.Name = bi.Name res.Category = bi.Category + res.RequireModel = bi.RequireModel return res }), }, nil From 12a78dbeba57919783cd6812cb1488df75fd6c1e Mon Sep 17 00:00:00 2001 From: Giulio Date: Thu, 27 Nov 2025 10:55:24 +0100 Subject: [PATCH 07/11] deprecate require_devices (#111) --- internal/orchestrator/bricks/testdata/bricks-list.yaml | 2 -- internal/orchestrator/bricksindex/bricks_index_test.go | 1 - internal/orchestrator/orchestrator_test.go | 2 -- internal/orchestrator/provision.go | 2 +- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/internal/orchestrator/bricks/testdata/bricks-list.yaml b/internal/orchestrator/bricks/testdata/bricks-list.yaml index 9a8c68c4..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,6 @@ bricks: local database. require_container: false require_model: false - require_devices: false mount_devices_into_container: false ports: [] category: storage diff --git a/internal/orchestrator/bricksindex/bricks_index_test.go b/internal/orchestrator/bricksindex/bricks_index_test.go index b8bf7c0a..b8d28c07 100644 --- a/internal/orchestrator/bricksindex/bricks_index_test.go +++ b/internal/orchestrator/bricksindex/bricks_index_test.go @@ -148,7 +148,6 @@ func TestBricksIndexYAMLFormats(t *testing.T) { category: storage require_container: true require_model: true - require_devices: false mount_devices_into_container: true model_name: a-complex-model required_devices: diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index d6c6a4dc..ab42d287 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -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 @@ -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: 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)) } From 299f6e245ef7eafd2a6a46fc5d902d2e217b837b Mon Sep 17 00:00:00 2001 From: Per Tillisch Date: Thu, 27 Nov 2025 05:27:45 -0800 Subject: [PATCH 08/11] feat: Do not exit from default Python script (#106) When a new App is created, it is populated with a simple Python script and "bare minimum" Arduino sketch. For initial explorations of a new system, or when troubleshooting, it is common to create very simple programs (AKA "Hello, world!"). Although a complex App will typically consist of a Python script and Arduino sketch program working in coordination, for users with prior experience with Arduino the natural approach to creating a minimal App will be to simply write some familiar Arduino sketch code, leaving the default Python script code as-is. The App is considered to be in a "stopped" state as soon as the Python script exits. This is the correct approach, but may be confusing to users due to the fact that, except perhaps under exceptional conditions, the Arduino sketch program runs perpetually. The author of a minimal sketch-based "Hello, world!" App will find it unintuitive if their App goes into a "stopped" state immediately after starting. The previous default Python script produced exactly that result. The default Python script is hereby changed to the more intuitive behavior of running perpetually. Co-authored-by: Davide --- .../app/generator/app_template/python/main.py | 17 +++++++++++++---- .../testdata/app-all.golden/python/main.py | 17 +++++++++++++---- .../app-no-sketch.golden/python/main.py | 17 +++++++++++++---- 3 files changed, 39 insertions(+), 12 deletions(-) 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) From b6723a5a4c90e3c57ce66f072e8d061460810dac Mon Sep 17 00:00:00 2001 From: Luca Rinaldi Date: Thu, 27 Nov 2025 15:38:31 +0100 Subject: [PATCH 09/11] fix: force app stop also if it isn't running (#112) --- internal/orchestrator/orchestrator.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 74851180..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}) From c309a644e076d8ce490c9056865a03c46357397b Mon Sep 17 00:00:00 2001 From: Luca Rinaldi Date: Thu, 27 Nov 2025 17:37:58 +0100 Subject: [PATCH 10/11] fix: prevent app-cli to restart adbd (#117) --- .../arduino-app-cli/etc/needrestart/conf.d/arduino-adbd.conf | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 debian/arduino-app-cli/etc/needrestart/conf.d/arduino-adbd.conf 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; + From 3f54506d4a97e63397195753eae099ddae154720 Mon Sep 17 00:00:00 2001 From: mirkoCrobu <214636120+mirkoCrobu@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:58:06 +0100 Subject: [PATCH 11/11] [Bug] remove compatible models from brick instance list endpoint (#120) * remove compatible models from brick instance list endpoint * add unit and e2e test --- internal/api/docs/openapi.yaml | 29 ++- internal/e2e/client/client.gen.go | 17 +- internal/e2e/daemon/bricks_instance_test.go | 9 + internal/orchestrator/bricks/bricks.go | 4 +- internal/orchestrator/bricks/bricks_test.go | 188 ++++++++++++++++++++ internal/orchestrator/bricks/types.go | 14 +- 6 files changed, 255 insertions(+), 6 deletions(-) diff --git a/internal/api/docs/openapi.yaml b/internal/api/docs/openapi.yaml index f32c9a76..77721c82 100644 --- a/internal/api/docs/openapi.yaml +++ b/internal/api/docs/openapi.yaml @@ -1192,7 +1192,7 @@ components: properties: bricks: items: - $ref: '#/components/schemas/BrickInstance' + $ref: '#/components/schemas/BrickInstanceListItem' nullable: true type: array type: object @@ -1380,6 +1380,33 @@ components: for backward compatibility.' type: object type: object + BrickInstanceListItem: + properties: + author: + type: string + category: + type: string + 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 BrickListItem: properties: author: diff --git a/internal/e2e/client/client.gen.go b/internal/e2e/client/client.gen.go index 496680c9..25472727 100644 --- a/internal/e2e/client/client.gen.go +++ b/internal/e2e/client/client.gen.go @@ -66,7 +66,7 @@ type AIModelsListResult struct { // AppBrickInstancesResult defines model for AppBrickInstancesResult. type AppBrickInstancesResult struct { - Bricks *[]BrickInstance `json:"bricks"` + Bricks *[]BrickInstanceListItem `json:"bricks"` } // AppDetailedBrick defines model for AppDetailedBrick. @@ -174,6 +174,21 @@ type BrickInstance struct { 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. + Variables *map[string]string `json:"variables,omitempty"` +} + // BrickListItem defines model for BrickListItem. type BrickListItem struct { Author *string `json:"author,omitempty"` diff --git a/internal/e2e/daemon/bricks_instance_test.go b/internal/e2e/daemon/bricks_instance_test.go index 0a6375bc..1b8ee7c5 100644 --- a/internal/e2e/daemon/bricks_instance_test.go +++ b/internal/e2e/daemon/bricks_instance_test.go @@ -97,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) }) diff --git a/internal/orchestrator/bricks/bricks.go b/internal/orchestrator/bricks/bricks.go index df49c1ec..85e6e601 100644 --- a/internal/orchestrator/bricks/bricks.go +++ b/internal/orchestrator/bricks/bricks.go @@ -71,7 +71,7 @@ func (s *Service) List() (BrickListResult, error) { } 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 { @@ -80,7 +80,7 @@ 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 diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go index 0077a5b2..266d41ef 100644 --- a/internal/orchestrator/bricks/bricks_test.go +++ b/internal/orchestrator/bricks/bricks_test.go @@ -644,3 +644,191 @@ func TestAppBrickInstanceModelsDetails(t *testing.T) { }) } } + +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/types.go b/internal/orchestrator/bricks/types.go index 1fca898f..bd63bd57 100644 --- a/internal/orchestrator/bricks/types.go +++ b/internal/orchestrator/bricks/types.go @@ -30,9 +30,19 @@ type BrickListItem struct { } type AppBrickInstancesResult struct { - BrickInstances []BrickInstance `json:"bricks"` + BrickInstances []BrickInstanceListItem `json:"bricks"` +} +type BrickInstanceListItem 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"` } - type BrickInstance struct { ID string `json:"id"` Name string `json:"name"`