Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/embeddedci-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
`vision_ocr`, `vision_find`, `vision_wait_for`, `vision_compare`, `vision_screen_health`,
`vision_calibrate`, `vision_add_reference`, `vision_press`, `vision_tap`. Requires `embeddedci`
2.13.8, the release with `embeddedci.aivision`.
- `vision_cloud_boards` lists the AI Vision boards on embeddedci.com, and `vision_connect` takes
`embeddedci:<name>` to drive one from anywhere (signed in with `BENCHPOD_API_KEY` or `benchpod
login`; `lease_as` takes embeddedci.com's device lease).

## 2.13.8

Expand Down
9 changes: 8 additions & 1 deletion packages/embeddedci-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,16 @@ one session drives both. Without `vision_connect`, the first `vision_*` call con
`AIVISION_CONNECTION` (token in `AIVISION_TOKEN`). Boxes and taps are in screen pixels once the
screen is calibrated.

A board paired with embeddedci.com is reached from anywhere as `embeddedci:<name>`:
`vision_cloud_boards` lists the organization's boards, and `vision_connect` takes the `connection`
it returns. Over embeddedci.com the server's sign-in applies (`BENCHPOD_API_KEY`, an API key with
the "Control AI Vision" scope, else the `benchpod login` session) and `lease_as` takes the
server's device lease, the one the web app and other runs see.

| Tool | Purpose | Parameters |
| --- | --- | --- |
| `vision_connect` | Open the AI Vision connection (closing a previous one) and report its status. | `connection`, `token`, `lease_as` |
| `vision_cloud_boards` | The AI Vision boards on embeddedci.com, online or not, with the `connection` to pass to `vision_connect`. | none |
| `vision_connect` | Open the AI Vision connection (a LAN address, `discover` or `embeddedci:<name>`; closing a previous one) and report its status. | `connection`, `token`, `lease_as` |
| `vision_disconnect` | Close the connection and release its lease. | none |
| `vision_status` | Version, calibration, camera lock, actuators, lease, capabilities, with warnings. | none |
| `vision_look` | The screen as an image (an image content block), or what the camera sees; a `region` zooms in at full sensor resolution. | `space=screen`, `overlay`, `region` |
Expand Down
52 changes: 46 additions & 6 deletions packages/embeddedci-mcp/src/embeddedci_mcp/vision.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field

from embeddedci.aivision import AIVision, AIVisionError, NotFoundError, WaitTimeout
from embeddedci.aivision import AIVision, AIVisionError, NotFoundError, WaitTimeout, list_boards

T = TypeVar("T")

Expand Down Expand Up @@ -74,7 +74,8 @@ def _hint(exc: AIVisionError) -> str:
if exc.code == "busy":
return "Someone else holds the board's lease; wait and retry. Reads (look, ocr, find, wait) still work."
if exc.code == "unauthorized":
return "Pass the board's token to vision_connect (or set AIVISION_TOKEN)."
return ("Over embeddedci.com: run `benchpod login` or set BENCHPOD_API_KEY (an API key with the Control "
"AI Vision scope). On the LAN: pass the board's token to vision_connect (or set AIVISION_TOKEN).")
if isinstance(exc, WaitTimeout):
return "The condition never held; vision_look shows what is on the screen now."
return ""
Expand All @@ -95,6 +96,16 @@ def run() -> T:
raise ToolError(f"invalid argument: {exc}") from exc


def _wrap(fn: Callable[[], T]) -> T:
"""``fn`` with an AI Vision error turned into a tool error (for calls that need no session)."""
try:
return fn()
except AIVisionError as exc:
hint = _hint(exc)
msg = f"{type(exc).__name__}: {exc}"
raise ToolError(f"{msg}. {hint}" if hint else msg) from exc


# -- results -----------------------------------------------------------------------------

class VBox(BaseModel):
Expand All @@ -119,6 +130,18 @@ def _el(e: Any) -> VElement:
return VElement(kind=e.kind, label=e.label, text=e.text, confidence=e.confidence, box=b, center=e.center)


class CloudBoard(BaseModel):
id: str
name: str
online: bool
connection: str = Field(description="What vision_connect takes: 'embeddedci:<name>'.")
last_active_at: Optional[str] = None


class CloudBoards(BaseModel):
boards: List[CloudBoard]


