also looking at this
feat: add bounded TCP transport for autopilot drivers
#71
22 files
+1798
-154
| @@ -148,6 +148,10 @@ jobs: | ||
| 148 | 148 | working-directory: ${{ runner.temp }} |
| 149 | 149 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver px4 --signed --drop-first-ack |
| 150 | 150 | 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 }} | |
| 151 | 155 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone payloads:bench |
| 152 | 156 | working-directory: ${{ runner.temp }} |
| 153 | 157 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone payloads:capture --driver replay --output "$RUNNER_TEMP/payload-capture" |
| @@ -7,12 +7,13 @@ export default defineCommand({ | ||
| 7 | 7 | description: 'Exercise acknowledged commands, parameters and missions against a scripted local protocol peer', |
| 8 | 8 | options: { |
| 9 | 9 | '--driver <name>': { description: 'ardupilot or px4', default: 'ardupilot' }, |
| 10 | '--transport <name>': { description: 'udp or tcp loopback peer', default: 'udp' }, | |
| 10 | 11 | '--drop-first-ack': 'Deliberately drop the first message-rate ACK to exercise retry', |
| 11 | 12 | '--signed': 'Use a public fixture key to test required MAVLink 2 signatures', |
| 12 | 13 | }, |
| 13 | 14 | async handle(options) { |
| 14 | 15 | 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 }) | |
| 16 | 17 | // eslint-disable-next-line |
| 17 | 18 | print(JSON.stringify(result, (key, value) => typeof value === 'bigint' ? String(value) : value, 2)) |
| 18 | 19 | } |
| @@ -71,7 +71,9 @@ It uses the oldest heartbeat/position/battery receipt time for freshness. Physic | ||
| 71 | 71 | reports position health false and never transmits. SITL assumes the simulator's position estimator |
| 72 | 72 | is healthy; that assumption must never be carried into a physical adapter. |
| 73 | 73 | |
| 74 | The 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. | |
| 74 | The wire codec is an experimental subset. [Shared acknowledged microservices](driver-protocols.md) | |
| 75 | add bounded command, parameter and waypoint transfers over UDP and explicit loopback TCP. Serial, | |
| 76 | actual firmware transport campaigns and complete firmware coverage remain open. | |
| 75 | 77 | Both external stacks are optional drivers. The [owned-stack ADR](adr/0001-owned-autonomy.md) and |
| 76 | 78 | [feature roadmap](roadmap/README.md) define the replacement path. A future physical adapter needs verified estimator status, source identity, sequence/replay handling, |
| 77 | 79 | authenticated transport, link supervision, flight-controller configuration and hardware validation. |
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | { |
| 7 | 7 | "evidence": "scripted-loopback-protocol-peer", |
| 8 | 8 | "driver": "ardupilot", |
| 9 | "transport": "udp", | |
| 9 | 10 | "signed": false, |
| 10 | 11 | "physicalFlightEnabled": false, |
| 11 | 12 | "firmwareSitlValidated": false, |
| @@ -82,6 +83,7 @@ | ||
| 82 | 83 | { |
| 83 | 84 | "evidence": "scripted-loopback-protocol-peer", |
| 84 | 85 | "driver": "ardupilot", |
| 86 | "transport": "udp", | |
| 85 | 87 | "signed": true, |
| 86 | 88 | "physicalFlightEnabled": false, |
| 87 | 89 | "firmwareSitlValidated": false, |
| @@ -155,9 +157,318 @@ | ||
| 155 | 157 | "executionConfirmed": false |
| 156 | 158 | } |
| 157 | 159 | }, |
| 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 | }, | |
| 158 | 468 | { |
| 159 | 469 | "evidence": "scripted-loopback-protocol-peer", |
| 160 | 470 | "driver": "px4", |
| 471 | "transport": "tcp", | |
| 161 | 472 | "signed": false, |
| 162 | 473 | "physicalFlightEnabled": false, |
| 163 | 474 | "firmwareSitlValidated": false, |
| @@ -234,6 +545,7 @@ | ||
| 234 | 545 | { |
| 235 | 546 | "evidence": "scripted-loopback-protocol-peer", |
| 236 | 547 | "driver": "px4", |
| 548 | "transport": "tcp", | |
| 237 | 549 | "signed": true, |
| 238 | 550 | "physicalFlightEnabled": false, |
| 239 | 551 | "firmwareSitlValidated": false, |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | # Acknowledged external-driver protocols |
| 2 | 2 | |
| 3 | 3 | Issue [#7](https://github.com/stacksjs/drone/issues/7) adds a shared MAVLink 2 transaction engine |
| 4 | behind the existing ArduPilot and PX4 UDP factories. It implements discovery, acknowledged commands, | |
| 4 | behind the ArduPilot and PX4 UDP/TCP factories. It implements discovery, acknowledged commands, | |
| 5 | 5 | message-rate requests, named parameter reads/writes, bounded waypoint transfers and resume requests. |
| 6 | 6 | Physical telemetry remains receive-only. No HTTP arm, mode or actuator endpoint is introduced. |
| 7 | 7 | |
| @@ -10,13 +10,14 @@ Physical telemetry remains receive-only. No HTTP arm, mode or actuator endpoint | ||
| 10 | 10 | ```sh |
| 11 | 11 | ./buddy drivers:bench --driver ardupilot --drop-first-ack |
| 12 | 12 | ./buddy drivers:bench --driver px4 --signed --drop-first-ack |
| 13 | ./buddy drivers:bench --driver px4 --transport tcp --signed --drop-first-ack | |
| 13 | 14 | bun run runtime:cli |
| 14 | 15 | ./storage/runtime-build/cli/drone drivers:bench --driver px4 --signed |
| 15 | 16 | bun tests/fixtures/drivers/evaluate.ts |
| 16 | 17 | ``` |
| 17 | 18 | |
| 18 | 19 | `drivers:bench` uses native Stacks `defineCommand()` and is shared by Buddy and the compiled CLI. |
| 19 | It creates a scripted loopback UDP peer with the selected firmware identity, performs discovery, | |
| 20 | It creates a scripted loopback UDP or TCP peer with the selected firmware identity, performs discovery, | |
| 20 | 21 | negotiates a requested 5 Hz message interval, reads/writes an integer, uploads/downloads two waypoints |
| 21 | 22 | and requests resume at item 1. The optional ACK drop verifies a retry with confirmation 1. |
| 22 | 23 | The 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 | ||
| 58 | 59 | no keys or physical authority. Constructor options are captured; changing `kind` on the caller's |
| 59 | 60 | object cannot promote a physical receiver. |
| 60 | 61 | |
| 62 | ## TCP framing, loss and reconnection | |
| 63 | ||
| 64 | Select TCP explicitly through either public factory: | |
| 65 | ||
| 66 | ```ts | |
| 67 | const driver = await createPx4Driver({ transport: 'tcp', kind: 'sitl', port: 5760 }) | |
| 68 | ``` | |
| 69 | ||
| 70 | TCP always connects to literal `127.0.0.1`, with a 2-second connection deadline and a separate | |
| 71 | 2-second deadline for the first matching heartbeat. The configured system/component and firmware | |
| 72 | identity must match before TCP telemetry can be admitted. No arbitrary host, serial port, physical | |
| 73 | writer or forwarding service is added. Existing UDP options and default transport remain compatible. | |
| 74 | Connect and initial-heartbeat admission check their absolute deadlines in the receiving callbacks, | |
| 75 | so a delayed timer cannot admit a late connection or first heartbeat after an event-loop pause. | |
| 76 | ||
| 77 | The stream decoder reconstructs v1/v2 packets across fragmented or coalesced reads, with CRC and | |
| 78 | the existing strict signing verifier. Its partial-frame storage is 280 bytes, the signed MAVLink 2 | |
| 79 | wire maximum; each decoding call accepts at most 65,536 bytes. TCP reads larger than that are | |
| 80 | processed in bounded chunks. Invalid CRC/length candidates resynchronize rather than refreshing | |
| 81 | telemetry. Unknown dialects and unsupported flags cannot satisfy a transaction. A decoder reset | |
| 82 | clears framing bytes while preserving the supplied verifier's in-session replay watermark. | |
| 83 | ||
| 84 | The per-connection stream watermark is 4 KiB so native pressure is observed below the separate | |
| 85 | application cap. Outbound bytes awaiting write callbacks are capped at 16 KiB, alongside the runtime's reported | |
| 86 | queue length. A write that reports backpressure is already accepted by the stream and is never | |
| 87 | resent on `drain`. Further writes during pressure terminate the session. No drain or write-callback | |
| 88 | progress for 500 ms also terminates it. These are application buffering bounds, not confirmation | |
| 89 | that a peer received or executed a command. Focused tests exercise the bound against an actual | |
| 90 | paused loopback peer; they do not establish kernel buffer capacity or hardware link timing. | |
| 91 | Native loopback campaigns distinguish the drain timeout from callback progress below the watermark, | |
| 92 | and verify recovery within budget without resending accepted bytes. | |
| 93 | Drain and write callbacks check expiry before crediting progress, so a delayed event loop cannot | |
| 94 | reset an already expired output budget. These host observations do not establish real-time scheduling. | |
| 95 | ||
| 96 | An explicitly typed TCP factory result also provides `transportSnapshot()`. Its detached, read-only | |
| 97 | record reports connection state, backpressure, pending callback bytes, runtime writable bytes and | |
| 98 | active progress/block timestamps in the local `performance.now()` clock domain. These local queue | |
| 99 | observations do not establish remote receipt or execution and cannot change transport authority. | |
| 100 | ||
| 101 | EOF, socket failure, heartbeat expiry and explicit close reject pending work and discard partial | |
| 102 | frames. Reconnection is deliberate: close the old adapter, reconcile any uncertain remote effects, | |
| 103 | then create a new adapter. Nothing automatically reconnects or replays commands. The new session | |
| 104 | requires fresh heartbeat/capability discovery, and PX4 setpoints require fresh explicit priming. | |
| 105 | An already accepted write may still reach the old peer after local failure. Fresh construction | |
| 106 | does not solve stale-ACK ambiguity, exclusive remote plan ownership or persistent signing replay | |
| 107 | state across sessions; those issue #7 gates remain open. | |
| 108 | ||
| 61 | 109 | ## Discovery, commands and timing |
| 62 | 110 | |
| 63 | 111 | Discovery 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 | ||
| 85 | 133 | | Ambiguous transaction keys | At most 64; then the session terminates | |
| 86 | 134 | | Download completion record | One final item or empty COUNT, retained up to 10 s for re-ACK | |
| 87 | 135 | |
| 88 | The UDP adapter polls every 25 ms and emits a client heartbeat at 1 Hz only after explicit SITL | |
| 136 | Both adapters poll every 25 ms and emit a client heartbeat at 1 Hz only after explicit SITL | |
| 89 | 137 | capability discovery. The pure engine can also be driven by deterministic `tick()` calls in tests. |
| 90 | 138 | Clock reversal, link expiry, transport failure and close terminate pending work and require a new |
| 91 | 139 | adapter. 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 | ||
| 170 | 218 | | Full ArduPilot and PX4 firmware SITL round trips | Pending pinned-firmware campaigns, actual supported commands, parameter metadata, mission variants and measured rates | |
| 171 | 219 | | Physical write capability and HIL | Disabled; requires independent authority, link, hardware and vehicle evidence | |
| 172 | 220 | | 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 | | |
| 174 | 223 | | Signing key lifecycle and persistent replay state | Deferred under #7/#9 | |
| 175 | 224 | | Complete mission commands, fence/rally, terrain, opaque IDs, partial transfers and mission ownership | Deferred under #7/#10/#11 | |
| 176 | 225 | | 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) | ||
| 184 | 233 | [parameter protocol and implementation differences](https://mavlink.io/en/services/parameter.html), |
| 185 | 234 | [common message definitions](https://mavlink.io/en/messages/common.html) and |
| 186 | 235 | [signing specification](https://mavlink.io/en/guide/message_signing.html). |
| 236 | Stream layout follows [MAVLink packet serialization](https://mavlink.io/en/guide/serialization.html); | |
| 237 | write acceptance and drain semantics follow the [Node socket contract](https://nodejs.org/api/net.html#socketwritebuffer-encoding-callback), exercised on the pinned Bun runtime. | |
| 187 | 238 | Protocol logic is original TypeScript. The optional development fixture generator uses pymavlink |
| 188 | 239 | 2.4.50 to retain independent wire vectors; no vendor Python package is a runtime dependency. |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "schemaVersion": 1, |
| 3 | "reviewedAt": "2026-10-20", | |
| 3 | "reviewedAt": "2026-10-22", | |
| 4 | 4 | "assignments": [ |
| 5 | 5 | { |
| 6 | 6 | "entry": "px4:src/modules/airship_att_control", |
| @@ -2372,15 +2372,15 @@ | ||
| 2372 | 2372 | { |
| 2373 | 2373 | "entry": "ardupilot:libraries/AP_HAL_Linux", |
| 2374 | 2374 | "issueKey": "runtime", |
| 2375 | "unclassified": [ | |
| 2375 | "unclassified": [], | |
| 2376 | "reviewed": [ | |
| 2376 | 2377 | "parameters", |
| 2377 | 2378 | "modes", |
| 2378 | 2379 | "protocols", |
| 2379 | 2380 | "hardware", |
| 2380 | 2381 | "failures" |
| 2381 | 2382 | ], |
| 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." | |
| 2384 | 2384 | }, |
| 2385 | 2385 | { |
| 2386 | 2386 | "entry": "ardupilot:libraries/AP_HAL_QURT", |
| @@ -2398,15 +2398,15 @@ | ||
| 2398 | 2398 | { |
| 2399 | 2399 | "entry": "ardupilot:libraries/AP_HAL_SITL", |
| 2400 | 2400 | "issueKey": "runtime", |
| 2401 | "unclassified": [ | |
| 2401 | "unclassified": [], | |
| 2402 | "reviewed": [ | |
| 2402 | 2403 | "parameters", |
| 2403 | 2404 | "modes", |
| 2404 | 2405 | "protocols", |
| 2405 | 2406 | "hardware", |
| 2406 | 2407 | "failures" |
| 2407 | 2408 | ], |
| 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." | |
| 2410 | 2410 | }, |
| 2411 | 2411 | { |
| 2412 | 2412 | "entry": "ardupilot:libraries/AP_Hott_Telem", |
| @@ -4307,6 +4307,54 @@ | ||
| 4307 | 4307 | "url": "https://github.com/PX4/PX4-Autopilot/blob/ddcaad6601be20453ec6acd0f08e567af3a7e1bb/src/drivers/camera_capture/camera_capture_params.yaml", |
| 4308 | 4308 | "license": "BSD-3-Clause", |
| 4309 | 4309 | "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" | |
| 4310 | 4358 | } |
| 4311 | 4359 | ], |
| 4312 | 4360 | "cases": [ |
| @@ -6337,6 +6385,331 @@ | ||
| 6337 | 6385 | "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.", |
| 6338 | 6386 | "No upstream firmware execution, SITL, HIL, physical exposure feedback or executable parity evidence is retained for this gap." |
| 6339 | 6387 | ] |
| 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 | ] | |
| 6340 | 6713 | } |
| 6341 | 6714 | ] |
| 6342 | 6715 | } |
| @@ -3,10 +3,10 @@ | ||
| 3 | 3 | The executable audit accounts for the 274 pinned PX4 module/driver and ArduPilot library directories |
| 4 | 4 | and six ArduPilot vehicle programs. Directory assignment is work ownership. It is not feature parity. |
| 5 | 5 | |
| 6 | The retained matrix is [parity-audit.json](https://github.com/stacksjs/drone/blob/main/docs/parity-audit.json). It currently contains **79 behavior | |
| 6 | The retained matrix is [parity-audit.json](https://github.com/stacksjs/drone/blob/main/docs/parity-audit.json). It currently contains **89 behavior | |
| 7 | 7 | cases**, including **26 bounded support claims** backed by **25 distinct executable test selectors** |
| 8 | and **53 explicitly unsupported cases**. There are **1,327 unclassified detail dimensions**. | |
| 9 | The audit retains **42 pinned source records**. No upstream firmware SITL, HIL or physical-vehicle | |
| 8 | and **63 explicitly unsupported cases**. There are **1,317 unclassified detail dimensions**. | |
| 9 | The audit retains **46 pinned source records**. No upstream firmware SITL, HIL or physical-vehicle | |
| 10 | 10 | validation is claimed by this audit. |
| 11 | 11 | |
| 12 | 12 | Every assignment classifies parameters, modes, protocols, hardware and failures separately. A |
| @@ -124,6 +124,21 @@ not prove hardware enablement. Board wiring, electrical/timing calibration, PPS/ | ||
| 124 | 124 | queue/restart behavior remain deeper work. The owned IMX500 first-row metadata, scoped capture |
| 125 | 125 | ACK/pose association and modeled shutter remain separate capabilities, with no new parity claim. |
| 126 | 126 | |
| 127 | Ten further unsupported cases sample pinned ArduPilot Linux and SITL HAL UART behavior. They cover | |
| 128 | baud and buffer budgets, TCP server and CSV replay modes, raw-byte packetisation and simulated-device | |
| 129 | dispatch, parity and CTS/AUTO flow control, deferred open/short-write continuation, and hidden byte-loss | |
| 130 | injection. Four new source records retain 35 exact line/symbol anchors. The prior 79 cases, 42 sources, | |
| 131 | 26 bounded claims and 25 selectors remain unchanged. Both HAL directories remain owned by runtime | |
| 132 | issue #2 and retain deeper review work in all five sampled dimensions. | |
| 133 | ||
| 134 | The companion TCP client is distinct from those HAL backends. Its 4 KiB stream watermark and 16 KiB | |
| 135 | outstanding-write cap do not implement configurable UART rings, hardware parity, electrical RTS/CTS, | |
| 136 | raw serial replay or automatic queued-byte continuation. Upstream's SITL loss injector can omit at | |
| 137 | most one byte per write while counting it as transmitted; this is distinct from dropping an entire | |
| 138 | ACK or independently sampling every byte. The reviewed source establishes dispatch to simulated | |
| 139 | serial devices, not full review of each generated sensor dialect. No new executable support or | |
| 140 | upstream firmware execution is claimed by these ten cases. | |
| 141 | ||
| 127 | 142 | Unreviewed parameters, full firmware mode sets, serial/CAN/electrical variants, redundant estimators, |
| 128 | 143 | real output paths, simulator campaigns and hardware evidence remain visible gaps owned by their |
| 129 | 144 | implementation issues. |
| @@ -192,11 +192,15 @@ | ||
| 192 | 192 | "app/Commands/DriversBench.ts", |
| 193 | 193 | "docs/driver-protocols.md", |
| 194 | 194 | "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" | |
| 196 | 200 | ], |
| 197 | 201 | "remaining": [ |
| 198 | 202 | "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", | |
| 200 | 204 | "Signing key lifecycle, persistent replay watermarks and cross-restart campaigns", |
| 201 | 205 | "COMMAND_INT/remote cancellation, full mission/parameter protocols and ownership reconciliation", |
| 202 | 206 | "Explicit FTP/log/event/time/RTCM, payload/gimbal, DDS/ROS 2 and CAN gateway services" |
| @@ -861,7 +865,7 @@ | ||
| 861 | 865 | "implementationStatus": "behavior-audit-in-progress", |
| 862 | 866 | "implemented": [ |
| 863 | 867 | "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", | |
| 865 | 869 | "Executable evidence selectors, pinned source hash/license/citation verification, issue link checks and upstream snapshot refresh/diff gates" |
| 866 | 870 | ], |
| 867 | 871 | "evidence": [ |
| @@ -872,7 +876,7 @@ | ||
| 872 | 876 | "docs/parity.md" |
| 873 | 877 | ], |
| 874 | 878 | "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", | |
| 876 | 880 | "Expand executable coverage from bounded subsets to behavior-level parity with retained firmware/SITL/HIL/vehicle evidence" |
| 877 | 881 | ] |
| 878 | 882 | }, |
| @@ -0,0 +1,44 @@ | ||
| 1 | import type { Socket } from 'node:net' | |
| 2 | import { createServer } from 'node:net' | |
| 3 | import { createSocket } from 'node:dgram' | |
| 4 | import type { MavlinkFrame } from '../mavlink' | |
| 5 | import { createMavlinkStreamDecoder, decodeDatagram } from '../mavlink' | |
| 6 | import type { MavlinkSigning } from './signing' | |
| 7 | ||
| 8 | /** Owned scripted peer only. No forwarding, credentials or external firmware. */ | |
| 9 | export 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 | } | |
| @@ -1,37 +1,37 @@ | ||
| 1 | import { createSocket } from 'node:dgram' | |
| 2 | 1 | import type { AutopilotDriverId } from './profiles' |
| 3 | 2 | import type { MissionWaypoint } from './messages' |
| 4 | 3 | import { createArduPilotDriver, createPx4Driver } from './index' |
| 5 | 4 | import { createMavlinkSigning } from './signing' |
| 6 | import { decodeDatagram, encodeFrameV2 } from '../mavlink' | |
| 5 | import type { MavlinkFrame } from '../mavlink' | |
| 6 | import { encodeFrameV2 } from '../mavlink' | |
| 7 | import { createDriverBenchPeer } from './bench-peer' | |
| 7 | 8 | import { driverProfiles } from './profiles' |
| 8 | 9 | import { messageView, missionAckPayload, missionCountPayload, normalizeWaypoint, parameterBytes, parseWaypoint, waypointPayload } from './messages' |
| 9 | 10 | |
| 10 | 11 | /** Scripted protocol peer, not an ArduPilot/PX4 firmware process or a flight simulator. */ |
| 11 | export async function runDriverProtocolBench(options: { driver: AutopilotDriverId, dropFirstAck?: boolean, signed?: boolean }) { | |
| 12 | export async function runDriverProtocolBench(options: { driver: AutopilotDriverId, transport?: 'udp' | 'tcp', dropFirstAck?: boolean, signed?: boolean }) { | |
| 12 | 13 | const { driver } = options |
| 13 | 14 | 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') | |
| 14 | 17 | const dropFirstAck = options.dropFirstAck === true, signed = options.signed === true |
| 15 | 18 | const encoding = driver === 'px4' ? 'bytewise' : 'c-cast' |
| 16 | 19 | // Public conformance key. Never use this fixture key on a deployed link. |
| 17 | 20 | const key = Uint8Array.from({ length: 32 }, (_, i) => i) |
| 18 | 21 | const timestamp = () => BigInt(Math.floor((Date.now() - Date.UTC(2015, 0, 1)) * 100)) |
| 19 | 22 | 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 | |
| 22 | 24 | let mission: Required<MissionWaypoint>[] = [], staged: Required<MissionWaypoint>[] = [], uploadCount = 0 |
| 23 | 25 | const confirmations: number[] = [] |
| 24 | 26 | 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)) | |
| 26 | 28 | } |
| 27 | 29 | 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) } |
| 28 | 30 | const heartbeat = () => { const p = new Uint8Array(9); p.set([2, driverProfiles[driver].autopilotId, 0, 4, 3], 4); send(0, p) } |
| 29 | 31 | 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[]) { | |
| 33 | 33 | try { |
| 34 | for (const frame of decodeDatagram(bytes, peerSigning)) { | |
| 34 | for (const frame of frames) { | |
| 35 | 35 | if (frame.systemId !== 245 || frame.componentId !== 191) continue |
| 36 | 36 | const p = frame.payload, v = messageView(frame) |
| 37 | 37 | if (frame.id === 76) { |
| @@ -82,15 +82,16 @@ export async function runDriverProtocolBench(options: { driver: AutopilotDriverI | ||
| 82 | 82 | } |
| 83 | 83 | } |
| 84 | 84 | 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 }) | |
| 87 | 87 | const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 20000) |
| 88 | 88 | let adapter: Awaited<ReturnType<typeof createArduPilotDriver>> | undefined |
| 89 | 89 | let heartbeatTimer: ReturnType<typeof setInterval> | undefined |
| 90 | 90 | try { |
| 91 | 91 | 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) | |
| 94 | 95 | const readyDeadline = performance.now() + 5000 |
| 95 | 96 | while (!Number.isFinite(adapter.protocol.snapshot().lastHeartbeatAtMs)) { if (performance.now() > readyDeadline || peerError) throw peerError ?? new Error('Fixture discovery heartbeat timeout'); await Bun.sleep(10) } |
| 96 | 97 | const operation = { signal: controller.signal }, protocol = adapter.protocol |
| @@ -100,7 +101,7 @@ export async function runDriverProtocolBench(options: { driver: AutopilotDriverI | ||
| 100 | 101 | const uploaded = await protocol.uploadMission(missionItems, operation), downloaded = await protocol.downloadMission(operation), resume = await protocol.resumeMission(1, operation) |
| 101 | 102 | if (peerError) throw peerError |
| 102 | 103 | 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 } | |
| 104 | 105 | } |
| 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() } | |
| 106 | 107 | } |
| @@ -1,9 +1,26 @@ | ||
| 1 | 1 | import type { UdpOptions } from '../udp' |
| 2 | 2 | import { createUdpAdapter } from '../udp' |
| 3 | import type { TcpAdapter, TcpOptions } from '../tcp' | |
| 4 | import { createTcpAdapter } from '../tcp' | |
| 5 | import type { AutopilotAdapter } from './session' | |
| 6 | ||
| 7 | export type TcpDriverOptions = Omit<TcpOptions, 'driver'> & { transport: 'tcp' } | |
| 8 | export type AutopilotDriverOptions = (Omit<UdpOptions, 'driver'> & { transport?: 'udp' }) | TcpDriverOptions | |
| 9 | function 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 | } | |
| 3 | 14 | |
| 4 | 15 | /** Explicit factories prevent guessing an autopilot from whichever packet arrives first. */ |
| 5 | export const createArduPilotDriver = (options: Omit<UdpOptions, 'driver'>) => createUdpAdapter({ ...options, driver: 'ardupilot' }) | |
| 6 | export const createPx4Driver = (options: Omit<UdpOptions, 'driver'>) => createUdpAdapter({ ...options, driver: 'px4' }) | |
| 16 | export function createArduPilotDriver(options: TcpDriverOptions): Promise<TcpAdapter> | |
| 17 | export function createArduPilotDriver(options: AutopilotDriverOptions): Promise<AutopilotAdapter> | |
| 18 | export function createArduPilotDriver(options: AutopilotDriverOptions) { return createDriver('ardupilot', options) } | |
| 19 | export function createPx4Driver(options: TcpDriverOptions): Promise<TcpAdapter> | |
| 20 | export function createPx4Driver(options: AutopilotDriverOptions): Promise<AutopilotAdapter> | |
| 21 | export function createPx4Driver(options: AutopilotDriverOptions) { return createDriver('px4', options) } | |
| 22 | ||
| 23 | export type { TcpAdapter, TcpTransportSnapshot } from '../tcp' | |
| 7 | 24 | |
| 8 | 25 | export type { AutopilotProtocol, DriverOperationOptions, CommandAcknowledgement, DriverProtocolError } from './protocol' |
| 9 | 26 | export type { DriverCapabilities, DriverParameter, MissionWaypoint, ParameterType } from './messages' |
| @@ -122,7 +122,7 @@ export function createDriverProtocol(options: { driver: AutopilotDriverId, kind: | ||
| 122 | 122 | const api = { |
| 123 | 123 | tick, |
| 124 | 124 | close: () => close(), |
| 125 | transportLost: () => close('transport-lost', 'UDP transport failed'), | |
| 125 | transportLost: (message = 'Autopilot transport failed') => close('transport-lost', message), | |
| 126 | 126 | 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 } }, |
| 127 | 127 | receive(frame: MavlinkFrame) { |
| 128 | 128 | if (terminal || frame.systemId !== systemId || frame.componentId !== componentId) return |
| @@ -0,0 +1,95 @@ | ||
| 1 | import type { Decision, FlightAdapter, Telemetry } from '../types' | |
| 2 | import type { MavlinkFrame } from '../mavlink' | |
| 3 | import type { MavlinkSigning } from './signing' | |
| 4 | import type { AutopilotDriverId } from './profiles' | |
| 5 | import type { AutopilotProtocol } from './protocol' | |
| 6 | import { zero, finiteVector } from '../geometry' | |
| 7 | import { encodeFrameV2, encodeVelocity } from '../mavlink' | |
| 8 | import { decodeAutopilotMode, driverProfiles } from './profiles' | |
| 9 | import { createDriverProtocol } from './protocol' | |
| 10 | ||
| 11 | export 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 | } | |
| 20 | export 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. */ | |
| 29 | export 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 | } | |
| @@ -91,6 +91,36 @@ export function decodeDatagram(bytes: Uint8Array, signing?: MavlinkSigning): Mav | ||
| 91 | 91 | return result |
| 92 | 92 | } |
| 93 | 93 | |
| 94 | /** Bounded byte-stream framing. Partial frames belong to exactly one connection. */ | |
| 95 | export 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 | ||
| 94 | 124 | /** LOCAL_NED velocity-only setpoint. Does not arm, take off, land or change modes. */ |
| 95 | 125 | export function encodeVelocity(velocity: Vector3, options: { sequence?: number, targetSystem?: number, targetComponent?: number, bootMs?: number, signing?: MavlinkSigning } = {}): Uint8Array { |
| 96 | 126 | if (![velocity.north, velocity.east, velocity.down].every(v => Number.isFinite(v) && Math.abs(v) <= 20)) |
| @@ -0,0 +1,120 @@ | ||
| 1 | import type { Socket } from 'node:net' | |
| 2 | import { createConnection } from 'node:net' | |
| 3 | import type { AutopilotAdapter, AutopilotSessionOptions } from './drivers/session' | |
| 4 | import { createAutopilotSession } from './drivers/session' | |
| 5 | import { createMavlinkSigning } from './drivers/signing' | |
| 6 | import { createMavlinkStreamDecoder } from './mavlink' | |
| 7 | ||
| 8 | export interface TcpOptions extends AutopilotSessionOptions { port: number } | |
| 9 | export 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 | } | |
| 18 | export interface TcpAdapter extends AutopilotAdapter { transportSnapshot(): TcpTransportSnapshot } | |
| 19 | ||
| 20 | /** One explicit loopback connection. Reconnect by creating a new adapter and reconciling state. */ | |
| 21 | export 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 | } | |
| @@ -1,136 +1,47 @@ | ||
| 1 | import type { Decision, FlightAdapter, Telemetry } from './types' | |
| 2 | 1 | import { createSocket } from 'node:dgram' |
| 3 | import type { AutopilotProtocol } from './drivers/protocol' | |
| 4 | import { createDriverProtocol } from './drivers/protocol' | |
| 2 | import type { AutopilotAdapter, AutopilotSessionOptions } from './drivers/session' | |
| 3 | import { createAutopilotSession } from './drivers/session' | |
| 5 | 4 | import { createMavlinkSigning } from './drivers/signing' |
| 6 | import { decodeDatagram, encodeFrameV2, encodeVelocity } from './mavlink' | |
| 7 | import { zero } from './geometry' | |
| 8 | import { finiteVector } from './geometry' | |
| 9 | import type { AutopilotDriverId } from './drivers/profiles' | |
| 10 | import { decodeAutopilotMode, driverProfiles } from './drivers/profiles' | |
| 5 | import { decodeDatagram } from './mavlink' | |
| 11 | 6 | |
| 12 | export interface UdpOptions { | |
| 13 | driver?: AutopilotDriverId | |
| 14 | kind: 'sitl' | 'telemetry-only' | |
| 7 | export type { AutopilotAdapter } from './drivers/session' | |
| 8 | export interface UdpOptions extends AutopilotSessionOptions { | |
| 15 | 9 | listenPort: number |
| 16 | 10 | /** Fixed loopback destination. Never point a SITL forwarder at a physical vehicle. */ |
| 17 | 11 | 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 | } | |
| 24 | export 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 } | |
| 30 | 12 | } |
| 31 | 13 | |
| 32 | 14 | /** Local MAVProxy/mavlink-router endpoint. Physical hardware is receive-only. */ |
| 33 | 15 | export 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') | |
| 39 | 16 | 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') | |
| 48 | 18 | const signing = options.signing ? createMavlinkSigning(options.signing) : undefined |
| 19 | let peerPort = options.sitlPort, closed = false | |
| 49 | 20 | 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()) | |
| 65 | 26 | 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 | |
| 68 | 28 | for (const frame of decodeDatagram(bytes, signing)) { |
| 69 | if (frame.systemId !== systemId || frame.componentId !== componentId) continue | |
| 70 | 29 | 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 | |
| 91 | 31 | } |
| 92 | 32 | }) |
| 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 | }) | |
| 109 | 38 | } |
| 39 | catch (error) { session.close(); try { socket.close() } catch {} throw error } | |
| 40 | const timer = setInterval(() => session.protocol.tick(), 25) | |
| 41 | timer.unref() | |
| 110 | 42 | 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() } }, | |
| 135 | 46 | } |
| 136 | 47 | } |
| @@ -1,7 +1,9 @@ | ||
| 1 | 1 | import { runDriverProtocolBench } from '../../../resources/functions/drone/drivers/bench' |
| 2 | 2 | const results = [] |
| 3 | 3 | for (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 | } | |
| 5 | 7 | } |
| 6 | 8 | const destination = process.argv[2] ?? new URL('../../../docs/driver-protocol-evidence.json', import.meta.url).pathname |
| 7 | 9 | await 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`) |
| @@ -0,0 +1,73 @@ | ||
| 1 | /** An independently owned OS peer, so runtime pause/buffering behavior cannot consume test traffic. */ | |
| 2 | export 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 | } | |
| @@ -0,0 +1,89 @@ | ||
| 1 | """QA-only OS socket peer. No application, firmware, or physical-device imports.""" | |
| 2 | ||
| 3 | import json | |
| 4 | import os | |
| 5 | import select | |
| 6 | import socket | |
| 7 | import sys | |
| 8 | import time | |
| 9 | ||
| 10 | ||
| 11 | def emit(record): | |
| 12 | print(json.dumps(record), flush=True) | |
| 13 | ||
| 14 | ||
| 15 | telemetry = bytes.fromhex(sys.argv[1]) | |
| 16 | if not 0 < len(telemetry) <= 1024: | |
| 17 | raise ValueError("Invalid QA telemetry length") | |
| 18 | ||
| 19 | listener = socket.socket() | |
| 20 | listener.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096) | |
| 21 | listener.bind(("127.0.0.1", 0)) | |
| 22 | listener.listen(1) | |
| 23 | listener.settimeout(5) | |
| 24 | emit({"type": "ready", "port": listener.getsockname()[1], | |
| 25 | "receiveBufferBytes": listener.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF)}) | |
| 26 | connection = None | |
| 27 | received = 0 | |
| 28 | try: | |
| 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}) | |
| 83 | except (BrokenPipeError, ConnectionResetError): | |
| 84 | pass | |
| 85 | finally: | |
| 86 | if connection is not None: | |
| 87 | connection.close() | |
| 88 | listener.close() | |
| 89 | emit({"type": "closed", "bytes": received}) | |
| @@ -5,8 +5,11 @@ | ||
| 5 | 5 | "../bin/drone.ts", |
| 6 | 6 | "../resources/functions/drone/drivers/**/*.ts", |
| 7 | 7 | "../resources/functions/drone/udp.ts", |
| 8 | "../resources/functions/drone/tcp.ts", | |
| 8 | 9 | "../resources/functions/drone/mavlink.ts", |
| 9 | 10 | "./unit/driver-protocol.test.ts", |
| 11 | "./unit/driver-tcp.test.ts", | |
| 12 | "./unit/mavlink-stream.test.ts", | |
| 10 | 13 | "../scripts/build-runtime-cli.ts", |
| 11 | 14 | "../scripts/qualify-public-safety-managed-budget.ts", |
| 12 | 15 | "../resources/functions/drone/runtime/**/*.ts", |
| @@ -0,0 +1,445 @@ | ||
| 1 | import type { Socket } from 'node:net' | |
| 2 | import type { TcpAdapter } from '../../resources/functions/drone/drivers' | |
| 3 | import { createServer } from 'node:net' | |
| 4 | import { expect, test } from 'bun:test' | |
| 5 | import { createArduPilotDriver, createPx4Driver } from '../../resources/functions/drone/drivers' | |
| 6 | import { createMavlinkStreamDecoder, encodeFrame, encodeFrameV2 } from '../../resources/functions/drone/mavlink' | |
| 7 | import { runDriverProtocolBench } from '../../resources/functions/drone/drivers/bench' | |
| 8 | import vectors from '../fixtures/drivers/wire.json' | |
| 9 | import telemetryVectors from '../fixtures/mavlink.json' | |
| 10 | import { createPressurePeer } from '../fixtures/drivers/pressure' | |
| 11 | ||
| 12 | async 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 | } | |
| 16 | async 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 | } | |
| 24 | function 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 | ||
| 31 | test('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 | ||
| 57 | test('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 | ||
| 67 | for (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 | ||
| 84 | async 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 | ||
| 102 | test('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 | ||
| 132 | test('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 | ||
| 162 | test('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 | ||
| 169 | test('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 | ||
| 187 | test('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 | ||
| 207 | test('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. | |
| 227 | test('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 | ||
| 250 | async 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. | |
| 263 | async 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 | ||
| 304 | test('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 | ||
| 324 | test('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 | ||
| 344 | test('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 | ||
| 372 | async 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 | ||
| 387 | test('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 | ||
| 408 | test('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 | ||
| 428 | test('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) | |
| @@ -0,0 +1,52 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import fixtures from '../fixtures/mavlink.json' | |
| 3 | import vectors from '../fixtures/drivers/wire.json' | |
| 4 | import { createMavlinkStreamDecoder } from '../../resources/functions/drone/mavlink' | |
| 5 | import { createMavlinkSigning } from '../../resources/functions/drone/drivers/signing' | |
| 6 | ||
| 7 | test('a stream reconstructs independent v1 and v2 wire fixtures across every split', () => { | |
| 8 | const packets = [fixtures.v1Heartbeat, fixtures.v2Heartbeat].map(hex => Buffer.from(hex, 'hex')) | |
| 9 | for (const packet of packets) { | |
| 10 | for (let split = 0; split <= packet.length; split++) { | |
| 11 | const decoder = createMavlinkStreamDecoder() | |
| 12 | const frames = [...decoder.push(packet.subarray(0, split)), ...decoder.push(packet.subarray(split))] | |
| 13 | expect(frames).toHaveLength(1) | |
| 14 | expect(frames[0]!.id).toBe(0) | |
| 15 | expect(frames[0]!.systemId).toBe(1) | |
| 16 | expect(decoder.retainedBytes).toBe(0) | |
| 17 | } | |
| 18 | } | |
| 19 | }) | |
| 20 | ||
| 21 | test('a bounded stream resynchronizes noise and damaged length or CRC without trusting them', () => { | |
| 22 | const valid = Buffer.from(fixtures.v2Heartbeat, 'hex') | |
| 23 | for (const field of [1, valid.length - 1]) { | |
| 24 | const damaged = Buffer.from(valid); damaged[field] = field === 1 ? 255 : damaged[field]! ^ 1 | |
| 25 | const decoder = createMavlinkStreamDecoder() | |
| 26 | const bytes = Buffer.concat([Buffer.from([7, 0, 8]), damaged, valid, Buffer.alloc(280)]) | |
| 27 | expect(decoder.push(bytes).map(frame => frame.id)).toEqual([0]) | |
| 28 | expect(decoder.retainedBytes).toBe(0) | |
| 29 | } | |
| 30 | const decoder = createMavlinkStreamDecoder() | |
| 31 | expect(decoder.push(Buffer.alloc(65_536, 0xFD))).toEqual([]) | |
| 32 | expect(decoder.retainedBytes).toBeLessThan(280) | |
| 33 | expect(() => decoder.push(Buffer.alloc(65_537))).toThrow('65536') | |
| 34 | decoder.reset() | |
| 35 | expect(decoder.retainedBytes).toBe(0) | |
| 36 | expect(decoder.push(valid)).toHaveLength(1) | |
| 37 | }) | |
| 38 | ||
| 39 | test('fragmented signed wire vectors verify once, reject replay, tamper and unsigned downgrade', () => { | |
| 40 | const signed = Buffer.from(vectors.packets.signedHeartbeat, 'hex') | |
| 41 | for (let split = 0; split <= signed.length; split++) { | |
| 42 | const signing = createMavlinkSigning({ key: Uint8Array.from({ length: 32 }, (_, i) => i), linkId: 7, timestamp: () => 1000000n }) | |
| 43 | const decoder = createMavlinkStreamDecoder(signing) | |
| 44 | expect([...decoder.push(signed.subarray(0, split)), ...decoder.push(signed.subarray(split))]).toHaveLength(1) | |
| 45 | expect(decoder.push(signed)).toEqual([]) | |
| 46 | const altered = Buffer.from(signed); altered[altered.length - 1]! ^= 1 | |
| 47 | expect(decoder.push(altered)).toEqual([]) | |
| 48 | expect(decoder.push(Buffer.from(fixtures.v2Heartbeat, 'hex'))).toEqual([]) | |
| 49 | decoder.reset() // Framing reset must not reset authenticated replay watermarks. | |
| 50 | expect(decoder.push(signed)).toEqual([]) | |
| 51 | } | |
| 52 | }) | |