nori-sdk 1.0.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.
- nori_sdk-1.0.0/.github/workflows/ci.yml +109 -0
- nori_sdk-1.0.0/.github/workflows/release.yml +55 -0
- nori_sdk-1.0.0/.gitignore +15 -0
- nori_sdk-1.0.0/CHANGELOG.md +435 -0
- nori_sdk-1.0.0/LICENSE +202 -0
- nori_sdk-1.0.0/PKG-INFO +403 -0
- nori_sdk-1.0.0/README.md +367 -0
- nori_sdk-1.0.0/examples/drive.py +72 -0
- nori_sdk-1.0.0/examples/mock_pick_place.py +92 -0
- nori_sdk-1.0.0/examples/pose_circle_demo.py +55 -0
- nori_sdk-1.0.0/examples/pose_test.py +560 -0
- nori_sdk-1.0.0/pyproject.toml +86 -0
- nori_sdk-1.0.0/src/nori_sdk/__init__.py +135 -0
- nori_sdk-1.0.0/src/nori_sdk/_png.py +60 -0
- nori_sdk-1.0.0/src/nori_sdk/auth.py +277 -0
- nori_sdk-1.0.0/src/nori_sdk/mock/__init__.py +17 -0
- nori_sdk-1.0.0/src/nori_sdk/mock/loopback.py +117 -0
- nori_sdk-1.0.0/src/nori_sdk/mock/robot.py +639 -0
- nori_sdk-1.0.0/src/nori_sdk/mock/session.py +115 -0
- nori_sdk-1.0.0/src/nori_sdk/motion.py +350 -0
- nori_sdk-1.0.0/src/nori_sdk/protocol.py +342 -0
- nori_sdk-1.0.0/src/nori_sdk/py.typed +0 -0
- nori_sdk-1.0.0/src/nori_sdk/signaling.py +162 -0
- nori_sdk-1.0.0/src/nori_sdk/signaling_supabase.py +433 -0
- nori_sdk-1.0.0/src/nori_sdk/teleop.py +1301 -0
- nori_sdk-1.0.0/src/nori_sdk/types.py +651 -0
- nori_sdk-1.0.0/src/nori_sdk/version.py +16 -0
- nori_sdk-1.0.0/src/nori_sdk/webrtc_compat.py +142 -0
- nori_sdk-1.0.0/tests/_spec.py +84 -0
- nori_sdk-1.0.0/tests/test_auth.py +235 -0
- nori_sdk-1.0.0/tests/test_conformance.py +418 -0
- nori_sdk-1.0.0/tests/test_measure_jog_scale.py +100 -0
- nori_sdk-1.0.0/tests/test_mock_robot.py +348 -0
- nori_sdk-1.0.0/tests/test_mock_session.py +106 -0
- nori_sdk-1.0.0/tests/test_motion.py +267 -0
- nori_sdk-1.0.0/tests/test_policy_gaps.py +387 -0
- nori_sdk-1.0.0/tests/test_protocol.py +109 -0
- nori_sdk-1.0.0/tests/test_public_api.py +154 -0
- nori_sdk-1.0.0/tests/test_session.py +408 -0
- nori_sdk-1.0.0/tests/test_types.py +84 -0
- nori_sdk-1.0.0/tests/test_webrtc_compat.py +125 -0
- nori_sdk-1.0.0/tests/test_wired_verbs.py +218 -0
- nori_sdk-1.0.0/tools/measure_jog_scale.py +201 -0
- nori_sdk-1.0.0/tools/mutate.py +294 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
|
|
3
|
+
# Runs on every push and PR. Three gates, and the third is the one with teeth: conformance
|
|
4
|
+
# validates this SDK's frames against the real nori-protocol schemas, so "we invented a field
|
|
5
|
+
# name" fails here rather than on a robot.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [main]
|
|
9
|
+
pull_request:
|
|
10
|
+
# The spec is a separate repo. A schema change there can invalidate this SDK without a
|
|
11
|
+
# commit here, so a nightly run catches the drift instead of the next contributor.
|
|
12
|
+
schedule:
|
|
13
|
+
- cron: "17 6 * * *"
|
|
14
|
+
workflow_dispatch:
|
|
15
|
+
|
|
16
|
+
concurrency:
|
|
17
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
18
|
+
cancel-in-progress: true
|
|
19
|
+
|
|
20
|
+
permissions:
|
|
21
|
+
contents: read
|
|
22
|
+
|
|
23
|
+
jobs:
|
|
24
|
+
test:
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
strategy:
|
|
27
|
+
fail-fast: false
|
|
28
|
+
matrix:
|
|
29
|
+
# 3.11 is the floor declared in pyproject (requires-python). Testing only the newest
|
|
30
|
+
# would let a 3.12+ idiom land and break the floor silently.
|
|
31
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
32
|
+
steps:
|
|
33
|
+
- uses: actions/checkout@v4
|
|
34
|
+
|
|
35
|
+
# The spec lives in Nori-Robotics/Nori-Protocol, a PRIVATE repo, so the default
|
|
36
|
+
# GITHUB_TOKEN cannot read it -- it is scoped to this repository alone. NORI_PROTOCOL_TOKEN
|
|
37
|
+
# must be a PAT (or app token) with read access to that repo.
|
|
38
|
+
- name: Check out nori-protocol
|
|
39
|
+
uses: actions/checkout@v4
|
|
40
|
+
with:
|
|
41
|
+
repository: Nori-Robotics/Nori-Protocol
|
|
42
|
+
path: spec/nori-protocol
|
|
43
|
+
token: ${{ secrets.NORI_PROTOCOL_TOKEN }}
|
|
44
|
+
|
|
45
|
+
- uses: actions/setup-python@v5
|
|
46
|
+
with:
|
|
47
|
+
python-version: ${{ matrix.python-version }}
|
|
48
|
+
cache: pip
|
|
49
|
+
|
|
50
|
+
- run: python -m pip install --upgrade pip
|
|
51
|
+
- run: pip install -e ".[dev]"
|
|
52
|
+
|
|
53
|
+
- name: Lint
|
|
54
|
+
run: ruff check .
|
|
55
|
+
|
|
56
|
+
- name: Typecheck
|
|
57
|
+
# Inert without src/nori_sdk/py.typed -- mypy refuses to check an installed package
|
|
58
|
+
# that does not advertise type information, and exits 0 having examined nothing.
|
|
59
|
+
run: mypy
|
|
60
|
+
|
|
61
|
+
- name: Test
|
|
62
|
+
# Set explicitly rather than relying on the submodule-path fallback. tests/_spec.py
|
|
63
|
+
# RAISES on a set-but-invalid NORI_PROTOCOL_DIR, so a broken spec checkout fails the
|
|
64
|
+
# job instead of silently skipping every conformance test and reporting green -- which
|
|
65
|
+
# is the exact failure mode this spec exists to prevent.
|
|
66
|
+
env:
|
|
67
|
+
NORI_PROTOCOL_DIR: ${{ github.workspace }}/spec/nori-protocol
|
|
68
|
+
run: pytest -q --strict-markers
|
|
69
|
+
|
|
70
|
+
- name: Assert conformance actually ran
|
|
71
|
+
# Belt and braces on the check above. `pytest --collect-only -q` prints one line per
|
|
72
|
+
# collected test, so a conformance suite that silently collected nothing is caught
|
|
73
|
+
# even if the skip logic changes underneath us.
|
|
74
|
+
env:
|
|
75
|
+
NORI_PROTOCOL_DIR: ${{ github.workspace }}/spec/nori-protocol
|
|
76
|
+
run: |
|
|
77
|
+
n=$(pytest --collect-only -q tests/test_conformance.py | grep -c '::' || true)
|
|
78
|
+
echo "collected $n conformance tests"
|
|
79
|
+
test "$n" -gt 20 || { echo "::error::conformance suite did not run ($n tests)"; exit 1; }
|
|
80
|
+
|
|
81
|
+
build:
|
|
82
|
+
# Catches the packaging mistakes tests never see: a module missing from the wheel, or
|
|
83
|
+
# py.typed not being included, which would ship an untyped package to every consumer.
|
|
84
|
+
runs-on: ubuntu-latest
|
|
85
|
+
steps:
|
|
86
|
+
- uses: actions/checkout@v4
|
|
87
|
+
- uses: actions/setup-python@v5
|
|
88
|
+
with:
|
|
89
|
+
python-version: "3.12"
|
|
90
|
+
- run: pip install build twine
|
|
91
|
+
- run: python -m build
|
|
92
|
+
- run: twine check dist/*
|
|
93
|
+
- name: Assert py.typed is in the wheel
|
|
94
|
+
# NB: this step's original `run: python -c "` multi-line form was INVALID
|
|
95
|
+
# YAML (the `:` inside the f-string made the scalar a mapping) — it made
|
|
96
|
+
# the whole workflow fail at parse time, 0s, on every push since the
|
|
97
|
+
# repo's first commit. Block scalar + heredoc keeps YAML out of it.
|
|
98
|
+
run: |
|
|
99
|
+
python - <<'EOF'
|
|
100
|
+
import pathlib, zipfile
|
|
101
|
+
wheel = next(pathlib.Path('dist').glob('*.whl'))
|
|
102
|
+
names = zipfile.ZipFile(wheel).namelist()
|
|
103
|
+
assert 'nori_sdk/py.typed' in names, f'py.typed missing from {wheel.name}'
|
|
104
|
+
print(f'{wheel.name} ships py.typed and {len(names)} entries')
|
|
105
|
+
EOF
|
|
106
|
+
- uses: actions/upload-artifact@v4
|
|
107
|
+
with:
|
|
108
|
+
name: dist
|
|
109
|
+
path: dist/
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI when a version tag is pushed, via Trusted Publishing (OIDC) — no stored
|
|
4
|
+
# API token anywhere. THE TAG IS THE PUBLISH BUTTON: push v1.0.0 and this ships it, so the
|
|
5
|
+
# tag comes after everything else (bench verification, README status flip), never before.
|
|
6
|
+
#
|
|
7
|
+
# One-time PyPI setup, doable BEFORE the project exists ("pending publisher"):
|
|
8
|
+
# pypi.org -> your account -> Publishing -> add a pending publisher:
|
|
9
|
+
# project "nori-sdk", owner "Nori-Robotics", repo "nori-sdk-py",
|
|
10
|
+
# workflow "release.yml", environment "pypi"
|
|
11
|
+
# Then in this repo's Settings -> Environments, create "pypi" (add required reviewers
|
|
12
|
+
# there if you want a human approval gate between the tag and the upload).
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
tags: ["v*"]
|
|
16
|
+
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
|
|
20
|
+
jobs:
|
|
21
|
+
build:
|
|
22
|
+
runs-on: ubuntu-latest
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
- uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.12"
|
|
28
|
+
- run: pip install build twine
|
|
29
|
+
- run: python -m build
|
|
30
|
+
- run: twine check dist/*
|
|
31
|
+
- name: Assert the tag matches the package version
|
|
32
|
+
# A v1.0.1 tag on a tree still declaring 1.0.0 would publish a version that
|
|
33
|
+
# contradicts its own metadata; PyPI would then refuse the NEXT honest upload.
|
|
34
|
+
run: |
|
|
35
|
+
v=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
|
|
36
|
+
test "v$v" = "${GITHUB_REF_NAME}" || { echo "::error::tag ${GITHUB_REF_NAME} != pyproject version $v"; exit 1; }
|
|
37
|
+
- uses: actions/upload-artifact@v4
|
|
38
|
+
with:
|
|
39
|
+
name: dist
|
|
40
|
+
path: dist/
|
|
41
|
+
|
|
42
|
+
publish:
|
|
43
|
+
needs: build
|
|
44
|
+
runs-on: ubuntu-latest
|
|
45
|
+
# The OIDC claim PyPI verifies names this environment; it also gives you a place to
|
|
46
|
+
# require manual approval before anything irreversible happens.
|
|
47
|
+
environment: pypi
|
|
48
|
+
permissions:
|
|
49
|
+
id-token: write
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/download-artifact@v4
|
|
52
|
+
with:
|
|
53
|
+
name: dist
|
|
54
|
+
path: dist/
|
|
55
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
.DS_Store
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.pyc
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.pytest_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.mypy_cache/
|
|
11
|
+
# Where CI checks out Nori-Protocol and where tests/_spec.py looks for it first. Ignored
|
|
12
|
+
# while it is a plain checkout; delete this line if it becomes a real submodule.
|
|
13
|
+
spec/
|
|
14
|
+
# tools/mutate.py concurrency guard (see its header)
|
|
15
|
+
.mutate.lock
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Newest first. This package targets **nori-protocol v1** — see the Status section of
|
|
4
|
+
`README.md` for what is and isn't hardware-verified.
|
|
5
|
+
|
|
6
|
+
## 1.0.0 — 2026-08-25
|
|
7
|
+
|
|
8
|
+
First public release. Everything below lands together; the headline items:
|
|
9
|
+
|
|
10
|
+
### Removed: the inert `RemoteTeleop(protocol_version=...)` kwarg
|
|
11
|
+
|
|
12
|
+
It was stored and used only to format the mismatch log line — it never changed what the SDK
|
|
13
|
+
emits or how mismatch is detected (`RobotInfo.from_wire` compares the robot's ack against
|
|
14
|
+
this build's `NORI_PROTOCOL_VERSION`, which is what an SDK build actually speaks). The
|
|
15
|
+
TypeScript SDK never had the option, for the same reason. Removed *before* the 1.0 API
|
|
16
|
+
freeze; if a real override need appears, a working kwarg can return compatibly in a minor.
|
|
17
|
+
|
|
18
|
+
### `estop()` now raises on a dead channel — a deliberate contract change
|
|
19
|
+
|
|
20
|
+
Previously `estop()` returned `None` whether or not the frame flew, like every other verb.
|
|
21
|
+
That is right for ordinary verbs (the watchdog makes a dropped frame meaningless) and wrong
|
|
22
|
+
for an E-stop — a caller should never mistake a silently dropped stop for a delivered one.
|
|
23
|
+
`estop()` now raises `TeleopError` in every mode — not just strict — when the channel is not open,
|
|
24
|
+
so the caller knows to reach for the physical button. **Migration:** a bare
|
|
25
|
+
`finally: robot.estop()` cleanup should become `try: robot.estop() except TeleopError: ...`
|
|
26
|
+
(or log-and-continue), or the raise will mask the original exception.
|
|
27
|
+
|
|
28
|
+
### `estop_confirmed()` — delivery is not execution
|
|
29
|
+
|
|
30
|
+
New awaitable: sends the estop, then awaits the robot *reporting* the latch in telemetry.
|
|
31
|
+
Only a report observed after the send counts — the cached merged frame is deliberately not
|
|
32
|
+
consulted, since the safety block is carried forward and a stale "latched" would confirm an
|
|
33
|
+
estop that went nowhere. Raises when no latch is seen; the only safe reading is "not stopped".
|
|
34
|
+
|
|
35
|
+
### Base sign convention pinned against the spec fixture
|
|
36
|
+
|
|
37
|
+
The SDK has always emitted raw REP-103 (+angular = LEFT, no client negation). The gateway
|
|
38
|
+
formerly un-negated angular to compensate for a TypeScript-era L2 quirk, inverting this SDK's
|
|
39
|
+
turns on A3/L3; that gateway change ships coordinated with this release, and a conformance
|
|
40
|
+
test now pins this SDK's emission byte-for-byte against `control_jog_base.json` so the
|
|
41
|
+
convention can never drift silently again.
|
|
42
|
+
|
|
43
|
+
### Link-mode LAN detection actually works now (2026-08-26 bench findings)
|
|
44
|
+
|
|
45
|
+
aiortc implements no candidate-pair stats, so the getStats-based LAN detection matched
|
|
46
|
+
nothing and reported "wan" unconditionally — found while bench-testing this release. Detection now reads
|
|
47
|
+
aioice's nominated pairs directly (private-attribute walk that degrades to "wan" if an
|
|
48
|
+
aiortc upgrade changes it), and deliberately refuses to call a VPN/overlay path "lan"
|
|
49
|
+
even when its candidates are ICE-type host: a tunnel's 1280-byte MTU with the tight
|
|
50
|
+
watchdog profile is the worst pairing (the same bench found Tailscale-carried sessions
|
|
51
|
+
silently dropping every fragmented frame — if you run a VPN on the operator machine,
|
|
52
|
+
prefer disabling it while driving). `estop_confirmed()`'s default timeout also widened
|
|
53
|
+
2 s → 5 s: the latch report crosses gateway → safety node → telemetry, and 2 s proved
|
|
54
|
+
tight on a busy stack.
|
|
55
|
+
|
|
56
|
+
### Session robustness for unattended runs
|
|
57
|
+
|
|
58
|
+
- A mid-session `robot_here` (the gateway rebroadcasts it on every signaling rejoin) no
|
|
59
|
+
longer marks a healthy session disconnected forever; `ready` is still sent, which is what
|
|
60
|
+
a genuinely restarted gateway needs to re-offer.
|
|
61
|
+
- The link-mode handshake actually fires: the robot-opened channel arrives already open, so
|
|
62
|
+
the open logic now runs immediately instead of waiting for an `open` event that fired
|
|
63
|
+
before we could subscribe. LAN sessions get LAN watchdog windows.
|
|
64
|
+
- `frames()` / `snapshot()` / `snapshot_png()` raise a named error (`track_timeout`,
|
|
65
|
+
default `ROBOT_WAIT_S`) instead of polling forever when no video track arrives; `stream()`
|
|
66
|
+
consumers are woken when the session stops instead of parking on an idle queue.
|
|
67
|
+
|
|
68
|
+
### The mock now refuses what the gateway refuses
|
|
69
|
+
|
|
70
|
+
Unknown action keys answer `blocked/"unknown_joint:<keys>"` (gateway-verbatim, incl.
|
|
71
|
+
`empty_action`); unknown jog vocabulary is dropped in the same silence; a latched robot
|
|
72
|
+
refuses a pose with `blocked/"estop_latched"` instead of silence, and action refusals say
|
|
73
|
+
`"estop_latched"` (previously `"latched"`, a string no gateway emits); a pose for an arm the
|
|
74
|
+
robot lacks refuses `blocked/"empty_pose"`; default capabilities now match a healthy A3
|
|
75
|
+
gateway (`task_jog`, `pose_targets`, `record`), so `pose()` works against a plain
|
|
76
|
+
`mock_session()` exactly as it does on hardware.
|
|
77
|
+
|
|
78
|
+
### Protocol: `control.pose` and `action_id` are finalized
|
|
79
|
+
|
|
80
|
+
Both graduated from PROPOSED in nori-protocol v1 alongside this release (older changelog
|
|
81
|
+
entries below describe them as proposed — that was true at their date).
|
|
82
|
+
|
|
83
|
+
### The doc examples now work
|
|
84
|
+
|
|
85
|
+
The module headline example commanded `{"base": {"x": ...}}` — the telemetry-namespace
|
|
86
|
+
spelling a robot reads as an explicit stop — and the `JogBuilder` docstring showed
|
|
87
|
+
`.base(x=1.0)`, which raises. Both now use `linear`, as do the tests that pinned the old
|
|
88
|
+
spelling.
|
|
89
|
+
|
|
90
|
+
## Pre-release — 2026-08-23
|
|
91
|
+
|
|
92
|
+
### `pose(wait=True)` no longer starves the watchdog; `goto_pose` is now an alias
|
|
93
|
+
|
|
94
|
+
A pose frame latches a target the arm takes SECONDS to reach, and `pose(wait=True)` sent that
|
|
95
|
+
one frame and then awaited in silence — control silence the gateway dead-man reads as "the
|
|
96
|
+
operator is gone" (warn scales motion to ZERO at 300 ms WAN, stop drops the latched target at
|
|
97
|
+
1 s). Every awaited move slower than t_stop died mid-flight as a phantom timeout — the same
|
|
98
|
+
hardware-found failure `action(wait=True)` had (2026-08-22). `pose(wait=True)` now streams the
|
|
99
|
+
empty-jog keep-alive (commands nothing, cancels nothing) until the terminal status, and gains
|
|
100
|
+
the same strict-mode liveness guard as every other motion verb.
|
|
101
|
+
|
|
102
|
+
`goto_pose` — the parallel-built name for the same verb — is now a thin alias with its
|
|
103
|
+
awaited-move defaults (`wait=True`, 15 s patience): one implementation, one feeder, one
|
|
104
|
+
capability gate, no drift. New code calls `pose()`.
|
|
105
|
+
|
|
106
|
+
### Cartesian pose targets — `RemoteTeleop.pose()` (spec: `control.pose`, PROPOSED)
|
|
107
|
+
|
|
108
|
+
`pose(side, position_m, orientation_xyzw=None, wait=False)` commands an absolute gripper-TCP
|
|
109
|
+
pose in `base_footprint` (metres, REP-103; optional ROS-order quaternion — omit it for "any
|
|
110
|
+
wrist angle"). The robot solves IK on-board and tracks through the same latch `action` uses,
|
|
111
|
+
answering on the shared `action_status` lifecycle — including the intermediate `active`
|
|
112
|
+
(solved, tracking) and a modelled failure vocabulary (`no_ik_solution`, `ik_timeout`,
|
|
113
|
+
`limit:<joint>`, `singularity`, `collision`, `lift_moved`, `frame:<name>`).
|
|
114
|
+
|
|
115
|
+
Gated on the `pose_targets` capability: explicitly-unsupported robots raise `TeleopError`
|
|
116
|
+
instead of a silent 10 s no-op (the payload would be ignored on the wire); a legacy ack with
|
|
117
|
+
no capabilities field is allowed through, per the probe-or-assume-legacy contract. New
|
|
118
|
+
builder `protocol.control_pose()` + `protocol.POSE_FRAME`; additive, no version bump.
|
|
119
|
+
|
|
120
|
+
## Pre-release — 2026-08-23
|
|
121
|
+
|
|
122
|
+
### The unwired verbs are wired
|
|
123
|
+
|
|
124
|
+
Four frame builders passed conformance for weeks with **zero session call sites**, and two
|
|
125
|
+
decoded frame types were parsed and then discarded. A builder with no call site is not a
|
|
126
|
+
feature — it is a promise the API does not keep, and conformance cannot tell the difference.
|
|
127
|
+
|
|
128
|
+
- **`policy_stream(action, **extra)`** — the headline Python use case could not start a
|
|
129
|
+
stream. Returns the status rather than raising on `ok: false`: unlike `record()`, a refusal
|
|
130
|
+
here is ordinary state (a stopped stream answers `ok:false` to `"status"` routinely), so
|
|
131
|
+
raising would make normal polling throw.
|
|
132
|
+
- **`policy_stream_status`** — the cached last reply, with the liveness rule documented: there
|
|
133
|
+
is NO unsolicited death notification, so a stream that dies mid-run is visible only by
|
|
134
|
+
polling. `MockRobot.die_mid_stream()` rehearses exactly that.
|
|
135
|
+
- **`perceive()` + `perception_age`** — perception decoded to a dataclass nothing surfaced.
|
|
136
|
+
Age is measured on OUR monotonic clock, not the frame's `ts_ns`, which is the robot's clock
|
|
137
|
+
and would fold in skew.
|
|
138
|
+
- **`action_status(id)` / `next_action_id()`** — the fire-and-forget-then-poll shape. The
|
|
139
|
+
verdict map is a bounded LRU (`ACTION_HISTORY = 256`): a policy issuing thousands of actions
|
|
140
|
+
must not grow it forever, and only the latest verdict per id is useful.
|
|
141
|
+
- **`record_state`**, **`set_leader_action()`**, **`set_video_quality()`**, **`call()`**.
|
|
142
|
+
- **A design error caught by its own test.** I first gated `policy_stream` on
|
|
143
|
+
`_require_live`, which checks `daemon_status.online` — i.e. MOTION health. The streamer is
|
|
144
|
+
served by the bridge in FRONT of the motion daemon and runs fine on a robot whose arms are
|
|
145
|
+
disabled, so strict mode would have refused a valid operation. Split out
|
|
146
|
+
`_require_connected` for the bridge-side verbs; motion verbs still get the full gate.
|
|
147
|
+
- The mock now answers `policy_stream` and can emit `perception`. A verb the double cannot
|
|
148
|
+
answer is a verb nobody can develop against.
|
|
149
|
+
|
|
150
|
+
### Real angles from a normalized wire — `descriptor.ranges_si`
|
|
151
|
+
|
|
152
|
+
`ranges` is in `norm_mode` units, and the normalized-to-physical mapping is the robot's own
|
|
153
|
+
**per-unit calibration** — not a nominal figure from the URDF. So a client wanting real angles
|
|
154
|
+
(to pose a URDF, run FK, feed a simulator) had to substitute the URDF's nominal joint limits,
|
|
155
|
+
wrong by that unit's calibration offset and wrong **silently**.
|
|
156
|
+
|
|
157
|
+
- **Protocol**: `descriptor.ranges_si`, optional and additive — radians for revolute, metres
|
|
158
|
+
for prismatic. Two fixtures (a full A3 with per-side calibration skew, and an inverted span).
|
|
159
|
+
50 fixtures / 18 schemas / 0 failures.
|
|
160
|
+
- **No gripper special case.** `ranges` already encodes the convention difference (body joint
|
|
161
|
+
`[-100,100]`, gripper `[0,100]`), so one linear map between the two entries covers both. A
|
|
162
|
+
hand-written gripper branch is the thing that rots when a robot changes convention.
|
|
163
|
+
- **Only normalized keys appear.** The A-series lift is deliberately absent: `ranges["lift.pos"]`
|
|
164
|
+
is already millimetres, so an SI entry would mean converting twice.
|
|
165
|
+
- **Inverted bounds are honoured, not sorted.** A calibration can reverse an axis and the
|
|
166
|
+
ORDER carries that; sorting it ascending would flip the joint.
|
|
167
|
+
- **SDK**: `motion.to_si()`, `from_si()`, `state_to_si()`. All return `None`/omit rather than
|
|
168
|
+
guessing — the frozen L-series never publishes this, so absence is the common case.
|
|
169
|
+
`state_to_si()` OMITS what it cannot convert rather than passing it through: a dict silently
|
|
170
|
+
mixing radians and normalized units is worse than a smaller one, because nothing downstream
|
|
171
|
+
can tell which key is in which unit.
|
|
172
|
+
|
|
173
|
+
## Pre-release — 2026-08-20
|
|
174
|
+
|
|
175
|
+
### Calibrated jog rates — `descriptor.jog_scale`
|
|
176
|
+
|
|
177
|
+
A jog is normalized `[-1,1]` and the robot owns what full deflection means, which is what
|
|
178
|
+
keeps one client working across models. The cost was that a script could not ask for a
|
|
179
|
+
REPEATABLE speed or discover what it just asked for. This closes that without adding a
|
|
180
|
+
velocity command — the robot still owns the envelope.
|
|
181
|
+
|
|
182
|
+
- **Protocol** (`Nori-Protocol`): `descriptor.jog_scale`, optional and additive, so no version
|
|
183
|
+
bump and no client breaks. Namespaced `joints` / `task` / `base` / `lift`, because a flat map
|
|
184
|
+
cannot express that `x` and `pitch` are task-space VERBS rather than joints — `shoulder_pan`
|
|
185
|
+
is not even a joint name on every model. Two fixtures (full A3, arms-only partial); 44
|
|
186
|
+
fixtures / 17 schemas / 0 failures.
|
|
187
|
+
- **Units are per namespace, each matching the thing it addresses**, so a client can verify
|
|
188
|
+
what it got: joints in norm_mode units/s (matching `telemetry.state` and `ranges`), lift in
|
|
189
|
+
mm/s (matching `<side>_lift.pos`), task and base in SI. **Joints are deliberately NOT
|
|
190
|
+
rad/s** — telemetry reports normalized positions, so a rad/s figure could not be checked
|
|
191
|
+
against anything a client can see, and verifiability was the entire point.
|
|
192
|
+
- **It promises the NOMINAL COMMANDED scale, not achieved velocity**, and says so in the
|
|
193
|
+
schema. Three things routinely make the real rate lower: the watchdog's `warn` state scales
|
|
194
|
+
all motion to ZERO, an acceleration limit means short jogs never reach the rate, and MoveIt
|
|
195
|
+
Servo scales near singularities.
|
|
196
|
+
- **Omission means UNKNOWN at every level** — missing block, missing namespace, missing key. A
|
|
197
|
+
rate of `0` is schema-invalid rather than meaning "cannot move"; that is expressed by leaving
|
|
198
|
+
the key out. The parser drops a non-positive rate rather than believing it, since a zero that
|
|
199
|
+
survived would silently scale every command to nothing.
|
|
200
|
+
- **SDK**: `JogScale` on `RobotDescriptor`, plus `motion.jog_rate()` and
|
|
201
|
+
`motion.normalized_for()`. Both return `None` rather than guessing — the L2 fleet is frozen
|
|
202
|
+
and will never publish this, so `None` is the common answer and callers must handle it.
|
|
203
|
+
- **`tools/measure_jog_scale.py`** — the part that makes the numbers true. Publishing the
|
|
204
|
+
gateway's constants would be moving a number onto the wire and calling it verified; if the
|
|
205
|
+
accel limit, the target leash and Servo mean steady-state is 0.72 where the constant says
|
|
206
|
+
0.8, then 0.8 is a lie with a decimal point on it. The tool fits steady-state rate per joint
|
|
207
|
+
per direction, discards the acceleration ramp, refuses runs that leave the middle of the
|
|
208
|
+
advertised range or that arrive while the watchdog is degraded, rejects non-linear runs by
|
|
209
|
+
R², takes a median across runs and **refuses to publish a joint whose spread exceeds 20%**.
|
|
210
|
+
Rehearsed against the mock, where it recovers the known `JOG_SCALE = 40.0` as 39.33.
|
|
211
|
+
|
|
212
|
+
### Public API surface audited and pinned
|
|
213
|
+
|
|
214
|
+
The surface an SDK promises drifts by accident — a helper loses its underscore, a name is
|
|
215
|
+
dropped from `__all__` while callers still import it, a new method ships undocumented. None of
|
|
216
|
+
that fails a normal suite, and all of it reaches users. It is now data, in
|
|
217
|
+
`tests/test_public_api.py`, with a snapshot of the top-level surface that has to be edited
|
|
218
|
+
deliberately.
|
|
219
|
+
|
|
220
|
+
- **`dir(nori_sdk)` omitted `RemoteTeleop`.** The lazy-import `__getattr__` resolved the three
|
|
221
|
+
optional-extra names on access but never listed them, so the class this package exists to
|
|
222
|
+
provide was missing from tab-completion, `help()` and IDE introspection until something
|
|
223
|
+
touched it first. Added `__dir__`.
|
|
224
|
+
- **Six public members of `RemoteTeleop` had no docstring**: `status`, `camera_layout`,
|
|
225
|
+
`is_connected`, `reset_latch`, `reset_arm`, `set_video_paused`. These are exactly the ones
|
|
226
|
+
carrying behaviour a signature cannot convey — that `is_connected` does not mean the robot
|
|
227
|
+
will move, that `reset_latch` is for a latch and not for `safe_hold`, that `camera_layout`
|
|
228
|
+
being `None` has two different meanings.
|
|
229
|
+
- **`set_jog()` was documented misleadingly**, reported from the first external review. The
|
|
230
|
+
README's "resend inside `t_warn_ms`" rule describes the WIRE, and `set_jog` does the
|
|
231
|
+
resending for you; read together they implied a caller should run its own timer, which would
|
|
232
|
+
race the SDK's. All three jog entry points now state who owns the repetition, in a table.
|
|
233
|
+
- **Five constants were reachable but undeclared** (`RETRY_S`, `ROBOT_WAIT_S`, `JOG_SCALE`,
|
|
234
|
+
`WATCHDOG_PROFILES`, `DEFAULT_LINK_MODE`). Reachable-but-undeclared is the worst of both:
|
|
235
|
+
people depend on it anyway and nothing stops it changing. Now in `__all__`.
|
|
236
|
+
- **`nori_sdk.types` is exported** alongside `motion` and `protocol`, which it should have
|
|
237
|
+
been all along.
|
|
238
|
+
- **Documented the forward-compatibility rule.** Three classes carry `raw` and the rest do
|
|
239
|
+
not, which is a rule rather than an oversight — and the gap it leaves is now stated: `on()`
|
|
240
|
+
and `stream()` hand you the PARSED object, so a field this SDK does not model is reachable
|
|
241
|
+
only via `protocol.decode()`.
|
|
242
|
+
- **`README.md` gained an API reference** — the actual question a new developer has after the
|
|
243
|
+
quickstart, which the layering table did not answer.
|
|
244
|
+
|
|
245
|
+
### The xfail list is now empty
|
|
246
|
+
|
|
247
|
+
All four remaining divergences fixed. Each is mutation-pinned; `tools/mutate.py` now runs 19.
|
|
248
|
+
|
|
249
|
+
- **`error` frames are modelled** (`RobotError`, and `"error"` added to `INBOUND_KINDS`). A
|
|
250
|
+
fatal robot error previously reached the caller only as an untyped dict. `fatal` defaults
|
|
251
|
+
FALSE per the schema — defaulting it true would tear down a live session over a soft stall.
|
|
252
|
+
Added `RECOVERY_ERROR_CODES` and `.recovered`, because three codes
|
|
253
|
+
(`obstruction_cleared`, `arm_recovered`, `motor_recovered`) report a fault *clearing*: a
|
|
254
|
+
client rendering every `error` as a fault shows a red banner for the good news.
|
|
255
|
+
- **A tile-less `camera_layout` is rejected.** It was accepted, and adopting one blanks the
|
|
256
|
+
grid for the rest of the session — the robot repeats the layout on open, so a single
|
|
257
|
+
malformed repeat could poison a good one. Note this is the opposite of an ABSENT layout,
|
|
258
|
+
which is how a single-camera robot says "the whole frame is the one camera".
|
|
259
|
+
- **`RecordVerb` carries all eleven spec verbs**, including the legacy aliases deployed
|
|
260
|
+
clients still send. Grouped in the source by what they do to DATA, since the names do not
|
|
261
|
+
signal it: `session_discard` is canonical and destructive (not a synonym for `session_end`
|
|
262
|
+
— they are opposites), `stop` also ends the session on L2, and `discard` **destroys on L2
|
|
263
|
+
but keeps on A3**. Added `DESTRUCTIVE_RECORD_VERBS`, from which `discard` is deliberately
|
|
264
|
+
absent: no static set can classify a verb whose meaning inverts per stack.
|
|
265
|
+
- **`RobotInfo` exposes `model` and `capabilities`**, plus `supports()`. `capabilities` is
|
|
266
|
+
three-valued — `None` means the robot did not say, which is NOT "supports nothing".
|
|
267
|
+
Collapsing absent into False would silently disable working features on every robot
|
|
268
|
+
predating the field. `model` is advisory only; branch on `descriptor`/`capabilities`.
|
|
269
|
+
|
|
270
|
+
### Mock and docs
|
|
271
|
+
|
|
272
|
+
- **The mock no longer derives its camera layout from `descriptor.cameras`.** The schema names
|
|
273
|
+
this as an antipattern in as many words: the layout frame is the only authoritative
|
|
274
|
+
description of the tiling, the descriptor is diagnostic metadata, and a mock that generates
|
|
275
|
+
one from the other can never reproduce a disagreement — hiding exactly the layout bugs it
|
|
276
|
+
exists to catch. `MockRobot(tiles=[...])` now rehearses that case.
|
|
277
|
+
- `DEFAULT_DESCRIPTOR` was commented "shaped like an L3". It is 5 DOF per arm, which is the
|
|
278
|
+
**L2** shape; A3 arms are 7 DOF. Corrected, with a note on why the smaller descriptor is
|
|
279
|
+
the right default.
|
|
280
|
+
- **L3 → A3 throughout.** L3 is retired; room names, the leader-arm note and the mock comment
|
|
281
|
+
referenced it. Historical statements about how the TS SDK's DOF vocabulary drifted now say
|
|
282
|
+
"the 7-DOF arm" rather than naming a dead model.
|
|
283
|
+
|
|
284
|
+
### The mock became usable for development, not just for tests
|
|
285
|
+
|
|
286
|
+
Driving `MockRobot` previously required private API (`teleop._control`, `teleop._handle_frame`).
|
|
287
|
+
That is fine inside this package and wrong to hand anybody else, since those names carry no
|
|
288
|
+
compatibility promise.
|
|
289
|
+
|
|
290
|
+
- **`nori_sdk.mock.mock_session()`** — an async context manager yielding a connected
|
|
291
|
+
`RemoteTeleop` backed by a `MockRobot`. No WebRTC, no network, no credentials; needs no
|
|
292
|
+
extras. A script written against it runs unchanged on hardware, with one line different.
|
|
293
|
+
Replies are delivered via `call_soon` rather than inline, so a client cannot accidentally
|
|
294
|
+
depend on synchronous reentrancy a real data channel would never give.
|
|
295
|
+
- **`MockRobot` enforces the watchdog.** Silence past `t_stop_ms` stops the motion and reports
|
|
296
|
+
`safe_hold`; `link("lan"|"wan")` selects the profile (150/500 vs 300/1000 ms), and the `ack`
|
|
297
|
+
advertises the profile it will actually enforce. This was the largest gap: the README calls
|
|
298
|
+
the watchdog "the one thing to internalize", the TypeScript mock has emulated it since
|
|
299
|
+
`sim.ts`, and this one hard-coded `"watchdog": "ok"`. A script that held a jog by sending one
|
|
300
|
+
frame and sleeping therefore passed locally and would have stopped dead on a robot.
|
|
301
|
+
`safe_hold` self-clears on the next control frame — only an E-STOP needs `reset_latch()`.
|
|
302
|
+
- **`MockRobot.step(dt)` integrates a pose**, clamped to `descriptor.ranges`, so telemetry
|
|
303
|
+
responds to commands. The clock is accumulated `dt`, not wall time, so a test can advance
|
|
304
|
+
two seconds instantly. Absolute `action` targets land in the pose too. Not a simulator: no
|
|
305
|
+
dynamics, no collision, no IK, and task-space arm keys are not resolved into joints.
|
|
306
|
+
- Caught while writing it: the first draft **held the last base velocity** when a later jog
|
|
307
|
+
omitted `base`. `control.json` says an absent `base` means STOP, so the mock would have
|
|
308
|
+
taught a script the opposite of what the robot does. Pinned by a test.
|
|
309
|
+
- **`examples/mock_pick_place.py`** — a runnable first task (discover, check motion health,
|
|
310
|
+
jog, absolute move, record an episode, stream telemetry, E-STOP), executed by the test suite
|
|
311
|
+
as a subprocess so it cannot rot.
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
### Four xfails retired — every one of them a silent lie to the caller
|
|
315
|
+
|
|
316
|
+
These shared a shape, which is why they were taken as a batch: each made the SDK **report
|
|
317
|
+
success while doing something else**, with no error raised anywhere. Each fix is pinned by a
|
|
318
|
+
mutation that reverts it and fails exactly one named test; nine mutations were run in total.
|
|
319
|
+
|
|
320
|
+
- **`JogBuilder.base()` emitted the telemetry namespace.** It built `{"base": {"x": ...,
|
|
321
|
+
"theta": ...}}` from `descriptor.base`'s `x.vel`/`theta.vel`. The jog namespace is
|
|
322
|
+
`linear`/`angular`, and a robot reads `x`/`theta` there as `linear=0, angular=0` — **an
|
|
323
|
+
explicit stop**. Any script driving the base did nothing and reported nothing. The signature
|
|
324
|
+
is now `base(linear=..., angular=...)`, and the old spelling **raises** rather than being
|
|
325
|
+
aliased: a quiet translation would leave every caller believing the two namespaces are
|
|
326
|
+
interchangeable, and the next DOF added under one name only would fail the same way again.
|
|
327
|
+
`JogBuilder.stop()` now writes the base zeros explicitly. `base()` also validates against
|
|
328
|
+
`descriptor.base`, as `arm()` and `lift()` already did.
|
|
329
|
+
- **`action(wait=True)` returned before the move happened.** `ActionStatus.done` counted
|
|
330
|
+
`accepted` as terminal (and `failed`, which is not in the spec's enum at all, while missing
|
|
331
|
+
`clamped` and `timeout`). Worse, `_handle_frame` resolved the future on the *first*
|
|
332
|
+
`action_status` regardless — so a caller was told the action was complete while the watchdog
|
|
333
|
+
was still free to abort it. Terminal is now exactly `done | blocked | clamped | timeout`,
|
|
334
|
+
pinned against the schema's enum rather than a hand-typed list, and the future waits for
|
|
335
|
+
one. Added `.succeeded`, because `clamped` is a finished move to a *different* pose than
|
|
336
|
+
requested. An unknown state counts as non-terminal, so a newer robot's vocabulary falls
|
|
337
|
+
through to the caller's timeout instead of being reported as a completed move.
|
|
338
|
+
**`MockRobot` was reproducing this bug** — it emitted only `accepted` — so the double and
|
|
339
|
+
the client agreed and the suite stayed green. It now emits the full `accepted -> active ->
|
|
340
|
+
done` lifecycle, with `action_outcome` to rehearse `clamped`/`timeout`.
|
|
341
|
+
- **`PolicyStreamStatus` modelled invented fields.** `state`/`detail` do not exist on the
|
|
342
|
+
wire; the real fields are `streaming`/`dest`/`fps_out`/`frames_sent`/`dropped`/`error`. And
|
|
343
|
+
`ok` defaulted **true**, so a truncated or malformed reply read as a running stream. Both
|
|
344
|
+
`ok` and `streaming` now default false — deliberately inverted from this SDK's usual
|
|
345
|
+
tolerance. A test asserts every field in the schema is modelled, so one added later cannot
|
|
346
|
+
quietly live only in `raw`. The docstring now records that there is **no unsolicited death
|
|
347
|
+
notification**: a stream that dies mid-run is observable only by polling `status`.
|
|
348
|
+
- **A stateless `daemon_status` invented an outage.** A missing `state` was coerced to
|
|
349
|
+
"offline". The bridge rebroadcasts every few seconds while offline, so one malformed repeat
|
|
350
|
+
would flip a healthy robot to offline in every watching UI and log. `from_wire` now returns
|
|
351
|
+
`None` — **a signature change: callers must handle it** — and `RemoteTeleop` drops the frame
|
|
352
|
+
without emitting, rather than handing subscribers a raw dict where every other
|
|
353
|
+
`daemon_status` gives them a `DaemonStatus`. Also picked up `robot_local_mic_muted`, which
|
|
354
|
+
rides here rather than on telemetry precisely because telemetry stops when the daemon does.
|
|
355
|
+
|
|
356
|
+
### Packaging and CI (new)
|
|
357
|
+
|
|
358
|
+
- **`py.typed` added — and `mypy` was inert without it.** The `strict = true` config has been
|
|
359
|
+
in `pyproject.toml` since the first commit; mypy refuses to check an installed package that
|
|
360
|
+
does not advertise type information, so it exited 0 having examined nothing. With the marker
|
|
361
|
+
in place it found 15 errors, one of them real (a `Future[ActionStatus]` variable reused to
|
|
362
|
+
hold a `Future[RecordState]`). Third-party stub noise from aiortc/websocket-client is
|
|
363
|
+
suppressed by module override, not by relaxing strictness. A CI step asserts `py.typed` is
|
|
364
|
+
actually in the built wheel — a package that ships without it silently untypes every
|
|
365
|
+
consumer.
|
|
366
|
+
- **`.github/workflows/ci.yml`**: ruff, mypy and pytest on 3.11/3.12/3.13, plus a build job
|
|
367
|
+
running `twine check`. `NORI_PROTOCOL_DIR` is set explicitly so a broken spec checkout is a
|
|
368
|
+
hard failure (`tests/_spec.py` raises on a set-but-invalid path) rather than a green run
|
|
369
|
+
that skipped all of conformance, and a separate step asserts the conformance suite collected
|
|
370
|
+
something. A nightly cron catches spec changes landing in Nori-Protocol without a commit
|
|
371
|
+
here. **Requires a `NORI_PROTOCOL_TOKEN` secret** with read access to the private spec repo.
|
|
372
|
+
|
|
373
|
+
## Pre-release — 2026-08-13
|
|
374
|
+
|
|
375
|
+
### Conformance against the spec (new)
|
|
376
|
+
|
|
377
|
+
`tests/test_conformance.py` runs this SDK against the real `nori-protocol` schemas and golden
|
|
378
|
+
fixtures, in both directions: every frame a robot can send must decode, and **every frame this
|
|
379
|
+
SDK builds must validate against the schema**. The second direction is the one that earns its
|
|
380
|
+
keep — it is what catches "we invented a field name".
|
|
381
|
+
|
|
382
|
+
- The spec is resolved from `NORI_PROTOCOL_DIR`, then `spec/nori-protocol` (the intended
|
|
383
|
+
submodule path), then a sibling checkout. **An explicitly-set-but-invalid `NORI_PROTOCOL_DIR`
|
|
384
|
+
is now a hard error, not a skip.** It previously returned `None`, which skipped the entire
|
|
385
|
+
conformance suite and exited **0** — a CI job with a typo would have been green while
|
|
386
|
+
testing nothing, which is precisely the failure this spec exists to prevent.
|
|
387
|
+
- Known divergences are `xfail(strict=True)` with a reason naming the consequence, rather than
|
|
388
|
+
deleted or left red: the suite stays green, each gap is documented where it will be found,
|
|
389
|
+
and fixing one makes the test XPASS and *fail the build*, forcing the marker off. There are
|
|
390
|
+
8, each a real bug in this SDK.
|
|
391
|
+
- Added coverage for `control_reset` and `control_leader`, which had none. That gap was hiding
|
|
392
|
+
a real defect: `control_reset()` built a frame with no `seq`, which the schema then required.
|
|
393
|
+
(The schema has since been relaxed — the L2 daemon defaults `seq` to `-1` and both clients
|
|
394
|
+
omit it there — so this is now a pinned agreement rather than a divergence.)
|
|
395
|
+
|
|
396
|
+
### Auth — clock-skew and credential fixes
|
|
397
|
+
|
|
398
|
+
Ported from the robot's own device-auth implementation; the two are deliberate twins and
|
|
399
|
+
must not drift.
|
|
400
|
+
|
|
401
|
+
- The refresh deadline runs on `time.monotonic()`, so no NTP step can move it.
|
|
402
|
+
- The cache hold is bounded on both sides: a `_MIN_CACHE_S` **floor** (bounds the grant rate)
|
|
403
|
+
and a **cap** at the server's own `expires_in` (a duration, so a wrong clock cannot distort
|
|
404
|
+
it). **Order matters** — the floor is the outermost bound. Applied the other way round the
|
|
405
|
+
cap undid the floor, and a server answering `expires_in: 1` set the grant rate to 3600/hour.
|
|
406
|
+
- Both bounds carry the same refresh lead, so we re-grant *before* expiry rather than at the
|
|
407
|
+
instant of death.
|
|
408
|
+
- `expires_in <= 0` means the server declared the token dead: the cap collapses to zero and
|
|
409
|
+
only the floor remains, instead of falling back to the default lifetime.
|
|
410
|
+
- A refresh token could reach logs: `"grant returned no access_token: {data}"` embedded the
|
|
411
|
+
whole grant response. It now names response **keys** only.
|
|
412
|
+
- `AuthError` carries `.status`; the refresh token is discarded only on a 4xx, not on a
|
|
413
|
+
network blip that says nothing about its validity.
|
|
414
|
+
- `tests/test_auth.py` (new, 15 cases). Every fix is pinned by a mutation that fails exactly
|
|
415
|
+
one test — the first version of two of these tests passed with the guard deleted.
|
|
416
|
+
|
|
417
|
+
### Fixes
|
|
418
|
+
|
|
419
|
+
- `protocol.py`'s documented `Jog` example used `{"base": {"x": 1.0}}` — the telemetry
|
|
420
|
+
namespace, not the jog namespace. A robot parses that as `linear=0, angular=0`, an explicit
|
|
421
|
+
**stop**, with no error. The example now shows `linear`/`angular` and says why.
|
|
422
|
+
- `README.md`'s status claim ("every frame this SDK builds validates") was asserted rather
|
|
423
|
+
than measured, and was false while two builders had no test. It now states what the
|
|
424
|
+
conformance suite actually covers.
|
|
425
|
+
|
|
426
|
+
### Known gaps
|
|
427
|
+
|
|
428
|
+
The 8 `xfail`s are the live list: the base jog namespace in `JogBuilder.base()`, missing legacy
|
|
429
|
+
`record` verbs, `PolicyStreamStatus`'s invented field names and true-defaulting `ok`, inverted
|
|
430
|
+
`ActionStatus.done` terminality, unmodelled `error` frames, tile-less `camera_layout` being
|
|
431
|
+
accepted, stateless `daemon_status` inventing an outage, and `model`/`capabilities` not being
|
|
432
|
+
exposed on `RobotInfo`.
|
|
433
|
+
|
|
434
|
+
Not addressed and larger than a bug fix: the session layer's blocking-send-on-the-event-loop
|
|
435
|
+
problem, `close()`/`connect()` thread lifecycle, and the absence of any hardware validation.
|