class VisionStatus(BaseModel):
connected: bool
url: str = ""
Expand Down Expand Up @@ -212,7 +235,7 @@ def _status(c: Optional[AIVision]) -> VisionStatus:
if not acts:
warnings.append("no actuator configured: vision_press and vision_tap will fail")
size = cal.get("screen_size")
return VisionStatus(connected=True, url=c.url, version=str(s.get("version", "")),
return VisionStatus(connected=True, url=f"embeddedci:{c.cloud_name}" if getattr(c, "cloud_name", None) else c.url, version=str(s.get("version", "")),
calibrated=bool(scr.get("calibrated")), screen_size=tuple(size) if size else None,
camera_locked=cam.get("locked"), actuators=acts,
lease=lease.get("who") if isinstance(lease, dict) else None,
Expand All @@ -225,16 +248,33 @@ def register(mcp: Any) -> None:
@mcp.tool(annotations=_ann("Connect to an AI Vision board", idempotent=True))
async def vision_connect(
connection: Annotated[Optional[str], Field(description=(
"host[:port] or URL of the ai-vision-2 board, or 'discover' (mDNS). Omit for "
"AIVISION_CONNECTION."))] = None,
token: Annotated[Optional[str], Field(description="The board's API token; omit for AIVISION_TOKEN.")] = None,
"host[:port] or URL of the ai-vision-2 board, 'discover' (mDNS), or 'embeddedci:<name>' for a "
"board on embeddedci.com (vision_cloud_boards lists them). Omit for AIVISION_CONNECTION."))] = None,
token: Annotated[Optional[str], Field(description=(
"The board's API token on the LAN; omit for AIVISION_TOKEN. Over embeddedci.com the "
"`benchpod login` session or BENCHPOD_API_KEY is used instead."))] = None,
lease_as: Annotated[Optional[str], Field(description=(
"Take the board's lease under this name, so nobody else drives the camera or the actuator "
"until vision_disconnect."))] = None,
) -> VisionStatus:
"""Open the AI Vision connection (closing a previous one) and report the board's status."""
return await _call(lambda: _status(VISION.connect(connection, token, lease_as)))

@mcp.tool(annotations=_ann("List AI Vision boards on embeddedci.com", read_only=True))
async def vision_cloud_boards() -> CloudBoards:
"""The AI Vision boards registered to your embeddedci.com organisation, and whether each is
online. Connect to one with vision_connect(connection) using its `connection`
('embeddedci:<name>'). Authenticates with BENCHPOD_API_KEY, else the `benchpod login`
session."""
def op() -> CloudBoards:
boards = [CloudBoard(id=str(d.get("id", "")), name=str(d.get("name", "")), online=bool(d.get("online")),
connection=str(d.get("connection") or f"embeddedci:{d.get('name', '')}"),
last_active_at=d.get("last_active_at"))
for d in list_boards()]
return CloudBoards(boards=boards)

return await anyio.to_thread.run_sync(lambda: _wrap(op))

@mcp.tool(annotations=_ann("Disconnect from the AI Vision board", idempotent=True))
async def vision_disconnect() -> VisionStatus:
"""Close the AI Vision connection and release its lease. Safe when not connected."""
Expand Down
2 changes: 1 addition & 1 deletion packages/embeddedci-mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"can_open", "can_write", "can_read", "can_respond", "can_status", "can_close",
"la_step", "command",
"cloud_list_devices",
"vision_connect", "vision_disconnect", "vision_status", "vision_look", "vision_ocr", "vision_find",
"vision_cloud_boards", "vision_connect", "vision_disconnect", "vision_status", "vision_look", "vision_ocr", "vision_find",
"vision_wait_for", "vision_compare", "vision_screen_health", "vision_calibrate", "vision_add_reference",
"vision_press", "vision_tap",
}
Expand Down
24 changes: 24 additions & 0 deletions packages/embeddedci-mcp/tests/test_vision_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,27 @@ def test_vision_live():
assert call("vision_wait_for", text=os.environ.get("AIVISION_HOME_TEXT", "Home"), timeout_s=10)["met"]
finally:
call("vision_disconnect")


def test_cloud_boards_lists_the_organizations_boards(monkeypatch):
from embeddedci_mcp import vision

monkeypatch.setattr(vision, "list_boards", lambda: [
{"id": "d1", "name": "bench-1", "online": True, "connection": "embeddedci:bench-1"},
{"id": "d2", "name": "bench-2", "online": False},
])
r = call("vision_cloud_boards")
assert [(b["name"], b["online"], b["connection"]) for b in r["boards"]] == [
("bench-1", True, "embeddedci:bench-1"), ("bench-2", False, "embeddedci:bench-2")]


