@totemsdk/act-mavlink 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +60 -0
- package/README.md +108 -2
- package/dist/adapter.d.ts +22 -0
- package/dist/adapter.js +403 -0
- package/dist/cjs/adapter.d.ts +22 -0
- package/dist/cjs/adapter.js +406 -0
- package/dist/cjs/conformance.d.ts +28 -0
- package/dist/cjs/conformance.js +122 -0
- package/dist/cjs/effects.d.ts +16 -0
- package/dist/cjs/effects.js +19 -0
- package/dist/cjs/envelope.d.ts +38 -0
- package/dist/cjs/envelope.js +98 -0
- package/dist/cjs/index.d.ts +19 -0
- package/dist/cjs/index.js +31 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/profile.d.ts +36 -0
- package/dist/cjs/profile.js +167 -0
- package/dist/cjs/transport.d.ts +56 -0
- package/dist/cjs/transport.js +33 -0
- package/dist/conformance.d.ts +28 -0
- package/dist/conformance.js +119 -0
- package/dist/effects.d.ts +16 -0
- package/dist/effects.js +16 -0
- package/dist/envelope.d.ts +38 -0
- package/dist/envelope.js +92 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +13 -0
- package/dist/profile.d.ts +36 -0
- package/dist/profile.js +159 -0
- package/dist/transport.d.ts +56 -0
- package/dist/transport.js +28 -0
- package/package.json +72 -3
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-025 §5.3 — governed MAVLink operations.
|
|
4
|
+
*
|
|
5
|
+
* `startMission` carries the authorized envelope digest; a start whose
|
|
6
|
+
* recomputed digest differs from the authorized one fails closed before
|
|
7
|
+
* dispatch. Acceptance is never completion: `startMission` reconciles through
|
|
8
|
+
* vehicle telemetry and only reports `confirmed` on observed arrival.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.createMavlinkDefinitions = createMavlinkDefinitions;
|
|
12
|
+
const act_core_1 = require("@totemsdk/act-core");
|
|
13
|
+
const effects_js_1 = require("./effects.js");
|
|
14
|
+
const envelope_js_1 = require("./envelope.js");
|
|
15
|
+
const profile_js_1 = require("./profile.js");
|
|
16
|
+
const transport_js_1 = require("./transport.js");
|
|
17
|
+
function decimal(value) {
|
|
18
|
+
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(9)));
|
|
19
|
+
}
|
|
20
|
+
function quantityValue(value, unit, units) {
|
|
21
|
+
if (value === undefined || value === null)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
24
|
+
return value;
|
|
25
|
+
if (typeof value === 'object' && 'value' in value && 'unit' in value) {
|
|
26
|
+
return units.convert(value, unit).value;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
function numberParameter(value) {
|
|
31
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
32
|
+
return value;
|
|
33
|
+
throw new transport_js_1.MavlinkTransportError('Invalid', `expected a number, got '${String(value)}'`);
|
|
34
|
+
}
|
|
35
|
+
function stringParameter(value) {
|
|
36
|
+
if (typeof value === 'string' && value.length > 0)
|
|
37
|
+
return value;
|
|
38
|
+
throw new transport_js_1.MavlinkTransportError('Invalid', `expected a non-empty string, got '${String(value)}'`);
|
|
39
|
+
}
|
|
40
|
+
function waypointIssues(value) {
|
|
41
|
+
if (!Array.isArray(value) || value.length === 0)
|
|
42
|
+
return 'at least one waypoint is required';
|
|
43
|
+
if (value.length > 100)
|
|
44
|
+
return 'mission exceeds the 100-waypoint limit';
|
|
45
|
+
for (const [index, entry] of value.entries()) {
|
|
46
|
+
if (!entry || typeof entry !== 'object')
|
|
47
|
+
return `waypoint ${index} must be an object`;
|
|
48
|
+
const waypoint = entry;
|
|
49
|
+
if (typeof waypoint.latitudeDeg !== 'number')
|
|
50
|
+
return `waypoint ${index} latitudeDeg must be a number`;
|
|
51
|
+
if (typeof waypoint.longitudeDeg !== 'number')
|
|
52
|
+
return `waypoint ${index} longitudeDeg must be a number`;
|
|
53
|
+
if (typeof waypoint.altitudeMetres !== 'number')
|
|
54
|
+
return `waypoint ${index} altitudeMetres must be a number`;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function createMavlinkDefinitions(options) {
|
|
59
|
+
const units = options.units ?? (0, act_core_1.defaultUnitRegistry)();
|
|
60
|
+
const taxonomy = (0, profile_js_1.mavlinkErrorTaxonomy)();
|
|
61
|
+
const transport = options.transport;
|
|
62
|
+
const arrivalTolerance = options.arrivalToleranceMetres ?? 15;
|
|
63
|
+
function vehicleOf(vehicleId) {
|
|
64
|
+
const vehicle = options.vehicles.find((candidate) => candidate.vehicleId === vehicleId);
|
|
65
|
+
if (!vehicle) {
|
|
66
|
+
throw new transport_js_1.MavlinkTransportError('Invalid', `vehicle '${vehicleId}' is not registered`);
|
|
67
|
+
}
|
|
68
|
+
return vehicle;
|
|
69
|
+
}
|
|
70
|
+
function resourceFor(vehicleId) {
|
|
71
|
+
return (0, act_core_1.resourceIdKey)((0, profile_js_1.vehicleResourceId)(options.site, vehicleId));
|
|
72
|
+
}
|
|
73
|
+
function envelopeFrom(params) {
|
|
74
|
+
const vehicleId = stringParameter(params.vehicle);
|
|
75
|
+
vehicleOf(vehicleId);
|
|
76
|
+
const altitude = quantityValue(params.maxAltitudeMetres, 'm', units);
|
|
77
|
+
const speed = quantityValue(params.maxSpeedMetresPerSecond, 'm/s', units);
|
|
78
|
+
const duration = quantityValue(params.maxDurationSeconds, 's', units);
|
|
79
|
+
if (altitude === undefined)
|
|
80
|
+
throw new transport_js_1.MavlinkTransportError('Invalid', 'maxAltitudeMetres is required');
|
|
81
|
+
if (speed === undefined)
|
|
82
|
+
throw new transport_js_1.MavlinkTransportError('Invalid', 'maxSpeedMetresPerSecond is required');
|
|
83
|
+
if (duration === undefined)
|
|
84
|
+
throw new transport_js_1.MavlinkTransportError('Invalid', 'maxDurationSeconds is required');
|
|
85
|
+
const geofenceLatitude = params.geofenceLatitudeDeg;
|
|
86
|
+
const geofenceLongitude = params.geofenceLongitudeDeg;
|
|
87
|
+
const geofenceRadius = params.geofenceRadiusMetres;
|
|
88
|
+
const hasGeofence = geofenceLatitude !== undefined || geofenceLongitude !== undefined || geofenceRadius !== undefined;
|
|
89
|
+
if (hasGeofence && (geofenceLatitude === undefined || geofenceLongitude === undefined || geofenceRadius === undefined)) {
|
|
90
|
+
throw new transport_js_1.MavlinkTransportError('Invalid', 'geofence requires latitude, longitude and radius');
|
|
91
|
+
}
|
|
92
|
+
const envelope = {
|
|
93
|
+
envelopeId: stringParameter(params.envelopeId),
|
|
94
|
+
vehicle: vehicleId,
|
|
95
|
+
waypoints: params.waypoints,
|
|
96
|
+
maxAltitudeMetres: decimal(altitude),
|
|
97
|
+
maxSpeedMetresPerSecond: decimal(speed),
|
|
98
|
+
maxDurationSeconds: decimal(duration),
|
|
99
|
+
...(hasGeofence
|
|
100
|
+
? {
|
|
101
|
+
geofence: {
|
|
102
|
+
latitudeDeg: numberParameter(geofenceLatitude),
|
|
103
|
+
longitudeDeg: numberParameter(geofenceLongitude),
|
|
104
|
+
radiusMetres: numberParameter(geofenceRadius),
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
: {}),
|
|
108
|
+
};
|
|
109
|
+
(0, envelope_js_1.assertValidMissionEnvelope)(envelope);
|
|
110
|
+
return envelope;
|
|
111
|
+
}
|
|
112
|
+
function envelopeParameters() {
|
|
113
|
+
return [
|
|
114
|
+
{ name: 'envelopeId', type: 'string', required: true, description: 'Authorized plan id.' },
|
|
115
|
+
{ name: 'vehicle', type: 'string', required: true, description: 'Vehicle id.' },
|
|
116
|
+
{
|
|
117
|
+
name: 'waypoints', type: 'array', required: true,
|
|
118
|
+
description: 'Ordered WGS-84 waypoints (latitudeDeg, longitudeDeg, altitudeMetres).',
|
|
119
|
+
validation: waypointIssues,
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'maxAltitudeMetres', type: 'quantity', required: true,
|
|
123
|
+
dimension: 'length', unit: 'm', description: 'Altitude ceiling.',
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: 'maxSpeedMetresPerSecond', type: 'quantity', required: true,
|
|
127
|
+
dimension: 'linearSpeed', unit: 'm/s', description: 'Ground-speed ceiling.',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'maxDurationSeconds', type: 'quantity', required: true,
|
|
131
|
+
dimension: 'time', unit: 's', description: 'Mission duration ceiling.',
|
|
132
|
+
},
|
|
133
|
+
{ name: 'geofenceLatitudeDeg', type: 'number', required: false },
|
|
134
|
+
{ name: 'geofenceLongitudeDeg', type: 'number', required: false },
|
|
135
|
+
{ name: 'geofenceRadiusMetres', type: 'number', required: false },
|
|
136
|
+
];
|
|
137
|
+
}
|
|
138
|
+
function telemetryConfirmed(telemetry, check) {
|
|
139
|
+
return check(telemetry);
|
|
140
|
+
}
|
|
141
|
+
const definitions = [
|
|
142
|
+
{
|
|
143
|
+
kind: 'flight:uploadMission',
|
|
144
|
+
description: 'Upload an authorized mission plan to the autopilot (no dispatch of motion).',
|
|
145
|
+
version: 1,
|
|
146
|
+
capability: 'industrial:action',
|
|
147
|
+
effect: 'write',
|
|
148
|
+
protocol: profile_js_1.MAVLINK_PROTOCOL,
|
|
149
|
+
errorTaxonomy: taxonomy,
|
|
150
|
+
schema: { parameters: envelopeParameters(), context: [] },
|
|
151
|
+
// Uploading a plan moves nothing; a failed upload is simply aborted.
|
|
152
|
+
policy: { failureMode: 'abort', timeoutMs: 30000 },
|
|
153
|
+
async prepare(params) {
|
|
154
|
+
const envelope = envelopeFrom(params);
|
|
155
|
+
const digest = (0, envelope_js_1.envelopeDigest)(envelope);
|
|
156
|
+
return {
|
|
157
|
+
resourceId: resourceFor(envelope.vehicle),
|
|
158
|
+
command: { action: 'uploadMission', envelope, envelopeDigest: digest },
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
deriveEffects(op) {
|
|
162
|
+
const command = op.command;
|
|
163
|
+
return (0, effects_js_1.deriveFlightEffects)({
|
|
164
|
+
resource: resourceFor(command.envelope.vehicle),
|
|
165
|
+
maxSpeedMetresPerSecond: command.envelope.maxSpeedMetresPerSecond,
|
|
166
|
+
maxDurationSeconds: command.envelope.maxDurationSeconds,
|
|
167
|
+
envelopeId: command.envelope.envelopeId,
|
|
168
|
+
envelopeDigest: command.envelopeDigest,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
async actuate(op) {
|
|
172
|
+
const command = op.command;
|
|
173
|
+
const result = await transport.uploadMission({
|
|
174
|
+
envelopeId: command.envelope.envelopeId,
|
|
175
|
+
envelopeDigest: command.envelopeDigest,
|
|
176
|
+
waypoints: command.envelope.waypoints,
|
|
177
|
+
});
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
data: { missionId: result.missionId, envelopeDigest: command.envelopeDigest },
|
|
181
|
+
};
|
|
182
|
+
},
|
|
183
|
+
async: { confirmation: 'completed' },
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
kind: 'flight:startMission',
|
|
187
|
+
description: 'Start an uploaded mission that matches the authorized envelope digest.',
|
|
188
|
+
version: 1,
|
|
189
|
+
capability: 'industrial:action',
|
|
190
|
+
effect: 'write',
|
|
191
|
+
protocol: profile_js_1.MAVLINK_PROTOCOL,
|
|
192
|
+
errorTaxonomy: taxonomy,
|
|
193
|
+
schema: {
|
|
194
|
+
parameters: [
|
|
195
|
+
{ name: 'missionId', type: 'string', required: true, description: 'Autopilot mission id.' },
|
|
196
|
+
{ name: 'envelopeDigest', type: 'string', required: true, description: 'Authorized envelope digest.' },
|
|
197
|
+
...envelopeParameters(),
|
|
198
|
+
],
|
|
199
|
+
context: [],
|
|
200
|
+
},
|
|
201
|
+
policy: { failureMode: 'fail-safe', timeoutMs: 30000 },
|
|
202
|
+
safeState: async () => {
|
|
203
|
+
await transport.returnToLaunch();
|
|
204
|
+
return { ok: true };
|
|
205
|
+
},
|
|
206
|
+
async prepare(params) {
|
|
207
|
+
const envelope = envelopeFrom(params);
|
|
208
|
+
const digest = (0, envelope_js_1.envelopeDigest)(envelope);
|
|
209
|
+
(0, transport_js_1.assertEnvelopeBinding)(envelope, stringParameter(params.envelopeDigest));
|
|
210
|
+
return {
|
|
211
|
+
resourceId: resourceFor(envelope.vehicle),
|
|
212
|
+
command: {
|
|
213
|
+
action: 'startMission',
|
|
214
|
+
missionId: stringParameter(params.missionId),
|
|
215
|
+
envelopeDigest: digest,
|
|
216
|
+
envelope,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
deriveEffects(op) {
|
|
221
|
+
const command = op.command;
|
|
222
|
+
return (0, effects_js_1.deriveFlightEffects)({
|
|
223
|
+
resource: resourceFor(command.envelope.vehicle),
|
|
224
|
+
maxSpeedMetresPerSecond: command.envelope.maxSpeedMetresPerSecond,
|
|
225
|
+
maxDurationSeconds: command.envelope.maxDurationSeconds,
|
|
226
|
+
envelopeId: command.envelope.envelopeId,
|
|
227
|
+
envelopeDigest: command.envelopeDigest,
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
async actuate(op) {
|
|
231
|
+
const command = op.command;
|
|
232
|
+
await transport.startMission({
|
|
233
|
+
missionId: command.missionId,
|
|
234
|
+
envelopeDigest: command.envelopeDigest,
|
|
235
|
+
});
|
|
236
|
+
return { ok: true, data: { missionId: command.missionId, envelopeDigest: command.envelopeDigest } };
|
|
237
|
+
},
|
|
238
|
+
async: {
|
|
239
|
+
confirmation: 'accepted',
|
|
240
|
+
async reconcile(op) {
|
|
241
|
+
const command = op.command;
|
|
242
|
+
const telemetry = await transport.readTelemetry();
|
|
243
|
+
const final = command.envelope.waypoints.at(-1);
|
|
244
|
+
const handle = { missionId: command.missionId };
|
|
245
|
+
if (final && (0, envelope_js_1.distanceMetres)({ latitudeDeg: telemetry.latitudeDeg, longitudeDeg: telemetry.longitudeDeg, altitudeMetres: telemetry.altitudeMetres }, final) <= arrivalTolerance) {
|
|
246
|
+
return {
|
|
247
|
+
status: 'confirmed',
|
|
248
|
+
handle,
|
|
249
|
+
progress: { fraction: 1, detail: 'arrived at final waypoint', at: Date.now() },
|
|
250
|
+
result: { telemetry },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const fraction = typeof telemetry.waypointIndex === 'number' && command.envelope.waypoints.length > 0
|
|
254
|
+
? Math.min(1, telemetry.waypointIndex / command.envelope.waypoints.length)
|
|
255
|
+
: undefined;
|
|
256
|
+
return {
|
|
257
|
+
status: 'running',
|
|
258
|
+
handle,
|
|
259
|
+
progress: {
|
|
260
|
+
...(fraction !== undefined ? { fraction } : {}),
|
|
261
|
+
detail: `en route${typeof telemetry.waypointIndex === 'number' ? ` (waypoint ${telemetry.waypointIndex})` : ''}`,
|
|
262
|
+
at: Date.now(),
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
async cancel() {
|
|
267
|
+
await transport.hold();
|
|
268
|
+
await transport.returnToLaunch();
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
kind: 'flight:hold',
|
|
274
|
+
description: 'Hold position; the autopilot remains in control.',
|
|
275
|
+
version: 1,
|
|
276
|
+
capability: 'industrial:action',
|
|
277
|
+
effect: 'write',
|
|
278
|
+
protocol: profile_js_1.MAVLINK_PROTOCOL,
|
|
279
|
+
errorTaxonomy: taxonomy,
|
|
280
|
+
schema: { parameters: [], context: [] },
|
|
281
|
+
policy: { failureMode: 'fail-safe', timeoutMs: 15000 },
|
|
282
|
+
safeState: async () => {
|
|
283
|
+
await transport.returnToLaunch();
|
|
284
|
+
return { ok: true };
|
|
285
|
+
},
|
|
286
|
+
async prepare() {
|
|
287
|
+
return { command: { action: 'hold' } };
|
|
288
|
+
},
|
|
289
|
+
deriveEffects() {
|
|
290
|
+
return { spends: [], physical: { speed: [] } };
|
|
291
|
+
},
|
|
292
|
+
async actuate() {
|
|
293
|
+
await transport.hold();
|
|
294
|
+
return { ok: true };
|
|
295
|
+
},
|
|
296
|
+
async: {
|
|
297
|
+
confirmation: 'accepted',
|
|
298
|
+
async reconcile() {
|
|
299
|
+
const telemetry = await transport.readTelemetry();
|
|
300
|
+
const stopped = telemetryConfirmed(telemetry, (snapshot) => snapshot.groundspeedMetresPerSecond <= 0.2);
|
|
301
|
+
return stopped
|
|
302
|
+
? { status: 'confirmed', progress: { fraction: 1, detail: 'vehicle holding', at: Date.now() } }
|
|
303
|
+
: { status: 'running', progress: { detail: 'slowing to hold', at: Date.now() } };
|
|
304
|
+
},
|
|
305
|
+
async cancel() {
|
|
306
|
+
await transport.returnToLaunch();
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
kind: 'flight:returnToLaunch',
|
|
312
|
+
description: 'Return to launch; completion is observed from telemetry.',
|
|
313
|
+
version: 1,
|
|
314
|
+
capability: 'industrial:action',
|
|
315
|
+
effect: 'write',
|
|
316
|
+
protocol: profile_js_1.MAVLINK_PROTOCOL,
|
|
317
|
+
errorTaxonomy: taxonomy,
|
|
318
|
+
schema: { parameters: [], context: [] },
|
|
319
|
+
policy: { failureMode: 'fail-safe', timeoutMs: 15000 },
|
|
320
|
+
safeState: async () => {
|
|
321
|
+
await transport.land();
|
|
322
|
+
return { ok: true };
|
|
323
|
+
},
|
|
324
|
+
async prepare() {
|
|
325
|
+
return { command: { action: 'returnToLaunch' } };
|
|
326
|
+
},
|
|
327
|
+
deriveEffects() {
|
|
328
|
+
return { spends: [] };
|
|
329
|
+
},
|
|
330
|
+
async actuate() {
|
|
331
|
+
await transport.returnToLaunch();
|
|
332
|
+
return { ok: true };
|
|
333
|
+
},
|
|
334
|
+
async: {
|
|
335
|
+
confirmation: 'accepted',
|
|
336
|
+
async reconcile() {
|
|
337
|
+
const telemetry = await transport.readTelemetry();
|
|
338
|
+
const home = telemetry.missionFinished === true && telemetry.groundspeedMetresPerSecond <= 0.5;
|
|
339
|
+
return home
|
|
340
|
+
? { status: 'confirmed', progress: { fraction: 1, detail: 'returned to launch', at: Date.now() } }
|
|
341
|
+
: { status: 'running', progress: { detail: 'returning to launch', at: Date.now() } };
|
|
342
|
+
},
|
|
343
|
+
async cancel() {
|
|
344
|
+
await transport.land();
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
kind: 'flight:land',
|
|
350
|
+
description: 'Land at the current position.',
|
|
351
|
+
version: 1,
|
|
352
|
+
capability: 'industrial:action',
|
|
353
|
+
effect: 'write',
|
|
354
|
+
protocol: profile_js_1.MAVLINK_PROTOCOL,
|
|
355
|
+
errorTaxonomy: taxonomy,
|
|
356
|
+
schema: { parameters: [], context: [] },
|
|
357
|
+
policy: { failureMode: 'fail-safe', timeoutMs: 15000 },
|
|
358
|
+
safeState: async () => {
|
|
359
|
+
await transport.land();
|
|
360
|
+
return { ok: true };
|
|
361
|
+
},
|
|
362
|
+
async prepare() {
|
|
363
|
+
return { command: { action: 'land' } };
|
|
364
|
+
},
|
|
365
|
+
deriveEffects() {
|
|
366
|
+
return { spends: [] };
|
|
367
|
+
},
|
|
368
|
+
async actuate() {
|
|
369
|
+
await transport.land();
|
|
370
|
+
return { ok: true };
|
|
371
|
+
},
|
|
372
|
+
async: {
|
|
373
|
+
confirmation: 'accepted',
|
|
374
|
+
async reconcile() {
|
|
375
|
+
const telemetry = await transport.readTelemetry();
|
|
376
|
+
return telemetry.armed === false
|
|
377
|
+
? { status: 'confirmed', progress: { fraction: 1, detail: 'landed and disarmed', at: Date.now() } }
|
|
378
|
+
: { status: 'running', progress: { detail: 'landing', at: Date.now() } };
|
|
379
|
+
},
|
|
380
|
+
async cancel() {
|
|
381
|
+
throw new transport_js_1.MavlinkTransportError('Unsupported', 'a landing cannot be cancelled; command return-to-launch from the operator console');
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
kind: 'flight:readTelemetry',
|
|
387
|
+
description: 'Read the vehicle telemetry snapshot used for reconciliation.',
|
|
388
|
+
version: 1,
|
|
389
|
+
capability: 'industrial:read',
|
|
390
|
+
effect: 'read',
|
|
391
|
+
protocol: profile_js_1.MAVLINK_PROTOCOL,
|
|
392
|
+
errorTaxonomy: taxonomy,
|
|
393
|
+
schema: { parameters: [], context: [] },
|
|
394
|
+
async prepare() {
|
|
395
|
+
return { command: { action: 'readTelemetry' } };
|
|
396
|
+
},
|
|
397
|
+
deriveEffects() {
|
|
398
|
+
return { spends: [] };
|
|
399
|
+
},
|
|
400
|
+
async actuate() {
|
|
401
|
+
return { ok: true, data: await transport.readTelemetry() };
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
];
|
|
405
|
+
return definitions;
|
|
406
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-025 §5.8 — autopilot conformance harness.
|
|
3
|
+
*
|
|
4
|
+
* Each supported autopilot (and each simulator) must pass these checks before
|
|
5
|
+
* it is offered in the catalogue. A second autopilot is added as a separate,
|
|
6
|
+
* tested profile — never by loosening the first.
|
|
7
|
+
*/
|
|
8
|
+
import { type MissionEnvelope } from './envelope.js';
|
|
9
|
+
import type { FlightVehicle } from './profile.js';
|
|
10
|
+
import type { MavlinkTransportPort } from './transport.js';
|
|
11
|
+
export interface MavlinkConformanceCheck {
|
|
12
|
+
name: string;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
detail?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface MavlinkConformanceReport {
|
|
17
|
+
vehicle: string;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
checks: MavlinkConformanceCheck[];
|
|
20
|
+
}
|
|
21
|
+
export interface MavlinkConformanceOptions {
|
|
22
|
+
site: string;
|
|
23
|
+
vehicles: readonly FlightVehicle[];
|
|
24
|
+
envelope: MissionEnvelope;
|
|
25
|
+
/** Distance from the final waypoint that counts as arrival. */
|
|
26
|
+
arrivalToleranceMetres?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function runMavlinkConformance(transport: MavlinkTransportPort, options: MavlinkConformanceOptions): Promise<MavlinkConformanceReport>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-025 §5.8 — autopilot conformance harness.
|
|
4
|
+
*
|
|
5
|
+
* Each supported autopilot (and each simulator) must pass these checks before
|
|
6
|
+
* it is offered in the catalogue. A second autopilot is added as a separate,
|
|
7
|
+
* tested profile — never by loosening the first.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.runMavlinkConformance = runMavlinkConformance;
|
|
11
|
+
const adapter_js_1 = require("./adapter.js");
|
|
12
|
+
const envelope_js_1 = require("./envelope.js");
|
|
13
|
+
async function runMavlinkConformance(transport, options) {
|
|
14
|
+
const checks = [];
|
|
15
|
+
const push = (name, ok, detail) => {
|
|
16
|
+
checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
|
|
17
|
+
};
|
|
18
|
+
const structural = (0, envelope_js_1.validateMissionEnvelope)(options.envelope);
|
|
19
|
+
push('envelope-valid', structural.length === 0, structural.join('; ') || undefined);
|
|
20
|
+
const outside = options.envelope.geofence
|
|
21
|
+
? (0, envelope_js_1.validateMissionEnvelope)({
|
|
22
|
+
...options.envelope,
|
|
23
|
+
waypoints: [
|
|
24
|
+
{
|
|
25
|
+
latitudeDeg: options.envelope.geofence.latitudeDeg + 1,
|
|
26
|
+
longitudeDeg: options.envelope.geofence.longitudeDeg,
|
|
27
|
+
altitudeMetres: 0,
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
: ['no geofence declared'];
|
|
32
|
+
push('geofence-rejection', outside.length > 0, outside.join('; ') || undefined);
|
|
33
|
+
const definitions = (0, adapter_js_1.createMavlinkDefinitions)({
|
|
34
|
+
transport,
|
|
35
|
+
site: options.site,
|
|
36
|
+
vehicles: options.vehicles,
|
|
37
|
+
...(options.arrivalToleranceMetres !== undefined
|
|
38
|
+
? { arrivalToleranceMetres: options.arrivalToleranceMetres }
|
|
39
|
+
: {}),
|
|
40
|
+
});
|
|
41
|
+
const upload = definitions.find((def) => def.kind === 'flight:uploadMission');
|
|
42
|
+
const start = definitions.find((def) => def.kind === 'flight:startMission');
|
|
43
|
+
if (!upload || !start) {
|
|
44
|
+
push('definitions-present', false, 'upload/start definitions missing');
|
|
45
|
+
return { vehicle: options.envelope.vehicle, ok: false, checks };
|
|
46
|
+
}
|
|
47
|
+
const uploadParams = paramsFromEnvelope(options.envelope);
|
|
48
|
+
const preparedUpload = await upload.prepare(uploadParams, {});
|
|
49
|
+
const uploaded = await upload.actuate(asPrepared(preparedUpload));
|
|
50
|
+
const missionId = uploaded.data?.missionId;
|
|
51
|
+
push('mission-upload', uploaded.ok && typeof missionId === 'string', missionId ?? uploaded.error);
|
|
52
|
+
const digest = (0, envelope_js_1.envelopeDigest)(options.envelope);
|
|
53
|
+
push('envelope-binding', uploaded.data?.envelopeDigest === digest, 'upload data must carry the authorized digest');
|
|
54
|
+
if (typeof missionId !== 'string') {
|
|
55
|
+
return { vehicle: options.envelope.vehicle, ok: checks.every((check) => check.ok), checks };
|
|
56
|
+
}
|
|
57
|
+
const preparedStart = await start.prepare({ ...uploadParams, missionId, envelopeDigest: digest }, {});
|
|
58
|
+
const started = await start.actuate(asPrepared(preparedStart));
|
|
59
|
+
push('mission-start', started.ok, started.error);
|
|
60
|
+
const telemetry = await transport.readTelemetry();
|
|
61
|
+
push('telemetry-shape', Number.isFinite(telemetry.latitudeDeg) &&
|
|
62
|
+
Number.isFinite(telemetry.longitudeDeg) &&
|
|
63
|
+
Number.isFinite(telemetry.altitudeMetres) &&
|
|
64
|
+
Number.isFinite(telemetry.groundspeedMetresPerSecond) &&
|
|
65
|
+
typeof telemetry.timestamp === 'string', telemetry.timestamp);
|
|
66
|
+
// Acceptance is not completion: unless the vehicle is already at the final
|
|
67
|
+
// waypoint, reconciliation must not report `confirmed`.
|
|
68
|
+
if (start.async?.reconcile) {
|
|
69
|
+
const progress = await start.async.reconcile(asPrepared(preparedStart), {
|
|
70
|
+
operationId: 'conformance',
|
|
71
|
+
status: 'accepted',
|
|
72
|
+
attempts: 1,
|
|
73
|
+
updatedAt: Date.now(),
|
|
74
|
+
result: started.data,
|
|
75
|
+
});
|
|
76
|
+
const final = options.envelope.waypoints.at(-1);
|
|
77
|
+
const arrived = final
|
|
78
|
+
? (0, envelope_js_1.distanceMetres)({
|
|
79
|
+
latitudeDeg: telemetry.latitudeDeg,
|
|
80
|
+
longitudeDeg: telemetry.longitudeDeg,
|
|
81
|
+
altitudeMetres: telemetry.altitudeMetres,
|
|
82
|
+
}, final) <= (options.arrivalToleranceMetres ?? 15)
|
|
83
|
+
: false;
|
|
84
|
+
push('acceptance-is-not-completion', arrived ? progress.status === 'confirmed' : progress.status !== 'confirmed', `telemetry arrival=${arrived}, reconcile=${progress.status}`);
|
|
85
|
+
}
|
|
86
|
+
push('safe-state-available', transport.returnToLaunch instanceof Function, 'return-to-launch must be available for fail-safe');
|
|
87
|
+
return { vehicle: options.envelope.vehicle, ok: checks.every((check) => check.ok), checks };
|
|
88
|
+
}
|
|
89
|
+
function asPrepared(prepared) {
|
|
90
|
+
return {
|
|
91
|
+
kind: 'conformance',
|
|
92
|
+
resourceId: prepared.resourceId,
|
|
93
|
+
command: prepared.command,
|
|
94
|
+
definitionVersion: 1,
|
|
95
|
+
schemaHash: 'conformance',
|
|
96
|
+
preparedCommandHash: 'conformance',
|
|
97
|
+
parameters: {},
|
|
98
|
+
context: {},
|
|
99
|
+
commitmentHash: 'conformance',
|
|
100
|
+
operationId: 'conformance',
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function paramsFromEnvelope(envelope) {
|
|
104
|
+
return {
|
|
105
|
+
envelopeId: envelope.envelopeId,
|
|
106
|
+
vehicle: envelope.vehicle,
|
|
107
|
+
waypoints: envelope.waypoints,
|
|
108
|
+
maxAltitudeMetres: { value: Number(envelope.maxAltitudeMetres), unit: 'm' },
|
|
109
|
+
maxSpeedMetresPerSecond: {
|
|
110
|
+
value: Number(envelope.maxSpeedMetresPerSecond),
|
|
111
|
+
unit: 'm/s',
|
|
112
|
+
},
|
|
113
|
+
maxDurationSeconds: { value: Number(envelope.maxDurationSeconds), unit: 's' },
|
|
114
|
+
...(envelope.geofence
|
|
115
|
+
? {
|
|
116
|
+
geofenceLatitudeDeg: envelope.geofence.latitudeDeg,
|
|
117
|
+
geofenceLongitudeDeg: envelope.geofence.longitudeDeg,
|
|
118
|
+
geofenceRadiusMetres: envelope.geofence.radiusMetres,
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-025 §5.5 — physical effects derived from the PREPARED mission command.
|
|
3
|
+
*
|
|
4
|
+
* Speed and duration are ceilings; the envelope reference binds the receipt to
|
|
5
|
+
* the exact plan that was authorized.
|
|
6
|
+
*/
|
|
7
|
+
import type { StepEffects } from '@totemsdk/agent-policy';
|
|
8
|
+
export interface FlightLimits {
|
|
9
|
+
/** Vehicle ResourceId key. */
|
|
10
|
+
resource: string;
|
|
11
|
+
maxSpeedMetresPerSecond: string;
|
|
12
|
+
maxDurationSeconds: string;
|
|
13
|
+
envelopeId: string;
|
|
14
|
+
envelopeDigest: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function deriveFlightEffects(limits: FlightLimits): StepEffects;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-025 §5.5 — physical effects derived from the PREPARED mission command.
|
|
4
|
+
*
|
|
5
|
+
* Speed and duration are ceilings; the envelope reference binds the receipt to
|
|
6
|
+
* the exact plan that was authorized.
|
|
7
|
+
*/
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.deriveFlightEffects = deriveFlightEffects;
|
|
10
|
+
function deriveFlightEffects(limits) {
|
|
11
|
+
return {
|
|
12
|
+
spends: [],
|
|
13
|
+
physical: {
|
|
14
|
+
speed: [{ resource: limits.resource, metresPerSecond: limits.maxSpeedMetresPerSecond }],
|
|
15
|
+
duration: [{ resource: limits.resource, seconds: limits.maxDurationSeconds }],
|
|
16
|
+
envelopes: [{ envelopeId: limits.envelopeId, envelopeDigest: limits.envelopeDigest }],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-025 §5.5 — mission envelope.
|
|
3
|
+
*
|
|
4
|
+
* The envelope is the unit of authority: `uploadMission` prepares and commits
|
|
5
|
+
* it, `startMission` is authorized against its digest, and RFC-023 enforces its
|
|
6
|
+
* speed/duration/altitude bounds from the prepared command. A start that
|
|
7
|
+
* references a different plan than the one authorized fails closed.
|
|
8
|
+
*/
|
|
9
|
+
export interface Waypoint {
|
|
10
|
+
latitudeDeg: number;
|
|
11
|
+
longitudeDeg: number;
|
|
12
|
+
altitudeMetres: number;
|
|
13
|
+
}
|
|
14
|
+
export interface Geofence {
|
|
15
|
+
latitudeDeg: number;
|
|
16
|
+
longitudeDeg: number;
|
|
17
|
+
radiusMetres: number;
|
|
18
|
+
}
|
|
19
|
+
export interface MissionEnvelope {
|
|
20
|
+
envelopeId: string;
|
|
21
|
+
/** ResourceId key of the target vehicle. */
|
|
22
|
+
vehicle: string;
|
|
23
|
+
waypoints: readonly Waypoint[];
|
|
24
|
+
/** Altitude ceiling, in metres (decimal string). */
|
|
25
|
+
maxAltitudeMetres: string;
|
|
26
|
+
/** Ground-speed ceiling, in m/s (decimal string). */
|
|
27
|
+
maxSpeedMetresPerSecond: string;
|
|
28
|
+
/** Mission wall-clock ceiling, in seconds (decimal string). */
|
|
29
|
+
maxDurationSeconds: string;
|
|
30
|
+
geofence?: Geofence;
|
|
31
|
+
}
|
|
32
|
+
/** Great-circle distance between two WGS-84 points, in metres. */
|
|
33
|
+
export declare function distanceMetres(a: Waypoint, b: Waypoint): number;
|
|
34
|
+
/** Canonical digest of the authorized plan. */
|
|
35
|
+
export declare function envelopeDigest(envelope: MissionEnvelope): string;
|
|
36
|
+
/** Structural and physical validation; returns human-readable issues. */
|
|
37
|
+
export declare function validateMissionEnvelope(envelope: MissionEnvelope): string[];
|
|
38
|
+
export declare function assertValidMissionEnvelope(envelope: MissionEnvelope): void;
|