Skip to content

Commit 8d4eb51

Browse files
authored
[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
1 parent 7e2e6ba commit 8d4eb51

File tree

7 files changed

+242
-33
lines changed

7 files changed

+242
-33
lines changed

internal/api/docs/openapi.yaml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,6 +1147,15 @@ components:
11471147
$ref: '#/components/schemas/ErrorResponse'
11481148
description: Precondition Failed
11491149
schemas:
1150+
AIModel:
1151+
properties:
1152+
description:
1153+
type: string
1154+
id:
1155+
type: string
1156+
name:
1157+
type: string
1158+
type: object
11501159
AIModelItem:
11511160
properties:
11521161
brick_ids:
@@ -1314,6 +1323,11 @@ components:
13141323
type: string
13151324
id:
13161325
type: string
1326+
models:
1327+
items:
1328+
$ref: '#/components/schemas/AIModel'
1329+
nullable: true
1330+
type: array
13171331
name:
13181332
type: string
13191333
readme:
@@ -1365,11 +1379,6 @@ components:
13651379
type: string
13661380
id:
13671381
type: string
1368-
models:
1369-
items:
1370-
type: string
1371-
nullable: true
1372-
type: array
13731382
name:
13741383
type: string
13751384
status:

internal/e2e/client/client.gen.go

Lines changed: 15 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/e2e/daemon/brick_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,17 @@ func TestBricksDetails(t *testing.T) {
115115
},
116116
}
117117

118+
expectedModelLiteInfo := []client.AIModel{
119+
{
120+
Id: f.Ptr("mobilenet-image-classification"),
121+
Name: f.Ptr("General purpose image classification"),
122+
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."),
123+
},
124+
{
125+
Id: f.Ptr("person-classification"),
126+
Name: f.Ptr("Person classification"),
127+
Description: f.Ptr("Person classification model based on WakeVision dataset. This model is trained to classify images into two categories: person and not-person."),
128+
}}
118129
response, err := httpClient.GetBrickDetailsWithResponse(t.Context(), validBrickID, func(ctx context.Context, req *http.Request) error { return nil })
119130
require.NoError(t, err)
120131
require.Equal(t, http.StatusOK, response.StatusCode(), "status code should be 200 ok")
@@ -133,5 +144,7 @@ func TestBricksDetails(t *testing.T) {
133144
require.NotEmpty(t, *response.JSON200.Readme)
134145
require.NotNil(t, response.JSON200.UsedByApps, "UsedByApps should not be nil")
135146
require.Equal(t, expectedUsedByApps, *(response.JSON200.UsedByApps))
147+
require.NotNil(t, response.JSON200.Models, "Models should not be nil")
148+
require.Equal(t, expectedModelLiteInfo, *(response.JSON200.Models))
136149
})
137150
}

internal/orchestrator/bricks/bricks.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,6 @@ func (s *Service) List() (BrickListResult, error) {
6464
Description: brick.Description,
6565
Category: brick.Category,
6666
Status: "installed",
67-
Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) string {
68-
return m.ID
69-
}),
7067
}
7168
}
7269
return res, nil
@@ -193,7 +190,6 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider,
193190
if err != nil {
194191
return BrickDetailsResult{}, fmt.Errorf("unable to get used by apps: %w", err)
195192
}
196-
197193
return BrickDetailsResult{
198194
ID: id,
199195
Name: brick.Name,
@@ -206,6 +202,13 @@ func (s *Service) BricksDetails(id string, idProvider *app.IDProvider,
206202
ApiDocsPath: apiDocsPath,
207203
CodeExamples: codeExamples,
208204
UsedByApps: usedByApps,
205+
Models: f.Map(s.modelsIndex.GetModelsByBrick(brick.ID), func(m modelsindex.AIModel) AIModel {
206+
return AIModel{
207+
ID: m.ID,
208+
Name: m.Name,
209+
Description: m.ModuleDescription,
210+
}
211+
}),
209212
}, nil
210213
}
211214

internal/orchestrator/bricks/bricks_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
package bricks
1717