def test_cloud_boards_without_a_sign_in_says_how(monkeypatch):
from embeddedci.aivision import AuthError
from embeddedci_mcp import vision

def no_login():
raise AuthError("listing boards on embeddedci.com needs an API key")

monkeypatch.setattr(vision, "list_boards", no_login)
with pytest.raises(ToolError, match="benchpod login"):
call("vision_cloud_boards")
17 changes: 15 additions & 2 deletions packages/embeddedci-mcp/tests/tools_surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -4830,6 +4830,19 @@
"type": "object"
}
},
"vision_cloud_boards": {
"annotations": {
"idempotentHint": false,
"openWorldHint": false,
"readOnlyHint": true,
"title": "List AI Vision boards on embeddedci.com"
},
"input": {
"properties": {},
"title": "vision_cloud_boardsArguments",
"type": "object"
}
},
"vision_compare": {
"annotations": {
"idempotentHint": false,
Expand Down Expand Up @@ -4886,7 +4899,7 @@
}
],
"default": null,
"description": "host[:port] or URL of the ai-vision-2 board, or 'discover' (mDNS). Omit for AIVISION_CONNECTION.",
"description": "host[:port] or URL of the ai-vision-2 board, 'discover' (mDNS), or 'embeddedci:<name>' for a board on embeddedci.com (vision_cloud_boards lists them). Omit for AIVISION_CONNECTION.",
"title": "Connection"
},
"lease_as": {
Expand All @@ -4912,7 +4925,7 @@
}
],
"default": null,
"description": "The board's API token; omit for AIVISION_TOKEN.",
"description": "The board's API token on the LAN; omit for AIVISION_TOKEN. Over embeddedci.com the `benchpod login` session or BENCHPOD_API_KEY is used instead.",
"title": "Token"
}
},
Expand Down
4 changes: 4 additions & 0 deletions packages/embeddedci/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
`discovery` extra. pytest: the `aivision` fixture and `--aivision`, `--aivision-token`,
`--aivision-lease`, `--aivision-artifacts`; a failing test saves the screen it saw.
See `docs/aivision.md`.
- `embeddedci.aivision` over embeddedci.com: `AIVision("embeddedci:<name>")` reaches a paired board
from anywhere, with the BenchPod SDK's sign-in (API key, `benchpod login`, GitHub Actions OIDC)
and embeddedci.com's device lease. `list_boards()` lists the organization's boards;
`register_cloud(name)`, `cloud()` and `unpair_cloud()` pair a board from its LAN.

## 2.13.7

Expand Down
4 changes: 4 additions & 0 deletions packages/embeddedci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1523,6 +1523,10 @@ with AIVision("ai-vision-2-5dcec6.local") as v: # or AIVISION_CONNECTION, o
assert v.compare("radio-fm", mask=["clock"]).score > 0.95
```

A board paired with embeddedci.com is reached from anywhere as `AIVision("embeddedci:<name>")`,
signed in like a cloud pod (`BENCHPOD_API_KEY` or `benchpod login`); `list_boards()` lists them and
`register_cloud(name)` pairs one from its LAN.

In pytest, ask for the `aivision` fixture and pass `--aivision <board>`. Every call, the result
types, the errors and the pytest options:
[docs/aivision.md](https://github.com/embeddedci-com/embeddedci-python/blob/main/packages/embeddedci/docs/aivision.md).
Expand Down
38 changes: 35 additions & 3 deletions packages/embeddedci/docs/aivision.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ with AIVision("ai-vision-2-5dcec6.local") as v:

## Connecting

`AIVision(connection=None, *, token=None, timeout=30.0, lease=None, lease_ttl_s=300)`
`AIVision(connection=None, *, token=None, timeout=30.0, lease=None, lease_ttl_s=300, lease_wait=0.0, api_base=None, api_key=None)`

| `connection` | Meaning |
| --- | --- |
| `"ai-vision-2-5dcec6.local"`, `"10.1.0.165"` | the board on port 80 |
| `"host:8080"`, `"http://host:8080"` | another port (the agent's simulator listens on 8080) |
| `"discover"` | the one board on the LAN over mDNS (`_aivision._tcp`); needs `embeddedci[discovery]` |
| `"embeddedci:<name>"` | the board over embeddedci.com (below) |
| omitted | the `AIVISION_CONNECTION` environment variable |

`token` is the board's API token (`/etc/ai-vision-agent/token`); it defaults to `AIVISION_TOKEN`,
Expand All @@ -43,6 +44,37 @@ hand.
`discover(timeout=3.0)` lists every board it hears (`DiscoveredBoard`: `hostname`, `addresses`,
`port`, `addr`). `resolve_connection(connection)` gives the base URL a connection string means.

### Over embeddedci.com

A board paired with embeddedci.com is reached from anywhere, by its name in your organization:

```python
from embeddedci.aivision import AIVision, list_boards

