physicalai-studio-plugin 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ *
2
+ !pyproject.toml
3
+ !src/
@@ -0,0 +1,212 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ share/python-wheels/
23
+ *.egg-info/
24
+ .installed.cfg
25
+ *.egg
26
+ MANIFEST
27
+
28
+ # PyInstaller
29
+ # Usually these files are written by a python script from a template
30
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
31
+ *.manifest
32
+ *.spec
33
+
34
+ # Installer logs
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *.cover
48
+ *.py.cover
49
+ .hypothesis/
50
+ .pytest_cache/
51
+ cover/
52
+
53
+ # Translations
54
+ *.mo
55
+ *.pot
56
+
57
+ # Django stuff:
58
+ *.log
59
+ local_settings.py
60
+ db.sqlite3
61
+ db.sqlite3-journal
62
+
63
+ # Flask stuff:
64
+ instance/
65
+ .webassets-cache
66
+
67
+ # Scrapy stuff:
68
+ .scrapy
69
+
70
+ # Sphinx documentation
71
+ docs/_build/
72
+
73
+ # PyBuilder
74
+ .pybuilder/
75
+ target/
76
+
77
+ # Jupyter Notebook
78
+ .ipynb_checkpoints
79
+
80
+ # IPython
81
+ profile_default/
82
+ ipython_config.py
83
+
84
+ # pyenv
85
+ # For a library or package, you might want to ignore these files since the code is
86
+ # intended to run in multiple environments; otherwise, check them in:
87
+ # .python-version
88
+
89
+ # pipenv
90
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
91
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
92
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
93
+ # install all needed dependencies.
94
+ #Pipfile.lock
95
+
96
+ # UV
97
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
98
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
99
+ # commonly ignored for libraries.
100
+ #uv.lock
101
+
102
+ # poetry
103
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
104
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
105
+ # commonly ignored for libraries.
106
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
107
+ #poetry.lock
108
+ #poetry.toml
109
+
110
+ # pdm
111
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
112
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
113
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
114
+ #pdm.lock
115
+ #pdm.toml
116
+ .pdm-python
117
+ .pdm-build/
118
+
119
+ # pixi
120
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
121
+ #pixi.lock
122
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
123
+ # in the .venv directory. It is recommended not to include this directory in version control.
124
+ .pixi
125
+
126
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
127
+ __pypackages__/
128
+
129
+ # Celery stuff
130
+ celerybeat-schedule
131
+ celerybeat.pid
132
+
133
+ # SageMath parsed files
134
+ *.sage.py
135
+
136
+ # Environments
137
+ .env
138
+ .envrc
139
+ .venv
140
+ env/
141
+ venv/
142
+ ENV/
143
+ env.bak/
144
+ venv.bak/
145
+
146
+ # Spyder project settings
147
+ .spyderproject
148
+ .spyproject
149
+
150
+ # Rope project settings
151
+ .ropeproject
152
+
153
+ # mkdocs documentation
154
+ /site
155
+
156
+ # mypy
157
+ .mypy_cache/
158
+ .dmypy.json
159
+ dmypy.json
160
+
161
+ # Pyre type checker
162
+ .pyre/
163
+
164
+ # pytype static type analyzer
165
+ .pytype/
166
+
167
+ # Cython debug symbols
168
+ cython_debug/
169
+
170
+ # PyCharm
171
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
172
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
173
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
174
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
175
+ #.idea/
176
+
177
+ # Abstra
178
+ # Abstra is an AI-powered process automation framework.
179
+ # Ignore directories containing user credentials, local state, and settings.
180
+ # Learn more at https://abstra.io/docs
181
+ .abstra/
182
+
183
+ # Visual Studio Code
184
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
185
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
186
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
187
+ # you could uncomment the following to ignore the entire vscode folder
188
+ .vscode/
189
+ .idea
190
+
191
+ # Ruff stuff:
192
+ .ruff_cache/
193
+
194
+ # PyPI configuration file
195
+ .pypirc
196
+
197
+ # Cursor
198
+ # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
199
+ # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
200
+ # refer to https://docs.cursor.com/context/ignore-files
201
+ .cursorignore
202
+ .cursorindexingignore
203
+
204
+ # Marimo
205
+ marimo/_static/
206
+ marimo/_lsp/
207
+ __marimo__/
208
+
209
+ # Custom
210
+ tmp*
211
+ lightning_logs
212
+ .DS_Store
@@ -0,0 +1,77 @@
1
+ # Copyright (C) 2026 Intel Corporation
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ # Plugin SDK-specific pre-commit configuration for prek workspace mode
5
+ # This config applies only to files in application/plugin/
6
+ #
7
+ # Kept in sync with application/backend/.pre-commit-config.yaml and
8
+ # application/trainer/.pre-commit-config.yaml (the canonical setup for
9
+ # Python apps in this repo). The backend's Alembic migration check is not
10
+ # included because this workspace does not own a database schema.
11
+
12
+ repos:
13
+ # ========================================================================== #
14
+ # PYTHON CHECKS #
15
+ # ========================================================================== #
16
+ - repo: https://github.com/pre-commit/pre-commit-hooks
17
+ rev: v5.0.0
18
+ hooks:
19
+ - id: debug-statements
20
+ - id: check-ast
21
+
22
+ # ========================================================================== #
23
+ # PYTHON LINTING & FORMATTING #
24
+ # ========================================================================== #
25
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
26
+ rev: "v0.11.11"
27
+ hooks:
28
+ # Run the linter with fixes (uses [tool.ruff] from pyproject.toml)
29
+ - id: ruff
30
+ args: ["--config=pyproject.toml", "--fix"]
31
+ # Run the formatter (uses [tool.ruff] from pyproject.toml)
32
+ - id: ruff-format
33
+ args: ["--config=pyproject.toml"]
34
+
35
+ # ========================================================================== #
36
+ # PYTHON TYPE CHECKING #
37
+ # ========================================================================== #
38
+ - repo: https://github.com/pre-commit/mirrors-mypy
39
+ rev: "v1.15.0"
40
+ hooks:
41
+ - id: mypy
42
+ # Uses [tool.mypy] from pyproject.toml
43
+ args: ["--config-file=pyproject.toml"]
44
+ additional_dependencies:
45
+ - types-PyYAML
46
+ - types-setuptools
47
+ - types-aiofiles
48
+
49
+ - repo: https://github.com/facebook/pyrefly-pre-commit
50
+ rev: 1.1.1
51
+ hooks:
52
+ - id: pyrefly-check
53
+ name: Pyrefly (type checking)
54
+ entry: uv run pyrefly check
55
+ language: system
56
+ pass_filenames: false
57
+
58
+ # ========================================================================== #
59
+ # SECURITY - SECRET DETECTION #
60
+ # ========================================================================== #
61
+ # gitleaks detects secrets (API keys, tokens, credentials) in code.
62
+ - repo: https://github.com/gitleaks/gitleaks
63
+ rev: v8.30.1
64
+ hooks:
65
+ - id: gitleaks
66
+
67
+ # ========================================================================== #
68
+ # DEPENDENCY LOCKFILE CHECKS #
69
+ # ========================================================================== #
70
+ - repo: local
71
+ hooks:
72
+ - id: check-uv-lock
73
+ name: Check uv.lock is up to date
74
+ entry: uv lock --check
75
+ language: system
76
+ pass_filenames: false
77
+ files: ^(pyproject\.toml|uv\.lock)$
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: physicalai-studio-plugin
3
+ Version: 0.1.0
4
+ Summary: Plugin SDK for Physical AI Studio — types and protocols for building robot catalog plugins
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: physicalai
7
+ Requires-Dist: pydantic>=2.12
@@ -0,0 +1,352 @@
1
+ # Physical AI Studio Plugin
2
+
3
+ Types, protocols, and utilities for building robot catalog plugins for **Physical AI Studio**.
4
+
5
+ External robot types register themselves with Studio through an [entry-point](#entry-point-registration) mechanism, so they can be discovered, configured, and driven without modifying Studio's internal code.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv add physicalai-studio-plugin
13
+ ```
14
+
15
+ Requires Python 3.12+. Dependencies are `pydantic>=2.12` and `physicalai`.
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ A minimal plugin has this structure:
22
+
23
+ ```
24
+ physicalai-my-robot-plugin/
25
+ ├── pyproject.toml
26
+ ├── README.md
27
+ └── src/
28
+ └── physicalai_my_robot_plugin/
29
+ ├── __init__.py
30
+ └── studio_catalog.py
31
+ ```
32
+
33
+ ### `pyproject.toml`
34
+
35
+ ```toml
36
+ [project]
37
+ name = "physicalai-my-robot-plugin"
38
+ version = "0.1.0"
39
+ requires-python = ">=3.12"
40
+ dependencies = [
41
+ "physicalai",
42
+ "physicalai-studio-plugin",
43
+ ]
44
+
45
+ [project.entry-points."physicalai.studio.catalog_plugins"]
46
+ my-robot = "physicalai_my_robot_plugin.studio_catalog:register_physicalai_studio_plugin"
47
+
48
+ [build-system]
49
+ requires = ["hatchling"]
50
+ build-backend = "hatchling.build"
51
+ ```
52
+
53
+ ### `studio_catalog.py`
54
+
55
+ ```python
56
+ from __future__ import annotations
57
+
58
+ from collections.abc import Awaitable, Callable
59
+ from pathlib import Path
60
+ from typing import TYPE_CHECKING, Any
61
+
62
+ from physicalai.robot.interface import Robot as PhysicalAIRobot
63
+ from physicalai_studio_plugin import (
64
+ CatalogRobotFactory,
65
+ PortScanner,
66
+ RobotAdapterOptions,
67
+ RobotAsset,
68
+ RobotCatalogDefinition,
69
+ RobotProbe,
70
+ SerialPortInfo,
71
+ )
72
+ from pydantic import BaseModel, Field
73
+
74
+ if TYPE_CHECKING:
75
+ from physicalai_studio_plugin import CatalogRobot
76
+
77
+
78
+ class MyRobotPayload(BaseModel):
79
+ connection_string: str = ""
80
+ serial_number: str = Field(...)
81
+
82
+
83
+ async def _build_my_robot(
84
+ robot: CatalogRobot[MyRobotPayload],
85
+ factory: CatalogRobotFactory,
86
+ ) -> PhysicalAIRobot:
87
+ port = await factory.find_port(
88
+ SerialPortInfo(
89
+ connection_string=robot.payload.connection_string or None,
90
+ serial_number=robot.payload.serial_number or None,
91
+ )
92
+ )
93
+ if port is None:
94
+ msg = f"Robot not found: {robot.payload.serial_number}"
95
+ raise RuntimeError(msg)
96
+ # ... create and return your PhysicalAIRobot implementation ...
97
+
98
+
99
+ class MyRobotProbe:
100
+ """Structurally implements RobotProbe[MyRobotPayload]."""
101
+
102
+ async def discover(self, manager: PortScanner) -> list[SerialPortInfo]:
103
+ await manager.find_robots()
104
+ return manager.robots
105
+
106
+ async def identify(
107
+ self, payload: MyRobotPayload, manager: PortScanner | None, joint: str | None = None
108
+ ) -> None:
109
+ pass
110
+
111
+ async def is_online(
112
+ self, payload: MyRobotPayload, manager: PortScanner | None = None
113
+ ) -> bool:
114
+ return True
115
+
116
+
117
+ def _definitions() -> list[RobotCatalogDefinition[MyRobotPayload]]:
118
+ return [
119
+ RobotCatalogDefinition[MyRobotPayload](
120
+ type="MyRobot_Follower",
121
+ display_name="My Robot Follower",
122
+ role="follower",
123
+ robot_builder=_build_my_robot,
124
+ robot_payload=MyRobotPayload,
125
+ asset=RobotAsset(
126
+ urdf_relative_path=Path("my_robot/model.urdf"),
127
+ packages={"my_robot": Path("my_robot")},
128
+ joint_map={"gripper.pos": ["gripper"]},
129
+ root_resolver=lambda: Path("/path/to/urdf"),
130
+ ),
131
+ adapter_options=RobotAdapterOptions(include_velocities=True),
132
+ probe=MyRobotProbe(),
133
+ ),
134
+ ]
135
+
136
+
137
+ def register_physicalai_studio_plugin(registry: Any) -> None:
138
+ for definition in _definitions():
139
+ registry.register_robot(definition)
140
+ ```
141
+
142
+ ---
143
+
144
+ ## API Reference
145
+
146
+ ### `RobotCatalogDefinition`
147
+
148
+ The primary data class that describes a robot type to Studio. Generic over the payload model — use ``RobotCatalogDefinition[MyRobotPayload]`` to link the payload, probe, and robot builder types together.
149
+
150
+ ```python
151
+ @dataclass
152
+ class RobotCatalogDefinition(Generic[_PayloadT]):
153
+ type: str # Unique identifier, e.g. "MyRobot_Follower"
154
+ display_name: str # Human-readable name
155
+ role: Literal["follower", "leader"]
156
+ robot_builder: BuildRobotCallable | None = None
157
+ robot_payload: type[_PayloadT] | None = None
158
+ asset: RobotAsset | None = None
159
+ adapter_options: RobotAdapterOptions = field(default_factory=RobotAdapterOptions)
160
+ probe: RobotProbe[_PayloadT] | None = None
161
+ ```
162
+
163
+ | Field | Description |
164
+ |-------|-------------|
165
+ | `type` | Stable identifier used in DB storage and API paths. Must be unique across all plugins. Convention: PascalCase with underscores, e.g. `"MyRobot_Follower"`. |
166
+ | `display_name` | Human-readable name shown in the Studio UI. |
167
+ | `role` | Either `"follower"` (executes actions) or `"leader"` (provides demonstrations). |
168
+ | `robot_builder` | Async callable that receives a robot payload and factory, returns a `PhysicalAIRobot` instance. |
169
+ | `robot_payload` | A Pydantic `BaseModel` subclass defining the configuration fields for this robot type (e.g. `serial_number`, `connection_string`). |
170
+ | `asset` | URDF and package maps for 3D visualization. |
171
+ | `adapter_options` | Controls velocity/effort forwarding behavior. |
172
+ | `probe` | Optional [`RobotProbe[_PayloadT]`](#robotprobe) typed to the same payload model. |
173
+
174
+ ### `RobotAdapterOptions`
175
+
176
+ ```python
177
+ @dataclass(frozen=True)
178
+ class RobotAdapterOptions:
179
+ include_velocities: bool = False
180
+ goal_time_scale: float = 1.0
181
+ external_effort_gain: float | None = 0.1
182
+ ```
183
+
184
+ ### `RobotAsset`
185
+
186
+ ```python
187
+ @dataclass(frozen=True)
188
+ class RobotAsset:
189
+ urdf_relative_path: Path
190
+ packages: dict[str, Path]
191
+ joint_map: dict[str, list[str]]
192
+ root_resolver: Callable[[], Path] | None = None
193
+ ```
194
+
195
+ | Field | Description |
196
+ |-------|-------------|
197
+ | `urdf_relative_path` | Path to the URDF file, relative to the packages root. |
198
+ | `packages` | Maps ROS package names to their filesystem paths, e.g. `{"my_robot": Path("my_robot")}`. |
199
+ | `joint_map` | Maps Studio's observation key names (e.g. `"gripper.pos"`) to URDF joint name(s). |
200
+ | `root_resolver` | Callable that returns the root directory for URDF lookup. Used by Studio to resolve URDF paths for the API. |
201
+
202
+ ### `RobotProbe`
203
+
204
+ ```python
205
+ @runtime_checkable
206
+ class RobotProbe(Protocol[_PayloadT]):
207
+ async def discover(self, manager: PortScanner) -> list[SerialPortInfo]: ...
208
+ async def identify(
209
+ self, payload: _PayloadT, manager: PortScanner | None, joint: str | None = None
210
+ ) -> None: ...
211
+ async def is_online(
212
+ self, payload: _PayloadT, manager: PortScanner | None = None
213
+ ) -> bool: ...
214
+ ```
215
+
216
+ Generic protocol over your robot's payload model. Implement it structurally — your class receives the typed payload directly instead of a raw dict. The ``_PayloadT`` type parameter is automatically inferred from ``identify`` / ``is_online`` signatures; you do not need to explicitly inherit from ``RobotProbe``.
217
+
218
+ ### `PortScanner`
219
+
220
+ ```python
221
+ class PortScanner(Protocol):
222
+ async def find_robots(self) -> None: ...
223
+ @property
224
+ def robots(self) -> list[SerialPortInfo]: ...
225
+ ```
226
+
227
+ Duck-type protocol for serial/network port scanners. Call `find_robots()` to refresh the device list, then read `robots` for the current results.
228
+
229
+ ### `CatalogRobotFactory`
230
+
231
+ ```python
232
+ class CatalogRobotFactory(Protocol):
233
+ async def find_port(self, port_info: SerialPortInfo) -> str | None: ...
234
+ ```
235
+
236
+ Factory protocol passed to your `robot_builder` callable. Use `find_port(SerialPortInfo(...))` to resolve a connection by serial number and/or configured connection string. Calibration data is now embedded in the robot payload model and does not require a factory method.
237
+
238
+ ### `SerialPortInfo`
239
+
240
+ ```python
241
+ class SerialPortInfo(BaseModel):
242
+ connection_string: str | None
243
+ serial_number: str | None
244
+ ```
245
+
246
+ Describes a discovered serial or network connection.
247
+
248
+ ### `PayloadContainer` / `CatalogRobot`
249
+
250
+ ```python
251
+ class PayloadContainer(Protocol[_PayloadT]):
252
+ payload: _PayloadT
253
+
254
+ class CatalogRobot(PayloadContainer[_PayloadT], Protocol[_PayloadT]):
255
+ type: str
256
+ ```
257
+
258
+ Protocols for the robot descriptor passed to `robot_builder`. The `payload` is an instance of your `robot_payload` model.
259
+
260
+ ### `BuildRobotCallable`
261
+
262
+ ```python
263
+ BuildRobotCallable = Callable[[_RobotT, _FactoryT], Awaitable[PhysicalAIRobot]]
264
+ ```
265
+
266
+ Type alias for the `robot_builder` callable signature.
267
+
268
+ ---
269
+
270
+ ## Entry Point Registration
271
+
272
+ Studio discovers plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/) in the group `physicalai.studio.catalog_plugins`.
273
+
274
+ In your `pyproject.toml`:
275
+
276
+ ```toml
277
+ [project.entry-points."physicalai.studio.catalog_plugins"]
278
+ my-robot = "physicalai_my_robot_plugin.studio_catalog:register_physicalai_studio_plugin"
279
+ ```
280
+
281
+ The callable must accept a single argument — the registry — and call `registry.register_robot(definition)` for each robot type:
282
+
283
+ ```python
284
+ def register_physicalai_studio_plugin(registry: Any) -> None:
285
+ for definition in _definitions():
286
+ registry.register_robot(definition)
287
+ ```
288
+
289
+ Studio calls all discovered entry points at startup. Duplicate `type` values raise a `ValueError`.
290
+
291
+ ---
292
+
293
+ ## Robot Builder Pattern
294
+
295
+ The `robot_builder` is an async function that receives a robot descriptor and a factory, performs connection setup, and returns a `PhysicalAIRobot`:
296
+
297
+ ```python
298
+ from physicalai_studio_plugin import CatalogRobot
299
+
300
+ async def _build_my_robot(
301
+ robot: CatalogRobot[MyRobotPayload],
302
+ factory: CatalogRobotFactory,
303
+ ) -> PhysicalAIRobot:
304
+ # `robot.payload` is already a validated MyRobotPayload instance.
305
+ # 1. Resolve the connection
306
+ port = await factory.find_port(
307
+ SerialPortInfo(
308
+ connection_string=robot.payload.connection_string or None,
309
+ serial_number=robot.payload.serial_number or None,
310
+ )
311
+ )
312
+ if port is None:
313
+ msg = f"Robot not found: {robot.payload.serial_number}"
314
+ raise RuntimeError(msg)
315
+
316
+ # 2. Return the driver
317
+ return MyRobotDriver(port=port, ...)
318
+ ```
319
+
320
+ ---
321
+
322
+ ## Testing
323
+
324
+ Create a minimal test file alongside your plugin:
325
+
326
+ ```python
327
+ from __future__ import annotations
328
+
329
+ from physicalai_studio_plugin import RobotCatalogDefinition, SerialPortInfo
330
+
331
+
332
+ def _fake_registry():
333
+ class _FakeRegistry:
334
+ def __init__(self):
335
+ self.definitions: list[RobotCatalogDefinition] = []
336
+
337
+ def register_robot(self, definition: RobotCatalogDefinition) -> None:
338
+ self.definitions.append(definition)
339
+
340
+ return _FakeRegistry()
341
+
342
+
343
+ def test_plugin_registration():
344
+ from physicalai_my_robot_plugin.studio_catalog import (
345
+ register_physicalai_studio_plugin,
346
+ )
347
+
348
+ registry = _fake_registry()
349
+ register_physicalai_studio_plugin(registry)
350
+ assert len(registry.definitions) == 1
351
+ assert registry.definitions[0].type == "MyRobot_Follower"
352
+ ```