dsh-nonead-universal-robots 0.0.0-stage → 0.8.1

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 (51) hide show
  1. package/CHANGELOG.md +1601 -0
  2. package/CHANGELOG.zh.md +1446 -0
  3. package/LICENSE +101 -0
  4. package/README.md +346 -3
  5. package/README.zh.md +343 -0
  6. package/THIRD_PARTY_NOTICES.md +185 -0
  7. package/assets/kinematics.json +1088 -0
  8. package/assets/models/ur10.glb +0 -0
  9. package/assets/models/ur10e.glb +0 -0
  10. package/assets/models/ur12e.glb +0 -0
  11. package/assets/models/ur15.glb +0 -0
  12. package/assets/models/ur16e.glb +0 -0
  13. package/assets/models/ur18.glb +0 -0
  14. package/assets/models/ur20.glb +0 -0
  15. package/assets/models/ur3.glb +0 -0
  16. package/assets/models/ur30.glb +0 -0
  17. package/assets/models/ur3e.glb +0 -0
  18. package/assets/models/ur5.glb +0 -0
  19. package/assets/models/ur5e.glb +0 -0
  20. package/assets/models/ur7e.glb +0 -0
  21. package/assets/models/ur8long.glb +0 -0
  22. package/cordis.patch.yml +16 -0
  23. package/lib/audit-log.js +126 -0
  24. package/lib/client.js +3959 -0
  25. package/lib/index.js +2209 -0
  26. package/lib/motion-budget.js +72 -0
  27. package/lib/pending-motion.js +225 -0
  28. package/lib/pose-store.js +218 -0
  29. package/lib/program-files.js +194 -0
  30. package/lib/push-channel.js +69 -0
  31. package/lib/safety-gate.js +411 -0
  32. package/lib/twin-paths.js +31 -0
  33. package/lib/twin-routes.js +952 -0
  34. package/lib/twin-worker.js +57 -0
  35. package/lib/worker.js +767 -0
  36. package/package.json +114 -6
  37. package/python/URBasic/__init__.py +16 -0
  38. package/python/URBasic/connectionState.py +10 -0
  39. package/python/URBasic/dashboard.py +621 -0
  40. package/python/URBasic/manipulation.py +1212 -0
  41. package/python/URBasic/realTimeClient.py +378 -0
  42. package/python/URBasic/robotConnector.py +40 -0
  43. package/python/URBasic/robotModel.py +430 -0
  44. package/python/URBasic/rtde.py +916 -0
  45. package/python/URBasic/rtdeConfiguration.xml +143 -0
  46. package/python/URBasic/rtdeConfigurationDefault.xml +175 -0
  47. package/python/URBasic/urScript.py +2153 -0
  48. package/python/URBasic/urScriptExt.py +654 -0
  49. package/python/__init__.py +0 -0
  50. package/python/ur_worker.py +5341 -0
  51. package/requirements.txt +4 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,1601 @@
