also looking at this
feat: add bounded TCP transport for autopilot drivers
#71
22 files
+1798
-154
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -119,6 +119,8 @@ jobs: | ||
| 119 | 119 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack |
| 120 | 120 | working-directory: ${{ runner.temp }} |
| 121 | 121 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver px4 --signed --drop-first-ack |
| 122 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --transport tcp --drop-first-ack | |
| 123 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver px4 --transport tcp --signed --drop-first-ack | |
| 122 | 124 | working-directory: ${{ runner.temp }} |
| 123 | 125 | - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone payloads:bench |
| 124 | 126 | working-directory: ${{ runner.temp }} |
| @@ -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 | } |
| @@ -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,41 @@ 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 | ||
| 75 | The stream decoder reconstructs v1/v2 packets across fragmented or coalesced reads, with CRC and | |
| 76 | the existing strict signing verifier. Its partial-frame storage is 280 bytes, the signed MAVLink 2 | |
| 77 | wire maximum; each decoding call accepts at most 65,536 bytes. TCP reads larger than that are | |
| 78 | processed in bounded chunks. Invalid CRC/length candidates resynchronize rather than refreshing | |
| 79 | telemetry. Unknown dialects and unsupported flags cannot satisfy a transaction. A decoder reset | |
| 80 | clears framing bytes while preserving the supplied verifier's in-session replay watermark. | |
| 81 | ||
| 82 | Outbound bytes awaiting write callbacks are capped at 16 KiB, alongside the runtime's reported | |
| 83 | queue length. A write that reports backpressure is already accepted by the stream and is never | |
| 84 | resent on `drain`. Further writes during pressure terminate the session. No drain or write-callback | |
| 85 | progress for 500 ms also terminates it. These are application buffering bounds, not confirmation | |
| 86 | that a peer received or executed a command. Focused tests exercise the bound against an actual | |
| 87 | paused loopback peer; they do not establish kernel buffer capacity or hardware link timing. | |
| 88 | ||
| 89 | EOF, socket failure, heartbeat expiry and explicit close reject pending work and discard partial | |
| 90 | frames. Reconnection is deliberate: close the old adapter, reconcile any uncertain remote effects, | |
| 91 | then create a new adapter. Nothing automatically reconnects or replays commands. The new session | |
| 92 | requires fresh heartbeat/capability discovery, and PX4 setpoints require fresh explicit priming. | |
| 93 | An already accepted write may still reach the old peer after local failure. Fresh construction | |
| 94 | does not solve stale-ACK ambiguity, exclusive remote plan ownership or persistent signing replay | |
| 95 | state across sessions; those issue #7 gates remain open. | |
| 96 | ||
| 61 | 97 | ## Discovery, commands and timing |
| 62 | 98 | |
| 63 | 99 | Discovery sends `MAV_CMD_REQUEST_MESSAGE` (512) for `AUTOPILOT_VERSION` (148). Both an accepted |
| @@ -85,7 +121,7 @@ of the request, not measured delivery rate. A denied/unsupported ACK is a typed | ||
| 85 | 121 | | Ambiguous transaction keys | At most 64; then the session terminates | |
| 86 | 122 | | Download completion record | One final item or empty COUNT, retained up to 10 s for re-ACK | |
| 87 | 123 | |
| 88 | The UDP adapter polls every 25 ms and emits a client heartbeat at 1 Hz only after explicit SITL | |
| 124 | Both adapters poll every 25 ms and emit a client heartbeat at 1 Hz only after explicit SITL | |
| 89 | 125 | capability discovery. The pure engine can also be driven by deterministic `tick()` calls in tests. |
| 90 | 126 | Clock reversal, link expiry, transport failure and close terminate pending work and require a new |
| 91 | 127 | adapter. Node/Bun event-loop timing is not a real-time safety mechanism. |
| @@ -170,7 +206,8 @@ supply those guarantees, and a sequence wrap must not discard fresh responses. N | ||
| 170 | 206 | | Full ArduPilot and PX4 firmware SITL round trips | Pending pinned-firmware campaigns, actual supported commands, parameter metadata, mission variants and measured rates | |
| 171 | 207 | | Physical write capability and HIL | Disabled; requires independent authority, link, hardware and vehicle evidence | |
| 172 | 208 | | 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 | | |
| 209 | | TCP stream framing, bounded output and explicit fresh-session reconnection | Implemented for scripted loopback peers; actual firmware transport campaigns remain open | | |
| 210 | | Serial transport and automatic reconnect policy | Deferred under #7; no serial adapter or transparent transaction replay | | |
| 174 | 211 | | Signing key lifecycle and persistent replay state | Deferred under #7/#9 | |
| 175 | 212 | | Complete mission commands, fence/rally, terrain, opaque IDs, partial transfers and mission ownership | Deferred under #7/#10/#11 | |
| 176 | 213 | | Parameter list/EXT, metadata, table change tracking and persistence | Deferred under #7 | |
| @@ -184,5 +221,7 @@ Sources: [MAVLink command protocol](https://mavlink.io/en/services/command.html) | ||
| 184 | 221 | [parameter protocol and implementation differences](https://mavlink.io/en/services/parameter.html), |
| 185 | 222 | [common message definitions](https://mavlink.io/en/messages/common.html) and |
| 186 | 223 | [signing specification](https://mavlink.io/en/guide/message_signing.html). |
| 224 | Stream layout follows [MAVLink packet serialization](https://mavlink.io/en/guide/serialization.html); | |
| 225 | 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 | 226 | Protocol logic is original TypeScript. The optional development fixture generator uses pymavlink |
| 188 | 227 | 2.4.50 to retain independent wire vectors; no vendor Python package is a runtime dependency. |
| @@ -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" |
| @@ -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,18 @@ | ||
| 1 | 1 | import type { UdpOptions } from '../udp' |
| 2 | 2 | import { createUdpAdapter } from '../udp' |
| 3 | import type { TcpOptions } from '../tcp' | |
| 4 | import { createTcpAdapter } from '../tcp' | |
| 5 | ||
| 6 | export type AutopilotDriverOptions = (Omit<UdpOptions, 'driver'> & { transport?: 'udp' }) | (Omit<TcpOptions, 'driver'> & { transport: 'tcp' }) | |
| 7 | function createDriver(driver: 'ardupilot' | 'px4', options: AutopilotDriverOptions) { | |
| 8 | if (options.transport === 'tcp') return createTcpAdapter({ ...options, driver }) | |
| 9 | if (options.transport !== undefined && options.transport !== 'udp') throw new TypeError('Unknown autopilot transport') | |
| 10 | return createUdpAdapter({ ...options, driver }) | |
| 11 | } | |
| 3 | 12 | |
| 4 | 13 | /** 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' }) | |
| 14 | export const createArduPilotDriver = (options: AutopilotDriverOptions) => createDriver('ardupilot', options) | |
| 15 | export const createPx4Driver = (options: AutopilotDriverOptions) => createDriver('px4', options) | |
| 7 | 16 | |
| 8 | 17 | export type { AutopilotProtocol, DriverOperationOptions, CommandAcknowledgement, DriverProtocolError } from './protocol' |
| 9 | 18 | 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,85 @@ | ||
| 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 | ||
| 10 | /** One explicit loopback connection. Reconnect by creating a new adapter and reconciling state. */ | |
| 11 | export async function createTcpAdapter(options: TcpOptions): Promise<AutopilotAdapter> { | |
| 12 | if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new RangeError('Invalid TCP port') | |
| 13 | const signing = options.signing ? createMavlinkSigning(options.signing) : undefined | |
| 14 | const decoder = createMavlinkStreamDecoder(signing) | |
| 15 | let socket: Socket | undefined, connected = false, closed = false, failed = false, blocked = false, matchedHeartbeat = false | |
| 16 | let timer: ReturnType<typeof setInterval> | undefined | |
| 17 | let blockedSince = 0, queuedBytes = 0, lastWriteProgress = 0 | |
| 18 | const session = createAutopilotSession(options, signing, { | |
| 19 | available: () => connected && !closed && !failed, | |
| 20 | write(bytes) { | |
| 21 | if (!socket || !connected || closed || failed || blocked || Math.max(socket.writableLength, queuedBytes) + bytes.length > 16_384) { | |
| 22 | fail('TCP output unavailable or backpressured; remote outcome is uncertain'); throw new Error('TCP output unavailable or backpressured; remote outcome is uncertain') | |
| 23 | } | |
| 24 | if (queuedBytes === 0) lastWriteProgress = performance.now() | |
| 25 | queuedBytes += bytes.length | |
| 26 | // Bound outstanding write callbacks as well as the runtime's writableLength. | |
| 27 | // false means accepted by the stream. Never resend these bytes on drain. | |
| 28 | try { | |
| 29 | if (!socket.write(bytes, error => { queuedBytes -= bytes.length; lastWriteProgress = performance.now(); if (error) fail('TCP write failed; remote outcome is uncertain') })) { blocked = true; blockedSince = performance.now() } | |
| 30 | } | |
| 31 | catch (error) { fail('TCP write failed; remote outcome is uncertain'); throw error } | |
| 32 | }, | |
| 33 | }) | |
| 34 | function stopPolling() { if (timer) { clearInterval(timer); timer = undefined } } | |
| 35 | function fail(message = 'TCP connection closed') { if (!failed && !closed) { failed = true; connected = false; stopPolling(); decoder.reset(); session.transportLost(message); socket?.destroy() } } | |
| 36 | const transport = createConnection({ host: '127.0.0.1', port: options.port }) | |
| 37 | socket = transport | |
| 38 | transport.setNoDelay(true) | |
| 39 | transport.on('error', () => fail('TCP socket error')) | |
| 40 | transport.on('end', () => fail()) | |
| 41 | transport.on('close', () => fail()) | |
| 42 | transport.on('drain', () => { blocked = false }) | |
| 43 | transport.on('data', bytes => { | |
| 44 | if (closed || failed) return | |
| 45 | if (typeof bytes === 'string') { fail('TCP byte stream unexpectedly decoded as text'); return } | |
| 46 | try { | |
| 47 | for (let offset = 0; offset < bytes.length; offset += 65_536) { | |
| 48 | for (const frame of decoder.push(bytes.subarray(offset, offset + 65_536))) { | |
| 49 | if (!matchedHeartbeat && frame.id !== 0) continue | |
| 50 | if (session.receive(frame) && frame.id === 0) matchedHeartbeat = true | |
| 51 | } | |
| 52 | } | |
| 53 | } | |
| 54 | catch { fail('TCP input decoding failed') } | |
| 55 | }) | |
| 56 | try { | |
| 57 | await new Promise<void>((resolve, reject) => { | |
| 58 | const timeout = setTimeout(() => { fail('TCP connection deadline expired'); finish(new Error('TCP connection deadline expired')) }, 2000) | |
| 59 | function finish(error?: Error) { | |
| 60 | clearTimeout(timeout); transport.off('connect', ready); transport.off('error', rejected); transport.off('close', disconnected) | |
| 61 | if (error) reject(error); else resolve() | |
| 62 | } | |
| 63 | const ready = () => { connected = true; finish() } | |
| 64 | const rejected = (error: Error) => finish(error) | |
| 65 | const disconnected = () => finish(new Error('TCP connection closed before ready')) | |
| 66 | transport.once('connect', ready); transport.once('error', rejected); transport.once('close', disconnected) | |
| 67 | }) | |
| 68 | if (failed || transport.destroyed || !connected) throw new Error('TCP connection unavailable') | |
| 69 | } | |
| 70 | catch (error) { session.close(); transport.destroy(); throw error } | |
| 71 | const heartbeatDeadline = performance.now() + 2000 | |
| 72 | timer = setInterval(() => { | |
| 73 | if (!matchedHeartbeat && performance.now() >= heartbeatDeadline) fail('TCP matching heartbeat deadline expired') | |
| 74 | if (blocked && performance.now() - blockedSince >= 500) fail('TCP output drain deadline expired; remote outcome is uncertain') | |
| 75 | if (queuedBytes > 0 && performance.now() - lastWriteProgress >= 500) fail('TCP write progress deadline expired; remote outcome is uncertain') | |
| 76 | session.protocol.tick() | |
| 77 | if (session.protocol.snapshot().fault) fail() | |
| 78 | }, 25) | |
| 79 | timer.unref() | |
| 80 | return { | |
| 81 | kind: session.kind, driver: session.driver, read: session.read, protocol: session.protocol, localPort: transport.localPort!, | |
| 82 | send: session.send, primeOffboard: session.primeOffboard, | |
| 83 | close() { if (!closed) { closed = true; connected = false; stopPolling(); decoder.reset(); session.close(); transport.destroy() } }, | |
| 84 | } | |
| 85 | } | |
| @@ -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`) |
| @@ -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,221 @@ | ||
| 1 | import type { Socket } from 'node:net' | |
| 2 | import { createServer } from 'node:net' | |
| 3 | import { expect, test } from 'bun:test' | |
| 4 | import { createArduPilotDriver, createPx4Driver } from '../../resources/functions/drone/drivers' | |
| 5 | import { createMavlinkStreamDecoder, encodeFrame, encodeFrameV2 } from '../../resources/functions/drone/mavlink' | |
| 6 | import { runDriverProtocolBench } from '../../resources/functions/drone/drivers/bench' | |
| 7 | import vectors from '../fixtures/drivers/wire.json' | |
| 8 | import telemetryVectors from '../fixtures/mavlink.json' | |
| 9 | ||
| 10 | async function until(condition: () => boolean) { | |
| 11 | const deadline = performance.now() + 1500 | |
| 12 | while (!condition()) { if (performance.now() > deadline) throw new Error('TCP peer observation timed out'); await Bun.sleep(5) } | |
| 13 | } | |
| 14 | async function peer() { | |
| 15 | const sockets: Socket[] = [], received: Buffer[] = [], receivedByConnection: Buffer[][] = [] | |
| 16 | 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', () => {}) }) | |
| 17 | await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) | |
| 18 | const address = server.address() | |
| 19 | if (!address || typeof address === 'string') throw new Error('Missing TCP peer port') | |
| 20 | return { port: address.port, sockets, received, receivedByConnection, close: async () => { for (const socket of sockets) socket.destroy(); await new Promise<void>(resolve => server.close(() => resolve())) } } | |
| 21 | } | |
| 22 | function telemetry(autopilot: number) { | |
| 23 | 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) | |
| 24 | const battery = new Uint8Array(31); battery[30] = 90 | |
| 25 | const position = new Uint8Array(28); new DataView(position.buffer).setFloat32(12, -12, true) | |
| 26 | return Buffer.concat([encodeFrame(0, heartbeat, 0, 1, 1), encodeFrame(1, battery, 1, 1, 1), encodeFrame(32, position, 2, 1, 1)]) | |
| 27 | } | |
| 28 | ||
| 29 | test('both TCP driver factories assemble fragmented telemetry and never write in physical mode', async () => { | |
| 30 | for (const [factory, autopilot] of [[createArduPilotDriver, 3], [createPx4Driver, 12]] as const) { | |
| 31 | const remote = await peer() | |
| 32 | let driver: Awaited<ReturnType<typeof factory>> | undefined | |
| 33 | try { | |
| 34 | const options = { transport: 'tcp' as const, kind: 'telemetry-only' as 'sitl' | 'telemetry-only', port: remote.port } | |
| 35 | driver = await factory(options) | |
| 36 | options.kind = 'sitl' | |
| 37 | await until(() => remote.sockets.length === 1) | |
| 38 | const bytes = telemetry(autopilot) | |
| 39 | remote.sockets[0]!.write(bytes.subarray(0, 2)) | |
| 40 | await Bun.sleep(20) | |
| 41 | expect(driver.read()?.batteryPercent).toBe(-1) | |
| 42 | remote.sockets[0]!.write(bytes.subarray(2)) | |
| 43 | await until(() => driver!.read()?.batteryPercent === 90) | |
| 44 | expect(driver.read()?.position.down).toBe(-12) | |
| 45 | expect(driver.read()?.positionHealthy).toBe(false) | |
| 46 | driver.send({ kind: 'hold', reason: 'receive-only' }) | |
| 47 | await expect(driver.protocol.sendCommand(400, [1])).rejects.toHaveProperty('code', 'read-only') | |
| 48 | await Bun.sleep(30) | |
| 49 | expect(remote.received).toHaveLength(0) | |
| 50 | } | |
| 51 | finally { driver?.close(); await remote.close() } | |
| 52 | } | |
| 53 | }) | |
| 54 | ||
| 55 | test('the TCP protocol bench completes discovery, retry, parameters and mission resume', async () => { | |
| 56 | const result = await runDriverProtocolBench({ driver: 'px4', transport: 'tcp', signed: true, dropFirstAck: true }) | |
| 57 | expect(result.transport).toBe('tcp') | |
| 58 | expect(result.confirmations).toEqual([0, 1]) | |
| 59 | expect(result.parameterAfter.value).toBe(43) | |
| 60 | expect(result.downloaded).toHaveLength(2) | |
| 61 | expect(result.resume).toEqual({ index: 1, accepted: true, executionConfirmed: false }) | |
| 62 | expect(result.physicalFlightEnabled).toBe(false) | |
| 63 | }) | |
| 64 | ||
| 65 | for (const driver of ['ardupilot', 'px4'] as const) { | |
| 66 | for (const signed of [false, true]) { | |
| 67 | test(`TCP ${driver} ${signed ? 'signed' : 'unsigned'} round trip reports its actual transport`, async () => { | |
| 68 | const result = await runDriverProtocolBench({ driver, transport: 'tcp', signed, dropFirstAck: true }) | |
| 69 | expect(result.transport).toBe('tcp') | |
| 70 | expect(result.capabilities.driver).toBe(driver) | |
| 71 | expect(result.confirmations).toEqual([0, 1]) | |
| 72 | expect(result.parameterBefore.value).toBe(42) | |
| 73 | expect(result.parameterAfter.value).toBe(43) | |
| 74 | expect(result.uploaded.count).toBe(2) | |
| 75 | expect(result.downloaded.map(item => item.latitudeDeg)).toEqual([0.001, 0.003]) | |
| 76 | expect(result.resume.executionConfirmed).toBe(false) | |
| 77 | expect(result.hardwareValidated).toBe(false) | |
| 78 | }) | |
| 79 | } | |
| 80 | } | |
| 81 | ||
| 82 | async function discover(remote: Awaited<ReturnType<typeof peer>>, driver: Awaited<ReturnType<typeof createArduPilotDriver>>, index = 0) { | |
| 83 | const connection = remote.sockets[index]! | |
| 84 | connection.write(telemetry(driver.driver === 'ardupilot' ? 3 : 12)) | |
| 85 | await until(() => Number.isFinite(driver.protocol.snapshot().lastHeartbeatAtMs)) | |
| 86 | const received = createMavlinkStreamDecoder() | |
| 87 | let requested = false | |
| 88 | 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 } | |
| 89 | connection.on('data', listener) | |
| 90 | const result = driver.protocol.discover() | |
| 91 | try { | |
| 92 | await until(() => requested) | |
| 93 | const ack = new Uint8Array(10); new DataView(ack.buffer).setUint16(0, 512, true); ack.set([245, 191], 8) | |
| 94 | connection.write(Buffer.concat([Buffer.from(vectors.packets.version, 'hex'), encodeFrameV2(77, ack, 1, 1, 1)])) | |
| 95 | await result | |
| 96 | } | |
| 97 | finally { connection.off('data', listener) } | |
| 98 | } | |
| 99 | ||
| 100 | test('peer EOF rejects pending work and reconnection starts with fresh framing, capabilities and no resumed request', async () => { | |
| 101 | const remote = await peer() | |
| 102 | let first: Awaited<ReturnType<typeof createArduPilotDriver>> | undefined, second: typeof first | |
| 103 | try { | |
| 104 | first = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port }) | |
| 105 | await until(() => remote.sockets.length === 1) | |
| 106 | await discover(remote, first) | |
| 107 | const pending = first.protocol.sendCommand(511, [32, 200000]).catch(error => error) | |
| 108 | remote.sockets[0]!.write(Buffer.from(telemetryVectors.v2Heartbeat, 'hex').subarray(0, 2)) // An unfinished frame must not cross connections. | |
| 109 | remote.sockets[0]!.end() | |
| 110 | expect((await pending).code).toBe('transport-lost') | |
| 111 | expect(first.read()).toBeUndefined() | |
| 112 | expect(first.protocol.snapshot().active).toBeNull() | |
| 113 | second = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port }) | |
| 114 | await until(() => remote.sockets.length === 2) | |
| 115 | remote.sockets[1]!.write(Buffer.from(telemetryVectors.v2Heartbeat, 'hex').subarray(2)) | |
| 116 | await Bun.sleep(30) | |
| 117 | expect(second.protocol.snapshot().capabilities).toBeNull() | |
| 118 | expect(second.protocol.snapshot().lastHeartbeatAtMs).toBe(-Infinity) | |
| 119 | expect(remote.receivedByConnection[1]).toHaveLength(0) | |
| 120 | await expect(second.protocol.sendCommand(511)).rejects.toHaveProperty('code', 'unavailable') | |
| 121 | await discover(remote, second, 1) | |
| 122 | expect(second.protocol.snapshot().quarantined).toEqual([]) | |
| 123 | expect(second.protocol.snapshot().active).toBeNull() | |
| 124 | await expect(first.protocol.sendCommand(512)).rejects.toHaveProperty('code', 'transport-lost') | |
| 125 | first.close(); first.close() | |
| 126 | } | |
| 127 | finally { first?.close(); second?.close(); await remote.close() } | |
| 128 | }) | |
| 129 | ||
| 130 | test('PX4 TCP setpoints require deliberate fresh priming and release stops the stream', async () => { | |
| 131 | const remote = await peer() | |
| 132 | let now = 0 | |
| 133 | const driver = await createPx4Driver({ transport: 'tcp', kind: 'sitl', port: remote.port, now: () => now }) | |
| 134 | try { | |
| 135 | await until(() => remote.sockets.length === 1) | |
| 136 | expect(driver.primeOffboard().ready).toBe(false) | |
| 137 | for (let step = 0; step <= 8; step++) { | |
| 138 | now = step * 200 | |
| 139 | remote.sockets[0]!.write(telemetry(12)) | |
| 140 | await until(() => driver.read()?.receivedAtMs === now) | |
| 141 | if (step === 0) { | |
| 142 | driver.send({ kind: 'hold', reason: 'unprimed' }) | |
| 143 | await Bun.sleep(10) | |
| 144 | expect(remote.received).toHaveLength(0) | |
| 145 | } | |
| 146 | expect(driver.primeOffboard().ready).toBe(step === 8) | |
| 147 | } | |
| 148 | driver.send({ kind: 'velocity', velocity: { north: 1, east: 0, down: 0 }, reason: 'primed' }) | |
| 149 | const frames = () => createMavlinkStreamDecoder().push(Buffer.concat(remote.received)) | |
| 150 | await until(() => frames().length === 10) | |
| 151 | expect(frames().every(frame => frame.id === 84)).toBe(true) | |
| 152 | expect(new DataView(frames().at(-1)!.payload.buffer).getFloat32(16, true)).toBe(1) | |
| 153 | driver.send({ kind: 'release', reason: 'release' }) | |
| 154 | await Bun.sleep(20) | |
| 155 | expect(frames()).toHaveLength(10) | |
| 156 | } | |
| 157 | finally { driver.close(); await remote.close() } | |
| 158 | }) | |
| 159 | ||
| 160 | test('TCP refuses invalid destinations and an actual closed listener without retaining a session', async () => { | |
| 161 | for (const port of [0, -1, 65536, NaN]) await expect(createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port })).rejects.toThrow('TCP port') | |
| 162 | const remote = await peer(), port = remote.port | |
| 163 | await remote.close() | |
| 164 | await expect(createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port })).rejects.toThrow() | |
| 165 | }) | |
| 166 | ||
| 167 | test('terminal peer loss stops polling before a caller explicitly closes the failed adapter', async () => { | |
| 168 | const remote = await peer() | |
| 169 | let clockReads = 0 | |
| 170 | const driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port, now: () => { clockReads++; return performance.now() } }) | |
| 171 | try { | |
| 172 | await until(() => remote.sockets.length === 1) | |
| 173 | remote.sockets[0]!.write(telemetry(3)) | |
| 174 | await until(() => Number.isFinite(driver.read()?.receivedAtMs)) | |
| 175 | remote.sockets[0]!.end() | |
| 176 | await until(() => driver.protocol.snapshot().fault !== null) | |
| 177 | const readsAtFailure = clockReads | |
| 178 | await Bun.sleep(100) | |
| 179 | expect(clockReads).toBe(readsAtFailure) | |
| 180 | expect(driver.read()).toBeUndefined() | |
| 181 | } | |
| 182 | finally { driver.close(); await remote.close() } | |
| 183 | }) | |
| 184 | ||
| 185 | test('outstanding writes to a real paused TCP peer are bounded without replaying a buffered setpoint', async () => { | |
| 186 | const remote = await peer() | |
| 187 | const driver = await createArduPilotDriver({ transport: 'tcp', kind: 'sitl', port: remote.port }) | |
| 188 | try { | |
| 189 | await until(() => remote.sockets.length === 1) | |
| 190 | remote.sockets[0]!.write(telemetry(3)) | |
| 191 | await until(() => Number.isFinite(driver.read()?.receivedAtMs)) | |
| 192 | remote.sockets[0]!.pause() | |
| 193 | expect(remote.sockets[0]!.readableFlowing).toBe(false) | |
| 194 | let attempted = 0 | |
| 195 | while (!driver.protocol.snapshot().fault && attempted < 200_000) { driver.send({ kind: 'hold', reason: 'paused-reader-boundary' }); attempted++ } | |
| 196 | expect(driver.protocol.snapshot().fault).toContain('backpressured') | |
| 197 | expect(attempted).toBeLessThan(200_000) | |
| 198 | expect(driver.read()).toBeUndefined() | |
| 199 | driver.send({ kind: 'velocity', velocity: { north: 1, east: 0, down: 0 }, reason: 'failed transport' }) | |
| 200 | await expect(driver.protocol.sendCommand(511)).rejects.toHaveProperty('code', 'transport-lost') | |
| 201 | } | |
| 202 | finally { driver.close(); await remote.close() } | |
| 203 | }) | |
| 204 | ||
| 205 | test('wrong firmware identity cannot establish a TCP session and silent startup expires', async () => { | |
| 206 | const remote = await peer() | |
| 207 | const driver = await createPx4Driver({ transport: 'tcp', kind: 'telemetry-only', port: remote.port }) | |
| 208 | try { | |
| 209 | await until(() => remote.sockets.length === 1) | |
| 210 | remote.sockets[0]!.write(telemetry(3)) | |
| 211 | await Bun.sleep(30) | |
| 212 | expect(driver.protocol.snapshot().lastHeartbeatAtMs).toBe(-Infinity) | |
| 213 | expect(driver.read()?.batteryPercent).toBe(-1) | |
| 214 | const deadline = performance.now() + 2500 | |
| 215 | while (!driver.protocol.snapshot().fault) { if (performance.now() > deadline) throw new Error('TCP initial heartbeat deadline did not settle'); await Bun.sleep(10) } | |
| 216 | expect(driver.protocol.snapshot().fault).toContain('matching heartbeat deadline') | |
| 217 | expect(driver.read()).toBeUndefined() | |
| 218 | expect(remote.received).toHaveLength(0) | |
| 219 | } | |
| 220 | finally { driver.close(); await remote.close() } | |
| 221 | }) | |
| @@ -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 | }) | |