1818
import (
19+
"os"
20+
"path/filepath"
1921
"testing"
2022

2123
"github.com/arduino/go-paths-helper"
@@ -24,6 +26,9 @@ import (
2426

2527
"github.com/arduino/arduino-app-cli/internal/orchestrator/app"
2628
"github.com/arduino/arduino-app-cli/internal/orchestrator/bricksindex"
29+
"github.com/arduino/arduino-app-cli/internal/orchestrator/config"
30+
"github.com/arduino/arduino-app-cli/internal/orchestrator/modelsindex"
31+
"github.com/arduino/arduino-app-cli/internal/store"
2732
)
2833

2934
func TestBrickCreate(t *testing.T) {
@@ -318,3 +323,169 @@ func TestGetBrickInstanceVariableDetails(t *testing.T) {
318323
})
319324
}
320325
}
326+
327+
func TestBricksDetails(t *testing.T) {
328+
tmpDir := t.TempDir()
329+
appsDir := filepath.Join(tmpDir, "ArduinoApps")
330+
dataDir := filepath.Join(tmpDir, "Data")
331+
assetsDir := filepath.Join(dataDir, "assets")
332+
333+
require.NoError(t, os.MkdirAll(appsDir, 0755))
334+
require.NoError(t, os.MkdirAll(assetsDir, 0755))
335+
336+
t.Setenv("ARDUINO_APP_CLI__APPS_DIR", appsDir)
337+
t.Setenv("ARDUINO_APP_CLI__DATA_DIR", dataDir)
338+
339+
cfg, err := config.NewFromEnv()
340+
require.NoError(t, err)
341+
342+
for _, brick := range []string{"object_detection", "weather_forecast", "one_model_brick"} {
343+
createFakeBrickAssets(t, assetsDir, brick)
344+
}
345+
createFakeApp(t, appsDir)
346+
347+
bIndex := &bricksindex.BricksIndex{
348+
Bricks: []bricksindex.Brick{
349+
{
350+
ID: "arduino:object_detection",
351+
Name: "Object Detection",
352+
Category: "video",
353+
ModelName: "yolox-object-detection", // Default model
354+
Variables: []bricksindex.BrickVariable{
355+
{Name: "EI_OBJ_DETECTION_MODEL", DefaultValue: "default_path", Description: "path to the model file"},
356+
{Name: "CUSTOM_MODEL_PATH", DefaultValue: "/home/arduino/.arduino-bricks/ei-models", Description: "path to the custom model directory"},
357+
},
358+
},
359+
{
360+
ID: "arduino:weather_forecast",
361+
Name: "Weather Forecast",
362+
Category: "miscellaneous",
363+
ModelName: "",
364+
},
365+
{
366+
ID: "arduino:one_model_brick",
367+
Name: "one model brick",
368+
Category: "video",
369+
ModelName: "face-detection", // Default model
370+
Variables: []bricksindex.BrickVariable{},
371+
},
372+
},
373+
}
374+
mIndex := &modelsindex.ModelsIndex{
375+
Models: []modelsindex.AIModel{
376+
377+
{
378+
ID: "yolox-object-detection",
379+
Name: "General purpose object detection - YoloX",
380+
ModuleDescription: "General purpose object detection...",
381+
Bricks: []string{"arduino:object_detection", "arduino:video_object_detection"},
382+
},
383+
{
384+
ID: "face-detection",
385+
Name: "Lightweight-Face-Detection",
386+
Bricks: []string{"arduino:object_detection", "arduino:video_object_detection", "arduino:one_model_brick"},
387+
},
388+
}}
389+
390+
svc := &Service{
391+
bricksIndex: bIndex,
392+
modelsIndex: mIndex,
393+
staticStore: store.NewStaticStore(assetsDir),
394+
}
395+
idProvider := app.NewAppIDProvider(cfg)
396+
397+
t.Run("Brick Not Found", func(t *testing.T) {
398+
res, err := svc.BricksDetails("arduino:non_existing", idProvider, cfg)
399+
require.Error(t, err)
400+
require.Equal(t, ErrBrickNotFound, err)
401+
require.Empty(t, res.ID)
402+
})
403+
404+
t.Run("Success - Full Details - multiple models", func(t *testing.T) {
405+
res, err := svc.BricksDetails("arduino:object_detection", idProvider, cfg)
406+
require.NoError(t, err)
407+
408+
require.Equal(t, "arduino:object_detection", res.ID)
409+
require.Equal(t, "Object Detection", res.Name)
410+
require.Equal(t, "Arduino", res.Author)
411+
require.Equal(t, "installed", res.Status)
412+
require.Contains(t, res.Variables, "EI_OBJ_DETECTION_MODEL")
413+
require.Equal(t, "default_path", res.Variables["EI_OBJ_DETECTION_MODEL"].DefaultValue)
414+
require.Equal(t, "# Documentation", res.Readme)
415+
require.Contains(t, res.ApiDocsPath, filepath.Join("arduino", "app_bricks", "object_detection", "API.md"))
416+
require.Len(t, res.CodeExamples, 1)
417+
require.Contains(t, res.CodeExamples[0].Path, "blink.ino")
418+
require.Len(t, res.UsedByApps, 1)
419+
require.Equal(t, "My App", res.UsedByApps[0].Name)
420+
require.NotEmpty(t, res.UsedByApps[0].ID)
421+
require.Len(t, res.Models, 2)
422+
require.Equal(t, "yolox-object-detection", res.Models[0].ID)
423+
require.Equal(t, "General purpose object detection - YoloX", res.Models[0].Name)
424+
require.Equal(t, "General purpose object detection...", res.Models[0].Description)
425+
require.Equal(t, "face-detection", res.Models[1].ID)
426+
require.Equal(t, "Lightweight-Face-Detection", res.Models[1].Name)
427+
require.Equal(t, "", res.Models[1].Description)
428+
})
429+
430+
t.Run("Success - Full Details - no models", func(t *testing.T) {
431+
res, err := svc.BricksDetails("arduino:weather_forecast", idProvider, cfg)
432+
require.NoError(t, err)
433+
434+
require.Equal(t, "arduino:weather_forecast", res.ID)
435+
require.Equal(t, "Weather Forecast", res.Name)
436+
require.Equal(t, "Arduino", res.Author)
437+
require.Equal(t, "installed", res.Status)
438+
require.Empty(t, res.Variables)
439+
require.Equal(t, "# Documentation", res.Readme)
440+
require.Contains(t, res.ApiDocsPath, filepath.Join("arduino", "app_bricks", "weather_forecast", "API.md"))
441+
require.Len(t, res.CodeExamples, 1)
442+
require.Contains(t, res.CodeExamples[0].Path, "blink.ino")
443+
require.Len(t, res.UsedByApps, 1)
444+
require.Equal(t, "My App", res.UsedByApps[0].Name)
445+
require.NotEmpty(t, res.UsedByApps[0].ID)
446+
require.Len(t, res.Models, 0)
447+
})
448+
449+
t.Run("Success - Full Details - one model", func(t *testing.T) {
450+
res, err := svc.BricksDetails("arduino:one_model_brick", idProvider, cfg)
451+
require.NoError(t, err)
452+
453+
require.Equal(t, "arduino:one_model_brick", res.ID)
454+
require.Equal(t, "one model brick", res.Name)
455+
require.Len(t, res.Models, 1)
456+
require.Equal(t, "face-detection", res.Models[0].ID)
457+
require.Equal(t, "Lightweight-Face-Detection", res.Models[0].Name)
458+
require.Equal(t, "", res.Models[0].Description)
459+
})
460+
}
461+
462+
func createFakeBrickAssets(t *testing.T, assetsDir, brick string) {
463+
t.Helper()
464+
465+
brickDocDir := filepath.Join(assetsDir, "docs", "arduino", brick)
466+
require.NoError(t, os.MkdirAll(brickDocDir, 0755))
467+
require.NoError(t, os.WriteFile(filepath.Join(brickDocDir, "README.md"),
468+
[]byte("# Documentation"), 0600))
469+
470+
brickExDir := filepath.Join(assetsDir, "examples", "arduino", brick)
471+
require.NoError(t, os.MkdirAll(brickExDir, 0755))
472+
require.NoError(t, os.WriteFile(filepath.Join(brickExDir, "blink.ino"),
473+
[]byte("void setup() {}"), 0600))
474+
}
475+
476+
func createFakeApp(t *testing.T, appsDir string) {
477+
t.Helper()
478+
myAppDir := filepath.Join(appsDir, "MyApp")
479+
require.NoError(t, os.MkdirAll(myAppDir, 0755))
480+
481+
appYamlContent := `
482+
name: My App
483+
bricks:
484+
- arduino:object_detection:
485+
- arduino:weather_forecast:
486+
`
487+
require.NoError(t, os.WriteFile(filepath.Join(myAppDir, "app.yaml"), []byte(appYamlContent), 0600))
488+
pythonDir := filepath.Join(myAppDir, "python")
489+
require.NoError(t, os.MkdirAll(pythonDir, 0755))
490+
require.NoError(t, os.WriteFile(filepath.Join(pythonDir, "main.py"), []byte("print('hello')"), 0600))
491+
}

internal/orchestrator/bricks/types.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@ type BrickListResult struct {
2020
}
2121

2222
type BrickListItem struct {
23-
ID string `json:"id"`
24-
Name string `json:"name"`
25-
Author string `json:"author"`
26-
Description string `json:"description"`
27-
Category string `json:"category"`
28-
Status string `json:"status"`
29-
Models []string `json:"models"`
23+
ID string `json:"id"`
24+
Name string `json:"name"`
25+
Author string `json:"author"`
26+
Description string `json:"description"`
27+
Category string `json:"category"`
28+
Status string `json:"status"`
3029
}
3130

3231
type AppBrickInstancesResult struct {
@@ -78,4 +77,11 @@ type BrickDetailsResult struct {
7877
ApiDocsPath string `json:"api_docs_path"`
7978
CodeExamples []CodeExample `json:"code_examples"`
8079
UsedByApps []AppReference `json:"used_by_apps"`
80+
Models []AIModel `json:"models"`
81+
}
82+
83+
type AIModel struct {
84+
ID string `json:"id"`
85+
Name string `json:"name"`
86+
Description string `json:"description"`
8187
}

0 commit comments

Comments
 (0)