print([(b["name"], b["online"]) for b in list_boards()])
with AIVision("embeddedci:bench-vision", lease="nightly") as v:
v.press("Radio", verify={"text": "FM"})
```

The calls are the same; embeddedci.com forwards them to the board. Sign-in is the BenchPod SDK's:
an API key with the "Control AI Vision" scope (`api_key=` or `BENCHPOD_API_KEY`), else the
`benchpod login` session, else GitHub Actions OIDC inside a workflow. `api_base=` (or
`BENCHPOD_API_BASE`) points at another server. `lease=` takes embeddedci.com's device lease, the one
the web app and other runs see (`lease_wait=` seconds to wait for a busy board); the board's own LAN
lease is not reachable this way, and neither is changing its pairing or uploading models.

To pair a board, connect to it on its LAN once:

```python
dev = AIVision("ai-vision-2-5dcec6.local").register_cloud("bench-vision")
print(dev["connection"]) # embeddedci:bench-vision
```

`register_cloud(name)` registers the board's key in your active organization and stores the
pairing on the board, which then connects on its own; `cloud()` shows the pairing and the last
connection error, and `unpair_cloud()` forgets it. The board shows in the BenchPod workspace on
embeddedci.com next to your pods.

## Coordinates

After `calibrate`, every box (`Box`: `x`, `y`, `w`, `h`, `space`, `center`) and every tap is in
Expand Down Expand Up @@ -96,8 +128,8 @@ Every error derives from `AIVisionError` (`code`, `status`, `detail`):

| Exception | When |
| --- | --- |
| `TransportError` | the board cannot be reached |
| `AuthError` | missing or wrong token |
| `TransportError` | the board cannot be reached (over embeddedci.com: the board is offline) |
| `AuthError` | missing or wrong token (over embeddedci.com: no sign-in, or it was refused) |
| `BadRequestError` | the board rejected the arguments |
| `NotCalibratedError` | the call needs `calibrate` first |
| `BusyError` | someone else holds the lease (`detail["who"]`, `detail["expires"]`) |
Expand Down
7 changes: 6 additions & 1 deletion packages/embeddedci/src/embeddedci/aivision/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@
with AIVision("ai-vision-2-5dcec6.local") as v:
v.wait_for("Home", timeout=20)
v.press("Radio", verify={"text": "FM"})

A board paired with embeddedci.com is reached from anywhere as ``AIVision("embeddedci:<name>")``;
:func:`list_boards` lists the organization's boards and :meth:`AIVision.register_cloud` pairs one
from its LAN.
"""

from __future__ import annotations

from .client import ENV_CONNECTION, ENV_TOKEN, MAX_VERIFY_S, MAX_WAIT_REQUEST_S, AIVision, resolve_connection
from .cloud import list_boards
from .discovery import DiscoveredBoard, discover
from .errors import (ActuatorError, AIVisionError, AuthError, BadRequestError, BusyError, CameraError,
ModelError, NotCalibratedError, NotFoundError, StorageError, TransportError, WaitTimeout)
from .results import (Box, Calibration, CheckItem, CheckResult, Comparison, Element, Health, Identification,
Image, Lease, OCRResult, PressResult, Snapshot, TextBlock, WaitResult)

__all__ = [
"AIVision", "resolve_connection", "discover", "DiscoveredBoard",
"AIVision", "resolve_connection", "discover", "DiscoveredBoard", "list_boards",
"ENV_CONNECTION", "ENV_TOKEN", "MAX_WAIT_REQUEST_S", "MAX_VERIFY_S",
"AIVisionError", "TransportError", "BadRequestError", "NotCalibratedError", "BusyError", "WaitTimeout",
"NotFoundError", "ActuatorError", "ModelError", "CameraError", "StorageError", "AuthError",
Expand Down
Loading