1
+ # Changelog
2
+
3
+ This project adheres to [Keep a Changelog](https://keepachangelog.com/) and [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [0.8.1] - 2026-10-29
6
+
7
+ ### Docs — installation rewritten for an npm-published package
8
+
9
+ - Both READMEs gained **"Option 0 — from npm"**
10
+ (`dsh.cmd plugin add dsh-nonead-universal-robots`, or `"dsh-nonead-universal-robots": "^0.8.0"`
11
+ in `dependencies`), and the stale "not published to npm (yet)" note is gone.
12
+ - In the hand-written bundle route the **primary** dependency is now the registry range `^0.8.0`;
13
+ `file:` and `github:` are documented as offline / air-gapped fallbacks.
14
+ - New **"Languages (UI copy)"** section: the eight languages shipped since 0.8.0, the fact that they
15
+ follow the DSH Desktop app language (`locale.preference`) and switch without a reload, and the
16
+ fallback order when no host locale service is present.
17
+ - **Documentation only** — `lib/`, `python/` and `src/` are untouched; `lib/client.js` was rebuilt so
18
+ it still matches `src/` byte for byte.
19
+
20
+ ## [0.8.0] - 2026-10-29
21
+
22
+ ### Added — client copy in 8 languages, following the desktop app language
23
+
24
+ - **Languages**: Simplified Chinese, English, Japanese, Spanish, French, German, Russian and
25
+ Brazilian Portuguese (`zh` / `en` / `ja` / `es` / `fr` / `de` / `ru` / `pt`). All eight
26
+ dictionaries have **exactly the same key set**, including the `safetyMode` / `robotMode` /
27
+ `programState` enum tables.
28
+ - **Follows the desktop app language**: the client now uses the host's own
29
+ `@deepseek-ai/dsh-client-locale` (declared in `dsh.client.inject`): the eight dictionaries are
30
+ registered with `ctx.locale.register('ur-twin', …)` and the host's `locale.preference` is read,
31
+ so switching the language in DSH Settings updates the panel and the dock entry **without a page
32
+ reload**. Without a locale service it falls back to `navigator.languages` → `navigator.language`
33
+ → Chinese.
34
+ - **Region tags are normalised**: `pt-BR` / `pt-PT` → `pt`, `es-419` → `es`, `ja-JP` → `ja`,
35
+ traditional `zh-TW` / `zh-Hant` → `zh`; an unknown language falls back to **Chinese, never to
36
+ English**.
37
+ - **Fixed a real defect**: the panel used to do `const S = activeStrings()` at module evaluation,
38
+ which **froze the language at load time** — the host supports switching without a reload, but the
39
+ UI could not follow. It now uses two proxies (`liveStrings()` / `liveNested()`) so every read
40
+ resolves against the current language, including module-level maps such as `SAFETY_TEXT`.
41
+ - **Tests**: `test/strings.test.mjs` grew to 12 cases — key/type parity across all eight locales,
42
+ non-empty enums, no Chinese leaking into non-CJK locales, host language taking precedence over the
43
+ browser, live switching, registration and disposal, and registration failures not throwing.
44
+
45
+ ## [0.7.6] - 2026-10-28
46
+
47
+ > **`npm publish` failed with `npm error code 9009` before it even reached npm.** That code is
48
+ > Windows' "command not found", and it came from `python scripts/check-worker-ops.py`: the release
49
+ > gate assumed a bare `python` on `PATH`. On this machine `python` is the Microsoft Store alias
50
+ > (which cannot execute at all) and `py` pointed at a `C:\Python314` that no longer exists — so a
51
+ > clean repository could not publish, and the message said nothing about the interpreter.
52
+
53
+ ### Fixed — release gates no longer depend on the bare name `python`
54
+
55
+ - **New `scripts/python-bin.mjs`**: resolves an interpreter by *proving it starts*
56
+ (`-c "import sys; print(sys.executable)"`), in this order — `UR_PYTHON`, `PYTHON`, the project
57
+ `.venv`/`venv`, `python3`, `python`, `py -3`, every install listed by `py -0p` (skipping paths that
58
+ no longer exist), then the usual install directories. Failures are reported per candidate, and
59
+ "could not start" (`EPERM`, sandbox pipe denial) is separated from "started but exited non-zero"
60
+ (`9009`, `101`) instead of collapsing to `exit null`.
61
+ - **New `scripts/run-py.mjs`**: runs a Python script through the resolved interpreter (`node
62
+ scripts/run-py.mjs scripts/check-ops.py`), and `node scripts/run-py.mjs --which` (also `npm run
63
+ python:where`) prints what was chosen. It pins `PYTHONUTF8` / `PYTHONIOENCODING` for the child, so
64
+ the gates' Chinese output is no longer mojibake.
65
+ - **`check:ops`, `test:python` and `verify:models`** now go through `run-py`; `scripts/run-tests.mjs`
66
+ and `scripts/check-host-compat.mjs` use the same resolver, and `run-tests` exports the resolved
67
+ `sys.executable` to `PYTHON` / `UR_PYTHON` so the 11 Python-dependent test files stop guessing the
68
+ name themselves.
69
+ - **`scripts/check-package-metadata.mjs`** skipped no local build directories: `.venv` (gitignored)
70
+ ships `numpy/__config__.py`, which records absolute paths, so the "hardcoded author path" gate
71
+ failed on a clean tree. `.venv`, `venv`, `.pytest_cache`, `Release`, `dist` and `build` are skipped,
72
+ and a self-check now fails if any of the essential entries is removed from the list.
73
+
74
+ ### Fixed — the host can be named by environment variable during publishing
75
+
76
+ - **`DSH_HOST_ASAR`** now works like the existing `--host-asar` flag. `prepublishOnly` cannot pass
77
+ command-line arguments, and `check:host:strict` failed on machines where DSH Desktop is installed
78
+ outside `Program Files` (here: `D:\Software\Nonead DSH Desktop`, asar layout, no
79
+ `resources\app\node_modules` on disk). Auto-discovery also covers `%LOCALAPPDATA%\Programs`.
80
+ - **Peer ranges** gained the host families that are actually installed today:
81
+ `^0.2.2-alpha.2` (`@deepseek-ai/dsh`, `@deepseek-ai/dsh-tools`), `^4.0.6-alpha.1`
82
+ (`@deepseek-ai/cordis`), `^3.18.6-alpha.1` (`@deepseek-ai/schemastery`). Pre-release ranges only
83
+ match their own version triplet, so a host upgrade requires an explicit entry.
84
+
85
+ ### Verified
86
+
87
+ - `npm run check` → 全部通过; `npm run check:host:strict` (with `DSH_HOST_ASAR`) → 全部通过,
88
+ peers 4/4, 104/104 tool schemas registered against the real host DSL.
89
+ - `npm test` → **56/56 passed, 0 failed, 0 skipped** (all 11 Python-dependent cases really executed).
90
+
91
+ ## [0.7.5] - 2026-10
92
+
93
+ ### Fixed
94
+ - **`ur_run_program` could run a program other than the one that was approved**: `play` now requires a confirmed load — the load command was sent, the loaded program is non-empty, its basename matches the requested target, and the reply is not a failure. `ur_load_program` reports `confirmed: true` only with positive evidence.
95
+ - **Motion calls no longer kill the worker on timeout**: `MOTION_OPS` is now separate from `APPROVAL_OPS`; `ur_wait_steady` gets a `timeout_s`-based budget and `killOnTimeout: false`, and while a motion is in flight (or its state is unknown) no timeout/abort kills the Python worker, so the RTDE/Dashboard session, the digital twin and the ability to stop the arm survive.
96
+ - **Pre-flight safety gate coverage**: `draw_*` radius/edge/center/origin, acceleration (`safetyMaxAccel`), force-mode `limits` / `task_frame` / `wrench` / `selection_vector`, and `speedj`/`speedl` shapes are checked; a parameter that is present but invalid is rejected instead of being treated as absent.
97
+ - **Approval coverage**: `set_tcp`, digital/analog outputs, tool voltage/output-mode/communication and conveyor tick are now gated (53 ops); `stopj` / `stopl` / `end_force_mode` are no longer gated — a stopping action must not require human approval.
98
+ - **`ur_delete_program`** validates the remote path on both the host and the worker side; **`programDir`** now injects the realpath'd absolute path instead of letting Python resolve it against its own cwd; downloads are size-capped.
99
+ - **Approval / audit**: `ur_send_script` shows the full script with its sha256 (64 KiB cap); SSH passwords are redacted in the approval text and the audit ledger; gated ops are still audited when approval is disabled.
100
+ - **Numeric hardening**: axis-move `distance` rejects NaN/Infinity; `set_tcp` is bounded.
101
+ - **Digital twin**: a shared per-robot frame pump (no N-times worker amplification), at most 4 concurrent streams (429 beyond that), write backpressure, idle timeout, and pending-motion preview scoped per robot.
102
+ - **Engineering**: CI workflow; `--host-asar` / `--strict` for the real-host compatibility gate; peer ranges accept the current host; `THIRD_PARTY_NOTICES.md` is shipped; README counts corrected.
103
+
104
+ ### Changed
105
+ - Approval gate: 49 -> 53 ops; README tables and `check-approval-gate.py` updated.
106
+ - Cancellation/timeout now states that the robot may still be moving and that `ur_stopj` / `ur_stopl` should be used to stop it; the plugin no longer tears down the worker session for that.
107
+
108
+ ## [0.7.4] - 2026-10
109
+
110
+ ### Changed
111
+ - **Version unified to 0.7.4**: the repository now reports a single version across `package.json` and both changelogs. No functional changes in this release; it aligns the repository version with the 0.7.x plugin line already published.
112
+
113
+ ## [0.7.3] - 2026-10-15
114
+
115
+ > **The local program-file cage could be walked out of on Windows, and the check meant to stop that
116
+ > was the one thing that could not see the escape.** A `realpath` that does not resolve junctions
117
+ > reported the junction path verbatim, so "the parent directory must still be inside `programDir`
118
+ > after `realpath`" held by construction.
119
+
120
+ ### Fixed — the cage missed Windows junctions, and never resolved two of the three paths
121
+
122
+ `programDir` is what stops `ur_upload_program` from reading an arbitrary local file and
123
+ `ur_download_program` from writing one, so every escape has to be refused. Measured on Windows: a
124
+ junction inside `programDir` (creatable without privilege — a file symlink is not) is **not** reported
125
+ as a symbolic link by `lstat` (`isSymbolicLink()` is `false`), and `readlink` answers `EINVAL`. The JS
126
+ `fs.realpathSync` resolves links itself in user space, so it returned the junction path **verbatim**:
127
+ the guard never saw the escape, and a reproduction confirmed that a write through that junction landed
128
+ outside `programDir`.
129
+
130
+ Three gaps, all closed:
131
+
132
+ - **The root judgement** now uses `realpathSync.native` (the OS `GetFinalPathNameByHandle`), which
133
+ resolves junctions along with every other reparse point. `rootReal` and the write-mode `parentReal`
134
+ share that one variable, so this re-arms both at once.
135
+ - **Read mode** never resolved the candidate at all — it only `stat`ed it, and `stat` follows links. It
136
+ now resolves the candidate and requires the result inside `rootReal`. Without this, an upload could
137
+ read an out-of-tree `.urp` through the same junction.
138
+ - **Write mode** judged only the parent directory, so a target file that already existed as a link
139
+ pointing outside was still followed by the write, and a *dangling* link would have had its target
140
+ created outside the tree. An existing target is now resolved too, and a link that cannot be resolved
141
+ is refused instead of being treated as an absent file.
142
+
143
+ `checkProgramTransfer` still answers only allow/deny and hands the caller back the `local_path` it was
144
+ given, so nothing downstream changes.
145
+
146
+ ### Tests — three escape cases, all really executed
147
+
148
+ `test/program-files.test.mjs` gained the read-mode junction case and the existing-target-link case; the
149
+ write-mode junction case is the one that caught this in the first place (it was the suite's single
150
+ failing test). `node --test test/program-files.test.mjs` → 11/11, none skipped.
151
+
152
+ ## [0.7.2] - 2026-10-15
153
+
154
+ > **The digital twin kept saying "data stalled" while the robot was moving, the pre-flight pose check
155
+ > told the model that a pose the arm was *standing in* was unreachable, and a conveyor-tick write
156
+ > could not say whether it had taken effect.** Those are the same failure in three places: a helper
157
+ > reporting a confident answer it never actually had.
158
+
159
+ ### Fixed — `ur_set_conveyor_tick` could not say whether it took effect
160
+
161
+ It dispatched `set_conveyor_tick_count()` and returned `ok_unverified`, while its sibling reader
162
+ `ur_get_conveyor` had had a full three-sentinel read-back (probe → start → payload → finish) all along.
163
+ Live test: writing `tick_count=1234` left the read-back at **0**, and the caller was told only
164
+ "已设置(未校验)" — with no way to tell "the conveyor is not configured" from "it worked".
165
+
166
+ It now reuses that same verified reader and separates the three outcomes honestly: read-back matches ⇒
167
+ `verified: true`; read-back differs ⇒ it **says plainly that the write did not take effect** and points
168
+ at the usual cause (no encoder/conveyor configured in PolyScope — the URSim default); read-back itself
169
+ fails ⇒ the `error`/`code` is handed to the caller. Having a read-back channel, it **graduates** out of
170
+ the "can only claim it was dispatched" list (that contract requires those ops to have no plain
171
+ `ok({...})` success path), so `UNVERIFIED_OPS` went 12 → 11.
172
+
173
+ ### Fixed — inspecting another handler's result with `.get("ok")` is always false
174
+
175
+ `ok(data)` in `python/ur_worker.py` is the **identity function** (`return data`); the `ok: true` field is
176
+ added by `main()` when the response is serialised. So the dicts handlers pass to one another carry **no
177
+ `"ok"` key at all** — any code deciding "did my read-back succeed?" with `.get("ok")` reads `None` and
178
+ takes the failure branch **every single time**. The first version of the conveyor fix above did exactly
179
+ that (it would have turned a self-verifying write into a permanently "unverified" one); the mistake
180
+ surfaced only because that fix reuses another handler's return value. The check is now
181
+ `result.get("error") is not None`, and both properties are pinned by probe assertions
182
+ (`discriminates_by_error_key`, `does_not_inspect_ok_key`) so the trap cannot return. A repo-wide scan
183
+ found one other `.get("ok")` (in `op_pose_list`), which reads a key it builds itself and is correct.
184
+
185
+ ### Fixed — `ur_check_pose` reported a false negative
186
+
187
+ The tool asks the controller two read-only questions (`is_within_safety_limits`, `get_inverse_kin`) and
188
+ writes the answers into output registers between sentinels. It wrote the **boolean** safety answer
189
+ straight into an **integer** register. URScript is strongly typed, so the controller aborted the script
190
+ **on that line**: nothing was written, `get_inverse_kin` was never reached, and the integer register read
191
+ back as `0` — so the tool reported "not within safety limits + IK unsolvable" for a pose the robot was
192
+ **standing in at that very moment**, and the model would refuse perfectly reachable poses.
193
+
194
+ Found by live testing on the robot rather than by reading the code: a script repeating the tool's own
195
+ line left its "how far did we get" marker at the start value, while the same script with the boolean
196
+ assigned to a `local` first and written through `if/else` ran to completion.
197
+
198
+ - the boolean now lands in a `local` first and is normalised to `1`/`0` by `if/else`;
199
+ - a **stage-marker register** records how far the script got, so "aborted in the limits step" and
200
+ "aborted in `get_inverse_kin`" are no longer reported as the same thing;
201
+ - the limits register is believed **only** when the stage marker proves that step finished; otherwise
202
+ the tool reports `NOT_EXECUTED` and gives **no** reachability verdict. The previous guard
203
+ (`limits_raw is not None`) was always true, because an unwritten integer register reads `0`, never
204
+ `None` — which is exactly how an aborted script turned into a confident "unreachable";
205
+ - pinned by `check_pose_script_types` — a **type check on the generated URScript** (every
206
+ `write_output_integer_register` argument must be an integer literal) — plus 11 assertions.
207
+
208
+ ### Added — pre-flight safety gate (`lib/safety-gate.js`)
209
+
210
+ An independent second layer that caps what a *single* command may ask for, so "the human said yes"
211
+ never means "anything goes": `safetyMaxStepM` (0.5 m), `safetyMaxLinearSpeed` (0.5 m/s),
212
+ `safetyMaxJointSpeed` (1.05 rad/s), `safetyMaxDurationS` (60 s), force-mode
213
+ `safetyMaxForceN`/`safetyMaxTorqueNm` (40 N / 10 N·m) and an optional `safetyWorkspace` box for
214
+ explicit Cartesian targets. It runs **before** the approval prompt, is **decoupled** from
215
+ `requireApprovalForMotion` (turning off human confirmation must not silently remove every physical
216
+ cap), and **fails closed** on a malformed `safetyWorkspace` instead of silently not checking.
217
+ Deliberately out of scope: `ur_send_script` (arbitrary URScript, not parsed).
218
+
219
+ ### Added — motion audit ledger (`lib/audit-log.js`)
220
+
221
+ Every gated command appends one NDJSON line (`<DSH_HOME>/nonead-ur/audit.ndjson` by default) with
222
+ `{ts, ip, op, tool_name, args, decision, reason}` and `decision ∈ {approved, denied, blocked,
223
+ unchecked}`. Before this, the only trace of an approval was in-memory: "what did the model actually
224
+ do to the robot" had no answer after the fact. A failing write is reported, never thrown — auditing
225
+ must not be able to break the dispatch path.
226
+
227
+ ### Added — `ur_check_pose` and `ur_set_speed_scaling` (83 → 85 tools, 40 → 41 gated)
228
+
229
+ `ur_check_pose` answers the question worth asking **before** anything moves: *is this TCP pose inside
230
+ the controller's safety limits, and does inverse kinematics solve (is it reachable)?* The controller
231
+ runs `is_within_safety_limits` + `get_inverse_kin`; the answers come back through output registers
232
+ behind start/finish sentinels, so the three outcomes stay distinct — completed (both answers),
233
+ half-run (limits written but the controller aborted at the IK call ⇒ `reachable: false`, with the
234
+ inference stated in the reply), and never ran (verification off / firmware without those functions ⇒
235
+ `NOT_EXECUTED`, no fabricated answer). It cannot use `urScript.py`'s `get_*` accessors for this: those
236
+ depend on RTDE **input** fields, and this plugin deliberately has no send recipe.
237
+
238
+ `ur_set_speed_scaling` sets the speed slider (`set_speed_slider`, 0 = stopped, 1 = full speed) — the
239
+ most direct way to slow everything down before handing over a delicate motion. Unlike the other
240
+ settings it **has** a read-back channel (`target_speed_fraction`), so it reports `verified: true` only
241
+ when the read-back matches, and `hardware_verified: false` because "`set_speed_slider` ⇒
242
+ `target_speed_fraction`" has never been confirmed on a real robot. It joins the approval gate (it
243
+ changes the speed of *every later* motion).
244
+
245
+ ### Added — program file transfer over SFTP (85 → 88 tools, 41 → 43 gated)
246
+
247
+ Deploying a program no longer requires walking to the teach pendant: `ur_upload_program`,
248
+ `ur_download_program` and `ur_delete_program` move `.urp` files between `programDir` and the robot,
249
+ and each one **verifies what it did** instead of trusting that a transfer was requested — upload
250
+ reads the remote size back, download reads the local size back, and delete `stat`s the path again
251
+ after `remove` (a mismatch is `VERIFY_FAILED`, never a success, because the caller's next step is
252
+ usually to run that program). A download never silently overwrites: an existing target is renamed to
253
+ `<path>.bak` first, and a failed backup refuses the download.
254
+
255
+ The local half needed a fence (`lib/program-files.js`): upload **reads** a local file and download
256
+ **writes** one, so without one the model could ship any local file (keys, credentials) to the robot,
257
+ or write robot files to any local path — `sanitizeArgs` blocks parameter *keys*, not *values*. Local
258
+ paths must resolve, after `realpath`, inside `programDir`, so `..` and symlinks out of the directory
259
+ are both rejected; only `.urp`; `maxProgramBytes` caps the size; a missing `programDir` fails closed.
260
+ `upload_program` and `delete_program` join the approval gate (clobbering or deleting a stored program
261
+ is not lighter than a motion, and their sibling `load_program` was already gated);
262
+ `download_program` does not, since it only reads the robot and what it writes is fenced.
263
+
264
+ ### Added — operator messaging (88 → 91 tools)
265
+
266
+ `ur_popup`, `ur_close_popup` and `ur_add_log` let the model talk to the person standing at the robot:
267
+ a modal popup on the teach pendant, and a line in the **controller's own log history** (which is what
268
+ makes "who asked it to do what" answerable on the shop floor later). None of them moves the arm, so
269
+ none joins the approval gate.
270
+
271
+ The text is **injection-checked**: the dashboard parses its commands line by line
272
+ (`dashboard.py` builds `'popup ' + text + '\n'`), so a newline inside the text would be executed as
273
+ another dashboard command — `"x\nshutdown"` is a shutdown. A newline or CR is therefore **rejected**
274
+ with a clear error instead of being quietly replaced, and the text is capped at 300 characters.
275
+
276
+ ### Added — Modbus / fieldbus I/O (91 → 99 tools, 43 → 48 gated)
277
+
278
+ Eight tools for the fieldbus side: `ur_modbus_add_signal`, `ur_modbus_delete_signal`,
279
+ `ur_modbus_get_signal_status`, `ur_modbus_send_custom_command`, `ur_modbus_set_output_register`,
280
+ `ur_modbus_set_output_signal`, `ur_modbus_set_runstate_dependent_choice` and
281
+ `ur_modbus_set_signal_update_frequency`.
282
+
283
+ **The vendored methods could not be reused**: all eight `modbus_*` methods in `urScript.py` are stubs
284
+ (`raise NotImplementedError('Function Not yet implemented')`), so the URScript is built here, sent
285
+ through the same start/finish-sentinel runner that `ur_check_pose` uses. That runner distinguishes
286
+ "ran to the end" / "started but did not finish" / "the controller never ran it" — and the second and
287
+ third outcomes are reported as failures with different wording, because they mean different things.
288
+
289
+ The honesty boundary matters more here than anywhere else so far: **Modbus writes get no response**
290
+ (the manual: `send custom command` "cannot be used to request data, since the response will not be
291
+ received"). Everything these tools can prove is that the URScript ran on the controller — never that
292
+ the PLC received it, let alone that a machine moved. Every reply therefore carries
293
+ `fieldbus_confirmed: false`, and only `ur_modbus_get_signal_status` returns a real value (read back
294
+ through an output register behind its own sentinels; a missing register is an error, not a zero).
295
+
296
+ Two guards the shape of the API forced:
297
+ - **Signal names are whitelist-checked.** They are interpolated into a URScript string literal, so a
298
+ name like `a", 0)` plus a newline would inject arbitrary script into the controller. Whitelist
299
+ (`[A-Za-z0-9_.- ]`, ≤64), not escaping.
300
+ - **A read must declare `kind`.** URScript is strongly typed: a digital signal returns a bool and a
301
+ register returns an unsigned int, and one script cannot safely write both into a float register
302
+ (writing a bool there aborts the program). The tool refuses to guess; a wrong `kind` surfaces as
303
+ `NOT_EXECUTED` with an explicit explanation of both possibilities.
304
+
305
+ `ur_modbus_add_signal`, `ur_modbus_delete_signal`, `ur_modbus_send_custom_command`,
306
+ `ur_modbus_set_output_register` and `ur_modbus_set_output_signal` join the approval gate — they drive
307
+ a PLC or change which fieldbus points the controller supervises. The read and the two
308
+ signal-configuration tools do not.
309
+
310
+ ### Added — named pose table (100 → 104 tools)
311
+
312
+ `ur_save_pose` / `ur_get_pose` / `ur_list_poses` / `ur_delete_pose`: a local table of named poses, one
313
+ JSON file per pose (`<poseDir>/<name>.json`, default `<DSH_HOME>/nonead-ur/poses`). They never touch the
314
+ controller unless you ask `ur_save_pose` to capture the current pose.
315
+
316
+ The split is the one the program files already use, for the same reason: `lib/pose-store.js` owns the
317
+ **name jail, the quota and the vector shapes** (pure logic, injectable IO, unit-tested), and the worker
318
+ only writes the file it is handed. The name becomes a file name — which is why the whitelist matters,
319
+ and why the first version of it was wrong: it accepted ASCII only, so `工位 1` was refused. The unit test
320
+ caught that; the rule is now "Unicode letters/digits, must start with one, no path separators, no
321
+ Windows-illegal characters, no reserved device names, no leading/trailing whitespace".
322
+
323
+ Details that took shape while writing the tests:
324
+ - **`NOT_FOUND` and `CORRUPT` are different answers.** Collapsing them into one "cannot read" sends the
325
+ caller off to re-save a pose that already exists but is damaged.
326
+ - Writes are atomic (`tmp` + `os.replace`), then **read back and compared**; a mismatch is
327
+ `VERIFY_FAILED`, never a success.
328
+ - One corrupt entry never fails a whole listing: it is reported per entry, with its name in `broken`.
329
+ - The quota refuses a **new** pose when the table is full but still allows **updating** an existing one —
330
+ otherwise "fix it and save again" would be impossible.
331
+ - A directory that cannot be read does **not** degrade to "0 poses" (that would silently disable the
332
+ quota); it refuses the write instead.
333
+
334
+ `scripts/check-tool-params.py` changed too: its docstring always claimed `_`-prefixed keys are protocol
335
+ internals, but the code only excluded the literal `{ip, _timeout_ms}`. The implementation now matches
336
+ its documentation (host-injected `_path` / `_dir` / `_pose_count` / `_max_poses` / `_capture` are
337
+ excluded, and `sanitizeArgs` strips any same-named key the model sends), plus a **new** rule forbidding
338
+ a *declared* `_`-prefixed parameter — otherwise a model could set a field the host intends to inject.
339
+
340
+ ### Fixed — a long motion could freeze the digital twin (`lib/motion-budget.js`)
341
+
342
+ Reported from a real robot: run something for 60 seconds and the 3D twin stops following. The twin's
343
+ only data source that does not stand in the worker's single-threaded stdin queue is the Python side's
344
+ telemetry push thread (`_twin_push_loop`) — and that thread lives in the same child process as RTDE and
345
+ the Dashboard.
346
+
347
+ The kill switch was the call budget: every tool call used `commandTimeoutMs` (default **60000 ms**), and
348
+ `UrWorker.call()` **kills the Python child on timeout** (correct as a general policy — a wedged
349
+ single-threaded worker would otherwise stall every later request). So a motion that legitimately ran
350
+ longer than 60 s had its child killed **mid-motion**: the robot kept moving, the push thread vanished
351
+ with the child, and the twin froze permanently (and the robot connection went with it).
352
+
353
+ Two changes, both in `lib/motion-budget.js`:
354
+ - the budget is now **derived from the call's own parameters** — `t=` (seconds) and `ur_wait_steady`'s
355
+ `timeout_s` each extend it by a margin, `move_force` gets a wide fallback because its duration depends
356
+ on the distance travelled and on what blocks it, the configured `commandTimeoutMs` stays a floor, and a
357
+ 1-hour cap keeps "long" from turning into "never times out";
358
+ - motion ops are called with **`killOnTimeout: false`**, so exceeding the budget reports a timeout
359
+ honestly instead of destroying the session. That is the same honesty rule used everywhere else here:
360
+ "I could not confirm it within my budget" is not a licence to tear the connection down.
361
+
362
+ ### Fixed — telemetry no longer shares the response channel (`lib/push-channel.js`)
363
+
364
+ The twin's pushed frames used to travel on **stdout, under the same `_PROTOCOL_LOCK` as responses**.
365
+ That lock is held for the whole of a response write, so a large response (or stdout back-pressure while
366
+ the host is momentarily slow to drain) could stop the pushes for longer than the host's
367
+ `TWIN_PUSH_FRESH_MS` (300 ms) — and past that window `lib/twin-routes.js` falls back to an **explicit
368
+ read**, which is precisely the read that stands in the worker's stdin queue behind a running motion.
369
+ The twin then sits on "data stalled" for the rest of the move.
370
+
371
+ Telemetry now has its own channel: **stderr + a `@@DSH-UR-PUSH@@ ` prefix, with its own lock**
372
+ (`_write_push_line` / `_PUSH_LOCK` on the Python side, `lib/push-channel.js` on the host side). It is
373
+ neither serialised behind responses nor affected by stdout back-pressure, and ordinary stderr
374
+ (tracebacks, URBasic prints) still lands in `stderrTail` untouched. The host accepts pushes on **both**
375
+ channels, so an older Python half keeps working.
376
+
377
+ The chunk splitter is a pure function with its own tests, because its failure modes are exactly the
378
+ ugly ones: a line split across two chunks, a chunk boundary **inside the magic prefix**, and a traceback
379
+ whose last line has no trailing newline (that one must be handed to the diagnostics immediately, or it
380
+ is lost forever).
381
+
382
+ ### Fixed — documentation
383
+
384
+ The READMEs claimed the RTDE recipe does not expose per-joint current "so `ur_get_joint_current` is
385
+ not provided". The recipe has carried `actual_current` all along and `ur_get_runtime_telemetry`
386
+ returns it as `joint_currents`; only a *dedicated* tool is absent.
387
+
388
+ ## [0.7.0] - 2026-10-14
389
+
390
+ > **The suite printed "0 failures" on a machine where 16 of its 46 items had never run.** The same
391
+ > day, `movej`/`movel` accepted a `t=` duration, echoed it back in the reply, and never sent it to the
392
+ > controller. Those are the same bug in two places: a green light that nothing stands behind.
393
+
394
+ ### Fixed — `t=` on movej/movel never reached the controller
395
+
396
+ - **Root cause** (`python/URBasic/urScript.py`): the duration was read into `t_val` while the script
397
+ template interpolated `tval`. The result was a *silently dropped* parameter: `ur_movej`/`ur_movel`
398
+ accepted `t`, reported it in the reply, and executed a speed-governed move instead of a timed one.
399
+ - **Fix**: one renamed identifier. `t > 0` now really takes precedence over `a`/`v` (the manual's rule)
400
+ and the returned script contains the `t=` you asked for.
401
+ - **Pinned by**: `motion_t_param` in the Python harness asserts the emitted script contains the
402
+ requested duration and that `t` really wins over `a`/`v`.
403
+
404
+ ### Fixed — the worker survived the host, and the orphan kept the RTDE session
405
+
406
+ - **Root cause** (`lib/index.js`): `ctx.effect`'s disposer returned `undefined`. Cordis only awaits a
407
+ disposer's result when it is a thenable (`if (isObject(result) && "then" in result)`), so the
408
+ graceful `worker.shutdown()` was never awaited, and `lib/worker.js` never disposed the child. On a
409
+ UR controller, RTDE is an **exclusive** session: the leftover Python process kept it claimed, and
410
+ the next host run failed to start RTDE until the orphan died.
411
+ - **Fix**: the disposer returns the cleanup promise (`Promise.resolve(...).catch(() => {}).finally(...)`).
412
+ In addition the respawn backoff timer is now cancellable and rejected on `dispose()`, `_spawnOnce()`
413
+ re-checks `_disposed` after its await (no spawn after teardown), and the child's `stdin` gets exactly
414
+ one `error` listener instead of one per call.
415
+ - **Also**: the spawned Python gets `PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`, and the worker forces
416
+ UTF-8 on its own stdio — a non-UTF-8 console locale used to turn one CJK byte into a
417
+ `UnicodeDecodeError` that killed the loop.
418
+
419
+ ### Fixed — Dashboard replies could land on the next command
420
+
421
+ - **Root cause** (`python/URBasic/dashboard.py`): `run()` notified its condition variable even when
422
+ `__receive()` returned `None` (nothing read), and `sendCommand()` returned whatever
423
+ `self.__last_respond` happened to hold. The reply to command *N* could therefore be handed to
424
+ command *N+1* — a robot that is **not** in remote-control mode was reported as
425
+ `remote_control: true`, and the model then sent URScript the controller silently discards.
426
+ - **Fix**: notification happens only inside `if msg is not None:`; a monotonic `respond_seq` plus
427
+ `__await_respond(since_seq, timeout)` makes "wait for *this* reply" explicit; `sendCommand()`
428
+ verifies the sequence actually advanced and otherwise returns `(False, '')` instead of a stale answer.
429
+ - **Also**: receive buffering went from `recv(1024)` with no accumulation to `RECV_CHUNK = 4096` plus a
430
+ line-splitting instance buffer, so a multi-chunk reply can no longer bleed into the next command.
431
+
432
+ ### Fixed — commands that could not be verified claimed success
433
+
434
+ - **Root cause**: for a class of state-setting ops, the only provable fact was "bytes reached the
435
+ socket", yet the reply said "已设置/已生效". UR only accepts URScript in **remote-control mode**;
436
+ in local/teach-pendant mode the controller **discards it silently** and reports nothing.
437
+ - **Fix**: a single `ok_unverified(result)` wrapper demotes those replies and adds `verified: false`
438
+ to `data` — applied to **12** ops (`set_tcp`, `set_payload`, `set_payload_inertia`, `set_gravity`,
439
+ `set_tool_voltage`, `tool_communication`, `set_tool_output_mode`, `motion_version`,
440
+ `force_mode_settings`, `zero_ftsensor`, `set_conveyor_tick`, `conveyor_tracking`).
441
+ - **Tool digital I/O** had the same shape with a worse outcome: `set_tool_digital_out()` ignored the
442
+ `Send()` result entirely, so a failed write was reported as success. It now returns the send result
443
+ and the worker turns a failure into `SEND_FAILED`. Reading a tool digital input is now
444
+ **sentinel-verified** (channel probe + start/finish tokens); with no finish token read back the
445
+ worker refuses to give a value rather than reporting a number nobody can vouch for.
446
+
447
+ ### Fixed — a refused RTDE session still reported `isRunning() === true`
448
+
449
+ - **Root cause** (`python/URBasic/rtde.py`): the `RTDE_CONTROL_PACKAGE_START` branch called
450
+ `__decodePayload()` and then ignored the decoded `accepted` boolean, setting `STARTED`
451
+ unconditionally. When the controller refuses (most often because the previous session has not been
452
+ released) `isRunning()` lied, `ensure_connected()`'s `if not RTDE.isRunning()` guard was defeated,
453
+ and every subsequent pose read burned the full 5 s `sync()` timeout before reporting the misleading
454
+ "timed out waiting for new RTDE data".
455
+ - **Fix**: `STARTED` only when `accepted is True`; otherwise `ERROR` plus a recorded
456
+ `__start_refused` and a one-line explanation. `__sendStart()` now pumps `__receive()` itself and
457
+ answers from the resulting state, so `run()`'s "re-setup after reconnect" branch can finally see a
458
+ refusal (it previously could not, because the old `__sendStart()` returned `True` as soon as the
459
+ bytes were written).
460
+ - **Also**: RTDE packets that straddle a `recv()` boundary used to be **dropped whole** (`byte_buffer`
461
+ was a local, and the incomplete tail was discarded with a misleading "unexpected packet_size" log).
462
+ Dropping the `timestamp` packet that refreshes the watchdog made `run()` rebuild a perfectly healthy
463
+ session. The buffer is now an instance field that accumulates across calls, a half packet is kept for
464
+ the next read, and a malformed length resynchronises by dropping **one** byte (counted, and surfaced
465
+ as `rtde_resync_bytes` in `ur_get_status`) instead of throwing the rest of the stream away.
466
+
467
+ ### Fixed — two client features that could never trigger
468
+
469
+ - **Joint-limit warning** (`src/client/twin-panel.js`): the limits were read from
470
+ `fkResult?.jointLimits`, a field the kinematics object never had, so the out-of-range highlight was
471
+ dead code. Source corrected to `kin.jointLimits`.
472
+ - **Multi-robot selector**: `updateRobotChoices()` ran *after* the `if (!connected) return` early exit,
473
+ so the list could only be populated once something was already connected — i.e. never, for the one
474
+ case it exists for (picking the robot to connect to). It now runs unconditionally, before that
475
+ guard, and `syncHudVisibility()` relies on `children.length` (the previous `childElementCount`
476
+ test is not part of the minimal DOM the panel runs against).
477
+
478
+ ### Fixed — smaller honesty gaps
479
+
480
+ - **Stale twin frames** (`lib/twin-routes.js`): the replay returned the whole cached body, including a
481
+ `pending_motion` preview up to 30 s old. An approval that had already ended kept being drawn on the
482
+ panel. That field is host-side state and is now **re-read live** (and omitted when there is none).
483
+ - **Log size cap** (`python/ur_worker.py`): `_CappedLog` counted characters while the file is opened as
484
+ UTF-8, so a CJK log could reach ~3× the declared limit (8 MB → ~24 MB). Accounting is now in bytes;
485
+ the `write()` return value stays a character count (file-like semantics).
486
+ - **`ur_list_programs`**: the built-in default password (`easybot`) and the `AutoAddPolicy()` host-key
487
+ stance are now reported (`used_default_password`, `host_key_policy`) instead of being silent, and an
488
+ optional `known_hosts` parameter switches to `RejectPolicy` for real verification. A missing
489
+ `known_hosts` file is an error — never a silent fall back to auto-add.
490
+ - **`realTimeClient.Send()`** never waited for the previous program's watchdog thread, so a state-setting
491
+ write could race that thread on the same 30003 socket (and lose to the `prgRest` it sends on the way
492
+ out) while reporting merely "sent". It now shares `__quiesce()` with `SendProgram()`.
493
+
494
+ ### Changed — the gates can no longer pass by skipping
495
+
496
+ - `scripts/run-tests.mjs` exited 0 when everything was skipped. On a machine without the Python
497
+ dependencies it printed "失败 0" while 11 test files and 5 gates had never run. Skips now fail the
498
+ run unless you explicitly ask for them (`--allow-skip` / `--skip-python`), and an all-skipped run
499
+ **always** fails.
500
+ - The Python probe is now two-tier (`interpreter` vs `deps`), and the declared-but-unread `needsDeps`
501
+ flag is actually honoured — the four AST/regex-only Python gates no longer get skipped just because
502
+ `numpy` is missing. Every `spawnSync` is anchored with `cwd: root`.
503
+ - `scripts/check-host-compat.mjs` hardcoded `C:\Program Files\DSH Desktop\...`, so on any other
504
+ install name (e.g. `Nonead DSH Desktop`) it could not find the host and **had never actually run**.
505
+ It now enumerates `Program Files\*\resources\app\node_modules`, and `--allow-missing-host` lets it
506
+ join `npm run check` while still failing loudly by default. First real run: peers satisfied,
507
+ **83/83 tools registered through the host's own DSL**, all four twin routes present.
508
+ - `package.json`: `prepublishOnly` now runs the full `check`; `check` includes the new `check:host`;
509
+ `devEngines` records the real Node floor (≥22.15 — two test files use `module.registerHooks`, so the
510
+ declared `engines: >=20` was never the truth for the test suite).
511
+ - **Tool counting has one source of truth** (`scripts/tool-registry.mjs`). It used to be computed
512
+ dynamically in one gate and hardcoded as `83` in two others; a tool added without updating all three
513
+ would make the gates contradict each other.
514
+ - **The five offline twin/SSE probes are now part of the suite.** `scripts/probe-*.mjs` were wired into
515
+ no gate at all — "a script nobody runs" is the very symptom this release is about. They need no robot
516
+ and no host; the two that exercise `EventSource` run with `--experimental-eventsource` (Node ≥22.3),
517
+ which is now the documented dev floor.
518
+
519
+ ### Changed — published package
520
+
521
+ - `lib/client.js.map` (3.5 MB, larger than the bundle itself) no longer ships: `files: ["lib"]` is an
522
+ allow-list that wins over `.npmignore`, so the exclusion has to be the negated entry
523
+ `"!lib/client.js.map"`.
524
+
525
+ ### Added — regression tests (19 new)
526
+
527
+ Every fix above is pinned by a test that would fail on the old code, including a real TCP socket pair
528
+ driving `__receive()` (START refusal, and a frame split across two `recv()` calls), a fake waiting
529
+ thread proving `Send()` quiesces, a CJK write proving the log cap counts bytes, a live `pendingMotion`
530
+ store proving stale frames drop a finished approval, and a real TCP fake Dashboard that never answers
531
+ (proving `sendCommand()` reports failure instead of a stale `true`).
532
+
533
+ ## [0.6.9] - 2026-10-12
534
+
535
+ > **Every model drew the same reach envelope, because the reach was never computed.** The link-length
536
+ > reader expected the *old* `links` shape, while the real data is 6 × `{x,y,z,roll,pitch,yaw}` — so it
537
+ > returned `null` for all 14 built-in models.
538
+
539
+ ### Fixed — the reach envelope and the fallback arm now follow the model
540
+
541
+ - **Root cause** (`src/client/robot/loader.js`): `linkLengthsFromKinematics()` accepted only
542
+ `[7 × 4×4 row-major number[16]]` and required `links.length >= 7`, whereas `assets/kinematics.json`
543
+ (the shape `fk.js` documents and consumes) holds **6 × `{x,y,z,roll,pitch,yaw}` fixed
544
+ displacements**. Every real model therefore returned `null`, and that single `null` produced both
545
+ symptoms:
546
+ - `reachRadiusFromKinematics()` also returned `null`, so `twin-panel` **never** updated
547
+ `reachSphere.scale` — the envelope stayed at its unit radius (1 m) for every robot, which is
548
+ exactly "the reach looks the same no matter which model is loaded";
549
+ - `loadRobotModel()`'s `armLinks` was always `undefined`, so the unknown-model fallback arm always
550
+ collapsed back to UR3-scale `ARM_LINKS`.
551
+ - **Fix**: the function now understands both shapes. In the displacement shape a segment's length is
552
+ the magnitude `hypot(x,y,z)` of its entry — the same convention `fk.js` and `model-contract.test.mjs`
553
+ already use — while the legacy 4×4 row-major shape keeps its "distance between adjacent origins"
554
+ behaviour. Either way it returns exactly 7 segments (base + 6 links, the last one approximated).
555
+ - **Measured after the fix** (reach upper bound = sum of the 6 segment magnitudes): `ur3 804 mm` ·
556
+ `ur3e 823` · `ur5 1098` · `ur5e`/`ur7e 1201` · `ur16e 1295` · `ur18 1358` · `ur10 1543` ·
557
+ `ur10e`/`ur12e 1627` · `ur15 1693` · `ur30 1729` · `ur8long 2135` · `ur20 2168` — a **2.70×** span
558
+ from UR3 to UR20.
559
+ - **Why this stayed green for so long**: the unit tests fed hand-made *matrix* fixtures
560
+ (`straightLinks()`) and never read the real `assets/kinematics.json`. Assertions that read the
561
+ **real data** now pin it down: all 14 models resolve, the envelope grows monotonically, and
562
+ UR20/UR3 > 2.5.
563
+ - **This was never a model-asset problem**: the 14 GLBs already differ per model (bounding boxes
564
+ ur3 0.35 m → ur20 1.06 m). Note the camera still frames each model by its own bounding box (the
565
+ 0.6.3 design), so with the reach layer switched off the arm still fills the same frame — that is
566
+ framing normalisation, not a wrong size.
567
+
568
+ ## [0.6.8] - 2026-10-12
569
+
570
+ > **The twin now follows the desktop app's colour theme.** The DOM half flips with the host's design
571
+ > tokens and the WebGL half is repainted by JS; two token names that do not exist in the DSH design
572
+ > system are gone — on a dark theme they rendered as a white card.
573
+
574
+ ### Changed — the digital twin follows the desktop app's colour theme
575
+
576
+ - **The DOM half** is now coloured with the host's design tokens
577
+ (`var(--dsw-alias-*, <previous dark value>)`): the tokens flip with the host's
578
+ `body[data-ds-dark-theme]` projection, so the panel follows whatever theme is selected under
579
+ Settings → Appearance — and any third-party theme — **with no JS involved**. The value after the
580
+ comma is the previous dark colour, so in an environment **without** a host token layer the
581
+ rendering is exactly what it was before.
582
+ - **The WebGL half** (which CSS cannot reach) is repainted by JS: new `src/client/theme.js` (theme
583
+ detection + 3D palette + `MutationObserver`); `twin-panel` builds the scene with the current
584
+ theme's palette and calls `scene.applyTheme()` on a theme change to rebuild the base grid
585
+ (`GridHelper` bakes its line colours into the geometry's vertex colours, so recolouring means
586
+ replacing the geometry). The fallback arm's material colour follows too.
587
+ - **Detection only trusts `body[data-ds-dark-theme]`** and never falls back to
588
+ `prefers-color-scheme` — otherwise "light theme + dark system" would be read backwards. With no
589
+ `body` available it treats the theme as light and does not throw.
590
+ - Also fixed two references to tokens that **do not exist** (`--dsw-alias-bg-elevated` and
591
+ `--dsw-alias-border-secondary` are not defined in the DSH design system): they silently fell back,
592
+ and a light fallback on a dark theme is a white card. The real token names were verified one by
593
+ one in the local `dsh-client-ui-theme` / `-layout` packages before this change.
594
+ - Four regression cases (the palette really reaches the grid's vertex colours, detection does not
595
+ fall back to the system preference, the observer fires and unsubscribes, the CSS references only
596
+ real tokens).
597
+
598
+ ## [0.6.7] - 2026-10-10
599
+
600
+ > **The digital twin no longer sits on "data stopped updating Ns".** 0.6.6 made a stall *visible*;
601
+ > this release makes it *not happen*: telemetry went from "the twin asks" to "the worker pushes".
602
+
603
+ ### Fixed — the twin keeps following the robot while it moves (pushed telemetry)
604
+
605
+ - **Root cause**: the twin's reads and every motion command share one **single-threaded stdin queue**
606
+ (`for line in sys.stdin` in `python/ur_worker.py`), and the motion confirmation loops
607
+ (`_movej_confirm` / `_movel_confirm` / `_wait_robot_idle`, 60 s budget) hold it for tens of seconds,
608
+ so a read simply queues until its 2.5 s budget expires. **The data itself never stopped**: RTDE is
609
+ written at 500 Hz by URBasic's own receiver thread into `model.dataDir`, which has nothing to do with
610
+ the command queue.
611
+ - **Fix**: the worker now runs a 20 Hz background thread (`_twin_push_snapshot` + `_twin_push_loop`) that
612
+ **pushes** joint angles and TCP as a JSON line **without `id`**; the host's `_onLine` recognises it
613
+ *before* matching responses by id and stores it in a `latestTelemetry` cache; every twin frame reads
614
+ that cache first (**zero worker round trips**) and only falls back to an explicit read when it is older
615
+ than 300 ms or missing.
616
+ - **Wire compatibility**: the absent `id` is the only marker — an older host just records it as an
617
+ unmatched response, with no side effects.
618
+ - **One write lock for pushes and responses** (`_PROTOCOL_LOCK`): concurrent writes to stdout that are
619
+ not line-atomic interleave two JSON lines, which shows up as randomly dropped frames and odd timeouts.
620
+ - **Strict validation**: a push whose joint angles or TCP are not 6-dimensional is **dropped outright**,
621
+ never allowed to overwrite a good reading with half a pose (the arm would visibly jump).
622
+ - **Off switch**: `DSH_UR_TWIN_PUSH=0` disables the push channel for comparison (the twin then falls back
623
+ to the 0.6.6 "read + stale" behaviour).
624
+ - **Evidence**: new `scripts/probe-twin-push-continuity.mjs` — with a fake worker whose `call()` never
625
+ settles (precisely "the queue is occupied"): **with push: 20 frames, max frame gap 112 ms; without push:
626
+ only 3 `stale` events.** Five regression cases cover push-priority, staleness fallback, error fallback,
627
+ push lines not counted as unmatched responses, and cache clearing on dispose.
628
+
629
+ ## [0.6.6] - 2026-10-8
630
+
631
+ > Code comments below refer to this unreleased change as `0.6.6`; `package.json` is **already 0.6.6**,
632
+ > but nothing is published yet — the profile still installs `#086d365` (0.6.5) from GitHub, so the ref
633
+ > has to be updated and the profile reinstalled after the push.
634
+
635
+ > **The digital twin stopped following the robot after a couple of moves, and never came back.**
636
+ > Three defects combined to produce that: the frame pump went completely silent while a read was
637
+ > outstanding (so the picture simply froze with no explanation), the twin's reads could *kill the
638
+ > robot session* by timing out, and one bad frame tore the whole client stream down — eight of them
639
+ > left it permanently dead. All three are fixed, each with a reproduction.
640
+
641
+ ### Changed — the 14 model assets were slimmed 40.6% (35.11 → 20.86 MB: lossless, then lossy)
642
+
643
+ - **New `scripts/compress-models.mjs`** does exactly two things that cannot change what you see:
644
+ ① trimesh writes **every** vertex index as `UNSIGNED_INT` (4 bytes each) while the largest index in
645
+ these 14 models is only 24228 — `UNSIGNED_SHORT` is plenty; ② every primitive carries a `_color`
646
+ attribute that **no material reads** (three's `GLTFLoader` dutifully puts it in
647
+ `geometry.attributes._color` and nothing ever looks at it). The BIN holes and orphaned `accessor`s
648
+ left behind are reclaimed too.
649
+ - **Proved, not claimed**: for every file the script asserts that vertex positions / UVs / texture bytes
650
+ are **byte-for-byte identical**, the index sequence is **value-for-value identical**, `accessor`
651
+ `min`/`max` and the node-name order are unchanged, and every `bufferView` is inside the buffer and
652
+ 4-byte aligned — otherwise it refuses to write. After compressing:
653
+ `python scripts/verify-models.py` (14/14) and `node test/model-contract.test.mjs` (6/6, including the
654
+ 14-model multi-pose assembly and inter-link gap checks) are green.
655
+ - **Deliberately not done**: no decimation and no vertex-precision change (Ruling 24); no Draco/meshopt
656
+ (the client's `GLTFLoader` registers no decoder — that would mean changing the load path and shipping
657
+ an extra wasm); no position quantisation (`loader.js`'s `applyLinksToGroups()` writes an **absolute**
658
+ transform into those 7 groups' `matrix` every frame, which would overwrite the node compensation and
659
+ make the arm come apart). Texture re-encoding is a separate, **explicit** switch — see step 2.
660
+ - **Step 2 · lossy, explicitly decided (→ 20.86 MB)**: `--texture-size 1024` box-filters the **eight**
661
+ 2048×2048 base-colour PNGs embedded by the five newer models (ur15 ×1, ur18 ×2, ur20 ×1, ur30 ×2,
662
+ ur8long ×2 — ur8long reuses ur15's texture and ur30 reuses ur20's) down to 1024×1024
663
+ (10.67 MB → 3.25 MB). Integer-ratio downsampling uses a **box average** rather than bicubic: bicubic
664
+ interpolates and therefore skips source pixels when shrinking, while the box average turns each 2×2
665
+ source block into one target pixel — which is what "halve the resolution" means. The 9 older models
666
+ only embed 16×8/32×8 PNGs (≈1 KB total); they are neither upscaled nor touched.
667
+ - **Implementation**: new `scripts/texture-resize.mjs` (dependency-free, cross-platform PNG decode /
668
+ resize / encode). Not `sharp`/`pngjs` (this repo ships zero third-party runtime deps), not Python PIL
669
+ (unavailable here), and not Windows `System.Drawing` — that exists **only on Windows**, so release
670
+ artifacts could not be rebuilt on CI/macOS. Decoding correctness is pinned by two independent pieces
671
+ of evidence: a **per-pixel cross-check against Windows GDI+** on the same real texture (7 sample
672
+ points including the corners — all identical) and `test/texture-resize.test.mjs` (round-trips plus a
673
+ real-asset sample).
674
+ - **Visual acceptance**: the same physical region shown side by side (original 1:1 vs. the shrunk image
675
+ scaled back up) is indistinguishable to the eye — and the twin panel is only a few hundred pixels
676
+ wide. A dry run prints each texture's before/after dimensions and bytes.
677
+ - **Measured composition** (`npm run analyze:models`): indices 12.78 MB (36.4%) + geometry 11.78 MB
678
+ (33.6%) + textures 10.67 MB (30.4%), with **all** of that texture coming from the 5 newer models.
679
+ - **Ruling change, recorded**: Ruling 24's "full original precision, no decimation" now applies to
680
+ **geometry only** (positions, UVs and indices are byte-for-byte/value-for-value unchanged by both
681
+ steps); the **base-colour textures** were reduced to a 1024 longest edge by explicit user decision on
682
+ 2026-10-09. `model-contract.test.mjs`'s floor therefore moved from 25 MB to 15 MB (its smallest file,
683
+ ur20 at 0.69 MB, still sits well above the 0.5 MB guard).
684
+ - **Still on the table (another notch, if ever needed)**: textures → 512² ≈ −2.5 MB more; lossless
685
+ WebP ≈ −2.5 MB; UV `float32` → `uint16` (`KHR_mesh_quantization`, three supports it) ≈ −2.2 MB.
686
+
687
+ ### Fixed — the twin froze and never resumed
688
+
689
+ - **A stalled frame pump is now loud, not silent** (`lib/twin-routes.js`). The pump refuses to stack
690
+ a second read on top of an outstanding one (correct: the worker is single-threaded, and its
691
+ in-flight ceiling is 8), but it used to emit **nothing at all** during that time — measured: 6 s
692
+ with zero events while a motion command held the worker
693
+ (`scripts/probe-twin-pump-stall.mjs`). The client therefore saw an open, quiet stream and kept the
694
+ last pose forever, indistinguishable from "the robot stopped". It now emits a throttled `stale`
695
+ event (once per second) while a read is outstanding, plus a `frame-error` if a read exceeds a
696
+ 15 s safety net — either way the connection stays open and the pump keeps going.
697
+ - **Twin reads can no longer destroy the robot session** (`lib/twin-worker.js`, `lib/worker.js`).
698
+ `UrWorker.call()` kills the Python child when a request exceeds its budget (by design: a
699
+ single-threaded worker stuck in library code would block everything after it). The twin shared
700
+ that default 60 s budget *and* the same worker queue as every motion command, so a read that
701
+ queued behind a long motion timed out and killed the child — taking RTDE and Dashboard with it,
702
+ after which the twin could never get a pose again. Twin reads now use a dedicated 2.5 s budget
703
+ with `killOnTimeout: false`: fail this frame fast, keep the session, try again on the next frame.
704
+ Motion commands keep their own budget and kill-on-timeout behaviour, unchanged.
705
+ - **A temporarily unavailable read degrades to a stale frame instead of "not connected"**
706
+ (`lib/twin-routes.js`). A worker-busy/timeout failure used to be answered as `connected:false`,
707
+ which made the client hide the entire 3D view and write "未连接机器人" — exactly the "it synced
708
+ two moves and then stopped" report. The host now replays the last known reading with
709
+ `stale: true` (original `ts` preserved so the age keeps growing, window capped at 30 s) and the
710
+ client keeps the picture and shows the stall. A genuine connection error still reports
711
+ `connected:false`.
712
+ - **The client stream can no longer be killed by a single bad frame** (`src/client/robot/twin-stream.js`).
713
+ `EventSource.onerror` *is* the event handler for events named `error`, and server-sent events are
714
+ dispatched by their `event:` field — so the host's documented "a failed frame emits an `error`
715
+ event and never tears the stream down" behaved in a real browser as the exact opposite: every bad
716
+ frame tore the connection down. Measured with a spec-compliant `EventSource`
717
+ (`scripts/probe-twin-stream-error-event.mjs`): one `error` frame ⇒ the client rebuilt the stream.
718
+ A server-sent event is now recognised by carrying `data` (a transport error does not), and the
719
+ host's event is named `frame-error` so the two can never collide again.
720
+ - **The stream never gives up** (`src/client/robot/twin-stream.js`). "8 consecutive reconnect
721
+ failures ⇒ give up" was a terminal state: no timer, no reconnection, the last frame on screen
722
+ forever — and real outages (host restart, a minimised window losing the connection, laptop
723
+ sleep/wake) are exactly "fails a dozen times, then recovers". Reconnection is now unbounded with
724
+ exponential backoff capped at 30 s, and the backoff only resets on a **real frame** (not on
725
+ `open`: an endpoint that accepts and sends nothing must not look like a recovery).
726
+ - **The feed's health is visible and self-healing** (`src/client/state.js` exposes
727
+ `getStreamHealth()` / `retryNow()`; `src/client/twin-panel.js` renders a dedicated HUD row). The
728
+ panel now says "data stopped updating Ns · retrying" (with the frame/failure counters), reports
729
+ "reconnecting", retries immediately when the window becomes visible again, and force-rebuilds the
730
+ channel after 10 s of stall (browser timer throttling can starve a backoff timer).
731
+ - **Worker failures carry machine-readable codes** (`lib/worker.js`): `WORKER_BUSY` /
732
+ `WORKER_TIMEOUT` / `WORKER_EXITED` / … so the twin can distinguish "we could not get an answer in
733
+ time" from "the robot is really not connected" without parsing prose.
734
+
735
+ ### Tests
736
+
737
+ - `twin-routes.test.mjs` — the SSE route had **no test at all**, which is how both the silent-stall
738
+ and the `error`-name collision stayed green: added frame/heartbeat/405 assertions, "a busy worker
739
+ emits `stale` (never silence)", the read-timeout safety net, single-flight backpressure, and the
740
+ stale-degradation policy including its 30 s window.
741
+ - `twin-stream.test.mjs` — the fake `EventSource` now dispatches **per spec** (a server-sent `error`
742
+ message also hits `onerror`), which is what the old fake could not express; added "never gives up"
743
+ over 20 consecutive failures, backoff cap/reset rules, and `poke()`.
744
+ - `twin-worker.test.mjs` (new) — the twin's read policy: short budget, `killOnTimeout:false`,
745
+ `null` worker handling, error codes.
746
+ - `stream-wiring.test.mjs`, `twin-panel.test.mjs` — stale marking from `stale`/`frame-error` events,
747
+ `getStreamHealth()`, `retryNow()`, the "data stopped updating" row and its forced reconnect.
748
+ - `worker.test.mjs` — `killOnTimeout:false` keeps the child alive; the default still kills it.
749
+ - Diagnostic probes kept in `scripts/` (`probe-twin-pump-stall.mjs`,
750
+ `probe-twin-stream-error-event.mjs`, `probe-sse-lifetime.mjs`) so the three transport-level
751
+ questions can be re-answered on a real machine instead of re-derived.
752
+
753
+ ## [0.6.5] - 2026-10
754
+
755
+ > This release turns the digital twin from a read-only preview into a bench tool, and replaces the
756
+ > 10 Hz polling loop with a push channel. It also finishes a batch of **honest readback** fixes in
757
+ > the worker: several tools reported values they could not actually read — a tool-telemetry call that
758
+ > was always `null`, a freedrive check that called a stale `0` normal, and a `power_off` that treated
759
+ > "the controller stopped answering because it just powered down" as a failure. Everything below was
760
+ > already in the tree but had never been written down; 0.6.3 and 0.6.4 were never released as
761
+ > separate entries.
762
+
763
+ ### Added — the host's fourth read-only route: a live SSE stream
764
+
765
+ - **`/dsh-nonead-ur/twin/stream`** (`lib/twin-routes.js`): Server-Sent Events instead of
766
+ poll-and-hope. One frame every `100 ms`, a `: ping` heartbeat every 15 s (an idle connection is
767
+ exactly what a proxy cuts after 30–60 s), a `pumping` flag for backpressure (no next frame until
768
+ the previous write has drained), an immediate first frame, `x-accel-buffering: no`, and `405` for
769
+ anything that is not a `GET`. **A failed frame emits an `error` event and never tears the stream
770
+ down** — the client keeps the last good frame.
771
+ - **The stream carries `detail` on its own slow cadence** (`TWIN_STREAM_DETAIL_MS` = 2000 ms): not on
772
+ every frame (a dashboard round trip per frame would saturate the robot at 10 Hz), and not never
773
+ (safety mode, temperatures and bus voltage would stay empty forever).
774
+ - **Client subscriber** (`src/client/robot/twin-stream.js`): `EventSource`, reconnect after 1 s, give
775
+ up after 8 consecutive failures *and tell the subscribers*, and say so explicitly when the
776
+ environment has no `EventSource` instead of silently doing nothing.
777
+ - **Stream and polling are mutually exclusive** (`src/client/state.js`): with `stream: true` not a
778
+ single `fetch` is made — running both would push every frame twice and make the arm jitter.
779
+ Switching robot rebuilds the whole stream, and the client half now **defaults to `stream: true`**.
780
+ - The host-compat gate now expects **4** exact twin routes (`state` / `asset` / `models` / `stream`),
781
+ not 3. The assertion exists so that a changed registration shape is *noticed*, not to freeze it.
782
+
783
+ ### Added — choosing among robots, and seeing which one you are looking at
784
+
785
+ - **`setIp()` clears the previous robot's readings** (`src/client/state.js`): switching target
786
+ immediately drops `connected`, `q`, `tcp`, `ts` and `detail`. Showing robot A's pose as robot B's
787
+ is worse than showing nothing. The same IP is a no-op (the snapshot object is not even rebuilt),
788
+ and a blank or non-string value means "hand it back to the host's resolution".
789
+ - **A robot picker and an identity row** (`src/client/twin-panel.js`): the picker is rebuilt only
790
+ when its signature (`${resolvedIp}|${candidates}`) changes — rebuilding it every frame swaps the
791
+ button out from under the finger. The HUD's first row now shows `model · IP`; the IP used to live
792
+ only in `data-ur-twin-ip` and was not visible anywhere in the UI.
793
+ - A disconnected frame **keeps the `ips` candidates**, otherwise the picker is unreachable exactly
794
+ when it is needed (several robots connected ⇒ the target is ambiguous); `setIp` aborts the
795
+ in-flight request and refetches at once instead of waiting for the next tick.
796
+
797
+ ### Added — look before it moves: a pending-motion preview
798
+
799
+ - **`lib/pending-motion.js`** turns an approval prompt into a preview, and **never pretends to know
800
+ more than it does**: `joints` (a whole ghost arm can be drawn) / `pose` (the plugin has no IK, so
801
+ only a marker at the target position) / `relative` (text only — the absolute target needs the
802
+ current TCP) / `opaque` (circles, squares, stars, program runs, force and speed commands have no
803
+ single target). It never throws: malformed arguments degrade to `opaque`, and the summary is
804
+ truncated at 200 characters.
805
+ - **`createPendingMotionStore()`** hands out a per-approval **token**, so when two approvals overlap
806
+ the one that finishes first cannot wipe the other's preview. `read()` treats a preview older than
807
+ `PENDING_MOTION_TTL_MS` (10 minutes) as finished — which is how an approval that was interrupted
808
+ stops leaving a phantom target on screen.
809
+ - `lib/index.js` calls `begin` **before** the prompt and `end` in a `finally`, so approving,
810
+ rejecting and "the approval service threw" all clear the preview.
811
+ - The state route carries `pending_motion` **only while an approval is pending** (the field is
812
+ absent, not `null`), so the client can tell "nothing pending" from "pending but unparseable".
813
+ Unlike `detail`, the client deliberately does **not** carry `pending_motion` over from the
814
+ previous frame.
815
+
816
+ ### Added — bench instrumentation in the twin
817
+
818
+ - **Ghost arm** (`src/client/robot/ghost-arm.js`): the real arm is cloned and overlaid on the actual
819
+ pose, so following error, lag and blending are visible at a glance. Geometry and textures are
820
+ **shared** with the real arm (one extra layer costs almost no VRAM) — and for that same reason the
821
+ clone's geometry is **never disposed**: those are the very objects the real arm renders. A pending
822
+ target is orange, the controller's current target is cyan.
823
+ - **TCP axes** (`src/client/robot/tcp-axes.js`): a fixed 8 cm `AxesHelper` was too big on a UR3, too
824
+ small on a UR30, and unlabelled. The size is now `0.12 ×` the arm radius, clamped to
825
+ `[0.03, 0.3]`, with X/Y/Z sprite labels drawn from a canvas (no external font, and a graceful
826
+ degradation where there is no canvas). The scale has to be applied to an **inner** node: the outer
827
+ node receives the `tool0` matrix every frame, and `scale` does not survive `matrix.fromArray`.
828
+ - **Engineering overlays** (`src/client/robot/overlays.js`), all off by default: reach envelope,
829
+ load centre of gravity, and a TCP force/torque arrow (1 N dead zone, 0.6 m cap). The reach radius
830
+ is the sum of the first six link lengths and is documented as an **upper bound**, not a vendor
831
+ "reach" figure. The CoG marker is implemented but **deliberately not exposed** — the plugin has no
832
+ op that reads the payload back, so the button would do nothing.
833
+ - **Screenshot export** (`src/client/robot/screenshot.js`): a PNG watermarked with model, IP, safety
834
+ mode, speed scaling, joint angles, TCP and a local timestamp. The watermark is drawn on a **copy**
835
+ of the canvas, and that copy is taken **synchronously right after a render** — the context is
836
+ `preserveDrawingBuffer: false`, so yielding to the event loop first can hand back a fully black
837
+ image.
838
+ - **Trajectory controls**: clear / pause-resume / export CSV. The CSV carries **joint angles as well
839
+ as position**, without which an export cannot reproduce a pose. Colours now run from dark blue to
840
+ bright cyan with sample age, which shows direction and relative speed at once; when no timestamps
841
+ are usable it degrades to index order instead of producing `NaN`.
842
+ - **Accessibility**: the canvas is `role="img"` with an `aria-label` and `tabindex="0"`, and the
843
+ arrow keys / `Home` drive the existing view presets — currently the only way a keyboard-only user
844
+ can operate the 3D view. The HUD is `role="status"` + `aria-live="polite"` (announce changes, not
845
+ the whole block), toggle buttons carry `aria-pressed`, and truncated readouts get a `title`.
846
+
847
+ ### Added — the client strings are now a real table
848
+
849
+ - `src/client/strings.js` does not gamble on `@deepseek-ai/dsh-client-locale` (which is not installed
850
+ on this host): the `zh` and `en` key sets are **exactly symmetric** (a test asserts this both ways),
851
+ interpolated entries are functions, and `resolveLocale` looks only at the primary subtag and
852
+ **falls back to Chinese, not English**, when it cannot tell. The right-dock card and overlay read
853
+ their text from the same table.
854
+
855
+ ### Fixed — readback: tools that reported what they could not read
856
+
857
+ - **`ur_get_tool_telemetry` returned nothing but `null`.** The tool-side fields were missing from the
858
+ RTDE recipe *and* the vendored accessors were `NotImplementedError` stubs. The recipe now declares
859
+ them (`tool_*`, `io_current`, `target_speed_fraction`; `tool_output_voltage` is an `INT32` per the
860
+ RTDE documentation and reads 24000 for 24 V), and the tool reads `dataDir` directly.
861
+ - **`ur_get_tool_analog_in` now prefers a direct RTDE read** and only falls back to the
862
+ script-plus-register route when it has to — that route interrupts whatever program is running. The
863
+ readback register defaults to 22 to stay clear of `send_script`'s 23.
864
+ - **`ur_get_speed_scaling` no longer reads a standstill as "scaled to zero".** `speed_scaling` is the
865
+ *actual* fraction and `target_speed_fraction` is the *requested* one; both are reported now, with
866
+ the semantics spelled out in the reply.
867
+ - **`ur_get_freedrive_status` uses a sentinel token** to tell "this firmware does not support it"
868
+ from "the value really is 0". The old implementation read a stale register and called it normal.
869
+ - **`ur_power_off` takes the `PowerOn` bit of `robot_status_bits` as the answer**, so a controller
870
+ that stops answering *because it has just powered down* is no longer reported as a failure.
871
+ - **`ur_draw_circle` writes a start sentinel**, turning a blind 60 s timeout into an immediate
872
+ `NOT_EXECUTED` when the controller never ran the script.
873
+ - **`ur_move_tool_x/y/z` convert the tool-frame displacement to the base frame in the worker**
874
+ (`_tool_axis_move`): read the current TCP pose, rotate the delta by that pose's rotation matrix,
875
+ and send a base-frame `movel`. `pose_trans` is deliberately *not* used — it caused runtime aborts
876
+ on CB3 / URSoftware 3.15.
877
+
878
+ ### Fixed — two defects that were visible on screen
879
+
880
+ - **The fallback arm was always UR3-sized.** `buildFallbackArm()` unconditionally used `ARM_LINKS`,
881
+ so a model that failed to load fell back to geometry that did not match the robot in front of you.
882
+ Link lengths now come from `linkLengthsFromKinematics(kin.links)`, with a per-value fallback and
883
+ never a `NaN`/`0`/negative output — a single `NaN` vertex makes the whole subtree disappear.
884
+ - **The right-dock 3D view was an absolutely positioned overlay.** Its containing block was not the
885
+ dock's content box, so it measured wider than the dock and overlapped the conversation column. It
886
+ is now a normal-flow flex child, and the start-panel guide column is hidden inline (its previous
887
+ value is recorded and restored on collapse). The entry card prefers a **deep clone of the native
888
+ card** (theme, radius and icon slot all ride on hashed class names that cannot be re-created by
889
+ hand), falls back to a self-built card when the native one is absent, and upgrades to the clone
890
+ once the guide column renders. Clicking another card, or the column disappearing, collapses it.
891
+
892
+ ### Changed — worker backpressure
893
+
894
+ - `DEFAULT_MAX_IN_FLIGHT = 8` in `lib/worker.js`: past the limit a call is **rejected, not queued**.
895
+ The worker is single-threaded, so a call queued behind a stuck one only burns its own timeout; the
896
+ error now names the likely cause ("the robot may not be responding").
897
+
898
+ ### Added / Changed — gates and self-checks
899
+
900
+ - **`scripts/check-new-ops.py`** — the behavioural gate for every worker op added in 0.6.0: it
901
+ asserts the exact URScript sent to a fake controller (force mode, `speedj`, `optimove`, `movec`,
902
+ conveyor tracking, the drawing waypoint sequence) plus 14 `ValueError` cases that must fail closed.
903
+ - **`scripts/check-test-manifest.mjs`** reconciles `test/test-manifest.json` against the real Python
904
+ invocations under `test/**` **in both directions**: a test that needs Python but is not listed
905
+ fails, and so does one that is listed but never touches it.
906
+ - **`scripts/check-package-metadata.mjs`**: `main` / `exports` / `dsh.bundle.patch` must resolve, the
907
+ client bundle must register under the package name, `files` must cover the runtime and must not
908
+ include the machine-local `python/sitecustomize.py`, and no source file may hardcode the author's
909
+ paths. **`scripts/check-doc-tools.mjs`** reconciles the README tool tables against the real
910
+ registry (parsing table rows only — the previous full-text scan still passed after a row was
911
+ deleted), and **`scripts/check-tool-params.py`** enforces the cross-language parameter contract in
912
+ both directions. Two traps are worth recording: `check-host-compat.mjs` probes the installed
913
+ Desktop `node_modules` **before** walking up, because this repository's own dev copy of
914
+ `@deepseek-ai/dsh-tools` is older and would report a misleading "0/83 registered"; and
915
+ `check-tool-params.py` writes its diagnostics in Chinese while keeping stdout ASCII, so a Chinese
916
+ Windows console cannot kill the check with `UnicodeEncodeError`.
917
+ - `npm test` runs **9 gates** (4 Node + 5 Python) alongside **35 test files** (11 Python, 24 Node);
918
+ `check-host-compat.mjs` (83/83 tools against the host's real DSL, 4 routes) stays behind
919
+ `npm run verify:host`. All **44** items pass on this host (the Python half needs `numpy`/`paramiko`
920
+ importable — see `python/sitecustomize.py`).
921
+
922
+ - **`tool_mode` is not the tool output mode, and its meaning is unconfirmed.** It measures 253 on
923
+ UR30 / PolyScope 5.21 and does **not** change when `ur_set_tool_output_mode(0)` / `(1)` is called.
924
+ Reading it is fine — it is passed through raw from RTDE — but the fixture in
925
+ `test/readback-fixes.test.mjs` invented a plausible-looking `2`, which made the field read like a
926
+ mode enum. The fixture now uses the measured 253 and states that the field's meaning is open.
927
+ - **Program load/run/pause/stop behave honestly against a controller that has nothing to run.**
928
+ `ur_run_program` reports the controller's refusal verbatim (`Failed to execute: play`) rather than
929
+ claiming success, and `ur_load_program` rejects a name containing angle brackets — which is correct
930
+ (`<未命名>` is how PolyScope *displays* an unsaved program, not a filename), but it does mean the
931
+ `loaded_program` string cannot be fed straight back into `ur_load_program`.
932
+
933
+ - **`ur_get_conveyor` now proves its URScript actually ran, instead of reading back a register nobody
934
+ wrote.** The tick can only come from a URScript program, and a program the controller rejects at
935
+ *load* time executes zero lines and raises no "program execution error" — so the old code happily
936
+ read `output_double_register_0` and reported whatever happened to be there. That is exactly how the
937
+ wrong-function-name defect above stayed invisible for so long. The tool now probes the channel
938
+ first, then runs a payload bracketed by start/finish sentinels in an **int** register while the
939
+ tick lands in a **double** one — two different register families on purpose, so a broken payload
940
+ path cannot take the sentinel down with it and collapse every diagnosis into "nothing happened".
941
+ Three outcomes are reported separately: **ran to the end** (`verified: true`, tick is
942
+ trustworthy), **started but never finished** (`UNSUPPORTED`, no tick value), or **never executed**
943
+ (`NOT_EXECUTED`, no tick value, likely cause named). Verified on UR30 / PolyScope 5.21: the tool
944
+ reports `verified: true`, and reading `output_int_register_20` back independently returns exactly
945
+ the finish token it claims. Two optional parameters (`register`, `payload_register`) choose the
946
+ registers; both are overwritten.
947
+ - **`ur_get_double_register` shares the same read path as the int registers.** It called
948
+ `OutputDoubleRegister` directly, so a missing or throwing accessor meant an exception instead of a
949
+ value; it now uses a new `_read_double_register` helper that falls back to the raw RTDE field, the
950
+ way `_read_int_register` always has.
951
+
952
+ ### Fixed — found by a live pass against URSim (UR30 / PolyScope 5.21.3)
953
+
954
+ - **`ur_get_conveyor` could only ever return 0, because an earlier "fix" made the controller
955
+ reject the whole script.** URScript's function for writing a double register is
956
+ **`write_output_float_register`**; `output_double_register_0` is the **RTDE field name** for the
957
+ same datum. An earlier release treated the two as one name and "corrected" the call to
958
+ `write_output_double_register` — a function that does not exist. The controller therefore rejects
959
+ the entire program at load time, so not a single line runs; `waitRobotIdleOrStopFlag()` never
960
+ sees an execution error, and `get_conveyor_tick_count()` goes on to read back register 0 —
961
+ a value nothing ever wrote. The visible complaint became a **silent wrong answer**. Measured on
962
+ PolyScope 5.21 / UR30: `write_output_float_register(0, 42.5)` is accepted and
963
+ `output_double_register_0` reads back 42.5, while `write_output_double_register(0, 99.5)` is
964
+ rejected outright (the `ur_send_script` sentinel never lands). The call is reverted, `urScript.py`
965
+ now carries the evidence in a comment, and `test/readback-fixes.test.mjs` fails if the wrong name
966
+ ever reappears — the earlier change shipped with **no test covering it at all**, which is how it
967
+ got through.
968
+
969
+ - **Every non-ASCII string the controller sent came back as mojibake.** `dashboard.py`'s receive
970
+ path built its text with `''.join(map(chr, out))` — one code point per byte, i.e. Latin-1 — so
971
+ PolyScope 5's `STOPPED <未命名>` arrived as `<æªå½å>` and `(三月 14 2025)` as `(䏿 14 2025)`: a
972
+ Chinese program name was unreadable in every readout. It now decodes UTF-8, falling back to
973
+ Latin-1 so an older firmware that really does send single-byte text still works.
974
+ - **`program_state` / `runtime_state` kept the program name.** Unlike `safetymode` / `robotmode`
975
+ (which carry a `label:` **prefix**), `programState` answers *state **plus** program name* —
976
+ measured `STOPPED <未命名>` while idle, `PLAYING <name>` while running. `_enum_token` only strips a
977
+ prefix, so the client's bare-token table missed and the HUD showed `程序 STOPPED <未命名>` instead
978
+ of `程序 已停止`. A dedicated `_program_state` now strips the trailing `<program name>` at the
979
+ protocol boundary, while leaving the `"<查询失败>"` sentinel untouched. Same class of defect as
980
+ the 0.6.2 prefix fix — and the fixture repeated the same mistake (`ur_programState` was mocked as
981
+ a bare `"STOPPED"`), so `test/safety-status.test.mjs` now feeds the real `STOPPED <未命名>` and
982
+ pins the strip in both directions.
983
+ - **`ur_get_digital_in(which="tool")` raised an opaque error, and could report a stale value.**
984
+ Tool digital inputs are not on RTDE, so the worker runs a URScript program — and on a UR30/URSim
985
+ with no tool attached the controller **ends that program with a runtime error**. Upstream then
986
+ raised `RuntimeError: Robot program execution error!!!` (no information at all) and
987
+ `urScript.get_tool_digital_in` went on to **read back output register 0** — whatever was left
988
+ there — as if it were the input level. The failure now returns an actionable error naming the
989
+ likely causes (no tool connected, tool I/O not enabled, tool port taken by TCI serial), and the
990
+ failure path reads **no** register. Verified against the controller that the register path was
991
+ never the problem: `write_output_integer_register(7, 12345)` reads back as exactly 12345.
992
+
993
+ ### Documentation
994
+
995
+ - **Restored the missing `## [0.6.0]` heading.** The manual cross-check notes ("Tool count 67 → 83")
996
+ had been sitting inside the 0.6.1 entry since they were written, with no version heading of their
997
+ own; every other release has one.
998
+
999
+ ## [0.6.2] - 2026-09
1000
+
1001
+ > A compatibility pass against DSH `0.2.0-rc.2` (cross-checked against `0.1.7-rc.2`) found three
1002
+ > tools that never reached the model. Their parameter schemas used an explicit `required: false`,
1003
+ > which the value-schema DSL rejects outright — and a rejected parameter fails **the whole tool**,
1004
+ > leaving only a warning line. The table read 83 tools while 80 could actually be called.
1005
+
1006
+ ### Added — the twin panel now uses telemetry the host was already sending
1007
+
1008
+ - **`detail=1` had no consumer.** The route has always been able to return the dashboard-side
1009
+ state (`safety_mode`, `robot_mode`, `program_state`, `running`, `speed_scaling`,
1010
+ `joint_temperatures`, `joint_currents`, `robot_voltage/current`), but the client never asked
1011
+ for it. It is now polled on a **slow cadence over the existing chain** (`detailMs`, default 2 s)
1012
+ — no second poller, so "only one request in flight" still holds and the pose channel keeps its
1013
+ 10 Hz. Without `detail` in a response the previous values are kept, so the row does not flicker.
1014
+ - The HUD gained two rows (safety/robot/program/speed, then joint temperatures and bus
1015
+ voltage/current), and an abnormal safety mode now **takes over the status line** instead of hiding
1016
+ behind `已连接 · ur5e`. Unknown mode strings are shown verbatim **and treated as abnormal** — a
1017
+ firmware that adds a mode must not be able to make a protective stop look normal.
1018
+
1019
+ ### Added — view toolbar, bounding-box framing, on-demand rendering
1020
+
1021
+ - **The camera and the base grid are no longer hard-coded for a ~0.8 m arm.** Both are derived from
1022
+ the model's bounding box, so UR3 (≈0.94 m reach) through UR20/UR8long (≈2.4 m) all open correctly
1023
+ framed; `min/maxDistance`, `near/far` and the grid extent follow the same radius. The grid is
1024
+ rebuilt (and the old one disposed) whenever the model changes.
1025
+ - **A toolbar that did not exist before**: reset view plus isometric / front / side / top presets.
1026
+ `scene.js` had referred to a "reset view" control since it was written, but the panel never had
1027
+ one — the only way back from a bad camera was collapsing and reopening the panel.
1028
+ - **On-demand rendering**: a frame is drawn only when something actually changed (new sample,
1029
+ resize, model swap, camera moved, preset applied). Every frame used to render unconditionally,
1030
+ including while disconnected and while the arm was standing still.
1031
+ - **Background tabs stop the frame loop entirely** (`visibilitychange`) and resume on return.
1032
+ Polling had already been throttled when hidden; rendering had not.
1033
+
1034
+ ### Fixed — three tools were silently unregistered
1035
+
1036
+ - The array-parameter helper emitted `required` unconditionally, so the call sites that asked for
1037
+ an **optional** array produced `required: false`. The value-schema DSL accepts `required` only as
1038
+ `true` (`required must be true when present`); a property is declared optional by **omitting**
1039
+ the key. The helper now omits it instead of writing `false`.
1040
+ - Restored: `ur_set_conveyor_tracking` (`direction`, `center`), `ur_force_mode` (`task_frame`,
1041
+ `wrench`, `limits`) and `ur_set_payload_inertia` (`inertia`). `scripts/check-host-compat.mjs`
1042
+ now reports **83/83** against both `0.1.7-rc.2` and `0.2.0-rc.2`.
1043
+ - The rule is byte-identical in both harness versions, so this was never a compatibility regression:
1044
+ the plugin's own `test/tool-schema-dsl.test.mjs` could not see it because it stubs
1045
+ `@deepseek-ai/dsh-tools` and checks only the author-keyword allowlist — never the value of
1046
+ `required`.
1047
+
1048
+ ### Fixed — dashboard replies kept their label prefix, so `NORMAL` read as a hazard
1049
+
1050
+ - **The safety mode never reached the client as a bare token.** UR answers the `safetymode` /
1051
+ `robotmode` dashboard queries with a labelled line (`"Safetymode: NORMAL"`, `"Robotmode: RUNNING"`),
1052
+ and the worker passed that line through verbatim into `safety_mode`, `robot_mode` and
1053
+ `program_state`. The client looks those up in a bare-token table, so the lookup missed — and
1054
+ `isSafetyHazard`, which deliberately treats an unrecognised mode as abnormal, flagged `NORMAL`
1055
+ as one. The twin panel then pinned "the robot may have stopped; check the teach pendant" to its
1056
+ status line **permanently, moving or not**, and the prefixed string also leaked into the HUD row,
1057
+ the screenshot watermark and `ur_get_status`.
1058
+ - Fix: `_enum_token` strips the `label:` prefix at the protocol boundary — the one place that knows
1059
+ the raw dashboard wire format — and every enum-valued field now goes through it. Replies that
1060
+ carry no prefix (`programState` answers `"PLAYING"`) and the `"<查询失败>"` sentinel pass through
1061
+ unchanged.
1062
+ - `test/safety-status.test.mjs` had invented bare replies (`"PROTECTIVE_STOP"`), which is precisely
1063
+ why the suite stayed green. It now feeds the real labelled form and pins the normalisation as a
1064
+ contract.
1065
+
1066
+ ## [0.6.1] - 2026-09
1067
+
1068
+ > A full-project audit (four delegated audits + hands-on verification; reports in `docs/audit/`)
1069
+ > found defects that the suite could not see: a **complete bypass of the motion approval gate**,
1070
+ > a **shipped file that was not tracked by git**, `test:node` that could never pass, and two
1071
+ > motion ops that did not do what they documented. This release fixes all of them and turns the
1072
+ > audit's own checks into real gates.
1073
+
1074
+ ### Security — the approval gate could be bypassed entirely (host-audit H-1)
1075
+
1076
+ - **`lib/worker.js` builds the wire payload as `{ id, op, _timeout_ms, ...params }` — `params`
1077
+ spread last — while the gate tests the closure's `op`.** Because the DSH value-schema does not
1078
+ reject undeclared arguments, a model could call any *ungated* tool with an extra
1079
+ `{"op":"power_off"}` and the worker would run `op_power_off` with **no approval prompt at all**.
1080
+ Every one of the 38 gated ops (`movej`, `send_script`, `run_program`, `brake_release`,
1081
+ `force_mode`, `shutdown`, …) was reachable this way; an extra `id` additionally let a call hang
1082
+ for its full timeout and then kill the worker.
1083
+ - Fix: tool arguments are now filtered against **the tool's own declared parameter list** before
1084
+ they reach the worker, and `op` is always the closure value the gate decided on — it can never
1085
+ be influenced by an argument. Verified by `test/approval-gate.test.mjs`, which drives the real
1086
+ tool registry and asserts that an injected `op` / `id` / `_timeout_ms` never reaches the worker.
1087
+
1088
+ ### Security — the gate depended on the schema applying its default (host-audit M-6)
1089
+
1090
+ - `requireApprovalForMotion` was the only one of the four `Config` fields with no `??` fallback in
1091
+ `apply()`, so any caller passing a config object that had not been through schema parsing got
1092
+ `undefined` — silently **disabling the gate** (fail-open). The default now lives in code
1093
+ (`config.requireApprovalForMotion ?? true`) and a test pins it.
1094
+
1095
+ ### Fixed — the approval set was incomplete (host-audit H-2)
1096
+
1097
+ - `set_payload` was ungated while its twin `set_payload_inertia` was gated, although both send
1098
+ `set_payload_mass` + `set_payload_cog` and both **re-zero the force/torque sensor** — i.e. the
1099
+ same physical effect as the gated `zero_ftsensor`. `set_gravity` was ungated too, although
1100
+ mis-setting it makes the arm drop or float. Both are gated now, and the pairing is asserted so
1101
+ one of a pair cannot drift out of the gate again.
1102
+
1103
+ ### Fixed — two ops did not do what they documented (worker-audit HIGH-1 / HIGH-2)
1104
+
1105
+ - **`draw_square` / `draw_rectangle` wrote a distance into the rotation component.** For
1106
+ `coordinate="z"` the vertical edge did `wp[1][3] -= border` — index 3 is **rx in radians**, so
1107
+ `draw_square(border=0.2)` rotated the tool ≈11.5° instead of drawing a square. Both now use
1108
+ index 2 (z, metres); a test asserts the whole waypoint sequence and that the orientation
1109
+ components never change.
1110
+ - **`conveyor_tracking` sent every conveyor argument in the wrong slot.** It emitted
1111
+ `conveyor_pulse_decode(a, b, 0)` (first slot is the *decoder type*, and `0` means "pulse decoding
1112
+ disabled"), `set_conveyor_tick_count(0, ticks_per_meter)` (second slot is a 0–4 bit-width enum),
1113
+ and `track_conveyor_linear(p[0,…], speed)` / `track_conveyor_circular(p[0,…], radius, speed)`
1114
+ (the manuals want a direction/centre pose, ticks per metre/revolution, and a boolean). Rewritten
1115
+ against the manual with distinct `setup_pulse` / `setup_absolute` / `linear` / `circular` /
1116
+ `stop` actions, correct units, and encoder pin ranges per firmware (CB3 0–3, e-Series 8–11). It
1117
+ reports `hardware_verified: false` and says so, because the signatures are manual-verified but
1118
+ the tracking behaviour needs a real conveyor.
1119
+
1120
+ ### Fixed — a crash path in the worker's error handler (worker-audit MEDIUM)
1121
+
1122
+ - `ur_worker.py`'s unexpected-exception handler logs `req.get("op")`, but `req` is bound *inside*
1123
+ the `try` — so a malformed first line raised `UnboundLocalError` **inside the handler**, never
1124
+ answered the request, and killed the worker. Now `req` is initialised before the `try`;
1125
+ reproduced and re-verified with `scripts/probe-malformed-request.py`.
1126
+
1127
+ ### Fixed — a dying worker could clobber its replacement (host-audit H-3)
1128
+
1129
+ - `lib/worker.js`'s `exit` handler unconditionally cleared `this.proc` / `_spawnPromise` and failed
1130
+ **every** in-flight request. A killed child's `exit` can arrive after a new child has already
1131
+ spawned and started serving (the 500 ms restart backoff is the window, and the twin panel issues
1132
+ ~20 calls/s), so a healthy call could be rejected with "UR worker exited" while its child was
1133
+ alive, `stats().running` lied, and `dispose()` could leave an orphaned Python process holding the
1134
+ controller's exclusive RTDE session. The handler now only clears state it owns, fails only the
1135
+ requests routed to *that* child, and keeps per-child stderr.
1136
+ - `proc.stdin` now has an `error` listener: an EPIPE after the child dies was an unhandled
1137
+ `'error'` event, which would take down the whole DSH host rather than failing one call.
1138
+
1139
+ ### Fixed — release blockers found by auditing the repository itself
1140
+
1141
+ - **`python/URBasic/rtdeConfiguration.xml` was not tracked by git** although `package.json`
1142
+ publishes it, `test/rtde-config.test.mjs` requires it, and `rtde.py` prefers it over the
1143
+ vendored default (which has no `target_*` fields, no bit registers 32–63, and a `<send>`
1144
+ section). A fresh clone therefore silently lost the recipe. Now tracked.
1145
+ - **`npm run test:node` could never pass**: `test/selftest.test.mjs` spawns Python but was missing
1146
+ from `PYTHON_DEPENDENT`. The list now lives in `test/test-manifest.json` and
1147
+ `scripts/check-test-manifest.mjs` re-derives "which test files actually call Python" from the
1148
+ sources, so a missing entry fails a check instead of turning into a mysterious `exit 1`.
1149
+ - The interpreter probe now verifies **the dependencies the worker needs** (`import numpy`) rather
1150
+ than that Python can start: previously a Python without numpy made the ten Python-dependent
1151
+ files *run and fail* instead of being skipped, so an environment problem looked like a plugin
1152
+ defect.
1153
+ - **`ur_set_analog_out` never declared `full_scale`** although the worker reads it and both READMEs
1154
+ and the CHANGELOG advertised it — so a 0–20 mA port was unreachable (`value=16` was rejected
1155
+ with "must be between 0 and 10.0"). Declared, with the domain behaviour documented.
1156
+ - **`numberArray()` hard-coded `required: true`**, which made documented-optional arrays mandatory
1157
+ in the schema (`force_mode.task_frame` / `wrench` / `limits`, `set_payload_inertia.inertia`) and
1158
+ made `set_payload_inertia`'s documented CB3 fallback unreachable. `required` is now explicit.
1159
+ - English README: the approval-gate list was missing every 0.6.0 op, and the install snippet still
1160
+ pinned `^0.4.0` (which installs a pre-0.5.0 release).
1161
+
1162
+ ### Added — the audit's checks are now gates that run in `npm test`
1163
+
1164
+ - **`test/approval-gate.test.mjs`** (new, 9 cases): drives the real tool registry and proves that a
1165
+ gated op never reaches the worker before approval, that all four fail-closed paths reject
1166
+ (no approval service / no agent / the service throws / any non-`allowed-once` answer), that
1167
+ read-only ops are not bothered, that `requireApprovalForMotion: false` really disables it, and
1168
+ that argument injection cannot re-route a call.
1169
+ - Six audit scripts were print-only and always exited 0 — the exact "a green check proves nothing"
1170
+ failure the project warns about. `check-worker-ops.py`, `check-approval-gate.py`,
1171
+ `check-rtde-recipe.py` and `check-tool-params.py` now **exit non-zero** on a real problem, and
1172
+ all of them (plus `check-test-manifest`, `check-package-metadata`, `check-doc-tools`, and a new
1173
+ `check-client-bundle.mjs`) are wired into `npm test` and `npm run check`.
1174
+ - `check-client-bundle.mjs` replaces the literal-sampling heuristic (which either looked at 6 of
1175
+ ~113 literals or produced false alarms) with a **deterministic rebuild-and-compare**: it rebuilds
1176
+ `src/client/**` into a scratch file and compares the result with the committed bundle, so a stale
1177
+ artifact fails the build. It never overwrites the committed bundle — `build-client.mjs` gained
1178
+ `BUILD_CLIENT_OUT` for that.
1179
+ - `scripts/probe-malformed-request.py` and `scripts/probe-sendprogram-blocking.py` keep two
1180
+ hardware-free probes: the first reproduces the malformed-line crash path, the second is the
1181
+ executable evidence that entering a never-ending mode (force/freedrive) does **not** stall later
1182
+ sends (measured 0.25 s — recorded so it is not re-investigated from source alone).
1183
+
1184
+ ### Documentation
1185
+
1186
+ - `docs/audit/` — four audit reports with `file:LINE` citations and quoted code
1187
+ (`worker-audit.md`, `host-audit.md`, `client-vendored-audit.md`, `verification-audit.md`), plus
1188
+ `round2-verification.md`, which records the hands-on verification, **corrects three over-rated
1189
+ HIGH findings** in the delegated reports, and lists what was checked and found clean (approval-set
1190
+ completeness, RTDE recipe headroom, package metadata, bundle freshness, `SendProgram` blocking).
1191
+
1192
+ ## [0.6.0] - 2026-09
1193
+
1194
+ > This release re-checked the plugin, line by line, against the three official URScript manuals
1195
+ > bundled in `ScriptManual/` (`scriptManual_3.15.4.pdf` = URSoftware 3.x / CB3,
1196
+ > `script_directory_Poly5.pdf` = PolyScope 5 / e-Series, `script_directory_PolyscopeX.pdf` =
1197
+ > PolyScope X / 10.x). It adds the capabilities the manuals document but the plugin lacked, fixes
1198
+ > three places where the **defaults or semantics disagreed with the manual**, and records the
1199
+ > cross-check in [`docs/urscript-manual-analysis.md`](./docs/urscript-manual-analysis.md).
1200
+ > Tool count 67 → **83**.
1201
+
1202
+ ### Added — force control (`ur_force_mode` / `ur_end_force_mode` / `ur_force_mode_settings`)
1203
+
1204
+ - A whole capability the plugin did not have at all: force mode makes the arm compliant along/about
1205
+ selected axes while it keeps applying a requested force/torque (sanding, assembly, surface
1206
+ following, any pressing process). Arguments match the manual one by one — `task_frame` /
1207
+ `selection_vector` (1 = compliant) / `wrench` / `type` (1-3) / `limits` (compliant axes = max TCP
1208
+ speed, stiff axes = max allowed deviation) / `damping` (0-1) / `gain_scaling` (0-2).
1209
+ - The implementation follows the manual's semantics of force mode as a **continuing state**: the
1210
+ script runs `while True: force_mode(...); sync() end` so it stays in force mode on the controller
1211
+ (the same shape URBasic uses), so `ur_force_mode` only *enters* it — leave with
1212
+ `ur_end_force_mode` (which preempts that program and calls `end_force_mode()`) or by stopping the
1213
+ program on the pendant.
1214
+ - The manual's recommended `sleep(0.02)` before entering force mode is inserted (Poly5 15.12 requires
1215
+ it to avoid motion along compliant axes and high deceleration), and the arguments are validated
1216
+ locally: `selection_vector` may only contain 0/1, `type` ∈ 1/2/3, `damping` ∈ [0,1],
1217
+ `gain_scaling` ∈ [0,2] — out-of-range values either make the controller refuse the script or make
1218
+ force mode unstable.
1219
+ - ⚠️ Stated plainly: `damping` / `gain_scaling` **cannot be read back from the controller** (the
1220
+ manuals only define `force_mode_set_*`), so `ur_force_mode_settings` reports what it set and
1221
+ returns `readback_supported: false` instead of pretending to read the current value.
1222
+
1223
+ ### Added — velocity control and standstill (`ur_speedj` / `ur_speedl` / `ur_stopj` / `ur_stopl` / `ur_wait_steady`)
1224
+
1225
+ - `speedj(qd, a, t)` / `speedl(xd, a, t, aRot)` / `stopj(a)` / `stopl(a, aRot)` with the manuals'
1226
+ signatures. Omitting `aRot` follows the manual's `aRot='a'` semantics (same value as `a`).
1227
+ - ⚠️ Velocity commands are **open-ended**: the manuals state that with `t` omitted the function
1228
+ returns once the target speed is reached — **returning is not stopping**. The tool's answer says
1229
+ so explicitly ("the arm is still moving; finish with stop*/wait_steady") and never pretends the
1230
+ motion is over when `t=0` (the default).
1231
+ - `ur_wait_steady` implements "wait until the robot is at rest" **without** URScript's `is_steady()`
1232
+ (Poly5 16.39 documents that it always returns false in force/teach mode, and it is an expression
1233
+ that needs an extra program plus register read-back). It polls the `actual_TCP_speed` and
1234
+ `actual_qd` fields that are **already in the 500 Hz RTDE stream** — no extra round trip, and it
1235
+ still gives a true answer while force mode is active. On timeout it does not fail: it reports
1236
+ `steady: false` together with the measured speeds.
1237
+
1238
+ ### Added — target values (`ur_get_target_values`) and a wider RTDE recipe
1239
+
1240
+ - Reads **where the controller is taking the arm**: target joint positions/velocities/accelerations
1241
+ and target TCP pose/speed (with the actual values alongside for comparison). This is the direct
1242
+ evidence for "command sent but not executed yet / being blended / held back by the safety limit /
1243
+ program cancelled".
1244
+ - Implemented by adding `target_q` / `target_qd` / `target_qdd` / `target_TCP_pose` /
1245
+ `target_TCP_speed` to the receive recipe in `URBasic/rtdeConfiguration.xml` (74 → 79 fields; UR
1246
+ allows 96 data values, and the two `output_bit_registers*` entries count as one), and by turning
1247
+ the five matching `NotImplementedError` stubs in `RobotModel` into real RTDE reads.
1248
+ - This is more reliable than sending a URScript expression and reading a register back: zero round
1249
+ trips, it does not interrupt a running program, and it cannot fail because the expression is
1250
+ missing on older firmware.
1251
+
1252
+ ### Added — tool-side configuration and telemetry (4 tools)
1253
+
1254
+ - `ur_set_tool_communication`: the tool-flange serial interface (TCI / RS-485), validated against
1255
+ the manual's `set_tool_communication(enabled, baud_rate, parity, stop_bits, rx_idle_chars,
1256
+ tx_idle_chars)` (only the eight documented baud rates, parity 0-2, stop bits 1-2, idle chars
1257
+ within the documented ranges). ⚠️ The description repeats the manual's warning that **enabling
1258
+ TCI disables the tool analog inputs**.
1259
+ - `ur_set_tool_output_mode`: tool output mode 0 = normal, 1 = power (dual-pin supply).
1260
+ - `ur_set_payload_inertia`: mass + centre of gravity + inertia matrix in one call
1261
+ (`set_target_payload`, PolyScope 5.10+), validating the manual's rules (Ixx/Iyy/Izz non-negative,
1262
+ every element |I| ≤ 133 kg·m²) and supporting `transition_time`. `set_target_payload` is used
1263
+ **only when an inertia matrix is supplied**; without one the tool falls back to
1264
+ `set_payload_mass` + `set_payload_cog` (CB3 3.x has no `set_target_payload`, so both paths work).
1265
+ The description also carries the manual's two notes: setting a payload **automatically re-zeros
1266
+ the force/torque measurement**, and `set_payload(m, cog)` **resets** the inertia matrix (the
1267
+ manual marks it deprecated).
1268
+ - `ur_get_tool_telemetry`: tool output current/voltage and I/O current (RTDE fields, free to read).
1269
+ The manuals also document `get_tool_temp()`, but the current recipe has no matching RTDE field, so
1270
+ that value is **not** provided — the description says why rather than inventing a number.
1271
+
1272
+ ### Fixed — defaults that disagreed with the manual (`ur_movej` / `ur_movel`)
1273
+
1274
+ - **`ur_movel`'s default speed was four times the manual value**: the old code used `a=1, v=1`
1275
+ while the manual (Poly5 15.30 / PolyScope X 15.29) says `a=1.2, v=0.25` (250 mm/s). On a cobot
1276
+ "no speed given" and "run at 1 m/s" are very different things; the defaults are the manual's now.
1277
+ - **`ur_movej` had the same problem** (old `a=1, v=1`; the manual says `a=1.4, v=1.05`).
1278
+ - The tool descriptions now also state the manual's other rule: **supplying `t` overrides `a`/`v`**
1279
+ ("Time setting has priority over speed and acceleration settings.") — the thing models get wrong.
1280
+
1281
+ ### Fixed — `ur_movec` was missing the manual's `mode` argument
1282
+
1283
+ - The manual's `movec(pose_via, pose_to, a, v, r, mode)` (Poly5 15.28) uses `mode` to pick the
1284
+ orientation interpolation: `0` interpolates from the current pose to the target, `1` keeps the
1285
+ orientation constant relative to the **tangent** of the arc (fixed-orientation circular motion).
1286
+ The old code never sent the argument at all, so a fixed-orientation arc meant bypassing the tool.
1287
+ It is sent now, defaulting to 0 exactly as the manual does.
1288
+
1289
+ ### Added — OptiMove, Motion Version and freedrive singularity (`ur_move_optimized` / `ur_motion_version` / `ur_get_freedrive_status`)
1290
+
1291
+ - `ur_move_optimized` exposes `optimovej(goal, a=0.3, v=0.3, r=0)` / `optimovel(...)` (Poly5
1292
+ 15.32/15.33, PolyScope X 15.31/15.32): the same targets as `movej`/`movel` but with jerk-limited
1293
+ speed profiles, so the motion is smoother and vibrates less. ⚠️ The manual is explicit that `a`
1294
+ and `v` are **fractions of what the robot is able to do** — `a, v ∈ (0, 1]`, where 1 is the fastest
1295
+ the robot can manage in that configuration — *not* rad/s or m/s. The tool validates that range and
1296
+ says so in its description, because passing `a=1.4` (a perfectly good `movej` argument) would be
1297
+ out of range here. The manual's `struct{pose, frame}` and world-model-object-name goal forms are
1298
+ **refused explicitly** (they need PolyScope-side objects; a wrong script would only produce a
1299
+ runtime error on the controller), and `goal_type` selects `joints` (optimovej) or `pose`
1300
+ (optimovel).
1301
+ - `ur_motion_version` sets the Motion Version (manual chapter 14) and/or the **jerk gain**
1302
+ (`jerk_gain_scaling_set`, 0.01-1.0). Version 2 clamps velocity/acceleration to the hardware limits
1303
+ during planning and shrinks overlapping blend radii dynamically, where version 1 skips the move and
1304
+ emits an "Overlapping Blends" warning; the jerk gain only affects jerk-limited profiles — version-2
1305
+ `movej`/`movel` and `optimovej`/`optimovel`, which is exactly what `ur_move_optimized` uses. ⚠️ The
1306
+ manual states that newer models and PolyScope X support **only version 2**, and CB3 has no such
1307
+ setting at all. Neither setting has a read-back channel, so the tool reports what it set
1308
+ (`readback_supported: false`) instead of pretending to read the current value.
1309
+ - `ur_get_freedrive_status` reads `get_freedrive_status()` (PolyScope X 15.20): how close the current
1310
+ pose is to a **singularity** during freedrive — `0` normal, `1` near, `2` too close (noticeable
1311
+ resistance). It is **not** an on/off flag for freedrive, and the manual's point is precisely that
1312
+ constrained freedrive degrades near singularities, so this is what tells an operator to follow a
1313
+ different path. The read goes through an output register (default int register 21, whose previous
1314
+ value is overwritten); on firmware predating the function it reports honestly that nothing came
1315
+ back rather than inventing a value.
1316
+
1317
+ ### Fixed — `servoj` ranges were the 3.x ones
1318
+
1319
+ - The `t` default moved from `0.008` in 3.15.4 to `0.002` in Poly5 15.44 / PolyScope X 15.43 (the
1320
+ manual's preferred "new setpoint every timestep"), and the `lookahead_time` lower bound moved from
1321
+ `0.03` to `0.01`. The tool keeps `0.008` as its default (CB3 compatibility) but now **accepts
1322
+ `t ≥ 0.002` and `lookahead_time ≥ 0.01`**, so a caller following the newer manual is no longer
1323
+ rejected by a stale range.
1324
+
1325
+ ### Fixed — `run-tests.mjs` honoured `PYTHON` but not `UR_PYTHON`
1326
+
1327
+ - The README and `test/selftest.test.mjs` have always used `UR_PYTHON || PYTHON || 'python'`, while
1328
+ the runner's probe only read `PYTHON` — so `UR_PYTHON=... npm test` would report "no Python
1329
+ found", skip every Python case, and contradict the skipped tests that could actually run. The
1330
+ probe now follows the documented convention.
1331
+
1332
+ ### Tooling / documentation
1333
+
1334
+ - New `scripts/pdf-extract2.py`: a **dependency-free** (standard library only) text extractor for the
1335
+ three manual PDFs. Both PolyScope manuals embed subset CID Type0 fonts (`/Encoding /Identity-H`),
1336
+ so the character codes in their content streams are **glyph ids** and the text can only be
1337
+ recovered through each font's `ToUnicode` CMap; 3.15.4 uses plain WinAnsi Type1 fonts where the
1338
+ byte *is* the character code. The script handles both, rebuilds word spacing from baseline and
1339
+ kerning, and drops the rotated copyright watermark stamped on every page. Output lands in
1340
+ `ScriptManual/txt/*.txt` (page-separated) and is the evidence behind every signature/default
1341
+ checked in this release.
1342
+ - `docs/urscript-manual-analysis.md`: function signatures, parameter ranges, version differences
1343
+ (3.15.4 → PolyScope 5 → PolyScope X additions / removals / renames / deprecations) and the safety
1344
+ limits the manuals state — each with a manual line citation.
1345
+ - Tool-count gates updated: `EXPECTED_TOOLS` in `test/tool-schema-dsl.test.mjs` and
1346
+ `scripts/check-host-compat.mjs` went 67 → 83, along with both READMEs' tool tables, the approval
1347
+ list, and `67/67` → `83/83`.
1348
+
1349
+ ## [0.5.0] - 2026-09
1350
+
1351
+ > An audit-and-hardening release whose theme is **"can you still believe it?"**. A code audit found
1352
+ > and fixed defects that made the plugin **hang forever**, **report success it never had**, and
1353
+ > **read another command's answer**. Tool count 53 → 67.
1354
+
1355
+ ### Fixed — defects that wedged the plugin permanently
1356
+
1357
+ - **One dropped link killed the worker for good (`RealTimeClient.__sendPrg` retried without bound)**: upstream's send loop `while not stopRunningFlag and not programSend` has **exactly one exit — a successful send**. With the robot offline, `select.select([], [None], …)` raises `TypeError` at once → bare `except` → `__connect()` (itself a 60 s loop) → **never returns**. This plugin's worker is **single-threaded** (`for line in sys.stdin`), so one dropped link occupies the whole call chain: Node's 60 s timeout fails only *that one* promise while the process lives on, and **every subsequent tool call** queues up until it times out too. Sends now have a hard budget (`__sendTimeout`, default 15 s), record the reason in `lastSendFailure`, and return `False` up the stack; `lib/worker.js` now **kills the child on timeout** (and the next call respawns a clean worker after a 500 ms backoff), so a caller only needs one retry.
1358
+ - **The RTDE receive thread "successfully" exited after 60 s, orphaning a live-but-dead connection**: `rtde.py`'s receive loop reused `__reconnectTimeout` (a "how long to establish" budget) as a **run-time window**; on expiry the loop ended having sent only PAUSE and **without closing the socket** ⇒ `isRunning()` stayed true (state sat at PAUSED) while data never updated again, and every later pose read blocked in `dataEvent.wait()`. Run time now uses a **separate data watchdog** (`__dataTimeout`, 30 s without any RTDE packet rebuilds the session), `__wait()` takes a timeout, and `__receive()` returns instead of raising `TypeError` when the socket is gone. **`dashboard.py` had the same defect**: its receive loop exited after 2 s without closing the socket, after which every dashboard command blocked forever in `wait_dbs()`; `wait_dbs()` now has a timeout (2 s default) and the loop only ends on `__stop_event`.
1359
+ - **Every "read the joint angles" call could spin forever**: `urScript.sync()` (`while RobotTimestamp() == initialRobotTime: sleep(0.001)`) and `waitRobotIdleOrStopFlag()` had no exit at all. The former is on the path of every `get_actual_*` (which default to `wait=True`); the latter is used by `get_tool_digital_in` / `get_conveyor`. Both now time out with a readable `TimeoutError`.
1360
+ - **The RTDE thread died outright on the first failed connect**: `rtde.py`'s `__connect()` wrote `self.sock` in its `except` — **an attribute that does not exist** — so a failure raised `AttributeError` out of `run()`: the real socket error was masked, the intended 60 s retry never happened, and `isRunning()` stayed false forever. Now `self.__sock`, closed defensively.
1361
+ - **`Dashboard.wait_dbs()` waited forever**: it now requires a timeout, and `Dashboard.__send()` waits for the answer with one.
1362
+ - **`_onLine` dropped protocol lines silently**: `lib/worker.js` now counts unparseable lines and unknown-id responses (via `stats()`) and keeps the evidence — they used to vanish entirely, surfacing as an inexplicable timeout.
1363
+ - **Graceful shutdown never actually worked**: the newly added "shut down the controller" tool and the host's graceful worker shutdown **collided on the op name** (`shutdown`), so the latter was parsed as the former: it answered `KeyError: 'ip'` and never exited the worker. The host now uses `shutdown_worker`, and a **cross-language contract test** was added (every host op must exist in Python's `HANDLERS`; an op name must not collide with a dashboard command). The end-to-end `selftest.test.mjs` case is what caught it.
1364
+
1365
+ ### Fixed — success reported where there was none, and answers read from the wrong command
1366
+
1367
+ - **`ur_movec` never sent a circular move**: `UrScript.movec()` hardcodes `movetype='p'` internally (`_move(movetype='p', …)`), so the `if movetype == 'c'` branch was dead ⇒ **`pose_via` was discarded entirely and the robot received a `movep`** (linear/blended) while the tool reported `movec(p…, p…)`. Even with `movetype='c'`, that branch emits an unsubstituted template. It now builds raw URScript, like `op_draw_circle`. (Verified by executing the real vendored module: the old path really does emit `movep(...)`.)
1368
+ - **The "arrived" test was not an orientation metric**: `_right_pose_tcp` compared the three axis-angle components, so `[0,0,2π]` vs `[0,0,0]` (**the same orientation**) read as 6.28 apart and reported "movement ended but target not reached", while `[0.05,0,0]` vs `[0,0,0.05]` (**different orientations**) passed. It now uses the angle `θ = acos((tr(Rₐᵀ R_b) − 1)/2)` with a 0.05 rad tolerance; position keeps its 10 mm linear tolerance.
1369
+ - **All four drawing ops reported "execution complete" unconditionally**: `_wait_robot_idle()` polled "is it running?" **immediately** after `SendProgram()` — which only queues bytes (the controller starts the script later) — so the first probe almost always saw "not running" and answered `ok: true`, whether or not the script ever ran. It must now **first observe the script actually running** before accepting "idle"; otherwise it reports honestly that the script was never seen to start (the controller most likely never executed it).
1370
+ - **The confirmation pollers read the previous dashboard command's answer**: `Dashboard.__send()` waited for *any* notification, **never checking that the answer belonged to the command**, and `last_respond` was only overwritten when a message actually arrived. The classic failure: a previous `isProgramSaved` had just answered "True", so the next `is in remote control` reported `remote_control: true` for a robot **not in remote mode** — the model then sent URScript that the controller **silently discards**, with the tool reporting success throughout. `Dashboard.sendCommand(cmd)` now clears `last_respond` first and accepts only an answer that arrives after the send; every dashboard read in the worker goes through `_dashboard_send()` / `_dashboard_cmd()`.
1371
+ - **`ur_set_tool_voltage` could never succeed**: it called `UrScript.set_tool_voltage()`, a `NotImplementedError` stub, so **every** call raised and answered `ok:false`. It now sends `set_tool_voltage(N)` URScript directly and validates that N is 0/12/24.
1372
+ - **`ur_set_analog_out` had the wrong unit**: URScript's `set_analog_out(n, f)` takes a **relative level f∈[0,1]**, while the tool documented "0-10 or 0-20". The old code passed the caller's number straight through ⇒ `value=5` became f=5 ⇒ the port drove **full scale** (≈10 V), with no range check at all. It now accepts engineering units (`full_scale` may be 20 for a current-domain port), converts to [0,1], reads the value back, and returns `value` / `fraction` / `read_back`.
1373
+ - **Configurable digital inputs were masked 8 bits off**: `RobotModel.ConfigurableInputBits(n)` computed `pow(2, n + 8)`, but `actual_digital_input_bits` is bit 0-7 standard DI, 8-15 configurable DI, 16-17 tool DI ⇒ `ur_get_digital_in(which="config", n=8)` actually read **tool DI 0**, n=9 read **tool DI 1**, and n≥10 always returned false. `ConfigurableOutputBits` had the identical offset. The plugin's own bulk reader (`_bit_masks`) was always correct, so two tools disagreed about the same robot. Both now use `n - 8`.
1374
+ - **`RobotStatus()` / `SafetyStatus()` raised `TypeError` before RTDE data arrives**: `1 & None` throws (`&` binds tighter than `==`), and that exception escaped `RealTimeClient.__waitForProgram2Finish` and **killed the guardian thread**, leaving `rtcProgramRunning = True` forever and sending `waitRobotIdleOrStopFlag()` into an endless spin. Both now return **all-false** ("state unknown") when the word is missing.
1375
+ - **`ActualJointVoltage()` returned the joint currents**: the name says voltage, the body read `actual_current` (amperes), while the real `actual_joint_voltage` sat in the RTDE recipe with no accessor at all. Fixed, along with the stubbed-but-available `ActualCurrent()` / `ActualQD()` / `SpeedScaling()` / `StandardAnalogOutput(n)`.
1376
+ - **`ur_run_program` / `ur_stop_program` / `ur_pause_program` ignored the answer**: dashboard replies like "could not understand / not allowed / failed" used to count as success. They now report only "dispatched" and **include the running state** so the caller has something to judge by.
1377
+ - **Dashboard fields in `ur_status` could all be stale**: every field now goes through a fresh-answer read, and an unreadable field is marked `<查询失败>` instead of passing an old value off as current.
1378
+ - **NaN / Infinity could reach the controller**: JSON permits `NaN`, `float("nan")` passed every check, and `movel(p[nan,…])` went out. On the way back Python writes non-finite values as `NaN` — not legal JSON — so Node's `JSON.parse` threw and `_onLine` **dropped the line silently**, leaving the caller with a generic "timed out". All numeric entry points now reject non-finite values and `respond()` uses `allow_nan=False` to fail loudly.
1379
+
1380
+ ### Added — UR capability (14 tools, 53 → 67)
1381
+
1382
+ - **Freedrive / teach mode**: `ur_set_freedrive`, `ur_set_teach_mode` (move the arm by hand; how to exit is spelled out in the description). Implemented with a never-ending URScript program, which is what `freedrive_mode()` semantics require, and which avoids the URBasic wrapper blocking a single-threaded worker.
1383
+ - **Power and brakes**: `ur_power_on`, `ur_power_off`, `ur_brake_release`, and `ur_shutdown` (controller power-off). Each carries a ⚠️ note about its physical consequence (loss of rigidity, possible fall under gravity).
1384
+ - **Protective-stop unlock only**: `ur_unlock_protective_stop` — unlike `ur_reset_error` it does **not** power on or release the brakes (the latter can set the arm moving); it re-reads the safety status bits afterwards and returns them.
1385
+ - **Live telemetry**: `ur_get_runtime_telemetry` returns joint currents/voltages/speeds, TCP speed and wrench, tool accelerometer, speed scaling, robot voltage/current and joint temperatures in one call. **These fields are already in the 500 Hz RTDE stream** (`URBasic/rtdeConfiguration.xml`), so reading them is free and needs no reconfiguration — no tool had ever exposed them. Plus the focused `ur_get_speed_scaling` and `ur_get_tcp_force`.
1386
+ - **Installation and I/O configuration**: `ur_set_gravity` (gravity direction for non-horizontal mounting, normalised, zero vector rejected), `ur_zero_ftsensor` (zero the force/torque sensor), `ur_get_tool_analog_in` (tool analog input; the function name was confirmed against the repository's official script manuals, but the **argument semantics are not verified on hardware** — it reports likely causes and allows a different read-back register when it cannot read).
1387
+ - **Conveyor tracking**: `ur_set_conveyor_tracking` (`linear` / `circular` / `stop`); previously only the tick counter could be read or written.
1388
+
1389
+ ### Added — digital twin (panel and routes)
1390
+
1391
+ - **Failure reasons are finally visible**: host failure responses now carry a machine-readable `code` (`no_robot` / `robot_not_connected` / `ambiguous_robot` / `worker_unavailable` / `robot_error`), echo the `ip` that was resolved, and list the candidate `ips` when ambiguous. The client used to render one fixed "not connected" line, so "the worker process is gone", "you have not connected yet" and "two robots are connected, pick one" looked identical (and the ambiguous case would poll forever). Each now maps to distinct wording and advice.
1392
+ - **`detail=1` slow channel**: adds the dashboard-side state (safety mode, robot mode, program state, running, speed scaling, joint temperatures/currents). The pose channel keeps polling independently at 10 Hz, and a failing detail query **never** takes the pose channel down.
1393
+ - **`/dsh-nonead-ur/twin/models`**: lists the GLBs actually present, so "the robot reports a model we have no asset for" is no longer a silent fallback.
1394
+ - **Asset caching**: GLB responses carry a content-hash strong `ETag` plus `immutable`, and honour `If-None-Match` with a 304. Closing the panel releases the model handle, and without validators **every reopen re-downloaded and re-parsed 1.5-3.5 MB**.
1395
+ - **State responses are uncacheable** (`Cache-Control: no-store`, and the client fetches with `{cache:'no-store'}`): a cached pose is worse than no pose.
1396
+ - **Robot model memoised per IP**: the twin's 10 Hz poll used to ask for the robot model (a Dashboard round trip) **every time**, although the name cannot change while the connection lives. The host caches it now and invalidates on reconnect.
1397
+
1398
+ ### Fixed — digital twin client
1399
+
1400
+ - **Every panel open leaked a WebGL context**: three's `renderer.dispose()` does **not** release the context (it only drops internal caches and removes its own context-lost listeners), and the panel creates a fresh canvas + renderer on each mount. Browsers cap live contexts at around 16 before evicting the oldest ⇒ the twin, or another WebGL view such as the task board, goes black. `dispose()` now calls `renderer.forceContextLoss()`, releases the never-released `GridHelper` geometry/material, and listens for `webglcontextlost` / `webglcontextrestored` to report a GPU reset honestly.
1401
+ - **Blurry on HiDPI displays**: `setPixelRatio` was never called (three defaults to 1). It now follows `devicePixelRatio` (capped at 2). A `ResizeObserver` was added too: the right dock is user-resizable while `window.resize` never fires for it, so the canvas used to be stretched by CSS until the window changed.
1402
+ - **A 0×0 canvas measurement was never retried**: a freshly opened panel may not have resolved its flex height yet, and the old code simply skipped — forever — leaving the canvas at its default backing store, stretched by CSS. It is now flagged and retried every frame.
1403
+ - **Without WebGL the panel was a permanent fake "loading" plus an unhandled rejection**: `new WebGLRenderer` threw outside any try/catch, turning into an unhandled promise rejection while the status line stayed on "loading model…" and the canvas stayed blank. It now degrades to a **numeric-only mode** (joints and TCP keep updating) and says why.
1404
+ - **The trajectory buffer filled up with duplicate frames**: `pushTrajectorySample()` ran **every frame** (60 fps) while sampling is 10 Hz ⇒ the same `ts` was pushed about six times, so the 600-point capacity held only ~100 real samples (≈10 s instead of the documented 60 s) and rebuilt ~600 arrays per frame. It is now pushed only on a new sample, with a regression test.
1405
+ - **The numeric panel showed interpolated values**: the joint row displayed the value computed to smooth the mesh while the TCP row used the raw sample, so the rows had different time bases (up to one sample period plus the render delay). Both now use the **measured sample** (interpolation serves the mesh only), and the rows carry a `title` so J5/J6 are readable despite the ellipsis.
1406
+ - **TCP orientation was mislabelled as RPY**: `rx/ry/rz` is UR's **axis-angle rotation vector** (direction = axis, magnitude = angle in radians), not roll/pitch/yaw. The HUD now says `TCP(m/axis-angle rad)`.
1407
+ - **The status line ignored changing failure reasons**: while disconnected, `applyConnection` returned early, so when the host's reason changed (say from "never connected" to "worker is gone") the UI kept showing the first one.
1408
+ - **An in-flight request could not be cancelled**: `stop()` only cleared the timer, so an issued request still ran to completion (up to the command timeout). It is now aborted via `AbortController`, distinguishing a deliberate cancel (no error snapshot) from a real failure.
1409
+
1410
+ ### Added — engineering hygiene
1411
+
1412
+ - **`npm test` now actually runs the tests** (it used to run one worker ping; the other 20-plus `*.test.mjs` files — client polling state machine, FK, model contract, twin routes, vendored-library defects — **were not run at all**, so a green `npm test` said nothing about the plugin). The new `scripts/run-tests.mjs` enumerates and runs all 21 files and summarises; `test/selftest.mjs` became a real `test/selftest.test.mjs` (end-to-end protocol self-check including a graceful-shutdown case — **which is exactly what caught the op-name collision above**).
1413
+ - **`test/vendored-fixes.test.mjs` + `test/ur-python-harness.py`**: pin every vendored defect fixed here as a **behaviour** gate (not "is that code present in the source"). The probe deliberately uses **real loopback sockets** instead of fakes: `__sendPrg` goes through `select.select`, which only accepts real fds, so a fake object would raise "argument must be an int" and bypass the very path under test.
1414
+ - **`test/tool-schema-dsl.test.mjs` grew cross-language contracts**: every host op must exist in Python's `HANDLERS`; an op name must not collide with a dashboard command; one op may not back two tools; tool names must be unique.
1415
+ - **Dead code removed**: `src/client/sidebar-entry.js` and `src/client/thumbnail.js` were no longer in the build graph (0.4.1's changelog already said "deleted", yet the files and their tests remained, making coverage look better than it was).
1416
+ - **`scripts/build-client.mjs` prefers the standalone esbuild binary**: the JS API validates that the JS package and the platform binary **share a version**, and this checkout has them out of sync (JS 0.25.12 / binary 0.25.0) ⇒ `Cannot start service`, and the build simply failed. The standalone binary needs no pairing; banner/footer are passed as inline text (the CLI's `--banner:js=` takes text, not a file path), with the JS API kept as a fallback.
1417
+ - **`scripts/check-host-compat.mjs` prefers a real DSH installation**: this repository's own `node_modules` also contains an (older, devDependency) `@deepseek-ai/dsh-tools`, and validating the DSL and peer versions against it yields misleading conclusions. Against the real install the check now reports **67/67 tools registering through the host's real DSL**. The expected counts were updated to 67 tools / 3 routes.
1418
+
1419
+ ### Changed
1420
+
1421
+ - **Every new tool with physical consequences joined the human-approval gate**: `set_freedrive` / `set_teach_mode` / `power_on` / `power_off` / `brake_release` / `unlock_protective_stop` / `shutdown` / `zero_ftsensor` / `conveyor_tracking` — each can take the arm out of program control or remove its rigidity.
1422
+ - **Long-running operations reserve headroom in their budget** (`_budget_ms`): Node's timeout starts when the request is written, so making both equal means a genuine timeout shows only Node's generic "timed out" instead of the worker's more specific reason.
1423
+ - **A failed send is no longer a success**: `op_send_script`, the four drawing ops, digital and analog outputs, `set_tcp`, `set_payload` and friends now check that the script actually left, answering `SEND_FAILED` with the reason when it did not.
1424
+ - **`_CappedLog` is thread-safe**: the RTDE, Dashboard and RealTime receiver threads all print directly, and a `_roll()` could close the handle in the middle of another thread's `write()` ⇒ that thread raised `ValueError` and died (and a dead RTDE thread means `isRunning()` is false forever).
1425
+ - **Full tracebacks for unexpected exceptions**: `traceback.format_exc(limit=1)` kept only the last frame, so a library-internal `AttributeError` lost all context; the protocol line is now a readable summary plus a stable error code.
1426
+
1427
+ ### Known items (stated honestly)
1428
+
1429
+ - **`scripts/twin-contiguity-check.py` (formerly the hidden `.twin-contiguity-check.py` at the repository root) chained its matrices in the wrong order**: it composed the DH segment as `R·Trans`, whereas the real renderer (`src/client/robot/fk.js`'s `poseToMatrix4()` = URDF `<origin>` semantics) uses **`Trans·R`**. The two differ by about **0.18 m** on `links[4]`/`links[5]` (where rpy and xyz are both non-zero) — so its PASS/FAIL had nothing to do with what the client actually draws. It now uses `Trans·R` and lives under `scripts/` (a hidden script in the repository root invites being mistaken for project configuration).
1430
+ - `python/sitecustomize.py` is a **machine-local shim** (it hardcodes this machine's user-level site-packages path). It works only because `lib/worker.js` points `PYTHONPATH` at the plugin's `python/` directory and CPython imports `sitecustomize` from there at startup. **On another machine, or from a published install (the file is not in the `files` list), `import numpy` fails and every tool call dies.** The right fix is to install the dependencies for the interpreter the worker uses (`pip install -r requirements.txt`) or point `pythonBin` at a venv; `ur_ping` / `--selfcheck` is the self-check for that chain. This release does **not** delete the file (it belongs to the local environment) but documents it here.
1431
+ - **The tool-side I/O and analog-output read-back used function names that do not exist in the manual (silent failure, not a missing feature)**: the repository carries UR's official script manuals (`ScriptManual/script_directory_Poly5.pdf`, `..._PolyscopeX.pdf`), and checking them one by one turned up **three names in the vendored code that appear nowhere in the manual**:
1432
+ - `get_tool_digital_in()` sent `write_output_int_register(0, read_tool_digital_in(n))` — **neither name exists** (the manual has `write_output_integer_register` and `get_tool_digital_in`). The controller rejects the whole script, and the method then **reads back a stale value from register 0 and reports it as the input level**.
1433
+ - `set_tool_digital_out()` sent `write_tool_digital_out(n, value)` — the manual has `set_tool_digital_out(n, b)`. The result: **the digital output was never set, yet the tool reported success**.
1434
+ - `get_standard_analog_out()` read `RobotModel.StandardAnalogOutput0` / `...1` — **neither attribute exists**, so it always raised `AttributeError`; and the `n == 1` branch put its `return` inside `if wait:`, returning `None` when `wait=False`.
1435
+ All three now use the manual's real names. This also showed that `get_tool_digital_out` *does* exist in the manual, so the earlier conclusion "tool digital outputs cannot be read back" was wrong — the read-back is implemented.
1436
+ - `python/sitecustomize.py` is a **machine-local shim** (it hardcodes this machine's user-level site-packages path). It only works because `lib/worker.js` points `PYTHONPATH` at the plugin's `python/` directory and CPython imports `sitecustomize` from there at startup. **On another machine, or from a published install (the file is not in the `files` list), `import numpy` fails and every tool call dies.** The right fix is to install the dependencies for the interpreter the worker uses (`pip install -r requirements.txt`) or point `pythonBin` at a venv; `ur_ping` / `--selfcheck` is the self-check for that chain. This release does **not** delete the file (it belongs to the local environment) but documents it here.
1437
+ - `ur_get_tool_analog_in` and `conveyor_tracking` (function names and argument semantics) are **not verified on hardware**: the function names were confirmed to exist in the repository's official manuals, but the **argument semantics and on-hardware behaviour** have not been exercised on URSim or a real robot. Both **report failure honestly** with likely causes when they cannot complete, instead of returning a stale value or pretending success. Validate on URSim or a real robot before production use.
1438
+ - `jointLimits[5]` is `null` for `ur3`/`ur3e` in `assets/kinematics.json`, so a J6 limit warning is not possible for those two models without new data.
1439
+
1440
+ ## [0.4.1] - 2026-09
1441
+
1442
+ ### Changed
1443
+ - **Deleted the no-longer-assembled left/centre modules**: `sidebar-entry.js` (left-sidebar entry row + centre-column view + the `dsh-panel-activate` protocol) and `thumbnail.js` (thumbnail 3D) went away with the "right sidebar only" change; the mutation hub they shared now lives in its own `mutation-hub.js` (behaviour unchanged, still a global singleton).
1444
+ - **The digital twin moved to the right sidebar**: the entry is now a card in the right sidebar's Start panel, directly below the "Workspace files / New terminal / Browser" cards, and selecting it shows the 3D view inside the right sidebar. The former left-sidebar entry row, its thumbnail view, and the center-column overlay are no longer used.
1445
+
1446
+ ### Fixed
1447
+ - **`ur_send_script`'s execution check could report a false success — worse than not verifying, because it fabricates evidence**: the first version appended the sentinels at the **top level** (`<start> + script + <finish>`). Measured on the real robot: wrapping a `def dance(): … end` + top-level call that way turns the submission into a shape where **the function body never runs while the top-level statements do** — DO3 was never set, yet both sentinel lines were read back, so the tool answered "ran to completion in 0.0 s". Now any script containing a `def` gets its sentinels injected **inside the function body** (first and last statement; blocks are paired by depth so nested if/while cannot capture the function's `end`), and they are **never** placed at the top level; when several functions are defined and no top-level call identifies one, it **refuses to verify** (script sent verbatim, `verified: null`, with the reason). Pure-statement scripts (the plugin's own movej/movel shape) still use top-level injection, where top-level statements execute anyway. The result now carries `injection` (`mode` / `function` / line numbers) so the injection point can be audited. The regression test reproduces the "top level only, function body never runs" semantics with a fake controller and demonstrates that the old shape must produce a false positive.
1448
+ - **`ur_send_script` used to report success it could not know about**: `RealTimeClient.SendProgram()` is one-way — the controller sends neither an ACK nor the URScript runtime error back — and the old implementation answered with nothing but "script sent, please confirm the result". A model reads that as "the motion happened", while in reality **not a single line may have run** (in local/teach-pendant mode URScript is discarded by design, and a script that errors on the controller aborts). The tool now injects a `write_output_integer_register(N, token)` line before *and* after the script and reads that register back, so the answer is one of three: **ran to completion** / **started but never reached the end** / **not even the start sentinel appeared (≈ the controller never executed it, with the most likely cause named)**. `verified` is `true`/`false`, and `null` when `verify=false` — "unconfirmed", not "success". Sentinel register defaults to int output register 23 (override with `register`; note its previous value is overwritten).
1449
+ - **`remote_control` probing is tri-state now**: the old code collapsed "could not ask" and "asked, got false" into the same `false`, so the tool told users of a robot in an unknown state to "enable Remote Control in PolyScope" — an assertion built on unknown information. `_remote_control()` now returns `True` / `False` / `None` (unknown); `ur_connect` mentions the mode only on an explicit false (for CB3 3.1–3.20 it distinguishes "enabled at the settings level by default" from "currently not in remote mode, where commands are silently dropped"), and says "unknown" when it is unknown. `ur_status` also reports `remote_control_raw` for diagnosis.
1450
+ - **Removed the defunct input section from the recipe**: `rtdeConfiguration.xml` now declares only the `receive` (output) recipe; the whole `<send>` section is gone. Every write goes over URScript and needs no input claim, while the upstream reconnect branch calls `__setupInput()` on **every** reconnect attempt — a non-empty input recipe would turn the controller's "parameter already in use" rejection into a loop (and that rejection surfaces from `__decodePayload` and crashes the whole worker). With no input section it is just an empty request.
1451
+ - **Black twin even with the robot connected (no `model`/`q`/`tcp`)**: `lib/twin-routes.js` read the op result one level too shallow (`jp.joint_positions`), while the real envelope is two levels deep — a response is `{id, ok, data: <op return value>}` and each op returns `{message, data:{…}}` (`ur_worker.py`'s `ok()` returns the dict it is handed **as-is**), and `UrWorker.call()` resolves with the response's `data`, so the actual payload sits one level further down at `.data`. `model`/`q`/`tcp` therefore stayed empty: the twin showed a black canvas whether or not a robot was connected, and the pose had nothing to sync to. The route now reads `.data`, and `test/twin-routes.test.mjs`'s fake worker was updated to the **real envelope shape** (it used to fabricate a flat object, which is why this stayed green), with a new reverse guard asserting a flat payload is not treated as data.
1452
+ - **Unbounded worker log + a tight RTDE reconnect loop**: `rtde.py`'s reconnect-failure branch printed on **every** iteration without sleeping, which on a downed link is tens of thousands of lines per second; the local `python/ur_worker.log` had grown to **277 MB / 10.76 M lines** (`RTDE reconnection failed!` alone) while spinning the CPU. That branch now throttles to **one line per 5 s** and calls `time.sleep(0.5)`; `ur_worker.py` adds a writer-independent hard cap on top: past 8 MB the log rolls to `ur_worker.log.1` (one generation only ⇒ disk stays within ~2× the cap). **The same defect has a second site**: `DashboardClient.__send` in `dashboard.py` — when the socket is dead `select.select` raises immediately and the `except` prints `"Could not send program!"` unconditionally; one measured session produced **296,644 such lines** and pushed the log to its 8 MB cap while spinning the CPU. It is now throttled to one line per 5 s with `time.sleep(0.2)` as well. Note also that the recipe's empty input side is what keeps this branch — which calls `__setupInput()` on every reconnect — from repeatedly claiming input variables (that would hit the upstream "parameter already in use" crash).
1453
+ - **World frame now coincides with the robot base frame**: `GridHelper` lies in the XZ plane by default (three.js's Y-up habit, normal +Y), while every link pose is a **base-frame** FK result (UR's base plane is XY with Z up) — different planes and mismatched axes, which showed up as a grid that read like a vertical wall with the base poking through it. The grid is now rotated +90° about X onto the **XY plane (z = 0)** and `camera.up` is set to **+Z**, so the **base plane coincides with the grid plane and the base X/Y/Z coincide with world X/Y/Z** (`createBaseGrid()` in `scene.js`).
1454
+ - **CB3 robots are no longer told to enable Remote Control**: `ur_connect` used to say "not in remote control mode, some motion commands may not execute" whenever the dashboard read `false`. But **CB3 robots running URSoftware 3.1–3.20 allow remote control by default with nothing to enable**, so that advice was wrong for that firmware. It now branches on `polyscopeVersion`: inside that range it states that remote control is enabled at the settings level by default (nothing to enable) **and that a `false` reading means the controller is currently not in remote mode, where commands are still dropped silently**; other firmware (e-Series, or CB3 outside the range) still gets the PolyScope instruction. (The wording was calibrated again against a real robot — see the tri-state entry above.)
1455
+ - **A stuck connection no longer needs a manual restart**: `UrScriptExt(...)` construction waits for RTDE data readiness (`urScript.py`'s 20×1 s), on top of the rtde / realTimeClient reconnect loops that each run up to 60 s. When the controller still holds an unreclaimed RTDE session from a previous attempt — it accepts one client at a time — construction can fail to return for tens of seconds and every later call piles up behind it. `ur_worker.py`'s `_connect_or_exit()` now gives construction a hard budget (`CONNECT_TIMEOUT_S = 20 s`, never more than the request's own `_timeout_ms`), writes back an error that names the cause and calls `os._exit(1)`: process exit is the only reliable way to release those half-built sockets. The existing `lib/worker.js` behaviour takes over from there — the exit fails every in-flight request and the **next call** respawns a clean process after a 500 ms backoff, so one retry is all the caller needs.
1456
+ - **Releases no longer carry `python/sitecustomize.py`**: that file is a machine-local interpreter shim (hardcoded local paths). The plugin's own `files` deliberately omits it, but both desktop editions packed the whole `python/**` directory. Both electron-builder `files` configs now exclude it explicitly.
1457
+ - **The digital twin placed every joint wrong** (plainly visible on a real UR3: the links came apart and sat off their joints): `loader.js`'s `assemble()` chained the 7 assembly groups as parent/child, while `applyFK()` writes the **absolute** base→link transform into each group's **local** `matrix` — so three.js composed the ancestor chain in `matrixWorld` and every link after the shoulder got its predecessors' transforms applied again. Each mesh's geometry is authored in **its own link frame** (Ruling 37 bakes the official `ur_macro.xacro` visual origins into the meshes), so the six link groups are now **direct children** of the motion root (identity). Measured across 14 models × 3 poses: worst adjacent-link gap 0–1.3 mm against 40–108 mm limits.
1458
+ - **`self.hasattr` typo in `rtde.py`**: `setData()`'s list branch read `self.hasattr(self.__rtde_input_config.names, ...)`, a method `RTDE` does not define — and `hasattr` on a list is wrong anyway. It now uses the scalar branch's semantics, `variable_name[ii] in self.__rtde_input_config.names`.
1459
+ - **Every RTDE write crashed** (`ur_set_digital_out` and friends died with `AttributeError: 'NoneType' object has no attribute 'names'` on a real robot): the plugin used to write outputs through `RTDE.setData()`, which requires the controller to have **claimed RTDE input variables** first — and the vendored `rtde.py` ships its `__setupInput()` call commented out, so `SETUP_INPUTS` was never sent and `__rtde_input_config` stayed `None`. **Writes now go over URScript** (`RealTimeClient.Send("set_standard_digital_out(...)")` and friends, matching the existing tool / analog / payload / TCP paths), so no RTDE input claim is needed at all. Avoiding input claims is deliberate: UR controllers reject a second `SETUP_INPUTS` for an already-claimed variable (`An input parameter is already in use.`), and that rejection surfaces from `__decodePayload` in the RTDE thread and **crashes the whole worker**, after which the session cannot be re-established until the controller releases the claim. (An earlier attempt that shipped an input recipe and re-enabled `__setupInput()` is superseded by this cleaner path.)
1460
+ - **The plugin's RTDE recipe now serves the receive side only, with an intentionally empty input side**: `python/URBasic/rtdeConfiguration.xml` (resolved by `rtde.py` before the vendored default) keeps int/double registers 0..23 and enables `output_bit_registers32_to_63`, while its `<send>` section declares **zero fields** so that no `SETUP_INPUTS` — including the upstream reconnect branch's call — can claim an input variable.
1461
+ - **`ur_get_bit_register` returned null for indexes 32–63**: the receive recipe enabled only `output_bit_registers0_to_31`, and `RobotModel.OutputBitRegister()` fills 32–63 only when `output_bit_registers32_to_63` is present. The new recipe enables it.
1462
+ - **`ur_set_digital_out` with `which="config"` raised `struct.error` for ports 8–15**: URBasic numbers the eight configurable outputs 0–7 internally (it builds the mask as `2 ** n`), while the tool uses UR's global I/O numbering 8–15, so `n = 8` produced 256 (`'B' format requires 0 <= number <= 255`). The call now converts to URBasic's index.
1463
+ - **`ur_list_programs` never returned a result**: its local `err` shadowed the module-level `err()` helper, so the error branch called a string.
1464
+ - **The client half could never activate**: `resolveRobotIp()` read `ctx.config` / `ctx.options`, but the cordis context proxy throws `cannot get property "config" without inject` for properties the plugin does not declare through `inject`. That read sat outside `apply()`'s `try`, so the entry's fiber ended FAILED and the renderer reported only "1 plugin(s) did not activate", with no cause. Configuration now comes from the second `apply(ctx, config)` argument.
1465
+
1466
+ ## [0.4.0] - 2026-09
1467
+
1468
+ ### Fixed
1469
+ - **Registration reports real results**: `apply()` now counts successful `ctx.tools.register` calls and lists every failed tool with its reason, instead of printing a hardcoded tool count — a partially registered tool set can no longer look healthy in the log. A total failure distinguishes a worker-init error from a host without a `tools` service or a changed `ctx.tools.register` API.
1470
+ - **Peer ranges accept the current harness**: `@deepseek-ai/dsh` and `@deepseek-ai/dsh-tools` are declared as `^0.1.2-rc.1 || ^0.1.7-rc.2`. The previous `^0.1.2-rc.1` does not match `0.1.7-rc.2` (a prerelease range only matches its own version tuple), so a strict peer check rejected exactly the runtime this plugin is built for.
1471
+
1472
+ ### Added
1473
+ - **`scripts/check-host-compat.mjs`**: validates this plugin against an installed host in one command — peer ranges, every tool parameter schema through the host's real value-schema DSL, the `dsh.client` declaration, and the client bundle's registration id. Run it after bumping the tested harness family.
1474
+ - **Read-only 3D digital twin**: a sidebar thumbnail and a center-column large view (three.js) sharing a single polling state source, live-syncing the robot's joint poses, tool (TCP) coordinate frame and recent motion trajectory. The twin is strictly read-only and never sends a command to the robot.
1475
+ - **Host read-only twin routes**: `/dsh-nonead-ur/twin/state` and `/dsh-nonead-ur/twin/asset`, both fenced to loopback callers (`localhost` / `[::1]` / `127.0.0.0/8`).
1476
+ - **Bundled robot meshes**: 14 official UR visual meshes converted to `assets/models/*.glb` (≈35 MB) and shipped with the package, together with `assets/kinematics.json`; the licence split is documented in `THIRD_PARTY_NOTICES.md` (9 models BSD-3-Clause, 5 models UR Graphical Documentation). `assets` is part of the published `files` list, and a `verify:models` script validates the GLB structure contract.
1477
+ - **`three` as a build-time dependency** (`devDependencies`), used by `scripts/build-client.mjs` to build the client bundle.
1478
+
1479
+ ## [0.3.9] - 2026-09
1480
+
1481
+ ### Fixed
1482
+ - **16 tools vanished under DSH 1.5.3**: tool parameter schemas used JSON-Schema numeric constraints (`minimum` / `maximum` / `exclusiveMinimum`), which the value-schema DSL no longer accepts, so every affected tool failed to register entirely — silently, because the closing log line printed a hardcoded `registered 49 tools`. Re-measured on a host that still rejects those keywords: 33/49 tools registered and 16 registrations failed (`ur_movej`, `ur_movel`, `ur_movep`, `ur_movec`, `ur_servoj`, `ur_draw_*`, and the register / I/O tools). The constraints are gone; the ranges now live in the tool descriptions and, authoritatively, in `python/ur_worker.py` (`_nonneg_float` / `_bounded_int` / `_bounded_float`).
1483
+
1484
+ ### Added
1485
+ - **`test/tool-schema-dsl.test.mjs`**: drives the real `apply()`, captures the real tool definitions, validates every parameter schema against the value-schema DSL vocabulary, and asserts all 49 tools register — with a self-check proving the test is not vacuous.
1486
+
1487
+ ## [0.3.8] - 2026-09
1488
+
1489
+ ### Changed
1490
+ - **Unified remote-control probe**: extracted `_remote_control(d)` and used it in `op_connect` / `op_status` / `op_get_robot_model` so the three places agree on what counts as remote (only an exact `"true"`; error-like or failed probe -> not remote) instead of three hand-written copies.
1491
+ - **Description sync**: `ur_get_robot_model` description now mentions the `remote_control` field it returns.
1492
+
1493
+ ## [0.3.7] - 2026-09
1494
+
1495
+ ### Fixed
1496
+ - **worker.js abort timer cleanup**: `call()`'s `onAbort` now calls `clearTimeout`, so an aborted request no longer leaves a stray timeout that later fires a no-op reject (a minor leak / untidiness).
1497
+ - **`op_status` distinguishable read failures**: a failed `dash()` query returns a `"<查询失败>"` marker instead of an empty string, so a field that could not be read is not mistaken for a genuinely empty value.
1498
+
1499
+ ## [0.3.6] - 2026-09
1500
+
1501
+ ### Robustness
1502
+ - **Motion/drawing parameter validation**: added `_nonneg_float` to reject negative/zero motion and drawing parameters (the controller treats negative speed/acceleration and reversed geometry as invalid):
1503
+ - `movej` / `movel` / `movep` / `movec`: `a` (acceleration) and `v` (speed) must be >0; `t` (time) and `r` (blend radius) >=0.
1504
+ - `draw_circle` / `draw_square` / `draw_rectangle` / `draw_star`: `r` / `border` / `width` / `height` / `side` must be >0.
1505
+ - `servoj`: `lookahead_time` 0.03-0.2, `gain` 100-2000, `t`>=0.
1506
+ - **Tool-definition schema constraints**: added `minimum`/`exclusiveMinimum` on `MOVE_PARAMS`, `movep`/`movec`, `servoj`, and `draw_*` parameters so the model sees the limit before calling.
1507
+
1508
+ ## [0.3.5] - 2026-09
1509
+
1510
+ ### Fixed
1511
+ - **Vendored URBasic operator precedence**: `RobotModel.DigitalInputbits` / `ConfigurableInputBits` / `DigitalOutputBits` / `ConfigurableOutputBits` used `n >= 0 & n < 8`, which Python parses as a chained comparison and mis-returned True for out-of-range n=8/15. Changed to `0 <= n < 8` (and `8 <= n < 16`) so out-of-range returns None.
1512
+ - **`urScriptExt.get_in` signature mismatch**: the `BCI` branch passed `wait` to `get_configurable_digital_in`, which only accepts `n`; removed it so it no longer raises `TypeError`.
1513
+ - **`urScriptExt.set_output` empty branches**: `BCO`/`BDO` now `return True`; `TDO` (tool digital output) now calls the implemented `set_tool_digital_out`; `BAO` (analog out) raises `NotImplementedError` instead of silently passing.
1514
+ - **`_right_pose_tcp` compared only the first 3 dims**: arrival confirmation now compares the full 6-D pose (position + orientation), with a 0.010 m position tolerance and a 0.05 rad orientation tolerance.
1515
+ - **`ur_set_analog_out` crash**: it called the URBasic `set_standard_analog_out` stub (raised `NotImplementedError`); now sends the URScript `set_analog_out` command directly.
1516
+ - **`_program_running` disconnect tolerance**: a failed dashboard probe no longer crashes the single-threaded worker (returns False so the confirm loop reports a clear outcome).
1517
+
1518
+ ### Robustness
1519
+ - **Argument range validation**: added `_bounded_int` for register indices (int/double 0-23, bool 0-63) and I/O port numbers (std 0-7, config 8-15, tool 0-1, analog 0-1); also added JSON-schema `minimum`/`maximum` on `index`/`n` in the tool definitions. Out-of-range or non-int values return a clear error instead of a KeyError.
1520
+ - Removed untracked `debug.log` and `Release/*.tgz`.
1521
+
1522
+ ## [0.3.4] - 2026-09
1523
+
1524
+ ### Fixed
1525
+ - **`ur_get_digital_in` with `which="std"` crashed**: fixed a vendored-URBasic method-name case error — `get_standard_digital_in` called the non-existent `RobotModel.DigitalInputBits` (uppercase B, raising `AttributeError`), while the actual definition is `DigitalInputbits` (lowercase b). Aligned the call with the definition so standard digital input reads work again. Found on a real UR3.
1526
+
1527
+ ## [0.3.3] - 2026-09
1528
+
1529
+ ### Added
1530
+ - **Tool digital I/O**: implemented and wired the `which="tool"` branch of `ur_get_digital_in` / `ur_set_digital_out` on top of the vendored URBasic:
1531
+ - `get_tool_digital_in`: tool digital inputs are not carried by RTDE, so run the URScript expression `read_tool_digital_in(n)` (via `SendProgram`), stash the result into `output_int_register_0`, and read it back over RTDE (same pattern as `get_conveyor_tick_count`). Verified on a real UR3 — reads a correct boolean.
1532
+ - `set_tool_digital_out`: send the URScript command `write_tool_digital_out(n, v)` over the RealTime client. Verified on a real UR3 — set/clear accepted.
1533
+ - `get_tool_digital_out`: remains a stub with a clarifying note (no reliable `read_tool_digital_out` read-back expression), controllable only via `set_tool_digital_out`.
1534
+
1535
+ ### Changed
1536
+ - The `ur_get_digital_in` tool description now notes that `which="tool"` reads by sending a short URScript program, which may interrupt a running program.
1537
+ - The previous `which="tool"` branches changed from "not implemented" hint to the real implementation.
1538
+
1539
+ ## [0.3.2] - 2026-09
1540
+
1541
+ ### Fixed
1542
+ - **Conveyor tick read crash**: `get_conveyor_tick_count()` inside `ur_get_conveyor` fixed two vendored-URBasic defects — the URScript now writes via `write_output_double_register(0, …)` (it used `write_output_float_register`, but the RTDE config only exposes `output_double_register_0`, so the float value never read back and always returned 0); the Python side now reads via `RobotModel.OutputDoubleRegister(0)` (it used the non-existent **lowercase attribute** `outputDoubleRegister[0]`, raising `AttributeError: 'RobotModel' object has no attribute 'outputDoubleRegister'`).
1543
+ - **`ur_get_robot_model` model corruption**: the remote-control probe reused `d.last_respond`, overwriting the model string, and on a successful probe it appended a stray `"e"` to the model (e.g. `UR5` → `UR5e`). `robot_model` now returns the clean model and the remote-control state is returned separately as `remote_control`.
1544
+ - **Motion-confirmation semantics**: `_movej_confirm` / `_movel_confirm` now honestly report **not-arrived (`ok=False`)** when the robot repeatedly stops off-target and is not running a program, instead of falsely reporting success ("移动结束(位置存在偏差)"); a failed joint/TCP read is also reported explicitly rather than silently `break`-ing.
1545
+ - **`ur_list_programs` connection error**: a failed SSH connection now returns a clear Chinese message (checking the SSH service and username/password) instead of surfacing a raw exception and misreporting "0 programs".
1546
+
1547
+ ### Robustness
1548
+ - **Vector argument validation**: a new `_vec` helper validates the dimension of joint/pose vectors across `movej`/`movel`/`move_x|y|z`/`draw_circle|square|rectangle|star`/`set_tcp`/`set_payload`/`movep`/`movec`/`servoj`, returning a clear error on wrong length, non-numeric, or missing values instead of emitting malformed URScript or an out-of-range index.
1549
+ - **Worker crash backoff**: `worker.js` now honors `restartDelayMs` (previously declared but never used), so a worker that exits on startup cannot spin in a tight crash loop.
1550
+ - **Worker spawn/exit race**: `call()` distinguishes "writable / not writable / process already exited" before writing and reports each clearly, rather than silently dropping the request.
1551
+
1552
+ ## [0.3.1] - (2026-09)
1553
+
1554
+ ### Fixed
1555
+ - **Security**: `ur_load_program` is now part of the motion approval gate (`APPROVAL_OPS`), and `program_name` / `programs_dir` are validated against a whitelist (rejecting control characters and shell/URScript metacharacters), closing the `load <file>\nplay` injection path that previously bypassed human approval.
1556
+ - **Security**: `ur_list_programs` now passes `programs_dir` through `shlex.quote` before building the `find` command, eliminating the remote shell injection over SSH.
1557
+ - **Usability**: removed `ur_get_inverse_kin`, which always returned `NotImplementedError` (the URBasic base implementation is an empty stub); rewrote `ur_set_payload` to send `set_payload_mass` / `set_payload_cog` URScript directly (it previously hit the same stub).
1558
+ - **Robustness**: the JS side now forwards `_timeout_ms` to the worker, and Python's `_movej_confirm` / `_movel_confirm` / draw completion confirmation are all **bounded by a timeout**, so the single-threaded worker can no longer be blocked forever by a motion loop that never returns.
1559
+ - **Behavior**: `ur_draw_*` now performs a bounded completion confirmation before returning, instead of prematurely reporting "sent"; `ur_get_digital_in` with `which="tool"` (not implemented in URBasic) now returns an explicit error telling the caller to use `std` / `config`.
1560
+
1561
+ ### Changed
1562
+ - Tool count reduced from 50 to 49 (removed `ur_get_inverse_kin`).
1563
+
1564
+ ## [0.3.0] - 2026-09
1565
+
1566
+ ### Added
1567
+ - **More tools**:
1568
+ - `ur_movep` (path move), `ur_movec` (circular move), `ur_servoj` (continuous-servo joint target, single-step online control).
1569
+ - `ur_get_digital_in_bits` / `ur_get_digital_out_bits` (**bit-mask** reads of digital in/out, std0-7 + config8-15).
1570
+ - `ur_get_conveyor` / `ur_set_conveyor_tick` (conveyor tick read / set).
1571
+
1572
+ ### Changed
1573
+ - Completed release metadata: `repository` (Nonead GitHub), `bugs`.
1574
+
1575
+ ## [0.2.0] - 2026-09
1576
+
1577
+ ### Changed
1578
+ - Renamed the plugin to `dsh-nonead-universal-robots` (all lowercase, npm-publishable) and declared a host requirement of `DSH ^0.1.2-rc.1` (`peerDependencies`).
1579
+ - Plugin description: a DSH plugin to control Universal Robots arms from natural language, developed by Nonead based on the same logic as the company's own nUR MCP Server.
1580
+
1581
+ ### Added
1582
+ - **Motion approval gate**: `ur_movej/ur_movel/ur_move_x|y|z/ur_draw_*/ur_run_program/ur_send_script/ur_reset_error` now go through the DSH approval channel (`ctx.approval`) and are only dispatched after human confirmation; fail-closed when there is no approval service / no agent. Disable with `requireApprovalForMotion`.
1583
+ - **More tools**: `ur_ping` (health), `ur_get_digital_in`/`ur_set_digital_out`, `ur_get_analog_in`/`ur_set_analog_out`, `ur_set_tool_voltage`, `ur_set_tcp`, `ur_set_payload`, `ur_get_inverse_kin`, `ur_draw_star` (pentagram).
1584
+ - **URSim compatibility**: `ur_list_programs` supports the real robot's `/programs` and URSim `~/URSim_Linux-*/programs.*`; `ur_load_program` / `ur_run_program` support full/URSim paths and `programs_dir`.
1585
+ - **Testing**: worker `--selfcheck` mode and `npm test` / `npm run test:python`.
1586
+
1587
+ ### Fixed
1588
+ - Fixed URBasic/RTDE returning **numpy data that could not be JSON-serialized** (the worker's `_json_default` recursive conversion).
1589
+ - Fixed **Chinese Windows stdout being GBK**, which corrupted the protocol pipe with `UnicodeEncodeError` (now writes UTF-8 bytes to `stdout.buffer`).
1590
+ - Fixed the dashboard `load` raw-response lag that made load results unreliable (now uses `ur_get_loaded_program` for authoritative confirmation).
1591
+ - Fixed numpy values being interpolated into `message` in `ur_get_joint_temperatures` / `op_status`.
1592
+ - Fixed worker process management: first-call/spawn race, environment scrubbing, auto-restart on crash.
1593
+
1594
+ ### Enhanced
1595
+ - Added a `remote_control` field to `ur_get_status` to help diagnose whether the robot is in remote-control mode.
1596
+ - worker.js scrubs the child process environment (drops `DSH_*` and credential-like variables).
1597
+
1598
+ ## [0.1.0] - 2026-09
1599
+
1600
+ - First release: `ur_*` control tools for connection/status/pose/registers/motion/drawing/programs/scripts.
1601
+ - Reuses the vendored `URBasic` and the same-source logic of Nonead's nUR MCP Server, packaged as DSH-native tools.