@totemsdk/act-mavlink 0.0.0-stage → 0.1.1
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,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-025 §5.5 — mission envelope.
|
|
4
|
+
*
|
|
5
|
+
* The envelope is the unit of authority: `uploadMission` prepares and commits
|
|
6
|
+
* it, `startMission` is authorized against its digest, and RFC-023 enforces its
|
|
7
|
+
* speed/duration/altitude bounds from the prepared command. A start that
|
|
8
|
+
* references a different plan than the one authorized fails closed.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.distanceMetres = distanceMetres;
|
|
12
|
+
exports.envelopeDigest = envelopeDigest;
|
|
13
|
+
exports.validateMissionEnvelope = validateMissionEnvelope;
|
|
14
|
+
exports.assertValidMissionEnvelope = assertValidMissionEnvelope;
|
|
15
|
+
const act_core_1 = require("@totemsdk/act-core");
|
|
16
|
+
const EARTH_RADIUS_METRES = 6371000;
|
|
17
|
+
/** Great-circle distance between two WGS-84 points, in metres. */
|
|
18
|
+
function distanceMetres(a, b) {
|
|
19
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
20
|
+
const dLat = toRad(b.latitudeDeg - a.latitudeDeg);
|
|
21
|
+
const dLon = toRad(b.longitudeDeg - a.longitudeDeg);
|
|
22
|
+
const lat1 = toRad(a.latitudeDeg);
|
|
23
|
+
const lat2 = toRad(b.latitudeDeg);
|
|
24
|
+
const h = Math.sin(dLat / 2) ** 2 +
|
|
25
|
+
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
|
26
|
+
return 2 * EARTH_RADIUS_METRES * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
27
|
+
}
|
|
28
|
+
/** Canonical digest of the authorized plan. */
|
|
29
|
+
function envelopeDigest(envelope) {
|
|
30
|
+
return (0, act_core_1.hashCanonical)('TOTEM_ACT_MAVLINK_MISSION_ENVELOPE_V1', {
|
|
31
|
+
envelopeId: envelope.envelopeId,
|
|
32
|
+
vehicle: envelope.vehicle,
|
|
33
|
+
waypoints: envelope.waypoints.map((waypoint) => ({
|
|
34
|
+
latitudeDeg: waypoint.latitudeDeg,
|
|
35
|
+
longitudeDeg: waypoint.longitudeDeg,
|
|
36
|
+
altitudeMetres: waypoint.altitudeMetres,
|
|
37
|
+
})),
|
|
38
|
+
maxAltitudeMetres: envelope.maxAltitudeMetres,
|
|
39
|
+
maxSpeedMetresPerSecond: envelope.maxSpeedMetresPerSecond,
|
|
40
|
+
maxDurationSeconds: envelope.maxDurationSeconds,
|
|
41
|
+
geofence: envelope.geofence
|
|
42
|
+
? {
|
|
43
|
+
latitudeDeg: envelope.geofence.latitudeDeg,
|
|
44
|
+
longitudeDeg: envelope.geofence.longitudeDeg,
|
|
45
|
+
radiusMetres: envelope.geofence.radiusMetres,
|
|
46
|
+
}
|
|
47
|
+
: null,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function positive(value) {
|
|
51
|
+
return Number.isFinite(Number(value)) && Number(value) > 0;
|
|
52
|
+
}
|
|
53
|
+
/** Structural and physical validation; returns human-readable issues. */
|
|
54
|
+
function validateMissionEnvelope(envelope) {
|
|
55
|
+
const issues = [];
|
|
56
|
+
if (envelope.envelopeId.trim() === '')
|
|
57
|
+
issues.push('envelopeId is required');
|
|
58
|
+
if (envelope.vehicle.trim() === '')
|
|
59
|
+
issues.push('vehicle is required');
|
|
60
|
+
if (envelope.waypoints.length === 0)
|
|
61
|
+
issues.push('at least one waypoint is required');
|
|
62
|
+
if (envelope.waypoints.length > 100)
|
|
63
|
+
issues.push('mission exceeds the 100-waypoint limit');
|
|
64
|
+
if (!positive(envelope.maxAltitudeMetres))
|
|
65
|
+
issues.push('maxAltitudeMetres must be positive');
|
|
66
|
+
if (!positive(envelope.maxSpeedMetresPerSecond))
|
|
67
|
+
issues.push('maxSpeedMetresPerSecond must be positive');
|
|
68
|
+
if (!positive(envelope.maxDurationSeconds))
|
|
69
|
+
issues.push('maxDurationSeconds must be positive');
|
|
70
|
+
const maxAltitude = Number(envelope.maxAltitudeMetres);
|
|
71
|
+
for (const [index, waypoint] of envelope.waypoints.entries()) {
|
|
72
|
+
if (!Number.isFinite(waypoint.latitudeDeg) || waypoint.latitudeDeg < -90 || waypoint.latitudeDeg > 90) {
|
|
73
|
+
issues.push(`waypoint ${index} latitude is out of range`);
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isFinite(waypoint.longitudeDeg) || waypoint.longitudeDeg < -180 || waypoint.longitudeDeg > 180) {
|
|
76
|
+
issues.push(`waypoint ${index} longitude is out of range`);
|
|
77
|
+
}
|
|
78
|
+
if (!Number.isFinite(waypoint.altitudeMetres) || waypoint.altitudeMetres < 0) {
|
|
79
|
+
issues.push(`waypoint ${index} altitude is invalid`);
|
|
80
|
+
}
|
|
81
|
+
else if (waypoint.altitudeMetres > maxAltitude) {
|
|
82
|
+
issues.push(`waypoint ${index} altitude ${waypoint.altitudeMetres} m exceeds the envelope ceiling ${maxAltitude} m`);
|
|
83
|
+
}
|
|
84
|
+
if (envelope.geofence) {
|
|
85
|
+
const distance = distanceMetres({ latitudeDeg: envelope.geofence.latitudeDeg, longitudeDeg: envelope.geofence.longitudeDeg, altitudeMetres: 0 }, waypoint);
|
|
86
|
+
if (distance > envelope.geofence.radiusMetres) {
|
|
87
|
+
issues.push(`waypoint ${index} is ${Math.round(distance)} m from the geofence centre (limit ${envelope.geofence.radiusMetres} m)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return issues;
|
|
92
|
+
}
|
|
93
|
+
function assertValidMissionEnvelope(envelope) {
|
|
94
|
+
const issues = validateMissionEnvelope(envelope);
|
|
95
|
+
if (issues.length > 0) {
|
|
96
|
+
throw new Error(`invalid mission envelope '${envelope.envelopeId}': ${issues.join('; ')}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-025 — governed MAVLink / autonomous flight profile.
|
|
3
|
+
*
|
|
4
|
+
* `@totemsdk/act-mavlink` authorizes a bounded mission envelope; the connector
|
|
5
|
+
* (`@totemsdk/edge-mavlink`) carries the wire frames and the autopilot owns
|
|
6
|
+
* stabilization, failsafes and the real-time loop.
|
|
7
|
+
*/
|
|
8
|
+
export { MavlinkTransportError, assertEnvelopeBinding, } from './transport.js';
|
|
9
|
+
export type { MavlinkTransportPort, MissionStartRequest, MissionUploadRequest, MissionUploadResult, TelemetrySnapshot, } from './transport.js';
|
|
10
|
+
export { assertValidMissionEnvelope, distanceMetres, envelopeDigest, validateMissionEnvelope, } from './envelope.js';
|
|
11
|
+
export type { Geofence, MissionEnvelope, Waypoint } from './envelope.js';
|
|
12
|
+
export { deriveFlightEffects } from './effects.js';
|
|
13
|
+
export type { FlightLimits } from './effects.js';
|
|
14
|
+
export { MAVLINK_PROTOCOL, createMavlinkProfile, mavlinkErrorTaxonomy, vehicleInterlocks, vehicleResource, vehicleResourceId, } from './profile.js';
|
|
15
|
+
export type { FlightVehicle, MavlinkProfileOptions } from './profile.js';
|
|
16
|
+
export { createMavlinkDefinitions } from './adapter.js';
|
|
17
|
+
export type { MavlinkAdapterOptions } from './adapter.js';
|
|
18
|
+
export { runMavlinkConformance } from './conformance.js';
|
|
19
|
+
export type { MavlinkConformanceCheck, MavlinkConformanceOptions, MavlinkConformanceReport, } from './conformance.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-025 — governed MAVLink / autonomous flight profile.
|
|
4
|
+
*
|
|
5
|
+
* `@totemsdk/act-mavlink` authorizes a bounded mission envelope; the connector
|
|
6
|
+
* (`@totemsdk/edge-mavlink`) carries the wire frames and the autopilot owns
|
|
7
|
+
* stabilization, failsafes and the real-time loop.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.runMavlinkConformance = exports.createMavlinkDefinitions = exports.vehicleResourceId = exports.vehicleResource = exports.vehicleInterlocks = exports.mavlinkErrorTaxonomy = exports.createMavlinkProfile = exports.MAVLINK_PROTOCOL = exports.deriveFlightEffects = exports.validateMissionEnvelope = exports.envelopeDigest = exports.distanceMetres = exports.assertValidMissionEnvelope = exports.assertEnvelopeBinding = exports.MavlinkTransportError = void 0;
|
|
11
|
+
var transport_js_1 = require("./transport.js");
|
|
12
|
+
Object.defineProperty(exports, "MavlinkTransportError", { enumerable: true, get: function () { return transport_js_1.MavlinkTransportError; } });
|
|
13
|
+
Object.defineProperty(exports, "assertEnvelopeBinding", { enumerable: true, get: function () { return transport_js_1.assertEnvelopeBinding; } });
|
|
14
|
+
var envelope_js_1 = require("./envelope.js");
|
|
15
|
+
Object.defineProperty(exports, "assertValidMissionEnvelope", { enumerable: true, get: function () { return envelope_js_1.assertValidMissionEnvelope; } });
|
|
16
|
+
Object.defineProperty(exports, "distanceMetres", { enumerable: true, get: function () { return envelope_js_1.distanceMetres; } });
|
|
17
|
+
Object.defineProperty(exports, "envelopeDigest", { enumerable: true, get: function () { return envelope_js_1.envelopeDigest; } });
|
|
18
|
+
Object.defineProperty(exports, "validateMissionEnvelope", { enumerable: true, get: function () { return envelope_js_1.validateMissionEnvelope; } });
|
|
19
|
+
var effects_js_1 = require("./effects.js");
|
|
20
|
+
Object.defineProperty(exports, "deriveFlightEffects", { enumerable: true, get: function () { return effects_js_1.deriveFlightEffects; } });
|
|
21
|
+
var profile_js_1 = require("./profile.js");
|
|
22
|
+
Object.defineProperty(exports, "MAVLINK_PROTOCOL", { enumerable: true, get: function () { return profile_js_1.MAVLINK_PROTOCOL; } });
|
|
23
|
+
Object.defineProperty(exports, "createMavlinkProfile", { enumerable: true, get: function () { return profile_js_1.createMavlinkProfile; } });
|
|
24
|
+
Object.defineProperty(exports, "mavlinkErrorTaxonomy", { enumerable: true, get: function () { return profile_js_1.mavlinkErrorTaxonomy; } });
|
|
25
|
+
Object.defineProperty(exports, "vehicleInterlocks", { enumerable: true, get: function () { return profile_js_1.vehicleInterlocks; } });
|
|
26
|
+
Object.defineProperty(exports, "vehicleResource", { enumerable: true, get: function () { return profile_js_1.vehicleResource; } });
|
|
27
|
+
Object.defineProperty(exports, "vehicleResourceId", { enumerable: true, get: function () { return profile_js_1.vehicleResourceId; } });
|
|
28
|
+
var adapter_js_1 = require("./adapter.js");
|
|
29
|
+
Object.defineProperty(exports, "createMavlinkDefinitions", { enumerable: true, get: function () { return adapter_js_1.createMavlinkDefinitions; } });
|
|
30
|
+
var conformance_js_1 = require("./conformance.js");
|
|
31
|
+
Object.defineProperty(exports, "runMavlinkConformance", { enumerable: true, get: function () { return conformance_js_1.runMavlinkConformance; } });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-025 §5.4 / §5.9 — MAVLink flight profile.
|
|
3
|
+
*
|
|
4
|
+
* The vehicle is an asset addressed at its autopilot. Safe state is
|
|
5
|
+
* return-to-launch (a `fail-safe` in flight must never command a de-energize
|
|
6
|
+
* analogue). Interlocks enforce the vehicle's declared ceilings before
|
|
7
|
+
* authorization; safety-coded errors require the safe state and never retry.
|
|
8
|
+
*/
|
|
9
|
+
import { type DeviceErrorTaxonomy, type IndustrialProfile, type Interlock, type Resource, type ResourceId } from '@totemsdk/act-core';
|
|
10
|
+
import { type MissionEnvelope } from './envelope.js';
|
|
11
|
+
export declare const MAVLINK_PROTOCOL = "mavlink";
|
|
12
|
+
export interface FlightVehicle {
|
|
13
|
+
vehicleId: string;
|
|
14
|
+
/** Ground-speed ceiling, in m/s (decimal string). */
|
|
15
|
+
maxSpeedMetresPerSecond: string;
|
|
16
|
+
/** Altitude ceiling, in metres (decimal string). */
|
|
17
|
+
maxAltitudeMetres: string;
|
|
18
|
+
/** Mission duration ceiling, in seconds (decimal string). */
|
|
19
|
+
maxDurationSeconds: string;
|
|
20
|
+
/** MAVLink addressing. */
|
|
21
|
+
systemId?: number;
|
|
22
|
+
componentId?: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function vehicleResourceId(site: string, vehicleId: string): ResourceId;
|
|
25
|
+
export declare function vehicleResource(site: string, vehicle: FlightVehicle): Resource;
|
|
26
|
+
/** Interlocks enforcing the vehicle's declared envelope ceilings. */
|
|
27
|
+
export declare function vehicleInterlocks(site: string, vehicle: FlightVehicle): Interlock[];
|
|
28
|
+
/** MAVLink result codes → device error classification (RFC-011 §4.7). */
|
|
29
|
+
export declare function mavlinkErrorTaxonomy(): DeviceErrorTaxonomy;
|
|
30
|
+
export interface MavlinkProfileOptions {
|
|
31
|
+
site: string;
|
|
32
|
+
vehicles: readonly FlightVehicle[];
|
|
33
|
+
}
|
|
34
|
+
/** Data-only flight profile (definitions are supplied by the adapter). */
|
|
35
|
+
export declare function createMavlinkProfile(options: MavlinkProfileOptions): IndustrialProfile;
|
|
36
|
+
export type { MissionEnvelope };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-025 §5.4 / §5.9 — MAVLink flight profile.
|
|
4
|
+
*
|
|
5
|
+
* The vehicle is an asset addressed at its autopilot. Safe state is
|
|
6
|
+
* return-to-launch (a `fail-safe` in flight must never command a de-energize
|
|
7
|
+
* analogue). Interlocks enforce the vehicle's declared ceilings before
|
|
8
|
+
* authorization; safety-coded errors require the safe state and never retry.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.MAVLINK_PROTOCOL = void 0;
|
|
12
|
+
exports.vehicleResourceId = vehicleResourceId;
|
|
13
|
+
exports.vehicleResource = vehicleResource;
|
|
14
|
+
exports.vehicleInterlocks = vehicleInterlocks;
|
|
15
|
+
exports.mavlinkErrorTaxonomy = mavlinkErrorTaxonomy;
|
|
16
|
+
exports.createMavlinkProfile = createMavlinkProfile;
|
|
17
|
+
const act_core_1 = require("@totemsdk/act-core");
|
|
18
|
+
const envelope_js_1 = require("./envelope.js");
|
|
19
|
+
exports.MAVLINK_PROTOCOL = 'mavlink';
|
|
20
|
+
function vehicleResourceId(site, vehicleId) {
|
|
21
|
+
return { site, area: 'flight', asset: vehicleId, point: 'autopilot' };
|
|
22
|
+
}
|
|
23
|
+
function vehicleResource(site, vehicle) {
|
|
24
|
+
const id = vehicleResourceId(site, vehicle.vehicleId);
|
|
25
|
+
return {
|
|
26
|
+
id,
|
|
27
|
+
kind: 'controller',
|
|
28
|
+
addresses: [
|
|
29
|
+
{
|
|
30
|
+
protocol: exports.MAVLINK_PROTOCOL,
|
|
31
|
+
endpoint: `system:${vehicle.systemId ?? 1}/component:${vehicle.componentId ?? 1}`,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
// Hold/land/return are deployment policy; return-to-launch is the default
|
|
35
|
+
// safe state. Never de-energize an airborne vehicle.
|
|
36
|
+
safeState: {
|
|
37
|
+
resourceId: id,
|
|
38
|
+
command: { action: 'flight:returnToLaunch' },
|
|
39
|
+
},
|
|
40
|
+
metadata: {
|
|
41
|
+
vehicleId: vehicle.vehicleId,
|
|
42
|
+
maxSpeedMetresPerSecond: vehicle.maxSpeedMetresPerSecond,
|
|
43
|
+
maxAltitudeMetres: vehicle.maxAltitudeMetres,
|
|
44
|
+
maxDurationSeconds: vehicle.maxDurationSeconds,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function numeric(value) {
|
|
49
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
50
|
+
return value;
|
|
51
|
+
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
|
|
52
|
+
return Number(value);
|
|
53
|
+
}
|
|
54
|
+
if (value && typeof value === 'object' && 'value' in value) {
|
|
55
|
+
return numeric(value.value);
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
function ceilingInterlock(site, vehicle, id, parameter, ceiling, unit) {
|
|
60
|
+
const resourceId = vehicleResourceId(site, vehicle.vehicleId);
|
|
61
|
+
return {
|
|
62
|
+
id,
|
|
63
|
+
kind: 'precondition',
|
|
64
|
+
resources: [resourceId],
|
|
65
|
+
safeState: { resourceId, command: { action: 'flight:returnToLaunch' } },
|
|
66
|
+
evaluate({ parameters }) {
|
|
67
|
+
const requested = numeric(parameters[parameter]);
|
|
68
|
+
if (requested === undefined)
|
|
69
|
+
return { satisfied: true };
|
|
70
|
+
return requested <= Number(ceiling)
|
|
71
|
+
? { satisfied: true }
|
|
72
|
+
: { satisfied: false, reason: `requested ${requested} ${unit} exceeds vehicle ceiling ${ceiling} ${unit}` };
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Interlocks enforcing the vehicle's declared envelope ceilings. */
|
|
77
|
+
function vehicleInterlocks(site, vehicle) {
|
|
78
|
+
const resourceId = vehicleResourceId(site, vehicle.vehicleId);
|
|
79
|
+
const interlocks = [
|
|
80
|
+
ceilingInterlock(site, vehicle, `mavlink:speed-ceiling:${vehicle.vehicleId}`, 'maxSpeedMetresPerSecond', vehicle.maxSpeedMetresPerSecond, 'm/s'),
|
|
81
|
+
ceilingInterlock(site, vehicle, `mavlink:altitude-ceiling:${vehicle.vehicleId}`, 'maxAltitudeMetres', vehicle.maxAltitudeMetres, 'm'),
|
|
82
|
+
ceilingInterlock(site, vehicle, `mavlink:duration-ceiling:${vehicle.vehicleId}`, 'maxDurationSeconds', vehicle.maxDurationSeconds, 's'),
|
|
83
|
+
];
|
|
84
|
+
// Geofence containment is dynamic: it depends on the waypoints in the
|
|
85
|
+
// prepared request, not only on scalar ceilings.
|
|
86
|
+
interlocks.push({
|
|
87
|
+
id: `mavlink:geofence:${vehicle.vehicleId}`,
|
|
88
|
+
kind: 'precondition',
|
|
89
|
+
resources: [resourceId],
|
|
90
|
+
safeState: { resourceId, command: { action: 'flight:returnToLaunch' } },
|
|
91
|
+
evaluate({ parameters }) {
|
|
92
|
+
const latitude = numeric(parameters.geofenceLatitudeDeg);
|
|
93
|
+
const longitude = numeric(parameters.geofenceLongitudeDeg);
|
|
94
|
+
const radius = numeric(parameters.geofenceRadiusMetres);
|
|
95
|
+
if (latitude === undefined || longitude === undefined || radius === undefined) {
|
|
96
|
+
return { satisfied: true };
|
|
97
|
+
}
|
|
98
|
+
const waypoints = parameters.waypoints;
|
|
99
|
+
if (!Array.isArray(waypoints))
|
|
100
|
+
return { satisfied: true };
|
|
101
|
+
for (const [index, waypoint] of waypoints.entries()) {
|
|
102
|
+
const candidate = waypoint;
|
|
103
|
+
const distance = (0, envelope_js_1.distanceMetres)({ latitudeDeg: latitude, longitudeDeg: longitude, altitudeMetres: 0 }, candidate);
|
|
104
|
+
if (distance > radius) {
|
|
105
|
+
return {
|
|
106
|
+
satisfied: false,
|
|
107
|
+
reason: `waypoint ${index} is ${Math.round(distance)} m from the geofence centre (limit ${radius} m)`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { satisfied: true };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
return interlocks;
|
|
115
|
+
}
|
|
116
|
+
/** MAVLink result codes → device error classification (RFC-011 §4.7). */
|
|
117
|
+
function mavlinkErrorTaxonomy() {
|
|
118
|
+
const taxonomy = (0, act_core_1.createDeviceErrorTaxonomy)();
|
|
119
|
+
const classes = {
|
|
120
|
+
// Failsafe/geofence conditions require the safe state and never retry.
|
|
121
|
+
GEOFENCE_BREACH: 'safety',
|
|
122
|
+
FAILSAFE_TRIGGERED: 'safety',
|
|
123
|
+
BATTERY_LOW: 'safety',
|
|
124
|
+
CRITICAL_BATTERY: 'safety',
|
|
125
|
+
// Mission/protocol rejection — retrying the same plan cannot succeed.
|
|
126
|
+
MAV_MISSION_ERROR: 'permanent',
|
|
127
|
+
MAV_MISSION_UNSUPPORTED: 'permanent',
|
|
128
|
+
MAV_MISSION_UNSUPPORTED_FRAME: 'permanent',
|
|
129
|
+
MAV_MISSION_NO_SPACE: 'permanent',
|
|
130
|
+
MAV_MISSION_INVALID: 'permanent',
|
|
131
|
+
MAV_MISSION_INVALID_PARAM1: 'permanent',
|
|
132
|
+
MAV_MISSION_INVALID_PARAM2: 'permanent',
|
|
133
|
+
MAV_MISSION_INVALID_PARAM3: 'permanent',
|
|
134
|
+
MAV_MISSION_INVALID_PARAM4: 'permanent',
|
|
135
|
+
MAV_MISSION_INVALID_PARAM5_X: 'permanent',
|
|
136
|
+
MAV_MISSION_INVALID_PARAM6_Y: 'permanent',
|
|
137
|
+
MAV_MISSION_INVALID_PARAM7: 'permanent',
|
|
138
|
+
MAV_MISSION_DENIED: 'auth',
|
|
139
|
+
MAV_RESULT_UNSUPPORTED: 'permanent',
|
|
140
|
+
MAV_RESULT_FAILED: 'permanent',
|
|
141
|
+
MAV_RESULT_CANCELLED: 'permanent',
|
|
142
|
+
// Link/transport — retry under the same operation id is safe.
|
|
143
|
+
LINK_TIMEOUT: 'transient',
|
|
144
|
+
MAV_RESULT_IN_PROGRESS: 'transient',
|
|
145
|
+
TIMEOUT: 'transient',
|
|
146
|
+
// Authorization.
|
|
147
|
+
AUTHORIZATION_DENIED: 'auth',
|
|
148
|
+
ENVELOPE_MISMATCH: 'auth',
|
|
149
|
+
};
|
|
150
|
+
for (const [code, classification] of Object.entries(classes)) {
|
|
151
|
+
taxonomy.register(exports.MAVLINK_PROTOCOL, code, classification);
|
|
152
|
+
}
|
|
153
|
+
return taxonomy;
|
|
154
|
+
}
|
|
155
|
+
/** Data-only flight profile (definitions are supplied by the adapter). */
|
|
156
|
+
function createMavlinkProfile(options) {
|
|
157
|
+
return {
|
|
158
|
+
id: 'mavlink',
|
|
159
|
+
version: 1,
|
|
160
|
+
resources: options.vehicles.map((vehicle) => vehicleResource(options.site, vehicle)),
|
|
161
|
+
interlocks: options.vehicles.flatMap((vehicle) => vehicleInterlocks(options.site, vehicle)),
|
|
162
|
+
errorTaxonomy: mavlinkErrorTaxonomy(),
|
|
163
|
+
capabilities: ['industrial:action', 'industrial:read', 'transport:mavlink'],
|
|
164
|
+
recipes: [],
|
|
165
|
+
definitions: [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-025 §5.2 — MAVLink transport port.
|
|
3
|
+
*
|
|
4
|
+
* The caller injects a MAVLink client (SDK, bridge, or simulator). This package
|
|
5
|
+
* owns mission authority, bounded execution and evidence; the autopilot owns
|
|
6
|
+
* stabilization, failsafes and the real-time loop.
|
|
7
|
+
*
|
|
8
|
+
* `startMission` carries the authorized envelope digest so a transport can
|
|
9
|
+
* refuse to start a mission that differs from the one that was authorized.
|
|
10
|
+
*/
|
|
11
|
+
import { type MissionEnvelope, type Waypoint } from './envelope.js';
|
|
12
|
+
export interface MissionUploadRequest {
|
|
13
|
+
envelopeId: string;
|
|
14
|
+
envelopeDigest: string;
|
|
15
|
+
waypoints: readonly Waypoint[];
|
|
16
|
+
}
|
|
17
|
+
export interface MissionUploadResult {
|
|
18
|
+
/** Autopilot-assigned mission id (the reconciliation handle). */
|
|
19
|
+
missionId: string;
|
|
20
|
+
}
|
|
21
|
+
export interface MissionStartRequest {
|
|
22
|
+
missionId: string;
|
|
23
|
+
envelopeDigest: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TelemetrySnapshot {
|
|
26
|
+
/** ISO-8601 timestamp from the autopilot. */
|
|
27
|
+
timestamp: string;
|
|
28
|
+
latitudeDeg: number;
|
|
29
|
+
longitudeDeg: number;
|
|
30
|
+
altitudeMetres: number;
|
|
31
|
+
groundspeedMetresPerSecond: number;
|
|
32
|
+
batteryPercent?: number;
|
|
33
|
+
waypointIndex?: number;
|
|
34
|
+
missionFinished?: boolean;
|
|
35
|
+
armed?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface MavlinkTransportPort {
|
|
38
|
+
uploadMission(request: MissionUploadRequest): Promise<MissionUploadResult>;
|
|
39
|
+
startMission(request: MissionStartRequest): Promise<void>;
|
|
40
|
+
hold(): Promise<void>;
|
|
41
|
+
returnToLaunch(): Promise<void>;
|
|
42
|
+
land(): Promise<void>;
|
|
43
|
+
/** Telemetry readback used for reconciliation; must originate from the vehicle. */
|
|
44
|
+
readTelemetry(): Promise<TelemetrySnapshot>;
|
|
45
|
+
/** Optional push path for telemetry. */
|
|
46
|
+
onTelemetry?(handler: (telemetry: TelemetrySnapshot) => void): () => void;
|
|
47
|
+
}
|
|
48
|
+
export declare class MavlinkTransportError extends Error {
|
|
49
|
+
readonly code: string;
|
|
50
|
+
constructor(code: string, message: string);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A start must reference the exact plan that was authorized. A digest that does
|
|
54
|
+
* not match the envelope fails closed before dispatch.
|
|
55
|
+
*/
|
|
56
|
+
export declare function assertEnvelopeBinding(envelope: MissionEnvelope, digest: string): void;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* RFC-025 §5.2 — MAVLink transport port.
|
|
4
|
+
*
|
|
5
|
+
* The caller injects a MAVLink client (SDK, bridge, or simulator). This package
|
|
6
|
+
* owns mission authority, bounded execution and evidence; the autopilot owns
|
|
7
|
+
* stabilization, failsafes and the real-time loop.
|
|
8
|
+
*
|
|
9
|
+
* `startMission` carries the authorized envelope digest so a transport can
|
|
10
|
+
* refuse to start a mission that differs from the one that was authorized.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.MavlinkTransportError = void 0;
|
|
14
|
+
exports.assertEnvelopeBinding = assertEnvelopeBinding;
|
|
15
|
+
const envelope_js_1 = require("./envelope.js");
|
|
16
|
+
class MavlinkTransportError extends Error {
|
|
17
|
+
constructor(code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'MavlinkTransportError';
|
|
20
|
+
this.code = code;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
exports.MavlinkTransportError = MavlinkTransportError;
|
|
24
|
+
/**
|
|
25
|
+
* A start must reference the exact plan that was authorized. A digest that does
|
|
26
|
+
* not match the envelope fails closed before dispatch.
|
|
27
|
+
*/
|
|
28
|
+
function assertEnvelopeBinding(envelope, digest) {
|
|
29
|
+
const expected = (0, envelope_js_1.envelopeDigest)(envelope);
|
|
30
|
+
if (digest !== expected) {
|
|
31
|
+
throw new MavlinkTransportError('EnvelopeMismatch', `envelope digest mismatch for '${envelope.envelopeId}' (authorized ${digest}, prepared ${expected})`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -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,119 @@
|
|
|
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 { createMavlinkDefinitions } from './adapter.js';
|
|
9
|
+
import { distanceMetres, envelopeDigest, validateMissionEnvelope, } from './envelope.js';
|
|
10
|
+
export async function runMavlinkConformance(transport, options) {
|
|
11
|
+
const checks = [];
|
|
12
|
+
const push = (name, ok, detail) => {
|
|
13
|
+
checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
|
|
14
|
+
};
|
|
15
|
+
const structural = validateMissionEnvelope(options.envelope);
|
|
16
|
+
push('envelope-valid', structural.length === 0, structural.join('; ') || undefined);
|
|
17
|
+
const outside = options.envelope.geofence
|
|
18
|
+
? validateMissionEnvelope({
|
|
19
|
+
...options.envelope,
|
|
20
|
+
waypoints: [
|
|
21
|
+
{
|
|
22
|
+
latitudeDeg: options.envelope.geofence.latitudeDeg + 1,
|
|
23
|
+
longitudeDeg: options.envelope.geofence.longitudeDeg,
|
|
24
|
+
altitudeMetres: 0,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
: ['no geofence declared'];
|
|
29
|
+
push('geofence-rejection', outside.length > 0, outside.join('; ') || undefined);
|
|
30
|
+
const definitions = createMavlinkDefinitions({
|
|
31
|
+
transport,
|
|
32
|
+
site: options.site,
|
|
33
|
+
vehicles: options.vehicles,
|
|
34
|
+
...(options.arrivalToleranceMetres !== undefined
|
|
35
|
+
? { arrivalToleranceMetres: options.arrivalToleranceMetres }
|
|
36
|
+
: {}),
|
|
37
|
+
});
|
|
38
|
+
const upload = definitions.find((def) => def.kind === 'flight:uploadMission');
|
|
39
|
+
const start = definitions.find((def) => def.kind === 'flight:startMission');
|
|
40
|
+
if (!upload || !start) {
|
|
41
|
+
push('definitions-present', false, 'upload/start definitions missing');
|
|
42
|
+
return { vehicle: options.envelope.vehicle, ok: false, checks };
|
|
43
|
+
}
|
|
44
|
+
const uploadParams = paramsFromEnvelope(options.envelope);
|
|
45
|
+
const preparedUpload = await upload.prepare(uploadParams, {});
|
|
46
|
+
const uploaded = await upload.actuate(asPrepared(preparedUpload));
|
|
47
|
+
const missionId = uploaded.data?.missionId;
|
|
48
|
+
push('mission-upload', uploaded.ok && typeof missionId === 'string', missionId ?? uploaded.error);
|
|
49
|
+
const digest = envelopeDigest(options.envelope);
|
|
50
|
+
push('envelope-binding', uploaded.data?.envelopeDigest === digest, 'upload data must carry the authorized digest');
|
|
51
|
+
if (typeof missionId !== 'string') {
|
|
52
|
+
return { vehicle: options.envelope.vehicle, ok: checks.every((check) => check.ok), checks };
|
|
53
|
+
}
|
|
54
|
+
const preparedStart = await start.prepare({ ...uploadParams, missionId, envelopeDigest: digest }, {});
|
|
55
|
+
const started = await start.actuate(asPrepared(preparedStart));
|
|
56
|
+
push('mission-start', started.ok, started.error);
|
|
57
|
+
const telemetry = await transport.readTelemetry();
|
|
58
|
+
push('telemetry-shape', Number.isFinite(telemetry.latitudeDeg) &&
|
|
59
|
+
Number.isFinite(telemetry.longitudeDeg) &&
|
|
60
|
+
Number.isFinite(telemetry.altitudeMetres) &&
|
|
61
|
+
Number.isFinite(telemetry.groundspeedMetresPerSecond) &&
|
|
62
|
+
typeof telemetry.timestamp === 'string', telemetry.timestamp);
|
|
63
|
+
// Acceptance is not completion: unless the vehicle is already at the final
|
|
64
|
+
// waypoint, reconciliation must not report `confirmed`.
|
|
65
|
+
if (start.async?.reconcile) {
|
|
66
|
+
const progress = await start.async.reconcile(asPrepared(preparedStart), {
|
|
67
|
+
operationId: 'conformance',
|
|
68
|
+
status: 'accepted',
|
|
69
|
+
attempts: 1,
|
|
70
|
+
updatedAt: Date.now(),
|
|
71
|
+
result: started.data,
|
|
72
|
+
});
|
|
73
|
+
const final = options.envelope.waypoints.at(-1);
|
|
74
|
+
const arrived = final
|
|
75
|
+
? distanceMetres({
|
|
76
|
+
latitudeDeg: telemetry.latitudeDeg,
|
|
77
|
+
longitudeDeg: telemetry.longitudeDeg,
|
|
78
|
+
altitudeMetres: telemetry.altitudeMetres,
|
|
79
|
+
}, final) <= (options.arrivalToleranceMetres ?? 15)
|
|
80
|
+
: false;
|
|
81
|
+
push('acceptance-is-not-completion', arrived ? progress.status === 'confirmed' : progress.status !== 'confirmed', `telemetry arrival=${arrived}, reconcile=${progress.status}`);
|
|
82
|
+
}
|
|
83
|
+
push('safe-state-available', transport.returnToLaunch instanceof Function, 'return-to-launch must be available for fail-safe');
|
|
84
|
+
return { vehicle: options.envelope.vehicle, ok: checks.every((check) => check.ok), checks };
|
|
85
|
+
}
|
|
86
|
+
function asPrepared(prepared) {
|
|
87
|
+
return {
|
|
88
|
+
kind: 'conformance',
|
|
89
|
+
resourceId: prepared.resourceId,
|
|
90
|
+
command: prepared.command,
|
|
91
|
+
definitionVersion: 1,
|
|
92
|
+
schemaHash: 'conformance',
|
|
93
|
+
preparedCommandHash: 'conformance',
|
|
94
|
+
parameters: {},
|
|
95
|
+
context: {},
|
|
96
|
+
commitmentHash: 'conformance',
|
|
97
|
+
operationId: 'conformance',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function paramsFromEnvelope(envelope) {
|
|
101
|
+
return {
|
|
102
|
+
envelopeId: envelope.envelopeId,
|
|
103
|
+
vehicle: envelope.vehicle,
|
|
104
|
+
waypoints: envelope.waypoints,
|
|
105
|
+
maxAltitudeMetres: { value: Number(envelope.maxAltitudeMetres), unit: 'm' },
|
|
106
|
+
maxSpeedMetresPerSecond: {
|
|
107
|
+
value: Number(envelope.maxSpeedMetresPerSecond),
|
|
108
|
+
unit: 'm/s',
|
|
109
|
+
},
|
|
110
|
+
maxDurationSeconds: { value: Number(envelope.maxDurationSeconds), unit: 's' },
|
|
111
|
+
...(envelope.geofence
|
|
112
|
+
? {
|
|
113
|
+
geofenceLatitudeDeg: envelope.geofence.latitudeDeg,
|
|
114
|
+
geofenceLongitudeDeg: envelope.geofence.longitudeDeg,
|
|
115
|
+
geofenceRadiusMetres: envelope.geofence.radiusMetres,
|
|
116
|
+
}
|
|
117
|
+
: {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -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;
|
package/dist/effects.js
ADDED
|
@@ -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
|
+
export function deriveFlightEffects(limits) {
|
|
8
|
+
return {
|
|
9
|
+
spends: [],
|
|
10
|
+
physical: {
|
|
11
|
+
speed: [{ resource: limits.resource, metresPerSecond: limits.maxSpeedMetresPerSecond }],
|
|
12
|
+
duration: [{ resource: limits.resource, seconds: limits.maxDurationSeconds }],
|
|
13
|
+
envelopes: [{ envelopeId: limits.envelopeId, envelopeDigest: limits.envelopeDigest }],
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|