ReviewOS

also looking at this

stacks/drone

feat: add bounded TCP transport for autopilot drivers

#71
Merged chris wants to merge codex/mavlink-tcp-transport into main
22 files +1798 -154

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.

.github/workflows/ci.ymlmodified+2-0
Changes to .github/workflows/ci.yml
@@ -119,6 +119,8 @@ jobs:
119119 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone drivers:bench --driver ardupilot --drop-first-ack
120120 working-directory: ${{ runner.temp }}
121121 - 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
122124 working-directory: ${{ runner.temp }}
123125 - run: $GITHUB_WORKSPACE/storage/runtime-build/cli/drone payloads:bench
124126 working-directory: ${{ runner.temp }}
app/Commands/DriversBench.tsmodified+2-1
Changes to app/Commands/DriversBench.ts
@@ -7,12 +7,13 @@ export default defineCommand({
77 description: 'Exercise acknowledged commands, parameters and missions against a scripted local protocol peer',
88 options: {
99 '--driver <name>': { description: 'ardupilot or px4', default: 'ardupilot' },
10 '--transport <name>': { description: 'udp or tcp loopback peer', default: 'udp' },
1011 '--drop-first-ack': 'Deliberately drop the first message-rate ACK to exercise retry',
1112 '--signed': 'Use a public fixture key to test required MAVLink 2 signatures',
1213 },
1314 async handle(options) {
1415 try {
15 const result = await runDriverProtocolBench({ driver: options.driver as AutopilotDriverId, signed: options.signed, dropFirstAck: options.dropFirstAck })
16 const result = await runDriverProtocolBench({ driver: options.driver as AutopilotDriverId, transport: options.transport as 'udp' | 'tcp', signed: options.signed, dropFirstAck: options.dropFirstAck })
1617 // eslint-disable-next-line
1718 print(JSON.stringify(result, (key, value) => typeof value === 'bigint' ? String(value) : value, 2))
1819 }
docs/driver-protocol-evidence.jsonmodified+312-0
Changes to docs/driver-protocol-evidence.json
@@ -6,6 +6,7 @@
66 {
77 "evidence": "scripted-loopback-protocol-peer",
88 "driver": "ardupilot",
9 "transport": "udp",
910 "signed": false,
1011 "physicalFlightEnabled": false,
1112 "firmwareSitlValidated": false,
@@ -82,6 +83,7 @@
8283 {
8384 "evidence": "scripted-loopback-protocol-peer",
8485 "driver": "ardupilot",
86 "transport": "udp",
8587 "signed": true,
8688 "physicalFlightEnabled": false,
8789 "firmwareSitlValidated": false,
@@ -155,9 +157,318 @@
155157 "executionConfirmed": false
156158 }
157159 },
160 {
161 "evidence": "scripted-loopback-protocol-peer",
162 "driver": "ardupilot",
163 "transport": "tcp",
164 "signed": false,
165 "physicalFlightEnabled": false,
166 "firmwareSitlValidated": false,
167 "hardwareValidated": false,
168 "capabilities": {
169 "driver": "ardupilot",
170 "bits": "139268",
171 "uid": "48879",
172 "flightSoftwareVersion": 0,
173 "missionInt": true,
174 "parameterEncoding": "c-cast",
175 "encodingEvidence": "advertised",
176 "physicalCommands": false
177 },
178 "rate": {
179 "command": 511,
180 "result": 0,
181 "progress": null,
182 "executionConfirmed": false
183 },
184 "confirmations": [
185 0,
186 1
187 ],
188 "droppedAcks": 1,
189 "parameterBefore": {
190 "id": "BENCH_INT",
191 "type": 6,
192 "value": 42,
193 "count": 1,
194 "index": 0
195 },
196 "parameterAfter": {
197 "id": "BENCH_INT",
198 "type": 6,
199 "value": 43,
200 "count": 1,
201 "index": 0
202 },
203 "uploaded": {
204 "count": 2,
205 "accepted": true
206 },
207 "downloaded": [
208 {
209 "command": 16,
210 "frame": 6,
211 "latitudeDeg": 0.001,
212 "longitudeDeg": 0.002,
213 "altitudeM": 10,
214 "holdSeconds": 0,
215 "acceptanceRadiusM": 2,
216 "passRadiusM": 0,
217 "yawDeg": null
218 },
219 {
220 "command": 16,
221 "frame": 6,
222 "latitudeDeg": 0.003,
223 "longitudeDeg": 0.004,
224 "altitudeM": 12,
225 "holdSeconds": 0,
226 "acceptanceRadiusM": 2,
227 "passRadiusM": 0,
228 "yawDeg": 90
229 }
230 ],
231 "resume": {
232 "index": 1,
233 "accepted": true,
234 "executionConfirmed": false
235 }
236 },
237 {
238 "evidence": "scripted-loopback-protocol-peer",
239 "driver": "ardupilot",
240 "transport": "tcp",
241 "signed": true,
242 "physicalFlightEnabled": false,
243 "firmwareSitlValidated": false,
244 "hardwareValidated": false,
245 "capabilities": {
246 "driver": "ardupilot",
247 "bits": "139268",
248 "uid": "48879",
249 "flightSoftwareVersion": 0,
250 "missionInt": true,
251 "parameterEncoding": "c-cast",
252 "encodingEvidence": "advertised",
253 "physicalCommands": false
254 },
255 "rate": {
256 "command": 511,
257 "result": 0,
258 "progress": null,
259 "executionConfirmed": false
260 },
261 "confirmations": [
262 0,
263 1
264 ],
265 "droppedAcks": 1,
266 "parameterBefore": {
267 "id": "BENCH_INT",
268 "type": 6,
269 "value": 42,
270 "count": 1,
271 "index": 0
272 },
273 "parameterAfter": {
274 "id": "BENCH_INT",
275 "type": 6,
276 "value": 43,
277 "count": 1,
278 "index": 0
279 },
280 "uploaded": {
281 "count": 2,
282 "accepted": true
283 },
284 "downloaded": [
285 {
286 "command": 16,
287 "frame": 6,
288 "latitudeDeg": 0.001,
289 "longitudeDeg": 0.002,
290 "altitudeM": 10,
291 "holdSeconds": 0,
292 "acceptanceRadiusM": 2,
293 "passRadiusM": 0,
294 "yawDeg": null
295 },
296 {
297 "command": 16,
298 "frame": 6,
299 "latitudeDeg": 0.003,
300 "longitudeDeg": 0.004,
301 "altitudeM": 12,
302 "holdSeconds": 0,
303 "acceptanceRadiusM": 2,
304 "passRadiusM": 0,
305 "yawDeg": 90
306 }
307 ],
308 "resume": {
309 "index": 1,
310 "accepted": true,
311 "executionConfirmed": false
312 }
313 },
314 {
315 "evidence": "scripted-loopback-protocol-peer",
316 "driver": "px4",
317 "transport": "udp",
318 "signed": false,
319 "physicalFlightEnabled": false,
320 "firmwareSitlValidated": false,
321 "hardwareValidated": false,
322 "capabilities": {
323 "driver": "px4",
324 "bits": "8212",
325 "uid": "48879",
326 "flightSoftwareVersion": 0,
327 "missionInt": true,
328 "parameterEncoding": "bytewise",
329 "encodingEvidence": "advertised",
330 "physicalCommands": false
331 },
332 "rate": {
333 "command": 511,
334 "result": 0,
335 "progress": null,
336 "executionConfirmed": false
337 },
338 "confirmations": [
339 0,
340 1
341 ],
342 "droppedAcks": 1,
343 "parameterBefore": {
344 "id": "BENCH_INT",
345 "type": 6,
346 "value": 42,
347 "count": 1,
348 "index": 0
349 },
350 "parameterAfter": {
351 "id": "BENCH_INT",
352 "type": 6,
353 "value": 43,
354 "count": 1,
355 "index": 0
356 },
357 "uploaded": {
358 "count": 2,
359 "accepted": true
360 },
361 "downloaded": [
362 {
363 "command": 16,
364 "frame": 6,
365 "latitudeDeg": 0.001,
366 "longitudeDeg": 0.002,
367 "altitudeM": 10,
368 "holdSeconds": 0,
369 "acceptanceRadiusM": 2,
370 "passRadiusM": 0,
371 "yawDeg": null
372 },
373 {
374 "command": 16,
375 "frame": 6,
376 "latitudeDeg": 0.003,
377 "longitudeDeg": 0.004,
378 "altitudeM": 12,
379 "holdSeconds": 0,
380 "acceptanceRadiusM": 2,
381 "passRadiusM": 0,
382 "yawDeg": 90
383 }
384 ],
385 "resume": {
386 "index": 1,
387 "accepted": true,
388 "executionConfirmed": false
389 }
390 },
391 {
392 "evidence": "scripted-loopback-protocol-peer",
393 "driver": "px4",
394 "transport": "udp",
395 "signed": true,
396 "physicalFlightEnabled": false,
397 "firmwareSitlValidated": false,
398 "hardwareValidated": false,
399 "capabilities": {
400 "driver": "px4",
401 "bits": "8212",
402 "uid": "48879",
403 "flightSoftwareVersion": 0,
404 "missionInt": true,
405 "parameterEncoding": "bytewise",
406 "encodingEvidence": "advertised",
407 "physicalCommands": false
408 },
409 "rate": {
410 "command": 511,
411 "result": 0,
412 "progress": null,
413 "executionConfirmed": false
414 },
415 "confirmations": [
416 0,
417 1
418 ],
419 "droppedAcks": 1,
420 "parameterBefore": {
421 "id": "BENCH_INT",
422 "type": 6,
423 "value": 42,
424 "count": 1,
425 "index": 0
426 },
427 "parameterAfter": {
428 "id": "BENCH_INT",
429 "type": 6,
430 "value": 43,
431 "count": 1,
432 "index": 0
433 },
434 "uploaded": {
435 "count": 2,
436 "accepted": true
437 },
438 "downloaded": [
439 {
440 "command": 16,
441 "frame": 6,
442 "latitudeDeg": 0.001,
443 "longitudeDeg": 0.002,
444 "altitudeM": 10,
445 "holdSeconds": 0,
446 "acceptanceRadiusM": 2,
447 "passRadiusM": 0,
448 "yawDeg": null
449 },
450 {
451 "command": 16,
452 "frame": 6,
453 "latitudeDeg": 0.003,
454 "longitudeDeg": 0.004,
455 "altitudeM": 12,
456 "holdSeconds": 0,
457 "acceptanceRadiusM": 2,
458 "passRadiusM": 0,
459 "yawDeg": 90
460 }
461 ],
462 "resume": {
463 "index": 1,
464 "accepted": true,
465 "executionConfirmed": false
466 }
467 },
158468 {
159469 "evidence": "scripted-loopback-protocol-peer",
160470 "driver": "px4",
471 "transport": "tcp",
161472 "signed": false,
162473 "physicalFlightEnabled": false,
163474 "firmwareSitlValidated": false,
@@ -234,6 +545,7 @@
234545 {
235546 "evidence": "scripted-loopback-protocol-peer",
236547 "driver": "px4",
548 "transport": "tcp",
237549 "signed": true,
238550 "physicalFlightEnabled": false,
239551 "firmwareSitlValidated": false,
docs/driver-protocols.mdmodified+43-4
Changes to docs/driver-protocols.md
@@ -1,7 +1,7 @@
11# Acknowledged external-driver protocols
22
33Issue [#7](https://github.com/stacksjs/drone/issues/7) adds a shared MAVLink 2 transaction engine
4behind the existing ArduPilot and PX4 UDP factories. It implements discovery, acknowledged commands,
4behind the ArduPilot and PX4 UDP/TCP factories. It implements discovery, acknowledged commands,
55message-rate requests, named parameter reads/writes, bounded waypoint transfers and resume requests.
66Physical telemetry remains receive-only. No HTTP arm, mode or actuator endpoint is introduced.
77
@@ -10,13 +10,14 @@ Physical telemetry remains receive-only. No HTTP arm, mode or actuator endpoint
1010```sh
1111./buddy drivers:bench --driver ardupilot --drop-first-ack
1212./buddy drivers:bench --driver px4 --signed --drop-first-ack
13./buddy drivers:bench --driver px4 --transport tcp --signed --drop-first-ack
1314bun run runtime:cli
1415./storage/runtime-build/cli/drone drivers:bench --driver px4 --signed
1516bun tests/fixtures/drivers/evaluate.ts
1617```
1718
1819`drivers:bench` uses native Stacks `defineCommand()` and is shared by Buddy and the compiled CLI.
19It creates a scripted loopback UDP peer with the selected firmware identity, performs discovery,
20It creates a scripted loopback UDP or TCP peer with the selected firmware identity, performs discovery,
2021negotiates a requested 5 Hz message interval, reads/writes an integer, uploads/downloads two waypoints
2122and requests resume at item 1. The optional ACK drop verifies a retry with confirmation 1.
2223The public signing key is a test vector, never a deployed credential. All sockets/timers close.
@@ -58,6 +59,41 @@ quarantined transaction keys and terminal faults. It does not transmit or refres
5859no keys or physical authority. Constructor options are captured; changing `kind` on the caller's
5960object cannot promote a physical receiver.
6061
62## TCP framing, loss and reconnection
63
64Select TCP explicitly through either public factory:
65
66```ts
67const driver = await createPx4Driver({ transport: 'tcp', kind: 'sitl', port: 5760 })
68```
69
70TCP always connects to literal `127.0.0.1`, with a 2-second connection deadline and a separate
712-second deadline for the first matching heartbeat. The configured system/component and firmware
72identity must match before TCP telemetry can be admitted. No arbitrary host, serial port, physical
73writer or forwarding service is added. Existing UDP options and default transport remain compatible.
74
75The stream decoder reconstructs v1/v2 packets across fragmented or coalesced reads, with CRC and
76the existing strict signing verifier. Its partial-frame storage is 280 bytes, the signed MAVLink 2
77wire maximum; each decoding call accepts at most 65,536 bytes. TCP reads larger than that are
78processed in bounded chunks. Invalid CRC/length candidates resynchronize rather than refreshing
79telemetry. Unknown dialects and unsupported flags cannot satisfy a transaction. A decoder reset
80clears framing bytes while preserving the supplied verifier's in-session replay watermark.
81
82Outbound bytes awaiting write callbacks are capped at 16 KiB, alongside the runtime's reported
83queue length. A write that reports backpressure is already accepted by the stream and is never
84resent on `drain`. Further writes during pressure terminate the session. No drain or write-callback
85progress for 500 ms also terminates it. These are application buffering bounds, not confirmation
86that a peer received or executed a command. Focused tests exercise the bound against an actual
87paused loopback peer; they do not establish kernel buffer capacity or hardware link timing.
88
89EOF, socket failure, heartbeat expiry and explicit close reject pending work and discard partial
90frames. Reconnection is deliberate: close the old adapter, reconcile any uncertain remote effects,
91then create a new adapter. Nothing automatically reconnects or replays commands. The new session
92requires fresh heartbeat/capability discovery, and PX4 setpoints require fresh explicit priming.
93An already accepted write may still reach the old peer after local failure. Fresh construction
94does not solve stale-ACK ambiguity, exclusive remote plan ownership or persistent signing replay
95state across sessions; those issue #7 gates remain open.
96
6197## Discovery, commands and timing
6298
6399Discovery 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
85121| Ambiguous transaction keys | At most 64; then the session terminates |
86122| Download completion record | One final item or empty COUNT, retained up to 10 s for re-ACK |
87123
88The UDP adapter polls every 25 ms and emits a client heartbeat at 1 Hz only after explicit SITL
124Both adapters poll every 25 ms and emit a client heartbeat at 1 Hz only after explicit SITL
89125capability discovery. The pure engine can also be driven by deterministic `tick()` calls in tests.
90126Clock reversal, link expiry, transport failure and close terminate pending work and require a new
91127adapter. 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
170206| Full ArduPilot and PX4 firmware SITL round trips | Pending pinned-firmware campaigns, actual supported commands, parameter metadata, mission variants and measured rates |
171207| Physical write capability and HIL | Disabled; requires independent authority, link, hardware and vehicle evidence |
172208| 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 |
174211| Signing key lifecycle and persistent replay state | Deferred under #7/#9 |
175212| Complete mission commands, fence/rally, terrain, opaque IDs, partial transfers and mission ownership | Deferred under #7/#10/#11 |
176213| 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)
184221[parameter protocol and implementation differences](https://mavlink.io/en/services/parameter.html),
185222[common message definitions](https://mavlink.io/en/messages/common.html) and
186223[signing specification](https://mavlink.io/en/guide/message_signing.html).
224Stream layout follows [MAVLink packet serialization](https://mavlink.io/en/guide/serialization.html);
225write acceptance and drain semantics follow the [Node socket contract](https://nodejs.org/api/net.html#socketwritebuffer-encoding-callback), exercised on the pinned Bun runtime.
187226Protocol logic is original TypeScript. The optional development fixture generator uses pymavlink
1882272.4.50 to retain independent wire vectors; no vendor Python package is a runtime dependency.
docs/roadmap/issues.jsonmodified+6-2
Changes to docs/roadmap/issues.json
@@ -192,11 +192,15 @@
192192 "app/Commands/DriversBench.ts",
193193 "docs/driver-protocols.md",
194194 "docs/driver-protocol-evidence.json",
195 "tests/unit/driver-protocol.test.ts"
195 "tests/unit/driver-protocol.test.ts",
196 "resources/functions/drone/tcp.ts",
197 "resources/functions/drone/drivers/session.ts",
198 "tests/unit/driver-tcp.test.ts",
199 "tests/unit/mavlink-stream.test.ts"
196200 ],
197201 "remaining": [
198202 "Pinned ArduPilot/PX4 firmware SITL round trips, supported commands, mission variants and measured message rates",
199 "Serial/TCP stream transport, reconnect/backpressure and hardware/HIL authority evidence",
203 "Serial transport, automatic reconnect policy, actual firmware TCP failure campaigns and hardware/HIL authority evidence",
200204 "Signing key lifecycle, persistent replay watermarks and cross-restart campaigns",
201205 "COMMAND_INT/remote cancellation, full mission/parameter protocols and ownership reconciliation",
202206 "Explicit FTP/log/event/time/RTCM, payload/gimbal, DDS/ROS 2 and CAN gateway services"
resources/functions/drone/drivers/bench-peer.tsadded+44-0
Changes to resources/functions/drone/drivers/bench-peer.ts
@@ -0,0 +1,44 @@
1import type { Socket } from 'node:net'
2import { createServer } from 'node:net'
3import { createSocket } from 'node:dgram'
4import type { MavlinkFrame } from '../mavlink'
5import { createMavlinkStreamDecoder, decodeDatagram } from '../mavlink'
6import type { MavlinkSigning } from './signing'
7
8/** Owned scripted peer only. No forwarding, credentials or external firmware. */
9export async function createDriverBenchPeer(transport: 'udp' | 'tcp', signing: MavlinkSigning | undefined, receive: (frames: MavlinkFrame[]) => void, onError: (error: Error) => void) {
10 let clientPort: number | null = null
11 if (transport === 'udp') {
12 const socket = createSocket('udp4')
13 socket.on('error', onError)
14 socket.on('message', (bytes, remote) => { if (remote.address === '127.0.0.1' && remote.port === clientPort) receive(decodeDatagram(bytes, signing)) })
15 await new Promise<void>((resolve, reject) => { socket.once('error', reject); socket.bind(0, '127.0.0.1', () => { socket.off('error', reject); resolve() }) })
16 return { port: socket.address().port, start(port: number) { clientPort = port }, send(bytes: Uint8Array) { if (clientPort !== null) socket.send(bytes, clientPort, '127.0.0.1') }, async close() { await new Promise<void>(resolve => socket.close(() => resolve())) } }
17 }
18 let connection: Socket | null = null
19 const server = createServer(socket => {
20 if (connection) { socket.destroy(); return }
21 connection = socket
22 const decoder = createMavlinkStreamDecoder(signing)
23 socket.on('error', onError)
24 socket.on('data', bytes => {
25 if (typeof bytes === 'string') { onError(new Error('Scripted TCP byte stream unexpectedly decoded as text')); socket.destroy(); return }
26 try { for (let at = 0; at < bytes.length; at += 65_536) receive(decoder.push(bytes.subarray(at, at + 65_536))) }
27 catch (error) { onError(error instanceof Error ? error : new Error(String(error))); socket.destroy() }
28 })
29 })
30 server.on('error', onError)
31 await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() }) })
32 const address = server.address()
33 if (!address || typeof address === 'string') throw new Error('Missing scripted TCP peer port')
34 return {
35 port: address.port,
36 start(port: number) { clientPort = port },
37 send(bytes: Uint8Array) {
38 if (!connection || connection.remotePort !== clientPort) return
39 if (connection.writableLength + bytes.length > 16_384) { onError(new Error('Scripted TCP peer exceeded output bound')); connection.destroy(); return }
40 connection.write(bytes)
41 },
42 async close() { connection?.destroy(); await new Promise<void>(resolve => server.close(() => resolve())) },
43 }
44}
resources/functions/drone/drivers/bench.tsmodified+17-16
Changes to resources/functions/drone/drivers/bench.ts
@@ -1,37 +1,37 @@
1import { createSocket } from 'node:dgram'
21import type { AutopilotDriverId } from './profiles'
32import type { MissionWaypoint } from './messages'
43import { createArduPilotDriver, createPx4Driver } from './index'
54import { createMavlinkSigning } from './signing'
6import { decodeDatagram, encodeFrameV2 } from '../mavlink'
5import type { MavlinkFrame } from '../mavlink'
6import { encodeFrameV2 } from '../mavlink'
7import { createDriverBenchPeer } from './bench-peer'
78import { driverProfiles } from './profiles'
89import { messageView, missionAckPayload, missionCountPayload, normalizeWaypoint, parameterBytes, parseWaypoint, waypointPayload } from './messages'
910
1011/** Scripted protocol peer, not an ArduPilot/PX4 firmware process or a flight simulator. */
11export async function runDriverProtocolBench(options: { driver: AutopilotDriverId, dropFirstAck?: boolean, signed?: boolean }) {
12export async function runDriverProtocolBench(options: { driver: AutopilotDriverId, transport?: 'udp' | 'tcp', dropFirstAck?: boolean, signed?: boolean }) {
1213 const { driver } = options
1314 if (!['ardupilot', 'px4'].includes(driver)) throw new TypeError('Choose ardupilot or px4')
15 const transport = options.transport ?? 'udp'
16 if (transport !== 'udp' && transport !== 'tcp') throw new TypeError('Choose udp or tcp')
1417 const dropFirstAck = options.dropFirstAck === true, signed = options.signed === true
1518 const encoding = driver === 'px4' ? 'bytewise' : 'c-cast'
1619 // Public conformance key. Never use this fixture key on a deployed link.
1720 const key = Uint8Array.from({ length: 32 }, (_, i) => i)
1821 const timestamp = () => BigInt(Math.floor((Date.now() - Date.UTC(2015, 0, 1)) * 100))
1922 const peerSigning = signed ? createMavlinkSigning({ key, linkId: 1, timestamp }) : undefined
20 const peer = createSocket('udp4')
21 let clientPort: number | null = null, sequence = 0, dropped = 0, integer = 42, peerError: Error | null = null
23 let sequence = 0, dropped = 0, integer = 42, peerError: Error | null = null
2224 let mission: Required<MissionWaypoint>[] = [], staged: Required<MissionWaypoint>[] = [], uploadCount = 0
2325 const confirmations: number[] = []
2426 const send = (id: number, payload: Uint8Array) => {
25 if (clientPort !== null) peer.send(encodeFrameV2(id, payload, sequence++ % 256, 1, 1, peerSigning), clientPort, '127.0.0.1')
27 peer.send(encodeFrameV2(id, payload, sequence++ % 256, 1, 1, peerSigning))
2628 }
2729 const ack = (command: number, result = 0) => { const p = new Uint8Array(10); new DataView(p.buffer).setUint16(0, command, true); p.set([result, 100], 2); p.set([245, 191], 8); send(77, p) }
2830 const heartbeat = () => { const p = new Uint8Array(9); p.set([2, driverProfiles[driver].autopilotId, 0, 4, 3], 4); send(0, p) }
2931 const value = () => { const p = new Uint8Array(25), v = new DataView(p.buffer); p.set(parameterBytes(integer, 6, encoding)); v.setUint16(4, 1, true); p.set(new TextEncoder().encode('BENCH_INT'), 8); p[24] = 6; send(22, p) }
30 peer.on('error', error => { peerError = error })
31 peer.on('message', (bytes, remote) => {
32 if (remote.address !== '127.0.0.1' || remote.port !== clientPort) return
32 function receive(frames: MavlinkFrame[]) {
3333 try {
34 for (const frame of decodeDatagram(bytes, peerSigning)) {
34 for (const frame of frames) {
3535 if (frame.systemId !== 245 || frame.componentId !== 191) continue
3636 const p = frame.payload, v = messageView(frame)
3737 if (frame.id === 76) {
@@ -82,15 +82,16 @@ export async function runDriverProtocolBench(options: { driver: AutopilotDriverI
8282 }
8383 }
8484 catch (error) { peerError = error instanceof Error ? error : new Error(String(error)) }
85 })
86 await new Promise<void>((resolve, reject) => { peer.once('error', reject); peer.bind(0, '127.0.0.1', () => { peer.off('error', reject); resolve() }) })
85 }
86 const peer = await createDriverBenchPeer(transport, peerSigning, receive, error => { peerError = error })
8787 const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 20000)
8888 let adapter: Awaited<ReturnType<typeof createArduPilotDriver>> | undefined
8989 let heartbeatTimer: ReturnType<typeof setInterval> | undefined
9090 try {
9191 const factory = driver === 'px4' ? createPx4Driver : createArduPilotDriver
92 adapter = await factory({ kind: 'sitl', listenPort: 0, sitlPort: peer.address().port, signing: signed ? { key, linkId: 2, timestamp } : undefined })
93 clientPort = adapter.localPort; heartbeat(); heartbeatTimer = setInterval(heartbeat, 250)
92 const signing = signed ? { key, linkId: 2, timestamp } : undefined
93 adapter = await factory(transport === 'tcp' ? { transport, kind: 'sitl', port: peer.port, signing } : { kind: 'sitl', listenPort: 0, sitlPort: peer.port, signing })
94 peer.start(adapter.localPort); heartbeat(); heartbeatTimer = setInterval(heartbeat, 250)
9495 const readyDeadline = performance.now() + 5000
9596 while (!Number.isFinite(adapter.protocol.snapshot().lastHeartbeatAtMs)) { if (performance.now() > readyDeadline || peerError) throw peerError ?? new Error('Fixture discovery heartbeat timeout'); await Bun.sleep(10) }
9697 const operation = { signal: controller.signal }, protocol = adapter.protocol
@@ -100,7 +101,7 @@ export async function runDriverProtocolBench(options: { driver: AutopilotDriverI
100101 const uploaded = await protocol.uploadMission(missionItems, operation), downloaded = await protocol.downloadMission(operation), resume = await protocol.resumeMission(1, operation)
101102 if (peerError) throw peerError
102103 if (JSON.stringify(downloaded) !== JSON.stringify(missionItems.map(normalizeWaypoint))) throw new Error('Fixture mission readback mismatch')
103 return { evidence: 'scripted-loopback-protocol-peer' as const, driver, signed, physicalFlightEnabled: false as const, firmwareSitlValidated: false as const, hardwareValidated: false as const, capabilities, rate, confirmations, droppedAcks: dropped, parameterBefore, parameterAfter, uploaded, downloaded, resume }
104 return { evidence: 'scripted-loopback-protocol-peer' as const, driver, transport, signed, physicalFlightEnabled: false as const, firmwareSitlValidated: false as const, hardwareValidated: false as const, capabilities, rate, confirmations, droppedAcks: dropped, parameterBefore, parameterAfter, uploaded, downloaded, resume }
104105 }
105 finally { clearTimeout(timeout); if (heartbeatTimer) clearInterval(heartbeatTimer); adapter?.close(); peer.close() }
106 finally { clearTimeout(timeout); if (heartbeatTimer) clearInterval(heartbeatTimer); adapter?.close(); await peer.close() }
106107}
resources/functions/drone/drivers/index.tsmodified+11-2
Changes to resources/functions/drone/drivers/index.ts
@@ -1,9 +1,18 @@
11import type { UdpOptions } from '../udp'
22import { createUdpAdapter } from '../udp'
3import type { TcpOptions } from '../tcp'
4import { createTcpAdapter } from '../tcp'
5
6export type AutopilotDriverOptions = (Omit<UdpOptions, 'driver'> & { transport?: 'udp' }) | (Omit<TcpOptions, 'driver'> & { transport: 'tcp' })
7function 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}
312
413/** Explicit factories prevent guessing an autopilot from whichever packet arrives first. */
5export const createArduPilotDriver = (options: Omit<UdpOptions, 'driver'>) => createUdpAdapter({ ...options, driver: 'ardupilot' })
6export const createPx4Driver = (options: Omit<UdpOptions, 'driver'>) => createUdpAdapter({ ...options, driver: 'px4' })
14export const createArduPilotDriver = (options: AutopilotDriverOptions) => createDriver('ardupilot', options)
15export const createPx4Driver = (options: AutopilotDriverOptions) => createDriver('px4', options)
716
817export type { AutopilotProtocol, DriverOperationOptions, CommandAcknowledgement, DriverProtocolError } from './protocol'
918export type { DriverCapabilities, DriverParameter, MissionWaypoint, ParameterType } from './messages'
resources/functions/drone/drivers/protocol.tsmodified+1-1
Changes to resources/functions/drone/drivers/protocol.ts
@@ -122,7 +122,7 @@ export function createDriverProtocol(options: { driver: AutopilotDriverId, kind:
122122 const api = {
123123 tick,
124124 close: () => close(),
125 transportLost: () => close('transport-lost', 'UDP transport failed'),
125 transportLost: (message = 'Autopilot transport failed') => close('transport-lost', message),
126126 snapshot() { return { driver, kind, targetSystem: systemId, targetComponent: componentId, lastHeartbeatAtMs: heartbeatAt, capabilities: capabilities ? { ...capabilities } : null, active: pending ? { key: pending.key, attempts: pending.attempts, deadlineMs: pending.deadline, inProgress: pending.inProgress } : null, fault: terminal?.message ?? null, quarantined: [...quarantine], knownMissionCount, physicalCommands: false as const } },
127127 receive(frame: MavlinkFrame) {
128128 if (terminal || frame.systemId !== systemId || frame.componentId !== componentId) return
resources/functions/drone/drivers/session.tsadded+95-0
Changes to resources/functions/drone/drivers/session.ts
@@ -0,0 +1,95 @@
1import type { Decision, FlightAdapter, Telemetry } from '../types'
2import type { MavlinkFrame } from '../mavlink'
3import type { MavlinkSigning } from './signing'
4import type { AutopilotDriverId } from './profiles'
5import type { AutopilotProtocol } from './protocol'
6import { zero, finiteVector } from '../geometry'
7import { encodeFrameV2, encodeVelocity } from '../mavlink'
8import { decodeAutopilotMode, driverProfiles } from './profiles'
9import { createDriverProtocol } from './protocol'
10
11export interface AutopilotSessionOptions {
12 driver?: AutopilotDriverId
13 kind: 'sitl' | 'telemetry-only'
14 systemId?: number
15 componentId?: number
16 now?: () => number
17 /** Strict MAVLink 2 signing. Unsigned input is rejected when configured. Never log the key. */
18 signing?: { key: Uint8Array, linkId: number, timestamp: () => bigint }
19}
20export interface AutopilotAdapter extends FlightAdapter {
21 readonly driver: AutopilotDriverId
22 readonly localPort: number
23 readonly protocol: AutopilotProtocol
24 /** One zero setpoint per call. Caller must sustain >= 5Hz during explicit SITL preparation. */
25 primeOffboard: () => { ready: boolean, reason: string }
26}
27
28/** Shared driver policy. A transport owns bytes, peer pinning, polling and resource cleanup. */
29export function createAutopilotSession(options: AutopilotSessionOptions, signing: MavlinkSigning | undefined, transport: { available(): boolean, write(bytes: Uint8Array): void }) {
30 const driver = options.driver ?? 'ardupilot', kind = options.kind
31 if (driver !== 'ardupilot' && driver !== 'px4') throw new TypeError('Unknown autopilot driver')
32 if (kind !== 'sitl' && kind !== 'telemetry-only') throw new TypeError('Unknown adapter mode')
33 const profile = driverProfiles[driver], now = options.now ?? (() => performance.now())
34 const systemId = options.systemId ?? 1, componentId = options.componentId ?? 1
35 if (![systemId, componentId].every(id => Number.isInteger(id) && id >= 1 && id <= 255)) throw new RangeError('Invalid autopilot identity')
36 let sequence = 0, closed = false, transportError = false
37 let heartbeatAt = -Infinity, positionAt = -Infinity, batteryAt = -Infinity, primeStarted = -Infinity, lastSetpointAt = -Infinity
38 let telemetry: Telemetry = { position: zero(), velocity: zero(), batteryPercent: -1, positionHealthy: false, armed: false, mode: 'manual', receivedAtMs: -Infinity }
39 const protocol = createDriverProtocol({ driver, kind, systemId, componentId, now, transmit(id, payload) {
40 if (closed || transportError || kind !== 'sitl' || !transport.available()) throw new Error('No writable simulator peer')
41 transport.write(encodeFrameV2(id, payload, sequence++ % 256, 245, 191, signing))
42 } })
43 function transportLost(message?: string) { transportError = true; protocol.transportLost(message) }
44 function read(): Telemetry | undefined {
45 if (closed || transportError) return undefined
46 telemetry = { ...telemetry, receivedAtMs: Math.min(heartbeatAt, positionAt, batteryAt), positionHealthy: kind === 'sitl' }
47 return structuredClone(telemetry)
48 }
49 function transmit(velocity: Telemetry['velocity']) {
50 const timestamp = now()
51 const frame = encodeVelocity(velocity, { sequence: sequence++ % 256, targetSystem: systemId, targetComponent: componentId, bootMs: timestamp, signing })
52 try { transport.write(frame); lastSetpointAt = timestamp }
53 catch { transportLost() }
54 }
55 return {
56 kind, driver, read, protocol, transportLost,
57 receive(frame: MavlinkFrame): boolean {
58 if (closed || transportError || frame.systemId !== systemId || frame.componentId !== componentId) return false
59 const view = new DataView(frame.payload.buffer, frame.payload.byteOffset, frame.payload.byteLength), receivedAt = now()
60 if (frame.id === 0) {
61 if (view.getUint8(5) !== profile.autopilotId || view.getUint8(4) !== profile.vehicleType) return false
62 heartbeatAt = receivedAt
63 telemetry.armed = (view.getUint8(6) & 128) !== 0
64 telemetry.mode = decodeAutopilotMode(driver, view.getUint32(0, true))
65 }
66 else if (frame.id === 1) { batteryAt = receivedAt; telemetry.batteryPercent = view.getInt8(30) }
67 else if (frame.id === 32) {
68 positionAt = receivedAt
69 telemetry.position = { north: view.getFloat32(4, true), east: view.getFloat32(8, true), down: view.getFloat32(12, true) }
70 telemetry.velocity = { north: view.getFloat32(16, true), east: view.getFloat32(20, true), down: view.getFloat32(24, true) }
71 }
72 protocol.receive(frame)
73 return true
74 },
75 primeOffboard() {
76 if (driver !== 'px4' || kind !== 'sitl') throw new Error('Offboard priming is supported only by the PX4 SITL driver')
77 const timestamp = now(), t = read()
78 if (!t || protocol.snapshot().fault || !transport.available() || !Number.isFinite(timestamp) || t.receivedAtMs > timestamp
79 || timestamp - t.receivedAtMs > 1000 || !finiteVector(t.position) || !finiteVector(t.velocity)
80 || t.batteryPercent < 25 || t.batteryPercent > 100 || t.mode === 'rtl' || t.mode === 'land') {
81 primeStarted = -Infinity
82 return { ready: false, reason: 'Fresh simulator telemetry and battery reserve required' }
83 }
84 if (timestamp < lastSetpointAt || timestamp - lastSetpointAt > 400 || !Number.isFinite(primeStarted)) primeStarted = timestamp
85 transmit(zero())
86 return { ready: !transportError && timestamp - primeStarted >= 1500, reason: 'Stream zero-velocity setpoints before selecting PX4 Offboard' }
87 },
88 send(decision: Decision) {
89 if (closed || transportError || protocol.snapshot().fault || kind !== 'sitl' || !transport.available() || decision.kind === 'release') return
90 if (driver === 'px4' && (now() - lastSetpointAt > 400 || now() < lastSetpointAt || now() - primeStarted < 1500 || !Number.isFinite(primeStarted) || telemetry.mode !== 'guided')) return
91 transmit(decision.kind === 'velocity' ? decision.velocity : zero())
92 },
93 close() { if (!closed) { closed = true; protocol.close() } },
94 }
95}
resources/functions/drone/mavlink.tsmodified+30-0
Changes to resources/functions/drone/mavlink.ts
@@ -91,6 +91,36 @@ export function decodeDatagram(bytes: Uint8Array, signing?: MavlinkSigning): Mav
9191 return result
9292}
9393
94/** Bounded byte-stream framing. Partial frames belong to exactly one connection. */
95export function createMavlinkStreamDecoder(signing?: MavlinkSigning) {
96 // MAVLink 2: 10-byte header, 255-byte payload, CRC and 13-byte signature.
97 const pending = new Uint8Array(280)
98 let used = 0
99 function discard(count: number) { pending.copyWithin(0, count, used); used -= count }
100 return {
101 get retainedBytes() { return used },
102 reset() { used = 0 },
103 push(bytes: Uint8Array): MavlinkFrame[] {
104 if (!(bytes instanceof Uint8Array) || bytes.length > 65_536) throw new RangeError('MAVLink stream chunk exceeds 65536 bytes')
105 const frames: MavlinkFrame[] = []
106 for (const byte of bytes) {
107 pending[used++] = byte
108 while (used > 0) {
109 const v2 = pending[0] === 0xFD
110 if (!v2 && pending[0] !== 0xFE) { discard(1); continue }
111 if (used < (v2 ? 3 : 2)) break
112 const length = pending[1]! + (v2 ? 12 + ((pending[2]! & 1) ? 13 : 0) : 8)
113 if (used < length) break
114 const decoded = decodeDatagram(pending.subarray(0, length), signing)
115 if (decoded.length === 1) { frames.push(decoded[0]!); discard(length) }
116 else discard(1) // Resynchronize after corrupt length/CRC, retaining any following frame.
117 }
118 }
119 return frames
120 },
121 }
122}
123
94124/** LOCAL_NED velocity-only setpoint. Does not arm, take off, land or change modes. */
95125export function encodeVelocity(velocity: Vector3, options: { sequence?: number, targetSystem?: number, targetComponent?: number, bootMs?: number, signing?: MavlinkSigning } = {}): Uint8Array {
96126 if (![velocity.north, velocity.east, velocity.down].every(v => Number.isFinite(v) && Math.abs(v) <= 20))
resources/functions/drone/tcp.tsadded+85-0
Changes to resources/functions/drone/tcp.ts
@@ -0,0 +1,85 @@
1import type { Socket } from 'node:net'
2import { createConnection } from 'node:net'
3import type { AutopilotAdapter, AutopilotSessionOptions } from './drivers/session'
4import { createAutopilotSession } from './drivers/session'
5import { createMavlinkSigning } from './drivers/signing'
6import { createMavlinkStreamDecoder } from './mavlink'
7
8export interface TcpOptions extends AutopilotSessionOptions { port: number }
9
10/** One explicit loopback connection. Reconnect by creating a new adapter and reconciling state. */
11export 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}
resources/functions/drone/udp.tsmodified+25-114
Changes to resources/functions/drone/udp.ts
@@ -1,136 +1,47 @@
1import type { Decision, FlightAdapter, Telemetry } from './types'
21import { createSocket } from 'node:dgram'
3import type { AutopilotProtocol } from './drivers/protocol'
4import { createDriverProtocol } from './drivers/protocol'
2import type { AutopilotAdapter, AutopilotSessionOptions } from './drivers/session'
3import { createAutopilotSession } from './drivers/session'
54import { createMavlinkSigning } from './drivers/signing'
6import { decodeDatagram, encodeFrameV2, encodeVelocity } from './mavlink'
7import { zero } from './geometry'
8import { finiteVector } from './geometry'
9import type { AutopilotDriverId } from './drivers/profiles'
10import { decodeAutopilotMode, driverProfiles } from './drivers/profiles'
5import { decodeDatagram } from './mavlink'
116
12export interface UdpOptions {
13 driver?: AutopilotDriverId
14 kind: 'sitl' | 'telemetry-only'
7export type { AutopilotAdapter } from './drivers/session'
8export interface UdpOptions extends AutopilotSessionOptions {
159 listenPort: number
1610 /** Fixed loopback destination. Never point a SITL forwarder at a physical vehicle. */
1711 sitlPort?: number
18 systemId?: number
19 componentId?: number
20 now?: () => number
21 /** Strict MAVLink 2 signing. Unsigned input is rejected when configured. Never log the key. */
22 signing?: { key: Uint8Array, linkId: number, timestamp: () => bigint }
23}
24export interface AutopilotAdapter extends FlightAdapter {
25 readonly driver: AutopilotDriverId
26 readonly localPort: number
27 readonly protocol: AutopilotProtocol
28 /** One zero setpoint per call. Caller must sustain >= 5Hz during explicit SITL preparation. */
29 primeOffboard: () => { ready: boolean, reason: string }
3012}
3113
3214/** Local MAVProxy/mavlink-router endpoint. Physical hardware is receive-only. */
3315export async function createUdpAdapter(options: UdpOptions): Promise<AutopilotAdapter> {
34 const driver = options.driver ?? 'ardupilot'
35 if (driver !== 'ardupilot' && driver !== 'px4') throw new TypeError('Unknown autopilot driver')
36 const profile = driverProfiles[driver]
37 const kind = options.kind
38 if (kind !== 'sitl' && kind !== 'telemetry-only') throw new TypeError('Unknown UDP adapter mode')
3916 const portValid = (port: number): boolean => Number.isInteger(port) && port > 0 && port <= 65535
40 if (!(options.listenPort === 0 || portValid(options.listenPort)) || (options.sitlPort !== undefined && !portValid(options.sitlPort)))
41 throw new RangeError('Invalid UDP port')
42 const now = options.now ?? (() => performance.now())
43 const systemId = options.systemId ?? 1
44 const componentId = options.componentId ?? 1
45 if (![systemId, componentId].every(id => Number.isInteger(id) && id >= 1 && id <= 255)) {
46 throw new RangeError('Invalid autopilot identity')
47 }
17 if (!(options.listenPort === 0 || portValid(options.listenPort)) || (options.sitlPort !== undefined && !portValid(options.sitlPort))) throw new RangeError('Invalid UDP port')
4818 const signing = options.signing ? createMavlinkSigning(options.signing) : undefined
19 let peerPort = options.sitlPort, closed = false
4920 const socket = createSocket('udp4')
50 let peerPort = options.sitlPort
51 let sequence = 0
52 let closed = false
53 let transportError = false
54 let heartbeatAt = -Infinity
55 let positionAt = -Infinity
56 let batteryAt = -Infinity
57 let primeStarted = -Infinity
58 let lastSetpointAt = -Infinity
59 let telemetry: Telemetry = { position: zero(), velocity: zero(), batteryPercent: -1, positionHealthy: false, armed: false, mode: 'manual', receivedAtMs: -Infinity }
60 const protocol = createDriverProtocol({ driver, kind, systemId, componentId, now, transmit(id, payload) {
61 if (closed || transportError || kind !== 'sitl' || peerPort === undefined) throw new Error('No writable simulator peer')
62 socket.send(encodeFrameV2(id, payload, sequence++ % 256, 245, 191, signing), peerPort, '127.0.0.1', (error) => { if (error) { transportError = true; protocol.transportLost() } })
63 } })
64 socket.on('error', () => { transportError = true; protocol.transportLost() })
21 const session = createAutopilotSession(options, signing, {
22 available: () => !closed && peerPort !== undefined,
23 write(bytes) { socket.send(bytes, peerPort!, '127.0.0.1', error => { if (error) session.transportLost() }) },
24 })
25 socket.on('error', () => session.transportLost())
6526 socket.on('message', (bytes, remote) => {
66 if (remote.address !== '127.0.0.1') return
67 if (peerPort !== undefined && remote.port !== peerPort) return
27 if (remote.address !== '127.0.0.1' || (peerPort !== undefined && remote.port !== peerPort)) return
6828 for (const frame of decodeDatagram(bytes, signing)) {
69 if (frame.systemId !== systemId || frame.componentId !== componentId) continue
7029 if (peerPort === undefined && frame.id !== 0) continue
71 const view = new DataView(frame.payload.buffer, frame.payload.byteOffset, frame.payload.byteLength)
72 const receivedAt = now()
73 if (frame.id === 0) {
74 if (view.getUint8(5) !== profile.autopilotId || view.getUint8(4) !== profile.vehicleType) continue
75 peerPort ??= remote.port
76 heartbeatAt = receivedAt
77 const mode = view.getUint32(0, true)
78 telemetry.armed = (view.getUint8(6) & 128) !== 0
79 telemetry.mode = decodeAutopilotMode(driver, mode)
80 }
81 else if (frame.id === 1) {
82 batteryAt = receivedAt
83 telemetry.batteryPercent = view.getInt8(30)
84 }
85 else if (frame.id === 32) {
86 positionAt = receivedAt
87 telemetry.position = { north: view.getFloat32(4, true), east: view.getFloat32(8, true), down: view.getFloat32(12, true) }
88 telemetry.velocity = { north: view.getFloat32(16, true), east: view.getFloat32(20, true), down: view.getFloat32(24, true) }
89 }
90 protocol.receive(frame)
30 if (session.receive(frame) && frame.id === 0) peerPort ??= remote.port
9131 }
9232 })
93 await new Promise<void>((resolve, reject) => {
94 socket.once('error', reject)
95 socket.bind(options.listenPort, '127.0.0.1', () => { socket.off('error', reject); resolve() })
96 })
97 const timer = setInterval(() => protocol.tick(), 25)
98 timer.unref()
99 function read(): Telemetry | undefined {
100 if (closed || transportError) return undefined
101 telemetry = { ...telemetry, receivedAtMs: Math.min(heartbeatAt, positionAt, batteryAt), positionHealthy: kind === 'sitl' }
102 return structuredClone(telemetry)
103 }
104 function transmit(velocity: Telemetry['velocity']) {
105 const timestamp = now()
106 const frame = encodeVelocity(velocity, { sequence: sequence++ % 256, targetSystem: systemId, targetComponent: componentId, bootMs: timestamp, signing })
107 socket.send(frame, peerPort!, '127.0.0.1', (error) => { if (error) transportError = true })
108 lastSetpointAt = timestamp
33 try {
34 await new Promise<void>((resolve, reject) => {
35 socket.once('error', reject)
36 socket.bind(options.listenPort, '127.0.0.1', () => { socket.off('error', reject); resolve() })
37 })
10938 }
39 catch (error) { session.close(); try { socket.close() } catch {} throw error }
40 const timer = setInterval(() => session.protocol.tick(), 25)
41 timer.unref()
11042 return {
111 kind, driver, read, protocol, localPort: socket.address().port,
112 primeOffboard() {
113 if (driver !== 'px4' || kind !== 'sitl') throw new Error('Offboard priming is supported only by the PX4 SITL driver')
114 const timestamp = now()
115 const t = read()
116 if (!t || protocol.snapshot().fault || peerPort === undefined || !Number.isFinite(timestamp) || t.receivedAtMs > timestamp
117 || timestamp - t.receivedAtMs > 1000 || !finiteVector(t.position) || !finiteVector(t.velocity)
118 || t.batteryPercent < 25 || t.batteryPercent > 100 || t.mode === 'rtl' || t.mode === 'land') {
119 primeStarted = -Infinity
120 return { ready: false, reason: 'Fresh simulator telemetry and battery reserve required' }
121 }
122 if (timestamp < lastSetpointAt || timestamp - lastSetpointAt > 400 || !Number.isFinite(primeStarted)) primeStarted = timestamp
123 transmit(zero())
124 return { ready: timestamp - primeStarted >= 1500, reason: 'Stream zero-velocity setpoints before selecting PX4 Offboard' }
125 },
126 send(decision: Decision) {
127 if (closed || transportError || protocol.snapshot().fault || kind !== 'sitl' || peerPort === undefined) return
128 // Release stops the setpoint stream. The autopilot's configured and tested timeout
129 // must own link-loss handling, including process crashes and power loss.
130 if (decision.kind === 'release') return
131 if (driver === 'px4' && (now() - lastSetpointAt > 400 || now() < lastSetpointAt || now() - primeStarted < 1500 || !Number.isFinite(primeStarted) || telemetry.mode !== 'guided')) return
132 transmit(decision.kind === 'velocity' ? decision.velocity : zero())
133 },
134 close() { if (!closed) { closed = true; clearInterval(timer); protocol.close(); socket.close() } },
43 kind: session.kind, driver: session.driver, read: session.read, protocol: session.protocol, localPort: socket.address().port,
44 send: session.send, primeOffboard: session.primeOffboard,
45 close() { if (!closed) { closed = true; clearInterval(timer); session.close(); socket.close() } },
13546 }
13647}
tests/fixtures/drivers/evaluate.tsmodified+3-1
Changes to tests/fixtures/drivers/evaluate.ts
@@ -1,7 +1,9 @@
11import { runDriverProtocolBench } from '../../../resources/functions/drone/drivers/bench'
22const results = []
33for (const driver of ['ardupilot', 'px4'] as const) {
4 for (const signed of [false, true]) results.push(await runDriverProtocolBench({ driver, signed, dropFirstAck: true }))
4 for (const transport of ['udp', 'tcp'] as const) {
5 for (const signed of [false, true]) results.push(await runDriverProtocolBench({ driver, transport, signed, dropFirstAck: true }))
6 }
57}
68const destination = process.argv[2] ?? new URL('../../../docs/driver-protocol-evidence.json', import.meta.url).pathname
79await Bun.write(destination, `${JSON.stringify({ evidence: 'scripted-loopback-protocol-conformance', firmwareSitlValidated: false, hardwareValidated: false, results }, (key, value) => typeof value === 'bigint' ? String(value) : value, 2)}\n`)
tests/tsconfig.runtime.jsonmodified+3-0
Changes to tests/tsconfig.runtime.json
@@ -5,8 +5,11 @@
55 "../bin/drone.ts",
66 "../resources/functions/drone/drivers/**/*.ts",
77 "../resources/functions/drone/udp.ts",
8 "../resources/functions/drone/tcp.ts",
89 "../resources/functions/drone/mavlink.ts",
910 "./unit/driver-protocol.test.ts",
11 "./unit/driver-tcp.test.ts",
12 "./unit/mavlink-stream.test.ts",
1013 "../scripts/build-runtime-cli.ts",
1114 "../scripts/qualify-public-safety-managed-budget.ts",
1215 "../resources/functions/drone/runtime/**/*.ts",
tests/unit/driver-tcp.test.tsadded+221-0
Changes to tests/unit/driver-tcp.test.ts
@@ -0,0 +1,221 @@
1import type { Socket } from 'node:net'
2import { createServer } from 'node:net'
3import { expect, test } from 'bun:test'
4import { createArduPilotDriver, createPx4Driver } from '../../resources/functions/drone/drivers'
5import { createMavlinkStreamDecoder, encodeFrame, encodeFrameV2 } from '../../resources/functions/drone/mavlink'
6import { runDriverProtocolBench } from '../../resources/functions/drone/drivers/bench'
7import vectors from '../fixtures/drivers/wire.json'
8import telemetryVectors from '../fixtures/mavlink.json'
9
10async 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}
14async 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}
22function 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
29test('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
55test('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
65for (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
82async 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
100test('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
130test('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
160test('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
167test('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
185test('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
205test('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})