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.
Files changed (44) hide show
  1. nori_sdk-1.0.0/.github/workflows/ci.yml +109 -0
  2. nori_sdk-1.0.0/.github/workflows/release.yml +55 -0
  3. nori_sdk-1.0.0/.gitignore +15 -0
  4. nori_sdk-1.0.0/CHANGELOG.md +435 -0
  5. nori_sdk-1.0.0/LICENSE +202 -0
  6. nori_sdk-1.0.0/PKG-INFO +403 -0
  7. nori_sdk-1.0.0/README.md +367 -0
  8. nori_sdk-1.0.0/examples/drive.py +72 -0
  9. nori_sdk-1.0.0/examples/mock_pick_place.py +92 -0
  10. nori_sdk-1.0.0/examples/pose_circle_demo.py +55 -0
  11. nori_sdk-1.0.0/examples/pose_test.py +560 -0
  12. nori_sdk-1.0.0/pyproject.toml +86 -0
  13. nori_sdk-1.0.0/src/nori_sdk/__init__.py +135 -0
  14. nori_sdk-1.0.0/src/nori_sdk/_png.py +60 -0
  15. nori_sdk-1.0.0/src/nori_sdk/auth.py +277 -0
  16. nori_sdk-1.0.0/src/nori_sdk/mock/__init__.py +17 -0
  17. nori_sdk-1.0.0/src/nori_sdk/mock/loopback.py +117 -0
  18. nori_sdk-1.0.0/src/nori_sdk/mock/robot.py +639 -0
  19. nori_sdk-1.0.0/src/nori_sdk/mock/session.py +115 -0
  20. nori_sdk-1.0.0/src/nori_sdk/motion.py +350 -0
  21. nori_sdk-1.0.0/src/nori_sdk/protocol.py +342 -0
  22. nori_sdk-1.0.0/src/nori_sdk/py.typed +0 -0
  23. nori_sdk-1.0.0/src/nori_sdk/signaling.py +162 -0
  24. nori_sdk-1.0.0/src/nori_sdk/signaling_supabase.py +433 -0
  25. nori_sdk-1.0.0/src/nori_sdk/teleop.py +1301 -0
  26. nori_sdk-1.0.0/src/nori_sdk/types.py +651 -0
  27. nori_sdk-1.0.0/src/nori_sdk/version.py +16 -0
  28. nori_sdk-1.0.0/src/nori_sdk/webrtc_compat.py +142 -0
  29. nori_sdk-1.0.0/tests/_spec.py +84 -0
  30. nori_sdk-1.0.0/tests/test_auth.py +235 -0
  31. nori_sdk-1.0.0/tests/test_conformance.py +418 -0
  32. nori_sdk-1.0.0/tests/test_measure_jog_scale.py +100 -0
  33. nori_sdk-1.0.0/tests/test_mock_robot.py +348 -0
  34. nori_sdk-1.0.0/tests/test_mock_session.py +106 -0
  35. nori_sdk-1.0.0/tests/test_motion.py +267 -0
  36. nori_sdk-1.0.0/tests/test_policy_gaps.py +387 -0
  37. nori_sdk-1.0.0/tests/test_protocol.py +109 -0
  38. nori_sdk-1.0.0/tests/test_public_api.py +154 -0
  39. nori_sdk-1.0.0/tests/test_session.py +408 -0
  40. nori_sdk-1.0.0/tests/test_types.py +84 -0
  41. nori_sdk-1.0.0/tests/test_webrtc_compat.py +125 -0
  42. nori_sdk-1.0.0/tests/test_wired_verbs.py +218 -0
  43. nori_sdk-1.0.0/tools/measure_jog_scale.py +201 -0
  44. 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.