ReviewOS

also looking at this

stacks/drone

feat: add bounded TCP transport for autopilot drivers

#71
Merged chris wants to merge codex/mavlink-tcp-transport into main
22 files +1798 -154
.github/workflows/ci.ymlmodified+4-0
Changes to .github/workflows/ci.yml
@@ -148,6 +148,10 @@ jobs:
148148 working-directory: ${{ runner.temp }}
149149 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver px4 --signed --drop-first-ack
150150 working-directory: ${{ runner.temp }}
151 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --transport tcp --drop-first-ack
152 working-directory: ${{ runner.temp }}
153 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver px4 --transport tcp --signed --drop-first-ack
154 working-directory: ${{ runner.temp }}
151155 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone payloads:bench
152156 working-directory: ${{ runner.temp }}
153157 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone payloads:capture --driver replay --output "$RUNNER_TEMP/payload-capture"
app/Commands/DriversBench.tsmodified+2-1
Changes to app/Commands/DriversBench.ts
@@ -7,12 +7,13 @@ export default defineCommand({
77 description: 'Exercise acknowledged commands, parameters and missions against a scripted local protocol peer',
88 options: {
99 '--driver <name>': { description: 'ardupilot or px4', default: 'ardupilot' },
10 '--transport <name>': { description: 'udp or tcp loopback peer', default: 'udp' },
1011 '--drop-first-ack': 'Deliberately drop the first message-rate ACK to exercise retry',
1112 '--signed': 'Use a public fixture key to test required MAVLink 2 signatures',
1213 },
1314 async handle(options) {
1415 try {
15 const result = await runDriverProtocolBench({ driver: options.driver as AutopilotDriverId, signed: options.signed, dropFirstAck: options.dropFirstAck })
16 const result = await runDriverProtocolBench({ driver: options.driver as AutopilotDriverId, transport: options.transport as 'udp' | 'tcp', signed: options.signed, dropFirstAck: options.dropFirstAck })
1617 // eslint-disable-next-line
1718 print(JSON.stringify(result, (key, value) => typeof value === 'bigint' ? String(value) : value, 2))
1819 }
docs/architecture.mdmodified+3-1
Changes to docs/architecture.md
@@ -71,7 +71,9 @@ It uses the oldest heartbeat/position/battery receipt time for freshness. Physic
7171reports position health false and never transmits. SITL assumes the simulator's position estimator
7272is healthy; that assumption must never be carried into a physical adapter.
7373
74The wire codec is an experimental subset. [Shared acknowledged microservices](driver-protocols.md) add bounded command, parameter and waypoint transfers; serial/TCP and complete firmware coverage remain open.
74The wire codec is an experimental subset. [Shared acknowledged microservices](driver-protocols.md)
75add bounded command, parameter and waypoint transfers over UDP and explicit loopback TCP. Serial,
76actual firmware transport campaigns and complete firmware coverage remain open.
7577Both external stacks are optional drivers. The [owned-stack ADR](adr/0001-owned-autonomy.md) and
7678[feature roadmap](roadmap/README.md) define the replacement path. A future physical adapter needs verified estimator status, source identity, sequence/replay handling,
7779authenticated transport, link supervision, flight-controller configuration and hardware validation.
docs/driver-protocol-evidence.jsonmodified+312-0
Changes to docs/driver-protocol-evidence.json
@@ -6,6 +6,7 @@
66 {
77 "evidence": "scripted-loopback-protocol-peer",
88 "driver": "ardupilot",
9 "transport": "udp",
910 "signed": false,
1011 "physicalFlightEnabled": false,
1112 "firmwareSitlValidated": false,
@@ -82,6 +83,7 @@
8283 {
8384 "evidence": "scripted-loopback-protocol-peer",
8485 "driver": "ardupilot",
86 "transport": "udp",
8587 "signed": true,
8688 "physicalFlightEnabled": false,
8789 "firmwareSitlValidated": false,
@@ -155,9 +157,318 @@
155157 "executionConfirmed": false
156158 }
157159 },
160 {
161 "evidence": "scripted-loopback-protocol-peer",
162 "driver": "ardupilot",
163 "transport": "tcp",
164 "signed": false,
165 "physicalFlightEnabled": false,
166 "firmwareSitlValidated": false,
167 "hardwareValidated": false,
168 "capabilities": {
169 "driver": "ardupilot",
170 "bits": "139268",
171 "uid": "48879",
172 "flightSoftwareVersion": 0,
173 "missionInt": true,
174 "parameterEncoding": "c-cast",
175 "encodingEvidence": "advertised",
176 "physicalCommands": false
177 },
178 "rate": {
179 "command": 511,
180 "result": 0,
181 "progress": null,
182 "executionConfirmed": false
183 },
184 "confirmations": [
185 0,
186 1
187 ],
188 "droppedAcks": 1,
189 "parameterBefore": {
190 "id": "BENCH_INT",
191 "type": 6,
192 "value": 42,
193 "count": 1,
194 "index": 0
195 },
196 "parameterAfter": {
197 "id": "BENCH_INT",
198 "type": 6,
199 "value": 43,
200 "count": 1,
201 "index": 0
202 },
203 "uploaded": {
204 "count": 2,
205 "accepted": true
206 },
207 "downloaded": [
208 {
209 "command": 16,
210 "frame": 6,
211 "latitudeDeg": 0.001,
212 "longitudeDeg": 0.002,
213 "altitudeM": 10,
214 "holdSeconds": 0,
215 "acceptanceRadiusM": 2,
216 "passRadiusM": 0,
217 "yawDeg": null
218 },
219 {
220 "command": 16,
221 "frame": 6,
222 "latitudeDeg": 0.003,
223 "longitudeDeg": 0.004,
224 "altitudeM": 12,
225 "holdSeconds": 0,
226 "acceptanceRadiusM": 2,
227 "passRadiusM": 0,
228 "yawDeg": 90
229 }
230 ],
231 "resume": {
232 "index": 1,
233 "accepted": true,
234 "executionConfirmed": false
235 }
236 },
237 {
238 "evidence": "scripted-loopback-protocol-peer",
239 "driver": "ardupilot",
240 "transport": "tcp",
241 "signed": true,
242 "physicalFlightEnabled": false,
243 "firmwareSitlValidated": false,
244 "hardwareValidated": false,
245 "capabilities": {
246 "driver": "ardupilot",
247 "bits": "139268",
248 "uid": "48879",
249 "flightSoftwareVersion": 0,
250 "missionInt": true,
251 "parameterEncoding": "c-cast",
252 "encodingEvidence": "advertised",
253 "physicalCommands": false
254 },
255 "rate": {
256 "command": 511,
257 "result": 0,
258 "progress": null,
259 "executionConfirmed": false
260 },
261 "confirmations": [
262 0,
263 1
264 ],
265 "droppedAcks": 1,
266 "parameterBefore": {
267 "id": "BENCH_INT",
268 "type": 6,
269 "value": 42,
270 "count": 1,
271 "index": 0
272 },
273 "parameterAfter": {
274 "id": "BENCH_INT",
275 "type": 6,
276 "value": 43,
277 "count": 1,
278 "index": 0
279 },
280 "uploaded": {
281 "count": 2,
282 "accepted": true
283 },
284 "downloaded": [
285 {
286 "command": 16,
287 "frame": 6,
288 "latitudeDeg": 0.001,
289 "longitudeDeg": 0.002,
290 "altitudeM": 10,
291 "holdSeconds": 0,
292 "acceptanceRadiusM": 2,
293 "passRadiusM": 0,
294 "yawDeg": null
295 },
296 {
297 "command": 16,
298 "frame": 6,
299 "latitudeDeg": 0.003,
300 "longitudeDeg": 0.004,
301 "altitudeM": 12,
302 "holdSeconds": 0,
303 "acceptanceRadiusM": 2,
304 "passRadiusM": 0,
305 "yawDeg": 90
306 }
307 ],
308 "resume": {
309 "index": 1,
310 "accepted": true,
311 "executionConfirmed": false
312 }
313 },
314 {
315 "evidence": "scripted-loopback-protocol-peer",
316 "driver": "px4",
317 "transport": "udp",
318 "signed": false,
319 "physicalFlightEnabled": false,
320 "firmwareSitlValidated": false,
321 "hardwareValidated": false,
322 "capabilities": {
323 "driver": "px4",
324 "bits": "8212",
325 "uid": "48879",
326 "flightSoftwareVersion": 0,
327 "missionInt": true,
328 "parameterEncoding": "bytewise",
329 "encodingEvidence": "advertised",
330 "physicalCommands": false
331 },
332 "rate": {
333 "command": 511,
334 "result": 0,
335 "progress": null,
336 "executionConfirmed": false
337 },
338 "confirmations": [
339 0,
340 1
341 ],
342 "droppedAcks": 1,
343 "parameterBefore": {
344 "id": "BENCH_INT",
345 "type": 6,
346 "value": 42,
347 "count": 1,
348 "index": 0
349 },
350 "parameterAfter": {
351 "id": "BENCH_INT",
352 "type": 6,
353 "value": 43,
354 "count": 1,
355 "index": 0
356 },
357 "uploaded": {
358 "count": 2,
359 "accepted": true
360 },
361 "downloaded": [
362 {
363 "command": 16,
364 "frame": 6,
365 "latitudeDeg": 0.001,
366 "longitudeDeg": 0.002,
367 "altitudeM": 10,
368 "holdSeconds": 0,
369 "acceptanceRadiusM": 2,
370 "passRadiusM": 0,
371 "yawDeg": null
372 },
373 {
374 "command": 16,
375 "frame": 6,
376 "latitudeDeg": 0.003,
377 "longitudeDeg": 0.004,
378 "altitudeM": 12,
379 "holdSeconds": 0,
380 "acceptanceRadiusM": 2,
381 "passRadiusM": 0,
382 "yawDeg": 90
383 }
384 ],
385 "resume": {
386 "index": 1,
387 "accepted": true,
388 "executionConfirmed": false
389 }
390 },
391 {
392 "evidence": "scripted-loopback-protocol-peer",
393 "driver": "px4",
394 "transport": "udp",
395 "signed": true,
396 "physicalFlightEnabled": false,
397 "firmwareSitlValidated": false,
398 "hardwareValidated": false,
399 "capabilities": {
400 "driver": "px4",
401 "bits": "8212",
402 "uid": "48879",
403 "flightSoftwareVersion": 0,
404 "missionInt": true,
405 "parameterEncoding": "bytewise",
406 "encodingEvidence": "advertised",
407 "physicalCommands": false
408 },
409 "rate": {
410 "command": 511,
411 "result": 0,
412 "progress": null,
413 "executionConfirmed": false
414 },
415 "confirmations": [
416 0,
417 1
418 ],
419 "droppedAcks": 1,
420 "parameterBefore": {
421 "id": "BENCH_INT",
422 "type": 6,
423 "value": 42,
424 "count": 1,
425 "index": 0
426 },
427 "parameterAfter": {
428 "id": "BENCH_INT",
429 "type": 6,
430 "value": 43,
431 "count": 1,
432 "index": 0
433 },
434 "uploaded": {
435 "count": 2,
436 "accepted": true
437 },
438 "downloaded": [
439 {
440 "command": 16,
441 "frame": 6,
442 "latitudeDeg": 0.001,
443 "longitudeDeg": 0.002,
444 "altitudeM": 10,
445 "holdSeconds": 0,
446 "acceptanceRadiusM": 2,
447 "passRadiusM": 0,
448 "yawDeg": null
449 },
450 {
451 "command": 16,
452 "frame": 6,
453 "latitudeDeg": 0.003,
454 "longitudeDeg": 0.004,
455 "altitudeM": 12,
456 "holdSeconds": 0,
457 "acceptanceRadiusM": 2,
458 "passRadiusM": 0,
459 "yawDeg": 90
460 }
461 ],
462 "resume": {
463 "index": 1,
464 "accepted": true,
465 "executionConfirmed": false
466 }
467 },
158468 {
159469 "evidence": "scripted-loopback-protocol-peer",
160470 "driver": "px4",
471 "transport": "tcp",
161472 "signed": false,
162473 "physicalFlightEnabled": false,
163474 "firmwareSitlValidated": false,
@@ -234,6 +545,7 @@
234545 {
235546 "evidence": "scripted-loopback-protocol-peer",
236547 "driver": "px4",
548 "transport": "tcp",
237549 "signed": true,
238550 "physicalFlightEnabled": false,
239551 "firmwareSitlValidated": false,
docs/driver-protocols.mdmodified+55-4
Changes to docs/driver-protocols.md
@@ -1,7 +1,7 @@
11# Acknowledged external-driver protocols
22
33Issue [#7](https://github.com/stacksjs/drone/issues/7) adds a shared MAVLink 2 transaction engine
4behind the existing ArduPilot and PX4 UDP factories. It implements discovery, acknowledged commands,
4behind the ArduPilot and PX4 UDP/TCP factories. It implements discovery, acknowledged commands,
55message-rate requests, named parameter reads/writes, bounded waypoint transfers and resume requests.
66Physical telemetry remains receive-only. No HTTP arm, mode or actuator endpoint is introduced.
77
@@ -10,13 +10,14 @@ Physical telemetry remains receive-only. No HTTP arm, mode or actuator endpoint
1010```sh
1111./buddy drivers:bench --driver ardupilot --drop-first-ack
1212./buddy drivers:bench --driver px4 --signed --drop-first-ack
13./buddy drivers:bench --driver px4 --transport tcp --signed --drop-first-ack
1314bun run runtime:cli
1415./storage/runtime-build/cli/drone drivers:bench --driver px4 --signed
1516bun tests/fixtures/drivers/evaluate.ts
1617```
1718
1819`drivers:bench` uses native Stacks `defineCommand()` and is shared by Buddy and the compiled CLI.
19It creates a scripted loopback UDP peer with the selected firmware identity, performs discovery,
20It creates a scripted loopback UDP or TCP peer with the selected firmware identity, performs discovery,
2021negotiates a requested 5 Hz message interval, reads/writes an integer, uploads/downloads two waypoints
2122and requests resume at item 1. The optional ACK drop verifies a retry with confirmation 1.
2223The public signing key is a test vector, never a deployed credential. All sockets/timers close.
@@ -58,6 +59,53 @@ quarantined transaction keys and terminal faults. It does not transmit or refres
5859no keys or physical authority. Constructor options are captured; changing `kind` on the caller's
5960object cannot promote a physical receiver.
6061
62## TCP framing, loss and reconnection
63
64Select TCP explicitly through either public factory:
65
66```ts
67const driver = await createPx4Driver({ transport: 'tcp', kind: 'sitl', port: 5760 })
68```
69
70TCP always connects to literal `127.0.0.1`, with a 2-second connection deadline and a separate
712-second deadline for the first matching heartbeat. The configured system/component and firmware
72identity must match before TCP telemetry can be admitted. No arbitrary host, serial port, physical
73writer or forwarding service is added. Existing UDP options and default transport remain compatible.
74Connect and initial-heartbeat admission check their absolute deadlines in the receiving callbacks,
75so a delayed timer cannot admit a late connection or first heartbeat after an event-loop pause.
76
77The stream decoder reconstructs v1/v2 packets across fragmented or coalesced reads, with CRC and
78the existing strict signing verifier. Its partial-frame storage is 280 bytes, the signed MAVLink 2
79wire maximum; each decoding call accepts at most 65,536 bytes. TCP reads larger than that are
80processed in bounded chunks. Invalid CRC/length candidates resynchronize rather than refreshing
81telemetry. Unknown dialects and unsupported flags cannot satisfy a transaction. A decoder reset
82clears framing bytes while preserving the supplied verifier's in-session replay watermark.
83
84The per-connection stream watermark is 4 KiB so native pressure is observed below the separate
85application cap. Outbound bytes awaiting write callbacks are capped at 16 KiB, alongside the runtime's reported
86queue length. A write that reports backpressure is already accepted by the stream and is never
87resent on `drain`. Further writes during pressure terminate the session. No drain or write-callback
88progress for 500 ms also terminates it. These are application buffering bounds, not confirmation
89that a peer received or executed a command. Focused tests exercise the bound against an actual
90paused loopback peer; they do not establish kernel buffer capacity or hardware link timing.
91Native loopback campaigns distinguish the drain timeout from callback progress below the watermark,
92and verify recovery within budget without resending accepted bytes.
93Drain and write callbacks check expiry before crediting progress, so a delayed event loop cannot
94reset an already expired output budget. These host observations do not establish real-time scheduling.
95
96An explicitly typed TCP factory result also provides `transportSnapshot()`. Its detached, read-only
97record reports connection state, backpressure, pending callback bytes, runtime writable bytes and
98active progress/block timestamps in the local `performance.now()` clock domain. These local queue
99observations do not establish remote receipt or execution and cannot change transport authority.
100
101EOF, socket failure, heartbeat expiry and explicit close reject pending work and discard partial
102frames. Reconnection is deliberate: close the old adapter, reconcile any uncertain remote effects,
103then create a new adapter. Nothing automatically reconnects or replays commands. The new session
104requires fresh heartbeat/capability discovery, and PX4 setpoints require fresh explicit priming.
105An already accepted write may still reach the old peer after local failure. Fresh construction
106does not solve stale-ACK ambiguity, exclusive remote plan ownership or persistent signing replay
107state across sessions; those issue #7 gates remain open.
108
61109## Discovery, commands and timing
62110
63111Discovery sends `MAV_CMD_REQUEST_MESSAGE` (512) for `AUTOPILOT_VERSION` (148). Both an accepted
@@ -85,7 +133,7 @@ of the request, not measured delivery rate. A denied/unsupported ACK is a typed
85133| Ambiguous transaction keys | At most 64; then the session terminates |
86134| Download completion record | One final item or empty COUNT, retained up to 10 s for re-ACK |
87135
88The UDP adapter polls every 25 ms and emits a client heartbeat at 1 Hz only after explicit SITL
136Both adapters poll every 25 ms and emit a client heartbeat at 1 Hz only after explicit SITL
89137capability discovery. The pure engine can also be driven by deterministic `tick()` calls in tests.
90138Clock reversal, link expiry, transport failure and close terminate pending work and require a new
91139adapter. Node/Bun event-loop timing is not a real-time safety mechanism.
@@ -170,7 +218,8 @@ supply those guarantees, and a sequence wrap must not discard fresh responses. N
170218| Full ArduPilot and PX4 firmware SITL round trips | Pending pinned-firmware campaigns, actual supported commands, parameter metadata, mission variants and measured rates |
171219| Physical write capability and HIL | Disabled; requires independent authority, link, hardware and vehicle evidence |
172220| COMMAND_INT, remote command cancellation and command-specific state checks | Deferred under #7 |
173| Serial and TCP, stream framing/reconnect/backpressure | Deferred under #7; only loopback UDP datagrams implemented |
221| TCP stream framing, bounded output and explicit fresh-session reconnection | Implemented for scripted loopback peers; actual firmware transport campaigns remain open |
222| Serial transport and automatic reconnect policy | Deferred under #7; no serial adapter or transparent transaction replay |
174223| Signing key lifecycle and persistent replay state | Deferred under #7/#9 |
175224| Complete mission commands, fence/rally, terrain, opaque IDs, partial transfers and mission ownership | Deferred under #7/#10/#11 |
176225| Parameter list/EXT, metadata, table change tracking and persistence | Deferred under #7 |
@@ -184,5 +233,7 @@ Sources: [MAVLink command protocol](https://mavlink.io/en/services/command.html)
184233[parameter protocol and implementation differences](https://mavlink.io/en/services/parameter.html),
185234[common message definitions](https://mavlink.io/en/messages/common.html) and
186235[signing specification](https://mavlink.io/en/guide/message_signing.html).
236Stream layout follows [MAVLink packet serialization](https://mavlink.io/en/guide/serialization.html);
237write acceptance and drain semantics follow the [Node socket contract](https://nodejs.org/api/net.html#socketwritebuffer-encoding-callback), exercised on the pinned Bun runtime.
187238Protocol logic is original TypeScript. The optional development fixture generator uses pymavlink
1882392.4.50 to retain independent wire vectors; no vendor Python package is a runtime dependency.
docs/parity-audit.jsonmodified+380-7
Changes to docs/parity-audit.json
@@ -1,6 +1,6 @@
11{
22 "schemaVersion": 1,
3 "reviewedAt": "2026-10-20",
3 "reviewedAt": "2026-10-22",
44 "assignments": [
55 {
66 "entry": "px4:src/modules/airship_att_control",
@@ -2372,15 +2372,15 @@
23722372 {
23732373 "entry": "ardupilot:libraries/AP_HAL_Linux",
23742374 "issueKey": "runtime",
2375 "unclassified": [
2375 "unclassified": [],
2376 "reviewed": [
23762377 "parameters",
23772378 "modes",
23782379 "protocols",
23792380 "hardware",
23802381 "failures"
23812382 ],
2382 "reviewed": [],
2383 "remaining": "Additional parameters, modes, protocol variants, hardware branches and failure paths require source review and independent evidence. Reviewed dimensions contain sampled behavior cases, not exhaustive equivalence."
2383 "remaining": "Sampled UART baud/buffer, device-mode, byte-protocol, hardware-flow and failure cases are reviewed gaps, not exhaustive HAL equivalence. Device subclasses, scheduling, console/stdio, parity/stop bits, port ownership, flush/receive-time bounds, reconnect, platform variants and every remaining library subsystem require deeper review and executable evidence."
23842384 },
23852385 {
23862386 "entry": "ardupilot:libraries/AP_HAL_QURT",
@@ -2398,15 +2398,15 @@
23982398 {
23992399 "entry": "ardupilot:libraries/AP_HAL_SITL",
24002400 "issueKey": "runtime",
2401 "unclassified": [
2401 "unclassified": [],
2402 "reviewed": [
24022403 "parameters",
24032404 "modes",
24042405 "protocols",
24052406 "hardware",
24062407 "failures"
24072408 ],
2408 "reviewed": [],
2409 "remaining": "Additional parameters, modes, protocol variants, hardware branches and failure paths require source review and independent evidence. Reviewed dimensions contain sampled behavior cases, not exhaustive equivalence."
2409 "remaining": "Sampled UART baud/buffer, device-mode, byte-protocol, hardware-flow and failure cases are reviewed gaps, not exhaustive HAL equivalence. Device subclasses, scheduling, console/stdio, parity/stop bits, port ownership, flush/receive-time bounds, reconnect, platform variants and every remaining library subsystem require deeper review and executable evidence."
24102410 },
24112411 {
24122412 "entry": "ardupilot:libraries/AP_Hott_Telem",
@@ -4307,6 +4307,54 @@
43074307 "url": "https://github.com/PX4/PX4-Autopilot/blob/ddcaad6601be20453ec6acd0f08e567af3a7e1bb/src/drivers/camera_capture/camera_capture_params.yaml",
43084308 "license": "BSD-3-Clause",
43094309 "licenseSource": "https://github.com/PX4/PX4-Autopilot/blob/ddcaad6601be20453ec6acd0f08e567af3a7e1bb/LICENSE"
4310 },
4311 {
4312 "id": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
4313 "upstream": "ardupilot",
4314 "path": "libraries/AP_HAL_Linux/UARTDriver.cpp",
4315 "revision": "afeb9c4941543d97f3e04fecf257b8f598f49a6a",
4316 "blobSha": "f253eb8a0881069d281f51e607fed449b764acae",
4317 "sha256": "c2c98f36c32d5425860e665fe3fb44586f3c2e7fd9eccfbdc52b0ef78bb84bbe",
4318 "lines": 452,
4319 "url": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/libraries/AP_HAL_Linux/UARTDriver.cpp",
4320 "license": "GPL-3.0-or-later",
4321 "licenseSource": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/COPYING.txt"
4322 },
4323 {
4324 "id": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.h",
4325 "upstream": "ardupilot",
4326 "path": "libraries/AP_HAL_Linux/UARTDriver.h",
4327 "revision": "afeb9c4941543d97f3e04fecf257b8f598f49a6a",
4328 "blobSha": "02a1afba7b88e923ce8fa5e4d6c08f85ad42b714",
4329 "sha256": "f2ac2f2b9966d016621971fcf401820dd49e40f459f40e2a7a55323e6746a6de",
4330 "lines": 105,
4331 "url": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/libraries/AP_HAL_Linux/UARTDriver.h",
4332 "license": "GPL-3.0-or-later",
4333 "licenseSource": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/COPYING.txt"
4334 },
4335 {
4336 "id": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
4337 "upstream": "ardupilot",
4338 "path": "libraries/AP_HAL_SITL/UARTDriver.cpp",
4339 "revision": "afeb9c4941543d97f3e04fecf257b8f598f49a6a",
4340 "blobSha": "25dd3c45de9f84425e1f6d999f2273a6057032e3",
4341 "sha256": "5a435adbb2c03526aa8746ed53ddf6494edb90f1646d2b0458aa9e832f0577ec",
4342 "lines": 1382,
4343 "url": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/libraries/AP_HAL_SITL/UARTDriver.cpp",
4344 "license": "GPL-3.0-or-later",
4345 "licenseSource": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/COPYING.txt"
4346 },
4347 {
4348 "id": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.h",
4349 "upstream": "ardupilot",
4350 "path": "libraries/AP_HAL_SITL/UARTDriver.h",
4351 "revision": "afeb9c4941543d97f3e04fecf257b8f598f49a6a",
4352 "blobSha": "222e63cae9833c1a40cd841b650f4e8ac58804dc",
4353 "sha256": "1ce5a1a8cb8fd86ffb5ebb6e593854915c0768872a61d2581d4d385d40c5bb29",
4354 "lines": 184,
4355 "url": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/libraries/AP_HAL_SITL/UARTDriver.h",
4356 "license": "GPL-3.0-or-later",
4357 "licenseSource": "https://github.com/ArduPilot/ardupilot/blob/afeb9c4941543d97f3e04fecf257b8f598f49a6a/COPYING.txt"
43104358 }
43114359 ],
43124360 "cases": [
@@ -6337,6 +6385,331 @@
63376385 "The counter decreases on acceptable intervals, so this is not described as 101 consecutive events. This is the input-capture callback path, not a demonstrated guard on the separate GPIO interrupt routine. Owned capture timeouts/lost-ACK models do not establish this interrupt-rate fuse.",
63386386 "No upstream firmware execution, SITL, HIL, physical exposure feedback or executable parity evidence is retained for this gap."
63396387 ]
6388 },
6389 {
6390 "id": "linux-uart-baud-buffer-configuration",
6391 "entry": "ardupilot:libraries/AP_HAL_Linux",
6392 "dimension": "parameters",
6393 "behavior": "Linux HAL baud changes and UART RX/TX ring allocation with 8192-byte RX and 32000-byte TX minimums are not exposed by the owned companion-driver options.",
6394 "support": "unsupported",
6395 "sources": [
6396 {
6397 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6398 "line": 82,
6399 "symbol": "_baudrate != b"
6400 },
6401 {
6402 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6403 "line": 105,
6404 "symbol": "rxS < 8192"
6405 },
6406 {
6407 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6408 "line": 108,
6409 "symbol": "txS < 32000"
6410 }
6411 ],
6412 "evidence": [],
6413 "limitations": [
6414 "Our fixed pending-write capacity does not provide configurable HAL RX/TX rings, baud changes or their clear-buffer semantics.",
6415 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6416 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6417 ]
6418 },
6419 {
6420 "id": "linux-uart-device-path-server-mode",
6421 "entry": "ardupilot:libraries/AP_HAL_Linux",
6422 "dimension": "modes",
6423 "behavior": "Linux HAL TCP-server wait mode and serial device-path selection are not provided by the companion TCP-client factory.",
6424 "support": "unsupported",
6425 "sources": [
6426 {
6427 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6428 "line": 129,
6429 "symbol": "_parseDevicePath"
6430 },
6431 {
6432 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6433 "line": 195,
6434 "symbol": "bool wait"
6435 },
6436 {
6437 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6438 "line": 196,
6439 "symbol": "TCPServerDevice"
6440 }
6441 ],
6442 "evidence": [],
6443 "limitations": [
6444 "The bench owns a local server as a test peer; that does not expose a vehicle HAL TCP-listener/wait mode. Invalid-path console fallback is not adopted as an owned safety policy.",
6445 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6446 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6447 ]
6448 },
6449 {
6450 "id": "linux-uart-queued-mavlink-packetisation",
6451 "entry": "ardupilot:libraries/AP_HAL_Linux",
6452 "dimension": "protocols",
6453 "behavior": "MAVLink packetisation of an arbitrary Linux HAL UART write ring into UDP datagrams is not implemented by the companion packet adapter.",
6454 "support": "unsupported",
6455 "sources": [
6456 {
6457 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6458 "line": 183,
6459 "symbol": "_packetise = true"
6460 },
6461 {
6462 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6463 "line": 341,
6464 "symbol": "mavlink_packetise"
6465 },
6466 {
6467 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6468 "line": 351,
6469 "symbol": "_writebuf.peekbytes"
6470 }
6471 ],
6472 "evidence": [],
6473 "limitations": [
6474 "Encoding one bounded MAVLink frame per companion datagram is narrower than packetising and advancing a HAL byte ring with compile-conditional GCS support.",
6475 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6476 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6477 ]
6478 },
6479 {
6480 "id": "linux-uart-physical-parity-flow-control",
6481 "entry": "ardupilot:libraries/AP_HAL_Linux",
6482 "dimension": "hardware",
6483 "behavior": "Configurable Linux HAL character-device UART parity and hardware flow-control APIs are not exposed by the owned packet-driver factories.",
6484 "support": "unsupported",
6485 "sources": [
6486 {
6487 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6488 "line": 133,
6489 "symbol": "S_ISCHR"
6490 },
6491 {
6492 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6493 "line": 417,
6494 "symbol": "configure_parity"
6495 },
6496 {
6497 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.h",
6498 "line": 37,
6499 "symbol": "set_flow_control"
6500 }
6501 ],
6502 "evidence": [],
6503 "limitations": [
6504 "No tty wiring, RTS/CTS electrical behavior, kernel termios configuration or hardware parity evidence is established by loopback TCP tests.",
6505 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6506 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6507 ]
6508 },
6509 {
6510 "id": "linux-uart-deferred-open-byte-continuation",
6511 "entry": "ardupilot:libraries/AP_HAL_Linux",
6512 "dimension": "failures",
6513 "behavior": "Linux HAL deferred device open and scheduled continuation of queued raw UART bytes after zero or short writes are not implemented as a companion-driver lifecycle.",
6514 "support": "unsupported",
6515 "sources": [
6516 {
6517 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6518 "line": 309,
6519 "symbol": "if (!_connected)"
6520 },
6521 {
6522 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6523 "line": 310,
6524 "symbol": "_device->open"
6525 },
6526 {
6527 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6528 "line": 363,
6529 "symbol": "_writebuf.advance"
6530 },
6531 {
6532 "source": "ardupilot:libraries/AP_HAL_Linux/UARTDriver.cpp",
6533 "line": 366,
6534 "symbol": "ret != vec[i].len"
6535 }
6536 ],
6537 "evidence": [],
6538 "limitations": [
6539 "The owned TCP adapter fails the session on loss or output pressure and requires deliberate reconciliation before a fresh connection. That is not upstream HAL queued-byte continuation and must not silently replay commands.",
6540 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6541 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6542 ]
6543 },
6544 {
6545 "id": "sitl-uart-configured-baud-budget",
6546 "entry": "ardupilot:libraries/AP_HAL_SITL",
6547 "dimension": "parameters",
6548 "behavior": "SITL telemetry baud-limit enablement with independent byte budgets for UART reads and writes is not provided by the companion protocol bench.",
6549 "support": "unsupported",
6550 "sources": [
6551 {
6552 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6553 "line": 1043,
6554 "symbol": "telem_baudlimit_enable"
6555 },
6556 {
6557 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6558 "line": 1046,
6559 "symbol": "baud_limits.write.max_bytes"
6560 },
6561 {
6562 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6563 "line": 1149,
6564 "symbol": "baud_limits.read.max_bytes"
6565 }
6566 ],
6567 "evidence": [],
6568 "limitations": [
6569 "A 25 ms protocol poll and requested MAVLink message interval are not UART baud emulation, serial throughput identification or delivery-rate evidence.",
6570 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6571 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6572 ]
6573 },
6574 {
6575 "id": "sitl-uart-timestamped-csv-mode",
6576 "entry": "ardupilot:libraries/AP_HAL_SITL",
6577 "dimension": "modes",
6578 "behavior": "The SITL logic_async_csv raw serial-byte replay mode with capture-time scheduling is not implemented by owned sensor or diagnostic replay.",
6579 "support": "unsupported",
6580 "sources": [
6581 {
6582 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6583 "line": 198,
6584 "symbol": "logic_async_csv"
6585 },
6586 {
6587 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6588 "line": 935,
6589 "symbol": "read_from_async_csv"
6590 },
6591 {
6592 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6593 "line": 951,
6594 "symbol": "data_timestamp_us > emit_timestamp_us"
6595 }
6596 ],
6597 "evidence": [],
6598 "limitations": [
6599 "Existing owned structured sensor/journal replays do not consume this UART CSV format, its startup delay or its malformed-record behavior.",
6600 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6601 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6602 ]
6603 },
6604 {
6605 "id": "sitl-uart-simulated-serial-device-dispatch",
6606 "entry": "ardupilot:libraries/AP_HAL_SITL",
6607 "dimension": "protocols",
6608 "behavior": "SITL UART dispatch to GPS1/GPS2, ADSB and selected simulated serial devices is not implemented by the companion MAVLink transport.",
6609 "support": "unsupported",
6610 "sources": [
6611 {
6612 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6613 "line": 87,
6614 "symbol": "create_serial_sim"
6615 },
6616 {
6617 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6618 "line": 121,
6619 "symbol": "create_serial_sim"
6620 },
6621 {
6622 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6623 "line": 152,
6624 "symbol": "create_serial_sim"
6625 }
6626 ],
6627 "evidence": [],
6628 "limitations": [
6629 "The cited lines establish device dispatch, not a reviewed inventory of each generated GPS or sensor wire dialect. Those simulator implementations require deeper review.",
6630 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6631 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6632 ]
6633 },
6634 {
6635 "id": "sitl-uart-cts-auto-flow-control",
6636 "entry": "ardupilot:libraries/AP_HAL_SITL",
6637 "dimension": "hardware",
6638 "behavior": "SITL real-UART CTS checking and AUTO hardware-flow-control detection/fallback are not exposed by the companion TCP adapter.",
6639 "support": "unsupported",
6640 "sources": [
6641 {
6642 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6643 "line": 262,
6644 "symbol": "CRTSCTS"
6645 },
6646 {
6647 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6648 "line": 1085,
6649 "symbol": "TIOCMGET"
6650 },
6651 {
6652 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6653 "line": 1086,
6654 "symbol": "TIOCM_CTS"
6655 },
6656 {
6657 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6658 "line": 1108,
6659 "symbol": "500U"
6660 },
6661 {
6662 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6663 "line": 1111,
6664 "symbol": "FLOW_CONTROL_DISABLE"
6665 }
6666 ],
6667 "evidence": [],
6668 "limitations": [
6669 "Software socket pressure is distinct from serial RTS/CTS electrical state. Upstream AUTO can disable flow control after its 500 ms detection condition; our TCP pressure deadline does not reproduce this policy.",
6670 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6671 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6672 ]
6673 },
6674 {
6675 "id": "sitl-uart-hidden-byte-loss-injection",
6676 "entry": "ardupilot:libraries/AP_HAL_SITL",
6677 "dimension": "failures",
6678 "behavior": "SITL UART can omit at most one byte per write under its configured loss test while counting that byte as transmitted; the companion bench has no equivalent raw-byte loss injector.",
6679 "support": "unsupported",
6680 "sources": [
6681 {
6682 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6683 "line": 335,
6684 "symbol": "lost_byte = 0"
6685 },
6686 {
6687 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6688 "line": 340,
6689 "symbol": "uart_byte_loss_pct"
6690 },
6691 {
6692 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6693 "line": 341,
6694 "symbol": "lost_byte = 1"
6695 },
6696 {
6697 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6698 "line": 347,
6699 "symbol": "_tx_stats_bytes += lost_byte"
6700 },
6701 {
6702 "source": "ardupilot:libraries/AP_HAL_SITL/UARTDriver.cpp",
6703 "line": 349,
6704 "symbol": "size - lost_byte"
6705 }
6706 ],
6707 "evidence": [],
6708 "limitations": [
6709 "Dropping one whole COMMAND_ACK and corrupting framing fixtures do not reproduce the upstream per-write loss probability, raw-byte removal or transmitted-byte accounting. This source does not establish independent loss sampling for every byte.",
6710 "The companion UDP/TCP adapter and scripted peer are not implementations of the upstream HAL UART backend or its raw-byte device contract.",
6711 "This is a sampled source-backed gap, not exhaustive review of this dimension. No upstream firmware SITL, hardware, HIL or vehicle execution is claimed."
6712 ]
63406713 }
63416714 ]
63426715}
docs/parity.mdmodified+18-3
Changes to docs/parity.md
@@ -3,10 +3,10 @@
33The executable audit accounts for the 274 pinned PX4 module/driver and ArduPilot library directories
44and six ArduPilot vehicle programs. Directory assignment is work ownership. It is not feature parity.
55
6The retained matrix is [parity-audit.json](https://github.com/stacksjs/drone/blob/main/docs/parity-audit.json). It currently contains **79 behavior
6The retained matrix is [parity-audit.json](https://github.com/stacksjs/drone/blob/main/docs/parity-audit.json). It currently contains **89 behavior
77cases**, including **26 bounded support claims** backed by **25 distinct executable test selectors**
8and **53 explicitly unsupported cases**. There are **1,327 unclassified detail dimensions**.
9The audit retains **42 pinned source records**. No upstream firmware SITL, HIL or physical-vehicle
8and **63 explicitly unsupported cases**. There are **1,317 unclassified detail dimensions**.
9The audit retains **46 pinned source records**. No upstream firmware SITL, HIL or physical-vehicle
1010validation is claimed by this audit.
1111
1212Every assignment classifies parameters, modes, protocols, hardware and failures separately. A
@@ -124,6 +124,21 @@ not prove hardware enablement. Board wiring, electrical/timing calibration, PPS/
124124queue/restart behavior remain deeper work. The owned IMX500 first-row metadata, scoped capture
125125ACK/pose association and modeled shutter remain separate capabilities, with no new parity claim.
126126
127Ten further unsupported cases sample pinned ArduPilot Linux and SITL HAL UART behavior. They cover
128baud and buffer budgets, TCP server and CSV replay modes, raw-byte packetisation and simulated-device
129dispatch, parity and CTS/AUTO flow control, deferred open/short-write continuation, and hidden byte-loss
130injection. Four new source records retain 35 exact line/symbol anchors. The prior 79 cases, 42 sources,
13126 bounded claims and 25 selectors remain unchanged. Both HAL directories remain owned by runtime
132issue #2 and retain deeper review work in all five sampled dimensions.
133
134The companion TCP client is distinct from those HAL backends. Its 4 KiB stream watermark and 16 KiB
135outstanding-write cap do not implement configurable UART rings, hardware parity, electrical RTS/CTS,
136raw serial replay or automatic queued-byte continuation. Upstream's SITL loss injector can omit at
137most one byte per write while counting it as transmitted; this is distinct from dropping an entire
138ACK or independently sampling every byte. The reviewed source establishes dispatch to simulated
139serial devices, not full review of each generated sensor dialect. No new executable support or
140upstream firmware execution is claimed by these ten cases.
141
127142Unreviewed parameters, full firmware mode sets, serial/CAN/electrical variants, redundant estimators,
128143real output paths, simulator campaigns and hardware evidence remain visible gaps owned by their
129144implementation issues.
docs/roadmap/issues.jsonmodified+8-4
Changes to docs/roadmap/issues.json
@@ -192,11 +192,15 @@
192192 "app/Commands/DriversBench.ts",
193193 "docs/driver-protocols.md",
194194 "docs/driver-protocol-evidence.json",
195 "tests/unit/driver-protocol.test.ts"
195 "tests/unit/driver-protocol.test.ts",
196 "resources/functions/drone/tcp.ts",
197 "resources/functions/drone/drivers/session.ts",
198 "tests/unit/driver-tcp.test.ts",
199 "tests/unit/mavlink-stream.test.ts"
196200 ],
197201 "remaining": [
198202 "Pinned ArduPilot/PX4 firmware SITL round trips, supported commands, mission variants and measured message rates",
199 "Serial/TCP stream transport, reconnect/backpressure and hardware/HIL authority evidence",
203 "Serial transport, automatic reconnect policy, actual firmware TCP failure campaigns and hardware/HIL authority evidence",
200204 "Signing key lifecycle, persistent replay watermarks and cross-restart campaigns",
201205 "COMMAND_INT/remote cancellation, full mission/parameter protocols and ownership reconciliation",
202206 "Explicit FTP/log/event/time/RTCM, payload/gimbal, DDS/ROS 2 and CAN gateway services"
@@ -861,7 +865,7 @@
861865 "implementationStatus": "behavior-audit-in-progress",
862866 "implemented": [
863867 "Pinned directory and six-vehicle-family assignments with per-dimension reviewed/unclassified status",
864 "59 source-backed behavior cases, bounded subset claims and explicit unsupported behaviors",
868 "89 source-backed behavior cases, bounded subset claims and explicit unsupported behaviors",
865869 "Executable evidence selectors, pinned source hash/license/citation verification, issue link checks and upstream snapshot refresh/diff gates"
866870 ],
867871 "evidence": [
@@ -872,7 +876,7 @@
872876 "docs/parity.md"
873877 ],
874878 "remaining": [
875 "Complete 1,347 currently unclassified parameter/mode/protocol/hardware/failure dimensions and deeper review inside sampled dimensions",
879 "Complete 1,317 currently unclassified parameter/mode/protocol/hardware/failure dimensions and deeper review inside sampled dimensions",
876880 "Expand executable coverage from bounded subsets to behavior-level parity with retained firmware/SITL/HIL/vehicle evidence"
877881 ]
878882 },
resources/functions/drone/drivers/bench-peer.tsadded+44-0
Changes to resources/functions/drone/drivers/bench-peer.ts
@@ -0,0 +1,44 @@
1import type { Socket } from 'node:net'
2import { createServer } from 'node:net'
3import { createSocket } from 'node:dgram'
4import type { MavlinkFrame } from '../mavlink'
5import { createMavlinkStreamDecoder, decodeDatagram } from '../mavlink'
6import type { MavlinkSigning } from './signing'
7
8/** Owned scripted peer only. No forwarding, credentials or external firmware. */
9export async function createDriverBenchPeer(transport: 'udp' | 'tcp', signing: MavlinkSigning | undefined, receive: (frames: MavlinkFrame[]) => void, onError: (error: Error) => void) {
10 let clientPort: number | null = null
11 if (transport === 'udp') {
12 const socket = createSocket('udp4')
13 socket.on('error', onError)
14 socket.on('message', (bytes, remote) => { if (remote.address === '127.0.0.1' && remote.port === clientPort) receive(decodeDatagram(bytes, signing)) })
15 await new Promise<void>((resolve, reject) => { socket.once('error', reject); socket.bind(0, '127.0.0.1', () => { socket.off('error', reject); resolve() }) })
16 return { port: socket.address().port, start(port: number) { clientPort = port }, send(bytes: Uint8Array) { if (clientPort !== null) socket.send(bytes, clientPort, '127.0.0.1') }, async close() { await new Promise<void>(resolve => socket.close(() => resolve())) } }
17 }
18 let connection: Socket | null = null
19 const server = createServer(socket => {
20 if (connection) { socket.destroy(); return }
21 connection = socket
22 const decoder = createMavlinkStreamDecoder(signing)
23 socket.on('error', onError)
24 socket.on('data', bytes => {
25 if (typeof bytes === 'string') { onError(new Error('Scripted TCP byte stream unexpectedly decoded as text')); socket.destroy(); return }
26 try { for (let at = 0; at < bytes.length; at += 65_536) receive(decoder.push(bytes.subarray(at, at + 65_536))) }
27 catch (error) { onError(error instanceof Error ? error : new Error(String(error))); socket.destroy() }
28 })
29 })
30 server.on('error', onError)
31 await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() }) })
32 const address = server.address()
33 if (!address || typeof address === 'string') throw new Error('Missing scripted TCP peer port')
34 return {
35 port: address.port,
36 start(port: number) { clientPort = port },
37 send(bytes: Uint8Array) {
38 if (!connection || connection.remotePort !== clientPort) return
39 if (connection.writableLength + bytes.length > 16_384) { onError(new Error('Scripted TCP peer exceeded output bound')); connection.destroy(); return }
40 connection.write(bytes)
41 },
42 async close() { connection?.destroy(); await new Promise<void>(resolve => server.close(() => resolve())) },
43 }
44}
resources/functions/drone/drivers/bench.tsmodified+17-16
Changes to resources/functions/drone/drivers/bench.ts
@@ -1,37 +1,37 @@
1import { createSocket } from 'node:dgram'
21import type { AutopilotDriverId } from './profiles'
32import type { MissionWaypoint } from './messages'
43import { createArduPilotDriver, createPx4Driver } from './index'
54import { createMavlinkSigning } from './signing'
6import { decodeDatagram, encodeFrameV2 } from '../mavlink'
5import type { MavlinkFrame } from '../mavlink'
6import { encodeFrameV2 } from '../mavlink'
7import { createDriverBenchPeer } from './bench-peer'
78import { driverProfiles } from './profiles'
89import { messageView, missionAckPayload, missionCountPayload, normalizeWaypoint, parameterBytes, parseWaypoint, waypointPayload } from './messages'
910
1011/** Scripted protocol peer, not an ArduPilot/PX4 firmware process or a flight simulator. */
11export async function runDriverProtocolBench(options: { driver: AutopilotDriverId, dropFirstAck?: boolean, signed?: boolean }) {
12export async function runDriverProtocolBench(options: { driver: AutopilotDriverId, transport?: 'udp' | 'tcp', dropFirstAck?: boolean, signed?: boolean }) {
1213 const { driver } = options
1314 if (!['ardupilot', 'px4'].includes(driver)) throw new TypeError('Choose ardupilot or px4')
15 const transport = options.transport ?? 'udp'
16 if (transport !== 'udp' && transport !== 'tcp') throw new TypeError('Choose udp or tcp')
1417 const dropFirstAck = options.dropFirstAck === true, signed = options.signed === true
1518 const encoding = driver === 'px4' ? 'bytewise' : 'c-cast'
1619 // Public conformance key. Never use this fixture key on a deployed link.
1720 const key = Uint8Array.from({ length: 32 }, (_, i) => i)
1821 const timestamp = () => BigInt(Math.floor((Date.now() - Date.UTC(2015, 0, 1)) * 100))
1922 const peerSigning = signed ? createMavlinkSigning({ key, linkId: 1, timestamp }) : undefined
20 const peer = createSocket('udp4')
21 let clientPort: number | null = null, sequence = 0, dropped = 0, integer = 42, peerError: Error | null = null
23 let sequence = 0, dropped = 0, integer = 42, peerError: Error | null = null
2224 let mission: Required<MissionWaypoint>[] = [], staged: Required<MissionWaypoint>[] = [], uploadCount = 0
2325 const confirmations: number[] = []
2426 const send = (id: number, payload: Uint8Array) => {
25 if (clientPort !== null) peer.send(encodeFrameV2(id, payload, sequence++ % 256, 1, 1, peerSigning), clientPort, '127.0.0.1')
27 peer.send(encodeFrameV2(id, payload, sequence++ % 256, 1, 1, peerSigning))
2628 }
2729 const ack = (command: number, result = 0) => { const p = new Uint8Array(10); new DataView(p.buffer).setUint16(0, command, true); p.set([result, 100], 2); p.set([245, 191], 8); send(77, p) }
2830 const heartbeat = () => { const p = new Uint8Array(9); p.set([2, driverProfiles[driver].autopilotId, 0, 4, 3], 4); send(0, p) }
2931 const value = () => { const p = new Uint8Array(25), v = new DataView(p.buffer); p.set(parameterBytes(integer, 6, encoding)); v.setUint16(4, 1, true); p.set(new TextEncoder().encode('BENCH_INT'), 8); p[24] = 6; send(22, p) }
30 peer.on('error', error => { peerError = error })
31 peer.on('message', (bytes, remote) => {
32 if (remote.address !== '127.0.0.1' || remote.port !== clientPort) return
32 function receive(frames: MavlinkFrame[]) {
3333 try {
34 for (const frame of decodeDatagram(bytes, peerSigning)) {
34 for (const frame of frames) {
3535 if (frame.systemId !== 245 || frame.componentId !== 191) continue
3636 const p = frame.payload, v = messageView(frame)
3737 if (frame.id === 76) {
@@ -82,15 +82,16 @@ export async function runDriverProtocolBench(options: { driver: AutopilotDriverI
8282 }
8383 }
8484 catch (error) { peerError = error instanceof Error ? error : new Error(String(error)) }
85 })
86 await new Promise<void>((resolve, reject) => { peer.once('error', reject); peer.bind(0, '127.0.0.1', () => { peer.off('error', reject); resolve() }) })
85 }
86 const peer = await createDriverBenchPeer(transport, peerSigning, receive, error => { peerError = error })
8787 const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 20000)
8888 let adapter: Awaited<ReturnType<typeof createArduPilotDriver>> | undefined
8989 let heartbeatTimer: ReturnType<typeof setInterval> | undefined
9090 try {
9191 const factory = driver === 'px4' ? createPx4Driver : createArduPilotDriver
92 adapter = await factory({ kind: 'sitl', listenPort: 0, sitlPort: peer.address().port, signing: signed ? { key, linkId: 2, timestamp } : undefined })
93 clientPort = adapter.localPort; heartbeat(); heartbeatTimer = setInterval(heartbeat, 250)
92 const signing = signed ? { key, linkId: 2, timestamp } : undefined
93 adapter = await factory(transport === 'tcp' ? { transport, kind: 'sitl', port: peer.port, signing } : { kind: 'sitl', listenPort: 0, sitlPort: peer.port, signing })
94 peer.start(adapter.localPort); heartbeat(); heartbeatTimer = setInterval(heartbeat, 250)
9495 const readyDeadline = performance.now() + 5000
9596 while (!Number.isFinite(adapter.protocol.snapshot().lastHeartbeatAtMs)) { if (performance.now() > readyDeadline || peerError) throw peerError ?? new Error('Fixture discovery heartbeat timeout'); await Bun.sleep(10) }
9697 const operation = { signal: controller.signal }, protocol = adapter.protocol
@@ -100,7 +101,7 @@ export async function runDriverProtocolBench(options: { driver: AutopilotDriverI
100101 const uploaded = await protocol.uploadMission(missionItems, operation), downloaded = await protocol.downloadMission(operation), resume = await protocol.resumeMission(1, operation)
101102 if (peerError) throw peerError
102103 if (JSON.stringify(downloaded) !== JSON.stringify(missionItems.map(normalizeWaypoint))) throw new Error('Fixture mission readback mismatch')
103 return { evidence: 'scripted-loopback-protocol-peer' as const, driver, signed, physicalFlightEnabled: false as const, firmwareSitlValidated: false as const, hardwareValidated: false as const, capabilities, rate, confirmations, droppedAcks: dropped, parameterBefore, parameterAfter, uploaded, downloaded, resume }
104 return { evidence: 'scripted-loopback-protocol-peer' as const, driver, transport, signed, physicalFlightEnabled: false as const, firmwareSitlValidated: false as const, hardwareValidated: false as const, capabilities, rate, confirmations, droppedAcks: dropped, parameterBefore, parameterAfter, uploaded, downloaded, resume }
104105 }
105 finally { clearTimeout(timeout); if (heartbeatTimer) clearInterval(heartbeatTimer); adapter?.close(); peer.close() }
106 finally { clearTimeout(timeout); if (heartbeatTimer) clearInterval(heartbeatTimer); adapter?.close(); await peer.close() }
106107}
resources/functions/drone/drivers/index.tsmodified+19-2
Changes to resources/functions/drone/drivers/index.ts
@@ -1,9 +1,26 @@
11import type { UdpOptions } from '../udp'
22import { createUdpAdapter } from '../udp'
3import type { TcpAdapter, TcpOptions } from '../tcp'
4import { createTcpAdapter } from '../tcp'
5import type { AutopilotAdapter } from './session'
6
7export type TcpDriverOptions = Omit<TcpOptions, 'driver'> & { transport: 'tcp' }
8export type AutopilotDriverOptions = (Omit<UdpOptions, 'driver'> & { transport?: 'udp' }) | TcpDriverOptions
9function createDriver(driver: 'ardupilot' | 'px4', options: AutopilotDriverOptions) {
10 if (options.transport === 'tcp') return createTcpAdapter({ ...options, driver })
11 if (options.transport !== undefined && options.transport !== 'udp') throw new TypeError('Unknown autopilot transport')
12 return createUdpAdapter({ ...options, driver })
13}
314
415/** Explicit factories prevent guessing an autopilot from whichever packet arrives first. */
5export const createArduPilotDriver = (options: Omit<UdpOptions, 'driver'>) => createUdpAdapter({ ...options, driver: 'ardupilot' })
6export const createPx4Driver = (options: Omit<UdpOptions, 'driver'>) => createUdpAdapter({ ...options, driver: 'px4' })
16export function createArduPilotDriver(options: TcpDriverOptions): Promise<TcpAdapter>
17export function createArduPilotDriver(options: AutopilotDriverOptions): Promise<AutopilotAdapter>
18export function createArduPilotDriver(options: AutopilotDriverOptions) { return createDriver('ardupilot', options) }
19export function createPx4Driver(options: TcpDriverOptions): Promise<TcpAdapter>
20export function createPx4Driver(options: AutopilotDriverOptions): Promise<AutopilotAdapter>
21export function createPx4Driver(options: AutopilotDriverOptions) { return createDriver('px4', options) }
22
23export type { TcpAdapter, TcpTransportSnapshot } from '../tcp'
724
825export type { AutopilotProtocol, DriverOperationOptions, CommandAcknowledgement, DriverProtocolError } from './protocol'
926export type { DriverCapabilities, DriverParameter, MissionWaypoint, ParameterType } from './messages'
resources/functions/drone/drivers/protocol.tsmodified+1-1
Changes to resources/functions/drone/drivers/protocol.ts
@@ -122,7 +122,7 @@ export function createDriverProtocol(options: { driver: AutopilotDriverId, kind:
122122 const api = {
123123 tick,
124124 close: () => close(),
125 transportLost: () => close('transport-lost', 'UDP transport failed'),
125 transportLost: (message = 'Autopilot transport failed') => close('transport-lost', message),
126126 snapshot() { return { driver, kind, targetSystem: systemId, targetComponent: componentId, lastHeartbeatAtMs: heartbeatAt, capabilities: capabilities ? { ...capabilities } : null, active: pending ? { key: pending.key, attempts: pending.attempts, deadlineMs: pending.deadline, inProgress: pending.inProgress } : null, fault: terminal?.message ?? null, quarantined: [...quarantine], knownMissionCount, physicalCommands: false as const } },
127127 receive(frame: MavlinkFrame) {
128128 if (terminal || frame.systemId !== systemId || frame.componentId !== componentId) return
resources/functions/drone/drivers/session.tsadded+95-0
Changes to resources/functions/drone/drivers/session.ts
@@ -0,0 +1,95 @@
1import type { Decision, FlightAdapter, Telemetry } from '../types'
2import type { MavlinkFrame } from '../mavlink'
3import type { MavlinkSigning } from './signing'
4import type { AutopilotDriverId } from './profiles'
5import type { AutopilotProtocol } from './protocol'
6import { zero, finiteVector } from '../geometry'
7import { encodeFrameV2, encodeVelocity } from '../mavlink'
8import { decodeAutopilotMode, driverProfiles } from './profiles'
9import { createDriverProtocol } from './protocol'
10
11export interface AutopilotSessionOptions {
12 driver?: AutopilotDriverId
13 kind: 'sitl' | 'telemetry-only'
14 systemId?: number
15 componentId?: number
16 now?: () => number
17 /** Strict MAVLink 2 signing. Unsigned input is rejected when configured. Never log the key. */
18 signing?: { key: Uint8Array, linkId: number, timestamp: () => bigint }
19}
20export interface AutopilotAdapter extends FlightAdapter {
21 readonly driver: AutopilotDriverId
22 readonly localPort: number
23 readonly protocol: AutopilotProtocol
24 /** One zero setpoint per call. Caller must sustain >= 5Hz during explicit SITL preparation. */
25 primeOffboard: () => { ready: boolean, reason: string }
26}
27
28/** Shared driver policy. A transport owns bytes, peer pinning, polling and resource cleanup. */
29export function createAutopilotSession(options: AutopilotSessionOptions, signing: MavlinkSigning | undefined, transport: { available(): boolean, write(bytes: Uint8Array): void }) {
30 const driver = options.driver ?? 'ardupilot', kind = options.kind
31 if (driver !== 'ardupilot' && driver !== 'px4') throw new TypeError('Unknown autopilot driver')
32 if (kind !== 'sitl' && kind !== 'telemetry-only') throw new TypeError('Unknown adapter mode')
33 const profile = driverProfiles[driver], now = options.now ?? (() => performance.now())
34 const systemId = options.systemId ?? 1, componentId = options.componentId ?? 1
35 if (![systemId, componentId].every(id => Number.isInteger(id) && id >= 1 && id <= 255)) throw new RangeError('Invalid autopilot identity')
36 let sequence = 0, closed = false, transportError = false
37 let heartbeatAt = -Infinity, positionAt = -Infinity, batteryAt = -Infinity, primeStarted = -Infinity, lastSetpointAt = -Infinity
38 let telemetry: Telemetry = { position: zero(), velocity: zero(), batteryPercent: -1, positionHealthy: false, armed: false, mode: 'manual', receivedAtMs: -Infinity }
39 const protocol = createDriverProtocol({ driver, kind, systemId, componentId, now, transmit(id, payload) {
40 if (closed || transportError || kind !== 'sitl' || !transport.available()) throw new Error('No writable simulator peer')
41 transport.write(encodeFrameV2(id, payload, sequence++ % 256, 245, 191, signing))
42 } })
43 function transportLost(message?: string) { transportError = true; protocol.transportLost(message) }
44 function read(): Telemetry | undefined {
45 if (closed || transportError) return undefined
46 telemetry = { ...telemetry, receivedAtMs: Math.min(heartbeatAt, positionAt, batteryAt), positionHealthy: kind === 'sitl' }
47 return structuredClone(telemetry)
48 }
49 function transmit(velocity: Telemetry['velocity']) {
50 const timestamp = now()
51 const frame = encodeVelocity(velocity, { sequence: sequence++ % 256, targetSystem: systemId, targetComponent: componentId, bootMs: timestamp, signing })
52 try { transport.write(frame); lastSetpointAt = timestamp }
53 catch { transportLost() }
54 }
55 return {
56 kind, driver, read, protocol, transportLost,
57 receive(frame: MavlinkFrame): boolean {
58 if (closed || transportError || frame.systemId !== systemId || frame.componentId !== componentId) return false
59 const view = new DataView(frame.payload.buffer, frame.payload.byteOffset, frame.payload.byteLength), receivedAt = now()
60 if (frame.id === 0) {
61 if (view.getUint8(5) !== profile.autopilotId || view.getUint8(4) !== profile.vehicleType) return false
62 heartbeatAt = receivedAt
63 telemetry.armed = (view.getUint8(6) & 128) !== 0
64 telemetry.mode = decodeAutopilotMode(driver, view.getUint32(0, true))
65 }
66 else if (frame.id === 1) { batteryAt = receivedAt; telemetry.batteryPercent = view.getInt8(30) }
67 else if (frame.id === 32) {
68 positionAt = receivedAt
69 telemetry.position = { north: view.getFloat32(4, true), east: view.getFloat32(8, true), down: view.getFloat32(12, true) }
70 telemetry.velocity = { north: view.getFloat32(16, true), east: view.getFloat32(20, true), down: view.getFloat32(24, true) }
71 }
72 protocol.receive(frame)
73 return true
74 },
75 primeOffboard() {
76 if (driver !== 'px4' || kind !== 'sitl') throw new Error('Offboard priming is supported only by the PX4 SITL driver')
77 const timestamp = now(), t = read()
78 if (!t || protocol.snapshot().fault || !transport.available() || !Number.isFinite(timestamp) || t.receivedAtMs > timestamp
79 || timestamp - t.receivedAtMs > 1000 || !finiteVector(t.position) || !finiteVector(t.velocity)
80 || t.batteryPercent < 25 || t.batteryPercent > 100 || t.mode === 'rtl' || t.mode === 'land') {
81 primeStarted = -Infinity
82 return { ready: false, reason: 'Fresh simulator telemetry and battery reserve required' }
83 }
84 if (timestamp < lastSetpointAt || timestamp - lastSetpointAt > 400 || !Number.isFinite(primeStarted)) primeStarted = timestamp
85 transmit(zero())
86 return { ready: !transportError && timestamp - primeStarted >= 1500, reason: 'Stream zero-velocity setpoints before selecting PX4 Offboard' }
87 },
88 send(decision: Decision) {
89 if (closed || transportError || protocol.snapshot().fault || kind !== 'sitl' || !transport.available() || decision.kind === 'release') return
90 if (driver === 'px4' && (now() - lastSetpointAt > 400 || now() < lastSetpointAt || now() - primeStarted < 1500 || !Number.isFinite(primeStarted) || telemetry.mode !== 'guided')) return
91 transmit(decision.kind === 'velocity' ? decision.velocity : zero())
92 },
93 close() { if (!closed) { closed = true; protocol.close() } },
94 }
95}
resources/functions/drone/mavlink.tsmodified+30-0
Changes to resources/functions/drone/mavlink.ts
@@ -91,6 +91,36 @@ export function decodeDatagram(bytes: Uint8Array, signing?: MavlinkSigning): Mav
9191 return result
9292}
9393
94/** Bounded byte-stream framing. Partial frames belong to exactly one connection. */
95export function createMavlinkStreamDecoder(signing?: MavlinkSigning) {
96 // MAVLink 2: 10-byte header, 255-byte payload, CRC and 13-byte signature.
97 const pending = new Uint8Array(280)
98 let used = 0
99 function discard(count: number) { pending.copyWithin(0, count, used); used -= count }
100 return {
101 get retainedBytes() { return used },
102 reset() { used = 0 },
103 push(bytes: Uint8Array): MavlinkFrame[] {
104 if (!(bytes instanceof Uint8Array) || bytes.length > 65_536) throw new RangeError('MAVLink stream chunk exceeds 65536 bytes')
105 const frames: MavlinkFrame[] = []
106 for (const byte of bytes) {
107 pending[used++] = byte
108 while (used > 0) {
109 const v2 = pending[0] === 0xFD
110 if (!v2 && pending[0] !== 0xFE) { discard(1); continue }
111 if (used < (v2 ? 3 : 2)) break
112 const length = pending[1]! + (v2 ? 12 + ((pending[2]! & 1) ? 13 : 0) : 8)
113 if (used < length) break
114 const decoded = decodeDatagram(pending.subarray(0, length), signing)
115 if (decoded.length === 1) { frames.push(decoded[0]!); discard(length) }
116 else discard(1) // Resynchronize after corrupt length/CRC, retaining any following frame.
117 }
118 }
119 return frames
120 },
121 }
122}
123
94124/** LOCAL_NED velocity-only setpoint. Does not arm, take off, land or change modes. */
95125export function encodeVelocity(velocity: Vector3, options: { sequence?: number, targetSystem?: number, targetComponent?: number, bootMs?: number, signing?: MavlinkSigning } = {}): Uint8Array {
96126 if (![velocity.north, velocity.east, velocity.down].every(v => Number.isFinite(v) && Math.abs(v) <= 20))
resources/functions/drone/tcp.tsadded+120-0
Changes to resources/functions/drone/tcp.ts
@@ -0,0 +1,120 @@
1import type { Socket } from 'node:net'
2import { createConnection } from 'node:net'
3import type { AutopilotAdapter, AutopilotSessionOptions } from './drivers/session'
4import { createAutopilotSession } from './drivers/session'
5import { createMavlinkSigning } from './drivers/signing'
6import { createMavlinkStreamDecoder } from './mavlink'
7
8export interface TcpOptions extends AutopilotSessionOptions { port: number }
9export interface TcpTransportSnapshot {
10 readonly state: 'connected' | 'failed' | 'closed'
11 readonly backpressured: boolean
12 /** Accepted bytes whose local write callbacks have not settled, not remote receipt. */
13 readonly pendingCallbackBytes: number
14 readonly runtimeWritableBytes: number
15 readonly blockedSinceMs: number | null
16 readonly lastWriteProgressAtMs: number | null
17}
18export interface TcpAdapter extends AutopilotAdapter { transportSnapshot(): TcpTransportSnapshot }
19
20/** One explicit loopback connection. Reconnect by creating a new adapter and reconciling state. */
21export async function createTcpAdapter(options: TcpOptions): Promise<TcpAdapter> {
22 if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new RangeError('Invalid TCP port')
23 const signing = options.signing ? createMavlinkSigning(options.signing) : undefined
24 const decoder = createMavlinkStreamDecoder(signing)
25 let socket: Socket | undefined, connected = false, closed = false, failed = false, blocked = false, matchedHeartbeat = false
26 let timer: ReturnType<typeof setInterval> | undefined
27 let blockedSince = 0, queuedBytes = 0, lastWriteProgress = 0, heartbeatDeadline = Infinity
28 const session = createAutopilotSession(options, signing, {
29 available: () => connected && !closed && !failed,
30 write(bytes) {
31 if (expireIo() || !socket || !connected || closed || failed || blocked || Math.max(socket.writableLength, queuedBytes) + bytes.length > 16_384) {
32 fail('TCP output unavailable or backpressured; remote outcome is uncertain'); throw new Error('TCP output unavailable or backpressured; remote outcome is uncertain')
33 }
34 if (queuedBytes === 0) lastWriteProgress = performance.now()
35 queuedBytes += bytes.length
36 // Bound outstanding write callbacks as well as the runtime's writableLength.
37 // false means accepted by the stream. Never resend these bytes on drain.
38 try {
39 if (!socket.write(bytes, error => {
40 const expired = expireIo()
41 queuedBytes -= bytes.length
42 if (expired) return
43 lastWriteProgress = performance.now()
44 if (error) fail('TCP write failed; remote outcome is uncertain')
45 })) { blocked = true; blockedSince = performance.now() }
46 }
47 catch (error) { fail('TCP write failed; remote outcome is uncertain'); throw error }
48 },
49 })
50 function stopPolling() { if (timer) { clearInterval(timer); timer = undefined } }
51 function fail(message = 'TCP connection closed') { if (!failed && !closed) { failed = true; connected = false; stopPolling(); decoder.reset(); session.transportLost(message); socket?.destroy() } }
52 function expireIo() {
53 if (closed || failed) return true
54 const now = performance.now()
55 if (!matchedHeartbeat && connected && now >= heartbeatDeadline) { fail('TCP matching heartbeat deadline expired'); return true }
56 if (blocked && now - blockedSince >= 500) { fail('TCP output drain deadline expired; remote outcome is uncertain'); return true }
57 if (queuedBytes > 0 && now - lastWriteProgress >= 500) { fail('TCP write progress deadline expired; remote outcome is uncertain'); return true }
58 return false
59 }
60 const connectionDeadline = performance.now() + 2000
61 // Observe stream pressure before the independent 16 KiB outstanding-callback cap.
62 const connectionOptions = { host: '127.0.0.1', port: options.port, writableHighWaterMark: 4096 }
63 const transport = createConnection(connectionOptions)
64 socket = transport
65 transport.setNoDelay(true)
66 transport.on('error', () => fail('TCP socket error'))
67 transport.on('end', () => fail())
68 transport.on('close', () => fail())
69 transport.on('drain', () => { if (!expireIo()) blocked = false })
70 transport.on('data', bytes => {
71 if (expireIo()) return
72 if (typeof bytes === 'string') { fail('TCP byte stream unexpectedly decoded as text'); return }
73 try {
74 for (let offset = 0; offset < bytes.length; offset += 65_536) {
75 for (const frame of decoder.push(bytes.subarray(offset, offset + 65_536))) {
76 if (!matchedHeartbeat && frame.id !== 0) continue
77 if (session.receive(frame) && frame.id === 0) matchedHeartbeat = true
78 }
79 }
80 }
81 catch { fail('TCP input decoding failed') }
82 })
83 try {
84 await new Promise<void>((resolve, reject) => {
85 const timeout = setTimeout(expired, Math.max(0, connectionDeadline - performance.now()))
86 function expired() { finish(new Error('TCP connection deadline expired')); fail('TCP connection deadline expired') }
87 function finish(error?: Error) {
88 clearTimeout(timeout); transport.off('connect', ready); transport.off('error', rejected); transport.off('close', disconnected)
89 if (error) reject(error); else resolve()
90 }
91 const ready = () => {
92 const receivedAt = performance.now()
93 if (receivedAt >= connectionDeadline) { expired(); return }
94 heartbeatDeadline = receivedAt + 2000
95 connected = true; finish()
96 }
97 const rejected = (error: Error) => finish(error)
98 const disconnected = () => finish(new Error('TCP connection closed before ready'))
99 transport.once('connect', ready); transport.once('error', rejected); transport.once('close', disconnected)
100 })
101 if (failed || transport.destroyed || !connected) throw new Error('TCP connection unavailable')
102 }
103 catch (error) { session.close(); transport.destroy(); throw error }
104 timer = setInterval(() => {
105 if (expireIo()) return
106 session.protocol.tick()
107 if (session.protocol.snapshot().fault) fail()
108 }, 25)
109 timer.unref()
110 return {
111 kind: session.kind, driver: session.driver, read: session.read, protocol: session.protocol, localPort: transport.localPort!,
112 send: session.send, primeOffboard: session.primeOffboard,
113 transportSnapshot() {
114 return { state: closed ? 'closed' : failed ? 'failed' : 'connected', backpressured: blocked,
115 pendingCallbackBytes: queuedBytes, runtimeWritableBytes: transport.writableLength,
116 blockedSinceMs: blocked ? blockedSince : null, lastWriteProgressAtMs: queuedBytes > 0 ? lastWriteProgress : null }
117 },
118 close() { if (!closed) { closed = true; connected = false; stopPolling(); decoder.reset(); session.close(); transport.destroy() } },
119 }
120}
resources/functions/drone/udp.tsmodified+25-114
Changes to resources/functions/drone/udp.ts
@@ -1,136 +1,47 @@
1import type { Decision, FlightAdapter, Telemetry } from './types'
21import { createSocket } from 'node:dgram'
3import type { AutopilotProtocol } from './drivers/protocol'
4import { createDriverProtocol } from './drivers/protocol'
2import type { AutopilotAdapter, AutopilotSessionOptions } from './drivers/session'
3import { createAutopilotSession } from './drivers/session'
54import { createMavlinkSigning } from './drivers/signing'
6import { decodeDatagram, encodeFrameV2, encodeVelocity } from './mavlink'
7import { zero } from './geometry'
8import { finiteVector } from './geometry'
9import type { AutopilotDriverId } from './drivers/profiles'
10import { decodeAutopilotMode, driverProfiles } from './drivers/profiles'
5import { decodeDatagram } from './mavlink'
116
12export interface UdpOptions {
13 driver?: AutopilotDriverId
14 kind: 'sitl' | 'telemetry-only'
7export type { AutopilotAdapter } from './drivers/session'
8export interface UdpOptions extends AutopilotSessionOptions {
159 listenPort: number
1610 /** Fixed loopback destination. Never point a SITL forwarder at a physical vehicle. */
1711 sitlPort?: number
18 systemId?: number
19 componentId?: number
20 now?: () => number
21 /** Strict MAVLink 2 signing. Unsigned input is rejected when configured. Never log the key. */
22 signing?: { key: Uint8Array, linkId: number, timestamp: () => bigint }
23}
24export interface AutopilotAdapter extends FlightAdapter {
25 readonly driver: AutopilotDriverId
26 readonly localPort: number
27 readonly protocol: AutopilotProtocol
28 /** One zero setpoint per call. Caller must sustain >= 5Hz during explicit SITL preparation. */
29 primeOffboard: () => { ready: boolean, reason: string }
3012}
3113
3214/** Local MAVProxy/mavlink-router endpoint. Physical hardware is receive-only. */
3315export async function createUdpAdapter(options: UdpOptions): Promise<AutopilotAdapter> {
34 const driver = options.driver ?? 'ardupilot'
35 if (driver !== 'ardupilot' && driver !== 'px4') throw new TypeError('Unknown autopilot driver')
36 const profile = driverProfiles[driver]
37 const kind = options.kind
38 if (kind !== 'sitl' && kind !== 'telemetry-only') throw new TypeError('Unknown UDP adapter mode')
3916 const portValid = (port: number): boolean => Number.isInteger(port) && port > 0 && port <= 65535
40 if (!(options.listenPort === 0 || portValid(options.listenPort)) || (options.sitlPort !== undefined && !portValid(options.sitlPort)))
41 throw new RangeError('Invalid UDP port')
42 const now = options.now ?? (() => performance.now())
43 const systemId = options.systemId ?? 1
44 const componentId = options.componentId ?? 1
45 if (![systemId, componentId].every(id => Number.isInteger(id) && id >= 1 && id <= 255)) {
46 throw new RangeError('Invalid autopilot identity')
47 }
17 if (!(options.listenPort === 0 || portValid(options.listenPort)) || (options.sitlPort !== undefined && !portValid(options.sitlPort))) throw new RangeError('Invalid UDP port')
4818 const signing = options.signing ? createMavlinkSigning(options.signing) : undefined
19 let peerPort = options.sitlPort, closed = false
4920 const socket = createSocket('udp4')
50 let peerPort = options.sitlPort
51 let sequence = 0
52 let closed = false
53 let transportError = false
54 let heartbeatAt = -Infinity
55 let positionAt = -Infinity
56 let batteryAt = -Infinity
57 let primeStarted = -Infinity
58 let lastSetpointAt = -Infinity
59 let telemetry: Telemetry = { position: zero(), velocity: zero(), batteryPercent: -1, positionHealthy: false, armed: false, mode: 'manual', receivedAtMs: -Infinity }
60 const protocol = createDriverProtocol({ driver, kind, systemId, componentId, now, transmit(id, payload) {
61 if (closed || transportError || kind !== 'sitl' || peerPort === undefined) throw new Error('No writable simulator peer')
62 socket.send(encodeFrameV2(id, payload, sequence++ % 256, 245, 191, signing), peerPort, '127.0.0.1', (error) => { if (error) { transportError = true; protocol.transportLost() } })
63 } })
64 socket.on('error', () => { transportError = true; protocol.transportLost() })
21 const session = createAutopilotSession(options, signing, {
22 available: () => !closed && peerPort !== undefined,
23 write(bytes) { socket.send(bytes, peerPort!, '127.0.0.1', error => { if (error) session.transportLost() }) },
24 })
25 socket.on('error', () => session.transportLost())
6526 socket.on('message', (bytes, remote) => {
66 if (remote.address !== '127.0.0.1') return
67 if (peerPort !== undefined && remote.port !== peerPort) return
27 if (remote.address !== '127.0.0.1' || (peerPort !== undefined && remote.port !== peerPort)) return
6828 for (const frame of decodeDatagram(bytes, signing)) {
69 if (frame.systemId !== systemId || frame.componentId !== componentId) continue
7029 if (peerPort === undefined && frame.id !== 0) continue
71 const view = new DataView(frame.payload.buffer, frame.payload.byteOffset, frame.payload.byteLength)
72 const receivedAt = now()
73 if (frame.id === 0) {
74 if (view.getUint8(5) !== profile.autopilotId || view.getUint8(4) !== profile.vehicleType) continue
75 peerPort ??= remote.port
76 heartbeatAt = receivedAt
77 const mode = view.getUint32(0, true)
78 telemetry.armed = (view.getUint8(6) & 128) !== 0
79 telemetry.mode = decodeAutopilotMode(driver, mode)
80 }
81 else if (frame.id === 1) {
82 batteryAt = receivedAt
83 telemetry.batteryPercent = view.getInt8(30)
84 }
85 else if (frame.id === 32) {
86 positionAt = receivedAt
87 telemetry.position = { north: view.getFloat32(4, true), east: view.getFloat32(8, true), down: view.getFloat32(12, true) }
88 telemetry.velocity = { north: view.getFloat32(16, true), east: view.getFloat32(20, true), down: view.getFloat32(24, true) }
89 }
90 protocol.receive(frame)
30 if (session.receive(frame) && frame.id === 0) peerPort ??= remote.port
9131 }
9232 })
93 await new Promise<void>((resolve, reject) => {
94 socket.once('error', reject)
95 socket.bind(options.listenPort, '127.0.0.1', () => { socket.off('error', reject); resolve() })
96 })
97 const timer = setInterval(() => protocol.tick(), 25)
98 timer.unref()
99 function read(): Telemetry | undefined {
100 if (closed || transportError) return undefined
101 telemetry = { ...telemetry, receivedAtMs: Math.min(heartbeatAt, positionAt, batteryAt), positionHealthy: kind === 'sitl' }
102 return structuredClone(telemetry)
103 }
104 function transmit(velocity: Telemetry['velocity']) {
105 const timestamp = now()
106 const frame = encodeVelocity(velocity, { sequence: sequence++ % 256, targetSystem: systemId, targetComponent: componentId, bootMs: timestamp, signing })
107 socket.send(frame, peerPort!, '127.0.0.1', (error) => { if (error) transportError = true })
108 lastSetpointAt = timestamp
33 try {
34 await new Promise<void>((resolve, reject) => {
35 socket.once('error', reject)
36 socket.bind(options.listenPort, '127.0.0.1', () => { socket.off('error', reject); resolve() })
37 })
10938 }
39 catch (error) { session.close(); try { socket.close() } catch {} throw error }
40 const timer = setInterval(() => session.protocol.tick(), 25)
41 timer.unref()
11042 return {
111 kind, driver, read, protocol, localPort: socket.address().port,
112 primeOffboard() {
113 if (driver !== 'px4' || kind !== 'sitl') throw new Error('Offboard priming is supported only by the PX4 SITL driver')
114 const timestamp = now()
115 const t = read()
116 if (!t || protocol.snapshot().fault || peerPort === undefined || !Number.isFinite(timestamp) || t.receivedAtMs > timestamp
117 || timestamp - t.receivedAtMs > 1000 || !finiteVector(t.position) || !finiteVector(t.velocity)
118 || t.batteryPercent < 25 || t.batteryPercent > 100 || t.mode === 'rtl' || t.mode === 'land') {
119 primeStarted = -Infinity
120 return { ready: false, reason: 'Fresh simulator telemetry and battery reserve required' }
121 }
122 if (timestamp < lastSetpointAt || timestamp - lastSetpointAt > 400 || !Number.isFinite(primeStarted)) primeStarted = timestamp
123 transmit(zero())
124 return { ready: timestamp - primeStarted >= 1500, reason: 'Stream zero-velocity setpoints before selecting PX4 Offboard' }
125 },
126 send(decision: Decision) {
127 if (closed || transportError || protocol.snapshot().fault || kind !== 'sitl' || peerPort === undefined) return
128 // Release stops the setpoint stream. The autopilot's configured and tested timeout
129 // must own link-loss handling, including process crashes and power loss.
130 if (decision.kind === 'release') return
131 if (driver === 'px4' && (now() - lastSetpointAt > 400 || now() < lastSetpointAt || now() - primeStarted < 1500 || !Number.isFinite(primeStarted) || telemetry.mode !== 'guided')) return
132 transmit(decision.kind === 'velocity' ? decision.velocity : zero())
133 },
134 close() { if (!closed) { closed = true; clearInterval(timer); protocol.close(); socket.close() } },
43 kind: session.kind, driver: session.driver, read: session.read, protocol: session.protocol, localPort: socket.address().port,
44 send: session.send, primeOffboard: session.primeOffboard,
45 close() { if (!closed) { closed = true; clearInterval(timer); session.close(); socket.close() } },
13546 }
13647}
tests/fixtures/drivers/evaluate.tsmodified+3-1
Changes to tests/fixtures/drivers/evaluate.ts
@@ -1,7 +1,9 @@
11import { runDriverProtocolBench } from '../../../resources/functions/drone/drivers/bench'
22const results = []
33for (const driver of ['ardupilot', 'px4'] as const) {
4 for (const signed of [false, true]) results.push(await runDriverProtocolBench({ driver, signed, dropFirstAck: true }))
4 for (const transport of ['udp', 'tcp'] as const) {
5 for (const signed of [false, true]) results.push(await runDriverProtocolBench({ driver, transport, signed, dropFirstAck: true }))
6 }
57}
68const destination = process.argv[2] ?? new URL('../../../docs/driver-protocol-evidence.json', import.meta.url).pathname
79await Bun.write(destination, `${JSON.stringify({ evidence: 'scripted-loopback-protocol-conformance', firmwareSitlValidated: false, hardwareValidated: false, results }, (key, value) => typeof value === 'bigint' ? String(value) : value, 2)}\n`)
tests/fixtures/drivers/pressure.tsadded+73-0
Changes to tests/fixtures/drivers/pressure.ts
@@ -0,0 +1,73 @@
1/** An independently owned OS peer, so runtime pause/buffering behavior cannot consume test traffic. */
2export async function createPressurePeer(telemetry: Uint8Array) {
3 const fixture = new URL('./tcp-pressure-peer.py', import.meta.url).pathname
4 const child = Bun.spawn(['python3', fixture, Buffer.from(telemetry).toString('hex')], { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' })
5 let port: number | undefined, receiveBufferBytes = 0, receivedBytes = 0, failure: Error | undefined, stderr = ''
6 async function consume(stream: ReadableStream<Uint8Array>, limit: number, receive: (text: string) => void) {
7 const reader = stream.getReader(), decoder = new TextDecoder()
8 let bytes = 0
9 try {
10 while (true) {
11 const chunk = await reader.read()
12 if (chunk.done) break
13 bytes += chunk.value.length
14 if (bytes > limit) throw new Error('Native pressure peer output exceeded its byte bound')
15 receive(decoder.decode(chunk.value, { stream: true }))
16 }
17 }
18 catch (error) { failure ??= error instanceof Error ? error : new Error(String(error)); child.kill('SIGKILL') }
19 finally { reader.releaseLock() }
20 }
21 let partial = ''
22 const stdout = consume(child.stdout, 131072, text => {
23 partial += text
24 while (partial.includes('\n')) {
25 const end = partial.indexOf('\n'), line = partial.slice(0, end); partial = partial.slice(end + 1)
26 const value: unknown = JSON.parse(line)
27 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Malformed native pressure peer observation')
28 if ('type' in value && value.type === 'ready' && 'port' in value && typeof value.port === 'number'
29 && Number.isInteger(value.port) && value.port > 0 && value.port <= 65535
30 && 'receiveBufferBytes' in value && typeof value.receiveBufferBytes === 'number'
31 && Number.isInteger(value.receiveBufferBytes) && value.receiveBufferBytes > 0 && value.receiveBufferBytes <= 16384) {
32 port = value.port; receiveBufferBytes = value.receiveBufferBytes
33 }
34 else if ('type' in value && (value.type === 'received' || value.type === 'closed') && 'bytes' in value && typeof value.bytes === 'number'
35 && Number.isInteger(value.bytes) && value.bytes >= receivedBytes && value.bytes <= 33554432) receivedBytes = value.bytes
36 else throw new Error('Invalid native pressure peer observation')
37 }
38 })
39 const errors = consume(child.stderr, 65536, text => { stderr += text })
40 async function close() {
41 let endError: unknown
42 try { child.stdin.end() }
43 catch (error) { endError = error; child.kill('SIGTERM') }
44 const guard = setTimeout(() => child.kill('SIGKILL'), 1000)
45 try {
46 const exit = await child.exited
47 await Promise.all([stdout, errors])
48 if (endError) throw new Error('Native pressure peer input close failed', { cause: endError })
49 if (failure) throw failure
50 if (exit !== 0 || partial || stderr) throw new Error(`Native pressure peer failed: exit ${exit}, ${stderr}`)
51 }
52 finally { clearTimeout(guard) }
53 }
54 try {
55 const deadline = performance.now() + 5000
56 while (port === undefined) {
57 if (failure) throw failure
58 if (child.exitCode !== null || performance.now() >= deadline) throw new Error('Native pressure peer did not establish its listener')
59 await Bun.sleep(5)
60 }
61 return { port, receiveBufferBytes,
62 receivedBytes() { if (failure) throw failure; return receivedBytes },
63 resume() {
64 child.stdin.write('resume\n')
65 void Promise.resolve(child.stdin.flush()).catch(error => { failure ??= error instanceof Error ? error : new Error(String(error)) })
66 }, close }
67 }
68 catch (error) {
69 try { await close() }
70 catch (cleanup) { throw new AggregateError([error, cleanup], 'Native pressure peer setup and cleanup failed') }
71 throw error
72 }
73}
tests/fixtures/drivers/tcp-pressure-peer.pyadded+89-0
Changes to tests/fixtures/drivers/tcp-pressure-peer.py
@@ -0,0 +1,89 @@
1"""QA-only OS socket peer. No application, firmware, or physical-device imports."""
2
3import json
4import os
5import select
6import socket
7import sys
8import time
9
10
11def emit(record):
12 print(json.dumps(record), flush=True)
13
14
15telemetry = bytes.fromhex(sys.argv[1])
16if not 0 < len(telemetry) <= 1024:
17 raise ValueError("Invalid QA telemetry length")
18
19listener = socket.socket()
20listener.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096)
21listener.bind(("127.0.0.1", 0))
22listener.listen(1)
23listener.settimeout(5)
24emit({"type": "ready", "port": listener.getsockname()[1],
25 "receiveBufferBytes": listener.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)})
26connection = None
27received = 0
28try:
29 connection, _ = listener.accept()
30 listener.close()
31 connection.setblocking(False)
32 os.set_blocking(sys.stdin.fileno(), False)
33 reading = False
34 commands = b""
35 pending = b""
36 next_heartbeat = 0
37 running = True
38 while running:
39 now = time.monotonic()
40 if now >= next_heartbeat:
41 pending += telemetry
42 if len(pending) > 65536:
43 raise RuntimeError("QA heartbeat output exceeded its bound")
44 next_heartbeat = now + 0.2
45 readers = [sys.stdin.fileno()] + ([connection] if reading else [])
46 readable, writable, _ = select.select(readers, [connection] if pending else [], [], 0.02)
47 if sys.stdin.fileno() in readable:
48 part = os.read(sys.stdin.fileno(), 128)
49 if not part:
50 break
51 commands += part
52 if len(commands) > 1024:
53 raise RuntimeError("QA command input exceeded its bound")
54 while b"\n" in commands:
55 command, commands = commands.split(b"\n", 1)
56 if command == b"resume":
57 reading = True
58 else:
59 raise ValueError("Unknown QA peer command")
60 if connection in writable:
61 try:
62 count = connection.send(pending)
63 if count == 0:
64 break
65 pending = pending[count:]
66 except BlockingIOError:
67 pass
68 if reading and connection in readable:
69 budget = 1048576
70 while budget > 0:
71 try:
72 part = connection.recv(min(65536, budget))
73 except BlockingIOError:
74 break
75 if not part:
76 running = False
77 break
78 received += len(part)
79 budget -= len(part)
80 if received > 33554432:
81 raise RuntimeError("QA receive count exceeded its bound")
82 emit({"type": "received", "bytes": received})
83except (BrokenPipeError, ConnectionResetError):
84 pass
85finally:
86 if connection is not None:
87 connection.close()
88 listener.close()
89 emit({"type": "closed", "bytes": received})
tests/tsconfig.runtime.jsonmodified+3-0
Changes to tests/tsconfig.runtime.json
@@ -5,8 +5,11 @@
55 "../bin/drone.ts",
66 "../resources/functions/drone/drivers/**/*.ts",
77 "../resources/functions/drone/udp.ts",
8 "../resources/functions/drone/tcp.ts",
89 "../resources/functions/drone/mavlink.ts",
910 "./unit/driver-protocol.test.ts",
11 "./unit/driver-tcp.test.ts",
12 "./unit/mavlink-stream.test.ts",
1013 "../scripts/build-runtime-cli.ts",
1114 "../scripts/qualify-public-safety-managed-budget.ts",
1215 "../resources/functions/drone/runtime/**/*.ts",
tests/unit/driver-tcp.test.tsadded+445-0
Changes to tests/unit/driver-tcp.test.ts
@@ -0,0 +1,445 @@
1import type { Socket } from 'node:net'
2import type { TcpAdapter } from '../../resources/functions/drone/drivers'
3import { createServer } from 'node:net'
4import { expect, test } from 'bun:test'
5import { createArduPilotDriver, createPx4Driver } from '../../resources/functions/drone/drivers'
6import { createMavlinkStreamDecoder, encodeFrame, encodeFrameV2 } from '../../resources/functions/drone/mavlink'
7import { runDriverProtocolBench } from '../../resources/functions/drone/drivers/bench'
8import vectors from '../fixtures/drivers/wire.json'
9import telemetryVectors from '../fixtures/mavlink.json'
10import { createPressurePeer } from '../fixtures/drivers/pressure'
11
12async function until(condition: () => boolean) {
13 const deadline = performance.now() + 1500
14 while (!condition()) { if (performance.now() > deadline) throw new Error('TCP peer observation timed out'); await Bun.sleep(5) }
15}
16async function peer() {
17 const sockets: Socket[] = [], received: Buffer[] = [], receivedByConnection: Buffer[][] = []
18 const server = createServer(socket => { sockets.push(socket); const own: Buffer[] = []; receivedByConnection.push(own); socket.on('data', data => { const bytes = typeof data === 'string' ? Buffer.from(data) : data; received.push(bytes); own.push(bytes) }); socket.on('error', () => {}) })
19 await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
20 const address = server.address()
21 if (!address || typeof address === 'string') throw new Error('Missing TCP peer port')
22 return { port: address.port, sockets, received, receivedByConnection, close: async () => { for (const socket of sockets) socket.destroy(); await new Promise<void>(resolve => server.close(() => resolve())) } }
23}
24function telemetry(autopilot: number) {
25 const heartbeat = new Uint8Array(9); new DataView(heartbeat.buffer).setUint32(0, autopilot === 12 ? 6 << 16 : 4, true); heartbeat.set([2, autopilot, 128, 4, 3], 4)
26 const battery = new Uint8Array(31); battery[30] = 90
27 const position = new Uint8Array(28); new DataView(position.buffer).setFloat32(12, -12, true)
28 return Buffer.concat([encodeFrame(0, heartbeat, 0, 1, 1), encodeFrame(1, battery, 1, 1, 1), encodeFrame(32, position, 2, 1, 1)])
29}
30
31test('both TCP driver factories assemble fragmented telemetry and never write in physical mode', async () => {
32 for (const [factory, autopilot] of [[createArduPilotDriver, 3], [createPx4Driver, 12]] as const) {
33 const remote = await peer()
34 let driver: Awaited<ReturnType<typeof factory>> | undefined
35 try {
36 const options = { transport: 'tcp' as const, kind: 'telemetry-only' as 'sitl' | 'telemetry-only', port: remote.port }
37 driver = await factory(options)
38 options.kind = 'sitl'
39 await until(() => remote.sockets.length === 1)
40 const bytes = telemetry(autopilot)
41 remote.sockets[0]!.write(bytes.subarray(0, 2))
42 await Bun.sleep(20)
43 expect(driver.read()?.batteryPercent).toBe(-1)
44 remote.sockets[0]!.write(bytes.subarray(2))
45 await until(() => driver!.read()?.batteryPercent === 90)
46 expect(driver.read()?.position.down).toBe(-12)
47 expect(driver.read()?.positionHealthy).toBe(false)
48 driver.send({ kind: 'hold', reason: 'receive-only' })
49 await expect(driver.protocol.sendCommand(400, [1])).rejects.toHaveProperty('code', 'read-only')
50 await Bun.sleep(30)
51 expect(remote.received).toHaveLength(0)
52 }
53 finally { driver?.close(); await remote.close() }
54 }
55})
56
57test('the TCP protocol bench completes discovery, retry, parameters and mission resume', async () => {
58 const result = await runDriverProtocolBench({ driver: 'px4', transport: 'tcp', signed: true, dropFirstAck: true })
59 expect(result.transport).toBe('tcp')
60 expect(result.confirmations).toEqual([0, 1])
61 expect(result.parameterAfter.value).toBe(43)
62 expect(result.downloaded).toHaveLength(2)
63 expect(result.resume).toEqual({ index: 1, accepted: true, executionConfirmed: false })
64 expect(result.physicalFlightEnabled).toBe(false)
65})
66
67for (const driver of ['ardupilot', 'px4'] as const) {
68 for (const signed of [false, true]) {
69 test(`TCP ${driver} ${signed ? 'signed' : 'unsigned'} round trip reports its actual transport`, async () => {
70 const result = await runDriverProtocolBench({ driver, transport: 'tcp', signed, dropFirstAck: true })
71 expect(result.transport).toBe('tcp')
72 expect(result.capabilities.driver).toBe(driver)
73 expect(result.confirmations).toEqual([0, 1])
74 expect(result.parameterBefore.value).toBe(42)
75 expect(result.parameterAfter.value).toBe(43)
76 expect(result.uploaded.count).toBe(2)
77 expect(result.downloaded.map(item => item.latitudeDeg)).toEqual([0.001, 0.003])
78 expect(result.resume.executionConfirmed).toBe(false)
79 expect(result.hardwareValidated).toBe(false)
80 })
81 }
82}
83
84async function discover(remote: Awaited<ReturnType<typeof peer>>, driver: Awaited<ReturnType<typeof createArduPilotDriver>>, index = 0) {
85 const connection = remote.sockets[index]!
86 connection.write(telemetry(driver.driver === 'ardupilot' ? 3 : 12))
87 await until(() => Number.isFinite(driver.protocol.snapshot().lastHeartbeatAtMs))
88 const received = createMavlinkStreamDecoder()
89 let requested = false
90 const listener = (bytes: Buffer | string) => { if (typeof bytes === 'string') throw new Error('Expected native bytes'); for (const frame of received.push(bytes)) if (frame.id === 76) requested = true }
91 connection.on('data', listener)
92 const result = driver.protocol.discover()
93 try {
94 await until(() => requested)
95 const ack = new Uint8Array(10); new DataView(ack.buffer).setUint16(0, 512, true); ack.set([245, 191], 8)
96 connection.write(Buffer.concat([Buffer.from(vectors.packets.version, 'hex'), encodeFrameV2(77, ack, 1, 1, 1)]))
97 await result
98 }
99 finally { connection.off('data', listener) }
100}
101
102test('peer EOF rejects pending work and reconnection starts with fresh framing, capabilities and no resumed request', async () => {
103 const remote = await peer()
104 let first: Awaited<ReturnType<typeof createArduPilotDriver>> | undefined, second: typeof first
105 try {
106 first = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
107 await until(() => remote.sockets.length === 1)
108 await discover(remote, first)
109 const pending = first.protocol.sendCommand(511, [32, 200000]).catch(error => error)
110 remote.sockets[0]!.write(Buffer.from(telemetryVectors.v2Heartbeat, 'hex').subarray(0, 2)) // An unfinished frame must not cross connections.
111 remote.sockets[0]!.end()
112 expect((await pending).code).toBe('transport-lost')
113 expect(first.read()).toBeUndefined()
114 expect(first.protocol.snapshot().active).toBeNull()
115 second = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
116 await until(() => remote.sockets.length === 2)
117 remote.sockets[1]!.write(Buffer.from(telemetryVectors.v2Heartbeat, 'hex').subarray(2))
118 await Bun.sleep(30)
119 expect(second.protocol.snapshot().capabilities).toBeNull()
120 expect(second.protocol.snapshot().lastHeartbeatAtMs).toBe(-Infinity)
121 expect(remote.receivedByConnection[1]).toHaveLength(0)
122 await expect(second.protocol.sendCommand(511)).rejects.toHaveProperty('code', 'unavailable')
123 await discover(remote, second, 1)
124 expect(second.protocol.snapshot().quarantined).toEqual([])
125 expect(second.protocol.snapshot().active).toBeNull()
126 await expect(first.protocol.sendCommand(512)).rejects.toHaveProperty('code', 'transport-lost')
127 first.close(); first.close()
128 }
129 finally { first?.close(); second?.close(); await remote.close() }
130})
131
132test('PX4 TCP setpoints require deliberate fresh priming and release stops the stream', async () => {
133 const remote = await peer()
134 let now = 0
135 const driver = await createPx4Driver({ transport: 'tcp', kind: 'sitl', port: remote.port, now: () => now })
136 try {
137 await until(() => remote.sockets.length === 1)
138 expect(driver.primeOffboard().ready).toBe(false)
139 for (let step = 0; step <= 8; step++) {
140 now = step * 200
141 remote.sockets[0]!.write(telemetry(12))
142 await until(() => driver.read()?.receivedAtMs === now)
143 if (step === 0) {
144 driver.send({ kind: 'hold', reason: 'unprimed' })
145 await Bun.sleep(10)
146 expect(remote.received).toHaveLength(0)
147 }
148 expect(driver.primeOffboard().ready).toBe(step === 8)
149 }
150 driver.send({ kind: 'velocity', velocity: { north: 1, east: 0, down: 0 }, reason: 'primed' })
151 const frames = () => createMavlinkStreamDecoder().push(Buffer.concat(remote.received))
152 await until(() => frames().length === 10)
153 expect(frames().every(frame => frame.id === 84)).toBe(true)
154 expect(new DataView(frames().at(-1)!.payload.buffer).getFloat32(16, true)).toBe(1)
155 driver.send({ kind: 'release', reason: 'release' })
156 await Bun.sleep(20)
157 expect(frames()).toHaveLength(10)
158 }
159 finally { driver.close(); await remote.close() }
160})
161
162test('TCP refuses invalid destinations and an actual closed listener without retaining a session', async () => {
163 for (const port of [0, -1, 65536, NaN]) await expect(createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port })).rejects.toThrow('TCP port')
164 const remote = await peer(), port = remote.port
165 await remote.close()
166 await expect(createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port })).rejects.toThrow()
167})
168
169test('terminal peer loss stops polling before a caller explicitly closes the failed adapter', async () => {
170 const remote = await peer()
171 let clockReads = 0
172 const driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port, now: () => { clockReads++; return performance.now() } })
173 try {
174 await until(() => remote.sockets.length === 1)
175 remote.sockets[0]!.write(telemetry(3))
176 await until(() => Number.isFinite(driver.read()?.receivedAtMs))
177 remote.sockets[0]!.end()
178 await until(() => driver.protocol.snapshot().fault !== null)
179 const readsAtFailure = clockReads
180 await Bun.sleep(100)
181 expect(clockReads).toBe(readsAtFailure)
182 expect(driver.read()).toBeUndefined()
183 }
184 finally { driver.close(); await remote.close() }
185})
186
187test('outstanding writes to a real paused TCP peer are bounded without replaying a buffered setpoint', async () => {
188 const remote = await peer()
189 const driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
190 try {
191 await until(() => remote.sockets.length === 1)
192 remote.sockets[0]!.write(telemetry(3))
193 await until(() => Number.isFinite(driver.read()?.receivedAtMs))
194 remote.sockets[0]!.pause()
195 expect(remote.sockets[0]!.readableFlowing).toBe(false)
196 let attempted = 0
197 while (!driver.protocol.snapshot().fault && attempted < 200_000) { driver.send({ kind: 'hold', reason: 'paused-reader-boundary' }); attempted++ }
198 expect(driver.protocol.snapshot().fault).toContain('backpressured')
199 expect(attempted).toBeLessThan(200_000)
200 expect(driver.read()).toBeUndefined()
201 driver.send({ kind: 'velocity', velocity: { north: 1, east: 0, down: 0 }, reason: 'failed transport' })
202 await expect(driver.protocol.sendCommand(511)).rejects.toHaveProperty('code', 'transport-lost')
203 }
204 finally { driver.close(); await remote.close() }
205})
206
207test('wrong firmware identity cannot establish a TCP session and silent startup expires', async () => {
208 const remote = await peer()
209 const driver = await createPx4Driver({ transport: 'tcp', kind: 'telemetry-only', port: remote.port })
210 try {
211 await until(() => remote.sockets.length === 1)
212 remote.sockets[0]!.write(telemetry(3))
213 await Bun.sleep(30)
214 expect(driver.protocol.snapshot().lastHeartbeatAtMs).toBe(-Infinity)
215 expect(driver.read()?.batteryPercent).toBe(-1)
216 const deadline = performance.now() + 2500
217 while (!driver.protocol.snapshot().fault) { if (performance.now() > deadline) throw new Error('TCP initial heartbeat deadline did not settle'); await Bun.sleep(10) }
218 expect(driver.protocol.snapshot().fault).toContain('matching heartbeat deadline')
219 expect(driver.read()).toBeUndefined()
220 expect(remote.received).toHaveLength(0)
221 }
222 finally { driver.close(); await remote.close() }
223})
224
225// Public factory deadline seam: actual loopback setup plus a real event-loop pause.
226// This catches late callback admission, not network SYN loss or physical timing.
227test('a native connect callback after the absolute setup deadline cannot establish a TCP adapter', async () => {
228 const sockets = new Set<Bun.Socket<undefined>>()
229 const server = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: {
230 open(socket) { sockets.add(socket) },
231 data() {},
232 close(socket) { sockets.delete(socket) },
233 } })
234 let driver: Awaited<ReturnType<typeof createArduPilotDriver>> | undefined
235 try {
236 const pending = createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: server.port })
237 .then(adapter => { driver = adapter; return undefined }, (error: unknown) => error)
238 const started = performance.now()
239 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2200)
240 expect(performance.now() - started).toBeGreaterThanOrEqual(2000)
241 const error = await pending
242 expect(error).toBeInstanceOf(Error)
243 if (!(error instanceof Error)) throw new Error('An expired native connect established an adapter')
244 expect(error.message).toContain('TCP connection deadline expired')
245 await until(() => sockets.size === 0)
246 }
247 finally { driver?.close(); server.stop(true) }
248}, 6000)
249
250async function fillNativeBackpressure(driver: TcpAdapter, sendBudget = 550000, fillDeadline = performance.now() + 10000) {
251 let attempted = 0
252 while (!driver.transportSnapshot().backpressured && !driver.protocol.snapshot().fault && attempted < sendBudget && performance.now() < fillDeadline) {
253 for (let index = 0; index < 128 && attempted < sendBudget && !driver.transportSnapshot().backpressured && !driver.protocol.snapshot().fault; index++) {
254 driver.send({ kind: 'hold', reason: 'native drain deadline' }); attempted++
255 }
256 await Bun.sleep(0)
257 }
258 return { attempted, blocked: driver.transportSnapshot() }
259}
260
261// A native pressure phase may make legitimate progress before its deadline.
262// Continue establishment only after observing that transition within its budget.
263async function observeNativeOutputExpiry(driver: TcpAdapter, mode: 'drain' | 'progress') {
264 const fillDeadline = performance.now() + 10000
265 let attempted = 0, phases = 0
266 const history: Array<{ before: ReturnType<TcpAdapter['transportSnapshot']>, after: ReturnType<TcpAdapter['transportSnapshot']>, elapsedMs: number, fault: string | null }> = []
267 try {
268 while (performance.now() < fillDeadline && attempted < 550000) {
269 let before = driver.transportSnapshot()
270 if (mode === 'drain') {
271 const phase = await fillNativeBackpressure(driver, 550000 - attempted, fillDeadline)
272 attempted += phase.attempted; before = phase.blocked
273 }
274 else if (before.pendingCallbackBytes === 0 && !before.backpressured) {
275 const phase = await fillNativePendingWrites(driver, 550000 - attempted, fillDeadline)
276 attempted += phase.attempted; before = phase.pending
277 }
278 const epoch = mode === 'drain' ? before.blockedSinceMs : before.lastWriteProgressAtMs
279 const ready = mode === 'drain' ? before.backpressured : !before.backpressured && before.pendingCallbackBytes > 0
280 if (!ready || before.state !== 'connected' || epoch === null) throw new Error(`Native TCP ${mode} pressure was not established within its byte/time budget`)
281 const started = performance.now()
282 try {
283 // No writes while waiting. A continuous stall keeps the original 1500 ms
284 // immediate-failure guard; a valid transition starts a new pressure phase.
285 await until(() => {
286 if (driver.protocol.snapshot().fault !== null) return true
287 const after = driver.transportSnapshot()
288 return mode === 'drain' ? !after.backpressured : after.pendingCallbackBytes === 0 || after.lastWriteProgressAtMs !== before.lastWriteProgressAtMs
289 })
290 }
291 finally {
292 phases++
293 if (history.length === 16) history.shift()
294 history.push({ before, after: driver.transportSnapshot(), elapsedMs: performance.now() - started, fault: driver.protocol.snapshot().fault })
295 }
296 if (driver.protocol.snapshot().fault !== null) return { attempted, pressure: before }
297 if (performance.now() - epoch >= 500) throw new Error(`Native TCP ${mode} transition was late or timing-ambiguous; pressure establishment cannot continue`)
298 }
299 throw new Error(`Native TCP sustained ${mode} pressure was not established within its byte/time budget`)
300 }
301 finally { console.log(JSON.stringify({ campaign: `native-tcp-${mode}-phases`, attempted, phases, retainedPhaseLimit: 16, history })) }
302}
303
304test('native TCP backpressure expires the drain deadline without an additional write', async () => {
305 const remote = await createPressurePeer(telemetry(3))
306 let driver: TcpAdapter | undefined
307 try {
308 driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
309 await until(() => driver!.read()?.batteryPercent === 90)
310 const { attempted, pressure: blocked } = await observeNativeOutputExpiry(driver, 'drain')
311 console.log(JSON.stringify({ campaign: 'native-tcp-drain', attempted, blocked, receivedBytes: remote.receivedBytes(), fault: driver.protocol.snapshot().fault }))
312 expect(blocked.backpressured).toBe(true)
313 expect(blocked.state).toBe('connected')
314 expect(blocked.pendingCallbackBytes).toBeLessThanOrEqual(16384)
315 expect(remote.receivedBytes()).toBe(0)
316 expect(driver.protocol.snapshot().fault).toContain('TCP output drain deadline expired')
317 expect(performance.now() - blocked.blockedSinceMs!).toBeGreaterThanOrEqual(500)
318 expect(driver.read()).toBeUndefined()
319 expect(driver.transportSnapshot().state).toBe('failed')
320 }
321 finally { driver?.close(); await remote.close() }
322}, 15000)
323
324test('late native drain callbacks cannot erase an expired output deadline', async () => {
325 const remote = await createPressurePeer(telemetry(3))
326 let driver: TcpAdapter | undefined
327 try {
328 driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
329 await until(() => driver!.read()?.batteryPercent === 90)
330 const { attempted, blocked } = await fillNativeBackpressure(driver)
331 expect(blocked.backpressured).toBe(true)
332 expect(blocked.state).toBe('connected')
333 remote.resume()
334 expect(driver.transportSnapshot().backpressured).toBe(true)
335 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 550)
336 await Bun.sleep(25)
337 console.log(JSON.stringify({ campaign: 'late-native-tcp-drain', attempted, blocked, after: driver.transportSnapshot(), fault: driver.protocol.snapshot().fault }))
338 expect(driver.protocol.snapshot().fault).toContain('TCP output drain deadline expired')
339 expect(driver.read()).toBeUndefined()
340 }
341 finally { driver?.close(); await remote.close() }
342}, 15000)
343
344test('a native drain within budget settles accepted bytes once and transport snapshots cannot change the connection', async () => {
345 const remote = await createPressurePeer(telemetry(3))
346 let driver: TcpAdapter | undefined
347 try {
348 driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
349 await until(() => driver!.read()?.batteryPercent === 90)
350 const { attempted, blocked } = await fillNativeBackpressure(driver)
351 expect(blocked.backpressured).toBe(true)
352 expect(blocked.pendingCallbackBytes).toBeGreaterThan(0)
353 const detached = driver.transportSnapshot()
354 Object.assign(detached, { state: 'closed', backpressured: false, pendingCallbackBytes: 0 })
355 expect(driver.transportSnapshot().state).toBe('connected')
356 expect(driver.transportSnapshot().backpressured).toBe(true)
357 remote.resume()
358 // Unsigned SET_POSITION_TARGET_LOCAL_NED is 53 payload bytes plus the 8-byte v1 envelope.
359 await until(() => remote.receivedBytes() === attempted * 61 && driver!.transportSnapshot().pendingCallbackBytes === 0)
360 expect(driver.transportSnapshot().backpressured).toBe(false)
361 expect(driver.protocol.snapshot().fault).toBeNull()
362 expect(blocked.backpressured).toBe(true)
363 driver.send({ kind: 'hold', reason: 'fresh write after observed native drain' })
364 await until(() => remote.receivedBytes() === (attempted + 1) * 61)
365 expect(driver.protocol.snapshot().fault).toBeNull()
366 driver.close()
367 expect(driver.transportSnapshot().state).toBe('closed')
368 }
369 finally { driver?.close(); await remote.close() }
370}, 15000)
371
372async function fillNativePendingWrites(driver: TcpAdapter, sendBudget = 550000, fillDeadline = performance.now() + 10000) {
373 let attempted = 0, observedPending = false
374 while (!observedPending && !driver.protocol.snapshot().fault && attempted < sendBudget && performance.now() < fillDeadline) {
375 for (let index = 0; index < 16 && attempted < sendBudget && !driver.transportSnapshot().backpressured; index++) {
376 driver.send({ kind: 'hold', reason: 'native callback progress deadline' }); attempted++
377 }
378 await Bun.sleep(0)
379 if (driver.transportSnapshot().pendingCallbackBytes > 0 && !driver.transportSnapshot().backpressured) {
380 await Bun.sleep(25)
381 observedPending = driver.transportSnapshot().pendingCallbackBytes > 0 && !driver.transportSnapshot().backpressured
382 }
383 }
384 return { attempted, pending: driver.transportSnapshot(), observedPending }
385}
386
387test('native TCP writes below the watermark expire when their callbacks make no progress', async () => {
388 const remote = await createPressurePeer(telemetry(3))
389 let driver: TcpAdapter | undefined
390 try {
391 driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
392 await until(() => driver!.read()?.batteryPercent === 90)
393 const { attempted, pressure: pending } = await observeNativeOutputExpiry(driver, 'progress')
394 console.log(JSON.stringify({ campaign: 'native-tcp-write-progress', attempted, pending, receivedBytes: remote.receivedBytes(), fault: driver.protocol.snapshot().fault }))
395 expect(pending.state).toBe('connected')
396 expect(pending.backpressured).toBe(false)
397 expect(pending.pendingCallbackBytes).toBeGreaterThan(0)
398 expect(pending.runtimeWritableBytes).toBeLessThan(4096)
399 expect(remote.receivedBytes()).toBe(0)
400 expect(driver.protocol.snapshot().fault).toContain('TCP write progress deadline expired')
401 expect(performance.now() - pending.lastWriteProgressAtMs!).toBeGreaterThanOrEqual(500)
402 expect(driver.read()).toBeUndefined()
403 expect(driver.transportSnapshot().state).toBe('failed')
404 }
405 finally { driver?.close(); await remote.close() }
406}, 15000)
407
408test('late native write callbacks cannot erase an expired below-watermark progress deadline', async () => {
409 const remote = await createPressurePeer(telemetry(3))
410 let driver: TcpAdapter | undefined
411 try {
412 driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port })
413 await until(() => driver!.read()?.batteryPercent === 90)
414 const { attempted, pending, observedPending } = await fillNativePendingWrites(driver)
415 expect(observedPending).toBe(true)
416 expect(pending.backpressured).toBe(false)
417 remote.resume()
418 expect(driver.transportSnapshot().pendingCallbackBytes).toBeGreaterThan(0)
419 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 550)
420 await Bun.sleep(25)
421 console.log(JSON.stringify({ campaign: 'late-native-tcp-progress', attempted, pending, after: driver.transportSnapshot(), fault: driver.protocol.snapshot().fault }))
422 expect(driver.protocol.snapshot().fault).toContain('TCP write progress deadline expired')
423 expect(driver.read()).toBeUndefined()
424 }
425 finally { driver?.close(); await remote.close() }
426}, 15000)
427
428test('a queued initial heartbeat cannot establish telemetry after its absolute matching deadline', async () => {
429 let remote: Bun.Socket<undefined> | undefined
430 const server = Bun.listen({ hostname: '127.0.0.1', port: 0, socket: {
431 open(socket) { remote = socket },
432 data() {},
433 } })
434 let driver: Awaited<ReturnType<typeof createArduPilotDriver>> | undefined
435 try {
436 driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: server.port })
437 await until(() => remote !== undefined)
438 remote!.write(telemetry(3))
439 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2200)
440 await Bun.sleep(25)
441 expect(driver.protocol.snapshot().fault).toContain('matching heartbeat deadline expired')
442 expect(driver.read()).toBeUndefined()
443 }
444 finally { driver?.close(); server.stop(true) }
445}, 6000)