diff --git a/.licensed.yml b/.licensed.yml index a5bd17e4..c77973b5 100644 --- a/.licensed.yml +++ b/.licensed.yml @@ -6,7 +6,6 @@ cache_path: .licenses apps: - source_path: ./cmd/arduino-app-cli - reviewed: go: # TODO: remove it after releasing this https://github.com/arduino/go-win32-utils/pull/10 diff --git a/internal/orchestrator/bricks/bricks_test.go b/internal/orchestrator/bricks/bricks_test.go new file mode 100644 index 00000000..0c8b1f88 --- /dev/null +++ b/internal/orchestrator/bricks/bricks_test.go @@ -0,0 +1,291 @@ +package bricks + +import ( + "os" + "path/filepath" + "testing" + + "github.com/arduino/go-paths-helper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.bug.st/f" + + "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/store" +) + +func TestBrickInstanceDetails(t *testing.T) { + basaedir := getBasedir(t) + service := setupTestService(t, basaedir) + app := getTestApp(t, "testdata/object-detection", "object-detection") + + testCases := []struct { + name string + brickID string + wantErr bool + wantErrMsg string + expectedBrickInstance BrickInstance + }{ + { + name: "Success - brick instance found in app", + brickID: "arduino:arduino_cloud", + wantErr: false, + expectedBrickInstance: BrickInstance{ + ID: "arduino:arduino_cloud", + Name: "Arduino Cloud", + Author: "Arduino", + Category: "", + Status: "installed", + Variables: map[string]string{ + "ARDUINO_DEVICE_ID": "", + "ARDUINO_SECRET": "", + }, + ModelID: "", + }, + }, + { + name: "Error - brick not found in index", + brickID: "arduino:non_existing_brick", + wantErr: true, + wantErrMsg: "brick not found", + }, + { + name: "Error - brick exists but is not in app", + brickID: "arduino:dbstorage_sqlstore", + wantErr: true, + wantErrMsg: "not added in the app", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + brickInstance, err := service.AppBrickInstanceDetails(&app, tc.brickID) + + if tc.wantErr { + require.Error(t, err) + if tc.wantErrMsg != "" { + require.Contains(t, err.Error(), tc.wantErrMsg) + } + + assert.Equal(t, BrickInstance{}, brickInstance) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedBrickInstance, brickInstance) + }) + } +} +func TestBricksDetails1(t *testing.T) { + basaedir := getBasedir(t) + service := setupTestService(t, basaedir) + testDataAssetsPath := paths.New(basaedir) + + expectedVars := map[string]BrickVariable{ + "ARDUINO_DEVICE_ID": { + DefaultValue: "", + Description: "Arduino Cloud Device ID", + Required: false, + }, + "ARDUINO_SECRET": { + DefaultValue: "", + Description: "Arduino Cloud Secret", + Required: false, + }, + } + readmePath := testDataAssetsPath.Join("docs", "arduino", "arduino_cloud", "README.md") + expectedReadmeBytes, err := os.ReadFile(readmePath.String()) + require.NoError(t, err, "Failed to read test readme file") + expectedReadme := string(expectedReadmeBytes) + expectedAPIPath := testDataAssetsPath.Join("api-docs", "arduino", "app_bricks", "arduino_cloud", "API.md").String() + examplesBasePath := testDataAssetsPath.Join("examples", "arduino", "arduino_cloud") + expectedExamples := []CodeExample{ + {Path: examplesBasePath.Join("1_led_blink.py").String()}, + {Path: examplesBasePath.Join("2_light_with_colors_monitor.py").String()}, + {Path: examplesBasePath.Join("3_light_with_colors_command.py").String()}, + } + + testCases := []struct { + name string + brickID string + wantErr bool + wantErrMsg string + expectedResult BrickDetailsResult + }{ + { + name: "Success - brick found", + brickID: "arduino:arduino_cloud", + wantErr: false, + expectedResult: BrickDetailsResult{ + ID: "arduino:arduino_cloud", + Name: "Arduino Cloud", + Author: "Arduino", + Description: "Connects to Arduino Cloud", + Category: "", + Status: "installed", + Variables: expectedVars, + Readme: expectedReadme, + ApiDocsPath: expectedAPIPath, + CodeExamples: expectedExamples, + }, + }, + { + name: "Error - brick not found", + brickID: "arduino:non_existing_brick", + wantErr: true, + wantErrMsg: "brick not found", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := service.BricksDetails(tc.brickID) + + if tc.wantErr { + require.Error(t, err) + if tc.wantErrMsg != "" { + require.Contains(t, err.Error(), tc.wantErrMsg) + } + assert.Equal(t, BrickDetailsResult{}, result) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestBricksDetails(t *testing.T) { + + basaedir := getBasedir(t) + service := setupTestService(t, basaedir) + testDataAssetsPath := paths.New(basaedir) + + testCases := []struct { + name string + brickID string + wantErr bool + wantErrMsg string + expectedResult BrickDetailsResult + }{ + { + name: "Success - brick found", + brickID: "arduino:arduino_cloud", + wantErr: false, + expectedResult: BrickDetailsResult{ + ID: "arduino:arduino_cloud", + Name: "Arduino Cloud", + Author: "Arduino", + Description: "Connects to Arduino Cloud", + Category: "", + Status: "installed", + Variables: map[string]BrickVariable{ + "ARDUINO_DEVICE_ID": { + DefaultValue: "", + Description: "Arduino Cloud Device ID", + Required: false, + }, + "ARDUINO_SECRET": { + DefaultValue: "", + Description: "Arduino Cloud Secret", + Required: false, + }, + }, + Readme: string(mustReadFile(t, testDataAssetsPath.Join( + "docs", "arduino", "arduino_cloud", "README.md", + ).String())), + ApiDocsPath: testDataAssetsPath.Join( + "api-docs", "arduino", "app_bricks", "arduino_cloud", "API.md", + ).String(), + CodeExamples: []CodeExample{ + {Path: testDataAssetsPath.Join("examples", "arduino", "arduino_cloud", "1_led_blink.py").String()}, + {Path: testDataAssetsPath.Join("examples", "arduino", "arduino_cloud", "2_light_with_colors_monitor.py").String()}, + {Path: testDataAssetsPath.Join("examples", "arduino", "arduino_cloud", "3_light_with_colors_command.py").String()}, + }, + }, + }, + { + name: "Error - brick not found", + brickID: "arduino:non_existing_brick", + wantErr: true, + wantErrMsg: "brick not found", + }, + { + name: "Success - brick with nil examples", + brickID: "arduino:streamlit_ui", + wantErr: false, + expectedResult: BrickDetailsResult{ + ID: "arduino:streamlit_ui", + Name: "WebUI - Streamlit", + Author: "Arduino", + Description: "A simplified user interface based on Streamlit and Python.", + Category: "ui", + Status: "installed", + Variables: map[string]BrickVariable{}, + Readme: string(mustReadFile(t, testDataAssetsPath.Join( + "docs", "arduino", "streamlit_ui", "README.md", + ).String())), + ApiDocsPath: testDataAssetsPath.Join( + "api-docs", "arduino", "app_bricks", "streamlit_ui", "API.md", + ).String(), + CodeExamples: []CodeExample{}, + }, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := service.BricksDetails(tc.brickID) + + if tc.wantErr { + // --- Error Case --- + require.Error(t, err) + if tc.wantErrMsg != "" { + require.Contains(t, err.Error(), tc.wantErrMsg) + } + assert.Equal(t, BrickDetailsResult{}, result) + return + } + + // --- Success Case --- + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func setupTestService(t *testing.T, baseDir string) *Service { + store := store.NewStaticStore(baseDir) + + bricksIndex, err := bricksindex.GenerateBricksIndexFromFile(paths.New(baseDir)) + require.NoError(t, err) + + service := NewService(nil, bricksIndex, store) + return service +} + +func getBasedir(t *testing.T) string { + cfg, err := config.NewFromEnv() + require.NoError(t, err) + baseDir := paths.New("../../e2e/daemon/testdata", "assets", cfg.RunnerVersion).String() + return baseDir +} +func getTestApp(t *testing.T, appPath, appName string) app.ArduinoApp { + app, err := app.Load(appPath) + assert.NoError(t, err) + assert.NotEmpty(t, app) + assert.NotNil(t, app.MainPythonFile) + assert.Equal(t, f.Must(filepath.Abs("testdata/"+appName+"/python/main.py")), app.MainPythonFile.String()) + // in case you want to test sketch based apps too + // assert.NotNil(t, app.MainSketchPath) + // assert.Equal(t, f.Must(filepath.Abs("testdata/"+appName+"/sketch")), app.MainSketchPath.String()) + return app +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + bytes, err := os.ReadFile(path) + require.NoError(t, err, "failed to read test file: %s", path) + return bytes +} diff --git a/internal/orchestrator/bricks/testdata/object-detection/README.md b/internal/orchestrator/bricks/testdata/object-detection/README.md new file mode 100644 index 00000000..32b85770 --- /dev/null +++ b/internal/orchestrator/bricks/testdata/object-detection/README.md @@ -0,0 +1,118 @@ +# Object Detection +The **Object Detection** example lets you perform object detection using a pre-trained machine learning model. It shows how to process input images, run inference, and visualize detected objects with bounding boxes and labels. + +![Object Detection Example](assets/docs_assets/thumbnail.png) + +## Description +This example uses a pre-trained model to detect objects in an uploaded image. The workflow involves uploading the input image, running it through the model, drawing bounding boxes around detected objects, and labeling each inference with its corresponding class name. The code is structured for easy adaptation to different models. + +The `assets` folder contains some static images and a CSS style sheet for the web interface. In the python folder, we find the main script. + +This example only uses the Arduino UNO Q CPU for running the application, as no C++ sketch is present in the example structure. + +## Bricks Used + +The code detector example uses the following Bricks: + +- `objectdetection`: Brick to identify objects within an image. +- `web_ui`: Brick to create a web interface. + +## Hardware and Software Requirements + +### Hardware + +- Arduino UNO Q (x1) +- USB-C® to USB-A Cable (x1) +- Personal computer with internet access + +### Software + +- Arduino App Lab + +**Note:** You can also run this example using your Arduino UNO Q as a Single Board Computer (SBC) using a [USB-C hub](https://store.arduino.cc/products/usb-c-to-hdmi-multiport-adapter-with-ethernet-and-usb-hub) with a mouse, keyboard and monitor attached. + +## How to Use the Example + +1. Run the app. +2. Open the app in your browser. +3. Upload an image you want to analyze. +4. Adjust the confidence threshold slider to set the minimum detection confidence. +5. Click the **Run detection** button to run object detection. +6. View the results with detected objects highlighted by bounding boxes and labels. + +## How it Works + +Once the application is running, you can access it from your web browser by navigating to `:7000`. At that point, the device begins performing the following: + +- **Initial Setup**: + - Loads the `object_detection` and `web_ui` Bricks. + - Applies custom Arduino-themed CSS for styling to the web UI. + +- **User Interface**: + - Split into two columns: + - **Left**: Image upload area and result display with bounding boxes. + - **Right**: Confidence threshold slider and action buttons: + - `Run detection` + - `Run again` + - `Change image` + +- **Image Upload + Display**: + - Supports JPG and PNG image uploads. + - Once detection is complete, it draws bounding boxes on the image and displays it. + +- **Detection Execution**: + - Triggered when the user clicks **Run detection**. + - The image is passed to the model with the selected confidence threshold. + - Results are stored in session state and displayed on the page. + - Inference time is printed to the console. + +## Understanding the Code + +Here is a brief explanation of the application script (main.py): + +```python +from arduino.app_utils import * +from arduino.app_bricks.web_ui import WebUI +from arduino.app_bricks.objectdetection import ObjectDetection +from arduino.app_utils import draw_bounding_boxes +from PIL import Image +import io +import base64 +import time + +object_detection = ObjectDetection() +``` + +The function `on_detect_objects` performs the following: + + - Read inputs from the browser + - Decode image and run inference + - Draw bounding boxes to overlay detected objects in the image. + - Send result (or error) back to the browser + +The App initialize the web interface, set up the endpoint and starts the runtime: + +```python +... + +ui = WebUI() +ui.on_message('detect_objects', on_detect_objects) +App.run() +``` + +In the frontend (index.html) the App manages the display of different interfaces: + +- Image drag & drop and upload button. +- Confidence control pairs a slider with a numeric input and a reset action. +- Status area shows progress/errors; `Run` and `Change Image` buttons appear contextually. + +The (app.js) manages the browser-side logic of the App by doing the following: + +- Initializes page elements (upload area, preview, confidence slider, Detect/Upload/Download buttons, result title). +- Handles **image selection** (upload or drag & drop), shows a preview, and stores the image as base64. +- Manages the **confidence control** (slider, input, reset, tooltip). +- Connects to the backend via **Socket.IO**. +- Sends a `detect_objects` request to the server when the user clicks **Run Detection**. +- Receives `detection_result` or `detection_error`; on success, displays the annotated result image and shows a success status. +- Controls UI states, including showing/hiding **Run Again**, **Change Image**, and **Download** actions. +- Supports **downloading** the annotated result as a PNG and resetting the view when changing images. \ No newline at end of file diff --git a/internal/orchestrator/bricks/testdata/object-detection/app.yaml b/internal/orchestrator/bricks/testdata/object-detection/app.yaml new file mode 100644 index 00000000..52e5972b --- /dev/null +++ b/internal/orchestrator/bricks/testdata/object-detection/app.yaml @@ -0,0 +1,8 @@ +name: Detect objects on images +icon: 🏞️ +description: Object detection in the browser + +bricks: + - arduino:web_ui + - arduino:object_detection + - arduino:arduino_cloud \ No newline at end of file diff --git a/internal/orchestrator/bricks/testdata/object-detection/python/main.py b/internal/orchestrator/bricks/testdata/object-detection/python/main.py new file mode 100644 index 00000000..509f787c --- /dev/null +++ b/internal/orchestrator/bricks/testdata/object-detection/python/main.py @@ -0,0 +1,64 @@ +# SPDX-FileCopyrightText: Copyright (C) 2025 ARDUINO SA +# +# SPDX-License-Identifier: MPL-2.0 + +from arduino.app_utils import * +from arduino.app_bricks.web_ui import WebUI +from arduino.app_bricks.object_detection import ObjectDetection +from arduino.app_utils import draw_bounding_boxes +from PIL import Image +import io +import base64 +import time + +object_detection = ObjectDetection() + +def on_detect_objects(client_id, data): + """Callback function to handle object detection requests.""" + try: + image_data = data.get('image') + confidence = data.get('confidence', 0.5) + if not image_data: + ui.send_message('detection_error', {'error': 'No image data'}) + return + + image_bytes = base64.b64decode(image_data) + pil_image = Image.open(io.BytesIO(image_bytes)) + + start_time = time.time() * 1000 + results = object_detection.detect(pil_image, confidence=confidence) + diff = time.time() * 1000 - start_time + + if results is None: + ui.send_message('detection_error', {'error': 'No results returned'}) + return + + img_with_boxes = draw_bounding_boxes(pil_image, results) + + if img_with_boxes is not None: + img_buffer = io.BytesIO() + img_with_boxes.save(img_buffer, format="PNG") + img_buffer.seek(0) + b64_result = base64.b64encode(img_buffer.getvalue()).decode("utf-8") + else: + # If drawing fails, send back the original image + img_buffer = io.BytesIO() + pil_image.save(img_buffer, format="PNG") + img_buffer.seek(0) + b64_result = base64.b64encode(img_buffer.getvalue()).decode("utf-8") + + response = { + 'success': True, + 'result_image': b64_result, + 'detection_count': len(results.get("detection", [])) if results else 0, + 'processing_time': f"{diff:.2f} ms" + } + ui.send_message('detection_result', response) + + except Exception as e: + ui.send_message('detection_error', {'error': str(e)}) + +ui = WebUI() +ui.on_message('detect_objects', on_detect_objects) + +App.run() \ No newline at end of file diff --git a/internal/store/store.go b/internal/store/store.go index dbc14df3..ab16bac2 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -99,6 +99,9 @@ func (s *StaticStore) GetBrickCodeExamplesPathFromID(brickID string) (paths.Path targetDir := paths.New(s.codeExamplesPath, namespace, brickName) dirEntries, err := targetDir.ReadDir() if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } return nil, fmt.Errorf("cannot read examples directory %q: %w", targetDir, err) } return dirEntries, nil diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 00000000..e316c199 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,187 @@ +package store + +import ( + "os" + "path/filepath" + "testing" + + "github.com/arduino/go-paths-helper" + "github.com/stretchr/testify/require" + + "github.com/arduino/arduino-app-cli/internal/orchestrator/config" +) + +const validBrickID = "arduino:arduino_cloud" + +func setupTestStore(t *testing.T) (*StaticStore, string) { + cfg, err := config.NewFromEnv() + require.NoError(t, err) + baseDir := paths.New("../e2e/daemon/testdata", "assets", cfg.RunnerVersion).String() + return NewStaticStore(baseDir), baseDir +} + +func TestGetBrickReadmeFromID(t *testing.T) { + + store, baseDir := setupTestStore(t) + + namespace, brickName, _ := parseBrickID(validBrickID) + + expectedReadmePath := filepath.Join(baseDir, "docs", namespace, brickName, "README.md") + expectedContent, err := os.ReadFile(expectedReadmePath) + require.NoError(t, err, "Error Reading README file: %s", expectedReadmePath) + require.NotEmpty(t, expectedContent, "ReadME file is empty: %s", expectedReadmePath) + + testCases := []struct { + name string + brickID string + wantContent string + wantErr bool + wantErrIs error + wantErrMsg string + }{ + { + name: "Success - file found", + brickID: validBrickID, + wantContent: string(expectedContent), + wantErr: false, + }, + { + name: "Failure - file not found", + brickID: "namespace:non_existent_brick", + wantContent: "", + wantErr: true, + wantErrIs: os.ErrNotExist, + }, + { + name: "Failure - invalid ID", + brickID: "invalid-id", + wantContent: "", + wantErr: true, + wantErrMsg: "invalid ID", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + + content, err := store.GetBrickReadmeFromID(tc.brickID) + + if tc.wantErr { + + require.Error(t, err, "should have returned an error") + + if tc.wantErrIs != nil { + + require.ErrorIs(t, err, tc.wantErrIs, "error type mismatch") + } + if tc.wantErrMsg != "" { + require.EqualError(t, err, tc.wantErrMsg, "error message mismatch") + } + } else { + require.NoError(t, err, "should not have returned an error") + } + require.Equal(t, tc.wantContent, content, "content mismatch") + }) + } +} + +func TestGetBrickComposeFilePathFromID(t *testing.T) { + + store, baseDir := setupTestStore(t) + + namespace, brickName, _ := parseBrickID(validBrickID) + + expectedPathString := filepath.Join(baseDir, "compose", namespace, brickName, "brick_compose.yaml") + + testCases := []struct { + name string + brickID string + wantPath string + wantErr bool + wantErrMsg string + }{ + { + name: "Success - valid ID", + brickID: validBrickID, + wantPath: expectedPathString, + wantErr: false, + }, + { + name: "Failure - invalid ID", + brickID: "invalid ID", + wantPath: "", + wantErr: true, + wantErrMsg: "invalid ID", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + path, err := store.GetBrickComposeFilePathFromID(tc.brickID) + + if tc.wantErr { + require.Error(t, err, "function was expected to return an error") + require.Nil(t, path, "path was expected to be nil") + require.EqualError(t, err, tc.wantErrMsg, "error message mismatch") + } else { + require.NoError(t, err, "function was not expected to return an error") + require.NotNil(t, path, "path was expected to be not nil") + require.Equal(t, tc.wantPath, path.String(), "path string mismatch") + } + }) + } +} + +func TestGetBrickCodeExamplesPathFromID(t *testing.T) { + store, _ := setupTestStore(t) + + const expectedEntryCount = 3 + + testCases := []struct { + name string + brickID string + wantNilList bool + wantEntryCount int + wantErr bool + wantErrMsg string + }{ + { + name: "Success - directory found", + brickID: validBrickID, + wantNilList: false, + wantEntryCount: expectedEntryCount, + wantErr: false, + }, + { + name: "Success - directory not found", + brickID: "namespace:non_existent_brick", + wantNilList: true, + wantErr: false, + }, + { + name: "Failure - invalid ID", + brickID: "invalid-id", + wantNilList: true, + wantErr: true, + wantErrMsg: "invalid ID", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pathList, err := store.GetBrickCodeExamplesPathFromID(tc.brickID) + if tc.wantErr { + require.Error(t, err, "should have returned an error") + require.EqualError(t, err, tc.wantErrMsg, "error message mismatch") + } else { + require.NoError(t, err, "should not have returned an error") + } + + if tc.wantNilList { + require.Nil(t, pathList, "pathList should be nil") + } else { + require.NotNil(t, pathList, "pathList should not be nil") + } + require.Equal(t, tc.wantEntryCount, len(pathList), "entry count mismatch") + }) + } +}