Skip to content

Commit de4ecd9

Browse files
committed
Merge remote-tracking branch 'origin/main' into reduce-app-cli-requirement
2 parents d904f8a + a2f7219 commit de4ecd9

File tree

9 files changed

+296
-18
lines changed

9 files changed

+296
-18
lines changed

Taskfile.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ tasks:
167167
cmds:
168168
- docker rm -f adbd
169169

170-
board:install-arduino-app-cli:
170+
board:install:
171171
desc: Install arduino-app-cli on the board
172172
interactive: true
173173
cmds:

internal/api/handlers/monitor.go

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,24 +83,53 @@ func monitorStream(mon net.Conn, ws *websocket.Conn) {
8383
}()
8484
}
8585

86+
func splitOrigin(origin string) (scheme, host, port string, err error) {
87+
parts := strings.SplitN(origin, "://", 2)
88+
if len(parts) != 2 {
89+
return "", "", "", fmt.Errorf("invalid origin format: %s", origin)
90+
}
91+
scheme = parts[0]
92+
hostPort := parts[1]
93+
hostParts := strings.SplitN(hostPort, ":", 2)
94+
host = hostParts[0]
95+
if len(hostParts) == 2 {
96+
port = hostParts[1]
97+
} else {
98+
port = "*"
99+
}
100+
return scheme, host, port, nil
101+
}
102+
86103
func checkOrigin(origin string, allowedOrigins []string) bool {
104+
scheme, host, port, err := splitOrigin(origin)
105+
if err != nil {
106+
slog.Error("WebSocket origin check failed", slog.String("origin", origin), slog.String("error", err.Error()))
107+
return false
108+
}
87109
for _, allowed := range allowedOrigins {
88-
if strings.HasSuffix(allowed, "*") {
89-
// String ends with *, match the prefix
90-
if strings.HasPrefix(origin, strings.TrimSuffix(allowed, "*")) {
91-
return true
92-
}
93-
} else {
94-
// Exact match
95-
if allowed == origin {
96-
return true
97-
}
110+
allowedScheme, allowedHost, allowedPort, err := splitOrigin(allowed)
111+
if err != nil {
112+
panic(err)
113+
}
114+
if allowedScheme != scheme {
115+
continue
98116
}
117+
if allowedHost != host && allowedHost != "*" {
118+
continue
119+
}
120+
if allowedPort != port && allowedPort != "*" {
121+
continue
122+
}
123+
return true
99124
}
125+
slog.Error("WebSocket origin check failed", slog.String("origin", origin))
100126
return false
101127
}
102128

103129
func HandleMonitorWS(allowedOrigins []string) http.HandlerFunc {
130+
// Do a dry-run of checkorigin, so it can panic if misconfigured now, not on first request
131+
_ = checkOrigin("http://example.com:8000", allowedOrigins)
132+
104133
upgrader := websocket.Upgrader{
105134
ReadBufferSize: 1024,
106135
WriteBufferSize: 1024,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// This file is part of arduino-app-cli.
2+
//
3+
// Copyright 2025 ARDUINO SA (http://www.arduino.cc/)
4+
//
5+
// This software is released under the GNU General Public License version 3,
6+
// which covers the main part of arduino-app-cli.
7+
// The terms of this license can be found at:
8+
// https://www.gnu.org/licenses/gpl-3.0.en.html
9+
//
10+
// You can be released from the requirements of the above licenses by purchasing
11+
// a commercial license. Buying such a license is mandatory if you want to
12+
// modify or otherwise use the software for commercial activities involving the
13+
// Arduino software without disclosing the source code of your own applications.
14+
// To purchase a commercial license, send an email to license@arduino.cc.
15+
16+
package handlers
17+
18+
import (
19+
"testing"
20+
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func TestCheckOrigin(t *testing.T) {
25+
origins := []string{
26+
"wails://wails",
27+
"wails://wails.localhost:*",
28+
"http://wails.localhost:*",
29+
"http://localhost:*",
30+
"https://localhost:*",
31+
"http://example.com:7000",
32+
"https://*:443",
33+
}
34+
35+
allow := func(origin string) {
36+
require.True(t, checkOrigin(origin, origins), "Expected origin %s to be allowed", origin)
37+
}
38+
deny := func(origin string) {
39+
require.False(t, checkOrigin(origin, origins), "Expected origin %s to be denied", origin)
40+
}
41+
allow("wails://wails")
42+
allow("wails://wails:8000")
43+
allow("http://wails.localhost")
44+
allow("http://example.com:7000")
45+
allow("https://blah.com:443")
46+
deny("wails://evil.com")
47+
deny("https://wails.localhost:8000")
48+
deny("http://example.com:8000")
49+
deny("http://blah.com:443")
50+
deny("https://blah.com:8080")
51+
}

internal/orchestrator/app/app.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func Load(appPath string) (ArduinoApp, error) {
4848
return ArduinoApp{}, fmt.Errorf("app path is not valid: %w", err)
4949
}
5050
if !exist {
51-
return ArduinoApp{}, fmt.Errorf("no such file or directory: %s", path)
51+
return ArduinoApp{}, fmt.Errorf("app path must be a directory: %s", path)
5252
}
5353
path, err = path.Abs()
5454
if err != nil {

internal/orchestrator/app/app_test.go

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,26 @@ import (
2525
)
2626

2727
func TestLoad(t *testing.T) {
28-
t.Run("empty", func(t *testing.T) {
28+
t.Run("it fails if the app path is empty", func(t *testing.T) {
2929
app, err := Load("")
3030
assert.Error(t, err)
3131
assert.Empty(t, app)
32+
assert.Contains(t, err.Error(), "empty app path")
3233
})
3334

34-
t.Run("AppSimple", func(t *testing.T) {
35+
t.Run("it fails if the app path exist but it's a file", func(t *testing.T) {
36+
_, err := Load("testdata/app.yaml")
37+
assert.Error(t, err)
38+
assert.Contains(t, err.Error(), "app path must be a directory")
39+
})
40+
41+
t.Run("it fails if the app path does not exist", func(t *testing.T) {
42+
_, err := Load("testdata/this-folder-does-not-exist")
43+
assert.Error(t, err)
44+
assert.Contains(t, err.Error(), "app path is not valid")
45+
})
46+
47+
t.Run("it loads an app correctly", func(t *testing.T) {
3548
app, err := Load("testdata/AppSimple")
3649
assert.NoError(t, err)
3750
assert.NotEmpty(t, app)

internal/orchestrator/modelsindex/models_index.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type AIModel struct {
4848
ModuleDescription string `yaml:"description"`
4949
Runner string `yaml:"runner"`
5050
Bricks []string `yaml:"bricks,omitempty"`
51+
ModelLabels []string `yaml:"model_labels,omitempty"`
5152
Metadata map[string]string `yaml:"metadata,omitempty"`
5253
ModelConfiguration map[string]string `yaml:"model_configuration,omitempty"`
5354
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package modelsindex
2+
3+
import (
4+
"testing"
5+
6+
"github.com/arduino/go-paths-helper"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
func TestModelsIndex(t *testing.T) {
12+
modelsIndex, err := GenerateModelsIndexFromFile(paths.New("testdata"))
13+
require.NoError(t, err)
14+
require.NotNil(t, modelsIndex)
15+
16+
t.Run("it parses a valid model-list.yaml", func(t *testing.T) {
17+
models := modelsIndex.GetModels()
18+
assert.Len(t, models, 2, "Expected 2 models to be parsed")
19+
})
20+
21+
t.Run("it gets a model by ID", func(t *testing.T) {
22+
model, found := modelsIndex.GetModelByID("not-existing-model")
23+
assert.False(t, found)
24+
assert.Nil(t, model)
25+
26+
model, found = modelsIndex.GetModelByID("face-detection")
27+
assert.Equal(t, "brick", model.Runner)
28+
require.True(t, found, "face-detection should be found")
29+
assert.Equal(t, "face-detection", model.ID)
30+
assert.Equal(t, "Lightweight-Face-Detection", model.Name)
31+
assert.Equal(t, "Face bounding box detection. This model is trained on the WIDER FACE dataset and can detect faces in images.", model.ModuleDescription)
32+
assert.Equal(t, []string{"face"}, model.ModelLabels)
33+
assert.Equal(t, "/models/ootb/ei/lw-face-det.eim", model.ModelConfiguration["EI_OBJ_DETECTION_MODEL"])
34+
assert.Equal(t, []string{"arduino:object_detection", "arduino:video_object_detection"}, model.Bricks)
35+
assert.Equal(t, "qualcomm-ai-hub", model.Metadata["source"])
36+
assert.Equal(t, "false", model.Metadata["ei-gpu-mode"])
37+
assert.Equal(t, "face-det-lite", model.Metadata["source-model-id"])
38+
assert.Equal(t, "https://aihub.qualcomm.com/models/face_det_lite", model.Metadata["source-model-url"])
39+
})
40+
41+
t.Run("it fails if model-list.yaml does not exist", func(t *testing.T) {
42+
nonExistentPath := paths.New("nonexistentdir")
43+
modelsIndex, err := GenerateModelsIndexFromFile(nonExistentPath)
44+
assert.Error(t, err)
45+
assert.Nil(t, modelsIndex)
46+
})
47+
48+
t.Run("it gets models by a brick", func(t *testing.T) {
49+
model := modelsIndex.GetModelsByBrick("not-existing-brick")
50+
assert.Nil(t, model)
51+
52+
model = modelsIndex.GetModelsByBrick("arduino:object_detection")
53+
assert.Len(t, model, 1)
54+
assert.Equal(t, "face-detection", model[0].ID)
55+
})
56+
57+
t.Run("it gets models by bricks", func(t *testing.T) {
58+
models := modelsIndex.GetModelsByBricks([]string{"arduino:non_existing"})
59+
assert.Len(t, models, 0)
60+
assert.Nil(t, models)
61+
62+
models = modelsIndex.GetModelsByBricks([]string{"arduino:video_object_detection"})
63+
assert.Len(t, models, 2)
64+
assert.Equal(t, "face-detection", models[0].ID)
65+
assert.Equal(t, "yolox-object-detection", models[1].ID)
66+
67+
models = modelsIndex.GetModelsByBricks([]string{"arduino:object_detection", "arduino:video_object_detection"})
68+
assert.Len(t, models, 2)
69+
assert.Equal(t, "face-detection", models[0].ID)
70+
assert.Equal(t, "yolox-object-detection", models[1].ID)
71+
})
72+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
models:
2+
- face-detection:
3+
runner: brick
4+
name : "Lightweight-Face-Detection"
5+
description: "Face bounding box detection. This model is trained on the WIDER FACE dataset and can detect faces in images."
6+
model_configuration:
7+
"EI_OBJ_DETECTION_MODEL": "/models/ootb/ei/lw-face-det.eim"
8+
model_labels:
9+
- face
10+
bricks:
11+
- arduino:object_detection
12+
- arduino:video_object_detection
13+
metadata:
14+
source: "qualcomm-ai-hub"
15+
ei-gpu-mode: false
16+
source-model-id: "face-det-lite"
17+
source-model-url: "https://aihub.qualcomm.com/models/face_det_lite"
18+
- yolox-object-detection:
19+
runner: brick
20+
name : "General purpose object detection - YoloX"
21+
description: "General purpose object detection model based on YoloX Nano. This model is trained on the COCO dataset and can detect 80 different object classes."
22+
model_configuration:
23+
"EI_OBJ_DETECTION_MODEL": "/models/ootb/ei/yolo-x-nano.eim"
24+
model_labels:
25+
- airplane
26+
- apple
27+
- backpack
28+
- banana
29+
- baseball bat
30+
- baseball glove
31+
- bear
32+
- bed
33+
- bench
34+
- bicycle
35+
- bird
36+
- boat
37+
- book
38+
- bottle
39+
- bowl
40+
- broccoli
41+
- bus
42+
- cake
43+
- car
44+
- carrot
45+
- cat
46+
- cell phone
47+
- chair
48+
- clock
49+
- couch
50+
- cow
51+
- cup
52+
- dining table
53+
- dog
54+
- donut
55+
- elephant
56+
- fire hydrant
57+
- fork
58+
- frisbee
59+
- giraffe
60+
- hair drier
61+
- handbag
62+
- hot dog
63+
- horse
64+
- keyboard
65+
- kite
66+
- knife
67+
- laptop
68+
- microwave
69+
- motorcycle
70+
- mouse
71+
- orange
72+
- oven
73+
- parking meter
74+
- person
75+
- pizza
76+
- potted plant
77+
- refrigerator
78+
- remote
79+
- sandwich
80+
- scissors
81+
- sheep
82+
- sink
83+
- skateboard
84+
- skis
85+
- snowboard
86+
- spoon
87+
- sports ball
88+
- stop sign
89+
- suitcase
90+
- surfboard
91+
- teddy bear
92+
- tennis racket
93+
- tie
94+
- toaster
95+
- toilet
96+
- toothbrush
97+
- traffic light
98+
- train
99+
- truck
100+
- tv
101+
- umbrella
102+
- vase
103+
- wine glass
104+
- zebra
105+
metadata:
106+
source: "edgeimpulse"
107+
ei-project-id: 717280
108+
source-model-id: "YOLOX-Nano"
109+
source-model-url: "https://github.com/Megvii-BaseDetection/YOLOX"
110+
bricks:
111+
- arduino:video_object_detection

pkg/board/remote/adb/adb.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,13 @@ func (a *ADBConnection) Forward(ctx context.Context, localPort int, remotePort i
8282
if err != nil {
8383
return err
8484
}
85-
if err := cmd.RunWithinContext(ctx); err != nil {
85+
if out, err := cmd.RunAndCaptureCombinedOutput(ctx); err != nil {
8686
return fmt.Errorf(
87-
"failed to forward ADB port %s to %s: %w",
87+
"failed to forward ADB port %s to %s: %w: %s",
8888
local,
8989
remote,
9090
err,
91+
out,
9192
)
9293
}
9394

@@ -99,8 +100,8 @@ func (a *ADBConnection) ForwardKillAll(ctx context.Context) error {
99100
if err != nil {
100101
return err
101102
}
102-
if err := cmd.RunWithinContext(ctx); err != nil {
103-
return fmt.Errorf("failed to kill all ADB forwarded ports: %w", err)
103+
if out, err := cmd.RunAndCaptureCombinedOutput(ctx); err != nil {
104+
return fmt.Errorf("failed to kill all ADB forwarded ports: %w: %s", err, out)
104105
}
105106
return nil
106107
}

0 commit comments

Comments
 (0)