diff --git a/core/frontend/package.json b/core/frontend/package.json index 00b12e0440..180869420e 100644 --- a/core/frontend/package.json +++ b/core/frontend/package.json @@ -11,6 +11,7 @@ "dev": "vite --host", "build": "NODE_ENV=production vite build", "serve": "echo 'This is a preview server for testing the built website. Please use `dev` command over `serve`.\nStarting preview server in 5 seconds..\n';sleep 5; vite preview", + "test:parameter-metadata": "esbuild tests/parameter-metadata.spec.ts --bundle --platform=node --format=cjs --outfile=/tmp/blueos-parameter-metadata-test.cjs && node --test /tmp/blueos-parameter-metadata-test.cjs", "lint": "eslint --max-warnings=0 --ext .js,.vue --ignore-path .gitignore --ignore-pattern src/components/vue-tour src && stylelint 'src/**/*.{vue,css,scss}'", "lint:css": "stylelint 'src/**/*.{vue,css,scss}'", "lint:css:fix": "stylelint 'src/**/*.{vue,css,scss}' --fix" diff --git a/core/frontend/src/components/parameter-editor/ParameterEditor.vue b/core/frontend/src/components/parameter-editor/ParameterEditor.vue index b6b5d2649c..b9598c32c6 100644 --- a/core/frontend/src/components/parameter-editor/ParameterEditor.vue +++ b/core/frontend/src/components/parameter-editor/ParameterEditor.vue @@ -159,7 +159,7 @@ import Fuse from 'fuse.js' import Vue from 'vue' import mavlink2rest from '@/libs/MAVLink2Rest' -import autopilot_data from '@/store/autopilot' +import autopilot_data, { refreshParameterMetadata } from '@/store/autopilot' import autopilot from '@/store/autopilot_manager' import Parameter, { printParam } from '@/types/autopilot/parameter' import { Dictionary } from '@/types/common' @@ -229,6 +229,9 @@ export default Vue.extend({ } }, }, + async mounted() { + await refreshParameterMetadata() + }, methods: { setDefaultParamValue(param: Parameter) { if (param.default === undefined || param.readonly) { diff --git a/core/frontend/src/components/parameter-editor/ParameterLoader.vue b/core/frontend/src/components/parameter-editor/ParameterLoader.vue index b62cba6d2a..ef8a253253 100755 --- a/core/frontend/src/components/parameter-editor/ParameterLoader.vue +++ b/core/frontend/src/components/parameter-editor/ParameterLoader.vue @@ -20,7 +20,10 @@ - + - Parameters written successfully + Parameter loading complete +
+ {{ skipped_params_length }} rejected parameter values were skipped. +
@@ -186,6 +192,7 @@ export default Vue.extend({ should_open: false, error: false as boolean | string, writing: false, + skipped_params: {} as Dictionary, }), computed: { progress(): number { @@ -209,6 +216,9 @@ export default Vue.extend({ write_finished(): boolean { return this.user_selected_params_length === 0 }, + skipped_params_length(): number { + return Object.keys(this.skipped_params).length + }, }, watch: { param_checkboxes: { @@ -226,11 +236,17 @@ export default Vue.extend({ }, }, }, + beforeDestroy() { + clearInterval(this.retry_interval) + }, methods: { done() { + clearInterval(this.retry_interval) + this.retry_interval = 0 this.$emit('done') this.error = false this.retries = 0 + this.skipped_params = {} setTimeout(() => { // delay this as the dialog is still open when the done event is emitted // which causes the text to change during the close animation @@ -246,7 +262,12 @@ export default Vue.extend({ } }, writeParams() { + clearInterval(this.retry_interval) + this.retry_interval = 0 this.writing = true + this.error = false + this.retries = 0 + this.skipped_params = {} this.initial_size = this.user_selected_params_length this.writeSelectedParams() this.retry_interval = setInterval(() => { @@ -254,13 +275,17 @@ export default Vue.extend({ this.retries += 1 if (this.retries > 5) { clearInterval(this.retry_interval) + this.retry_interval = 0 + for (const name of Object.keys(this.user_selected_params)) { + Vue.set(this.skipped_params, name, true) + } this.retries = 0 - this.error = 'Failed to write some parameters. Please restart the vehicle and try again.' return } this.writeSelectedParams() } else { clearInterval(this.retry_interval) + this.retry_interval = 0 } }, 1000) }, @@ -273,7 +298,8 @@ export default Vue.extend({ } }, writeParam(name: string, value: number) { - mavlink2rest.setParam(name, value, autopilot_data.system_id) + const parameter_type = autopilot_data.parameter(name)?.paramType.type + mavlink2rest.setParam(name, value, autopilot_data.system_id, parameter_type) }, filterParamsByReadOnly(params: Dictionary): Dictionary { return Object.fromEntries( @@ -288,7 +314,7 @@ export default Vue.extend({ Object.entries(params).filter(([name, value]) => { const param = autopilot_data.parameter(name) if (!param) { - return true + return false } return Math.abs(param.value - value) > 0.0001 }), @@ -296,7 +322,7 @@ export default Vue.extend({ }, filterParamsBySelection(params: Dictionary): Dictionary { return Object.fromEntries( - Object.entries(params).filter(([name]) => this.param_checkboxes[name]), + Object.entries(params).filter(([name]) => this.param_checkboxes[name] && !this.skipped_params[name]), ) }, updateSelectAllStatus() { diff --git a/core/frontend/src/store/autopilot.ts b/core/frontend/src/store/autopilot.ts index 11930e836f..7711cc9d37 100644 --- a/core/frontend/src/store/autopilot.ts +++ b/core/frontend/src/store/autopilot.ts @@ -181,4 +181,9 @@ export { AutopilotStore } const autopilot_data: AutopilotStore = getModule(AutopilotStore) parameterFetcher.setStore(autopilot_data) + +export async function refreshParameterMetadata(): Promise { + await parameterFetcher.refreshParameterMetadata() +} + export default autopilot_data diff --git a/core/frontend/src/types/autopilot/parameter-fetcher.ts b/core/frontend/src/types/autopilot/parameter-fetcher.ts index 27eebd6f73..908b36b4d7 100644 --- a/core/frontend/src/types/autopilot/parameter-fetcher.ts +++ b/core/frontend/src/types/autopilot/parameter-fetcher.ts @@ -1,10 +1,9 @@ import mavlink2rest from '@/libs/MAVLink2Rest' // eslint-disable-next-line import/no-cycle -import ardupilot_data from '@/store/autopilot' -import { AutopilotStore } from '@/store/autopilot' +import ardupilot_data, { AutopilotStore } from '@/store/autopilot' +import autopilot from '@/store/autopilot_manager' import ParametersTable from './parameter-table' -import autopilot from '@/store/autopilot_manager' export default class ParameterFetcher { parameter_table = new ParametersTable() @@ -56,6 +55,10 @@ export default class ParameterFetcher { } } + async refreshParameterMetadata(): Promise { + if (await this.parameter_table.refreshComponentMetadata()) this.updateStore() + } + requestParamsWatchdog(): void { if (this.total_params_count !== null && this.loaded_params_count > 0 diff --git a/core/frontend/src/types/autopilot/parameter-metadata.ts b/core/frontend/src/types/autopilot/parameter-metadata.ts new file mode 100644 index 0000000000..6d4f929364 --- /dev/null +++ b/core/frontend/src/types/autopilot/parameter-metadata.ts @@ -0,0 +1,310 @@ +export interface ParameterMetadata { + Description?: string + DisplayName?: string + Increment?: string + Range?: { + high?: string + low?: string + } + RebootRequired?: string + ReadOnly?: string + Bitmask?: { [key: number]: string } + Values?: { [key: number]: string } + User?: string + Units?: string + Default?: string +} + +export interface ArduPilotMetadataFile { + [key: string]: { + [key: string]: ParameterMetadata | number + } +} + +interface ComponentParameterValue { + value: number + description: string +} + +interface ComponentParameterBit { + index: number + description: string +} + +interface ComponentParameterMetadata { + name: string + shortDesc?: string + longDesc?: string + default?: number + min?: number + max?: number + increment?: number + readOnly?: boolean + rebootRequired?: boolean + values?: ComponentParameterValue[] + bitmask?: ComponentParameterBit[] + units?: string +} + +interface ComponentParameterMetadataFile { + version: number + parameters: ComponentParameterMetadata[] +} + +export type ParameterMetadataTable = Record + +export interface ComponentMetadataState { + metadata: ParameterMetadataTable + etag?: string +} + +export interface ComponentMetadataStateUpdate extends ComponentMetadataState { + changed: boolean +} + +const ARDUPILOT_METADATA_PATH = '/assets/ArduPilot-Parameter-Repository/' + +export function selectArduPilotMetadataPath( + paths: string[], + folder: string, + major: number | undefined, + minor: number, +): string { + if (major !== undefined) { + for (let candidateMinor = minor; candidateMinor >= 0; candidateMinor -= 1) { + const candidate = `${ARDUPILOT_METADATA_PATH}${folder}-${major}.${candidateMinor}/apm.pdef.json` + if (paths.includes(`/public${candidate}`)) return candidate + } + } + + const sameVehicle = paths + .map((path) => { + const match = path.match(new RegExp(`/${folder}-(\\d+)\\.(\\d+)/apm\\.pdef\\.json$`)) + return match === null ? undefined : { + path: path.replace('/public', ''), + major: Number(match[1]), + minor: Number(match[2]), + } + }) + .filter((candidate): candidate is { path: string; major: number; minor: number } => candidate !== undefined) + .sort((left, right) => right.major - left.major || right.minor - left.minor) + + if (sameVehicle.length > 0) return sameVehicle[0].path + if (paths.length === 0) throw new Error('No bundled ArduPilot parameter metadata is available') + return paths[0].replace('/public', '') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +function optionalString(value: Record, field: string): string | undefined { + const fieldValue = value[field] + if (fieldValue !== undefined && typeof fieldValue !== 'string') { + throw new Error(`Parameter metadata field ${field} must be a string`) + } + return fieldValue as string | undefined +} + +function optionalNumber(value: Record, field: string): number | undefined { + const fieldValue = value[field] + if (fieldValue !== undefined && !isFiniteNumber(fieldValue)) { + throw new Error(`Parameter metadata field ${field} must be a finite number`) + } + return fieldValue as number | undefined +} + +function optionalBoolean(value: Record, field: string): boolean | undefined { + const fieldValue = value[field] + if (fieldValue !== undefined && typeof fieldValue !== 'boolean') { + throw new Error(`Parameter metadata field ${field} must be a boolean`) + } + return fieldValue as boolean | undefined +} + +function parseValueList(value: unknown): ComponentParameterValue[] | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) throw new Error('Parameter values must be an array') + + return value.map((entry) => { + if (!isRecord(entry) || !isFiniteNumber(entry.value) || typeof entry.description !== 'string') { + throw new Error('Parameter value entry is invalid') + } + return { value: entry.value, description: entry.description } + }) +} + +function parseBitmask(value: unknown): ComponentParameterBit[] | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) throw new Error('Parameter bitmask must be an array') + + return value.map((entry) => { + if (!isRecord(entry) || !isFiniteNumber(entry.index) || typeof entry.description !== 'string') { + throw new Error('Parameter bitmask entry is invalid') + } + return { index: entry.index, description: entry.description } + }) +} + +function parseComponentParameter(value: unknown): ComponentParameterMetadata { + if (!isRecord(value) || typeof value.name !== 'string' || value.name.length === 0) { + throw new Error('Parameter metadata entry is missing a name') + } + + return { + name: value.name, + shortDesc: optionalString(value, 'shortDesc'), + longDesc: optionalString(value, 'longDesc'), + default: optionalNumber(value, 'default'), + min: optionalNumber(value, 'min'), + max: optionalNumber(value, 'max'), + increment: optionalNumber(value, 'increment'), + readOnly: optionalBoolean(value, 'readOnly'), + rebootRequired: optionalBoolean(value, 'rebootRequired'), + values: parseValueList(value.values), + bitmask: parseBitmask(value.bitmask), + units: optionalString(value, 'units'), + } +} + +function parseComponentMetadata(value: unknown): ComponentParameterMetadataFile { + if (!isRecord(value) + || !isFiniteNumber(value.version) + || !Number.isInteger(value.version) + || value.version < 3 + || !Array.isArray(value.parameters)) { + throw new Error('Component parameter metadata document is invalid') + } + return { + version: value.version, + parameters: value.parameters.map(parseComponentParameter), + } +} + +function valuesToRecord(values: ComponentParameterValue[]): { [key: number]: string } { + return Object.fromEntries(values.map(({ value, description }) => [value, description])) +} + +function bitmaskToRecord(bits: ComponentParameterBit[]): { [key: number]: string } { + return Object.fromEntries(bits.map(({ index, description }) => [index, description])) +} + +function normalizeComponentParameter(parameter: ComponentParameterMetadata): ParameterMetadata { + const metadata: ParameterMetadata = {} + if (parameter.shortDesc !== undefined) metadata.DisplayName = parameter.shortDesc + if (parameter.longDesc !== undefined) metadata.Description = parameter.longDesc + if (parameter.default !== undefined) metadata.Default = parameter.default.toString() + if (parameter.increment !== undefined) metadata.Increment = parameter.increment.toString() + if (parameter.units !== undefined) metadata.Units = parameter.units + if (parameter.readOnly !== undefined) metadata.ReadOnly = parameter.readOnly ? 'True' : 'False' + if (parameter.rebootRequired !== undefined) { + metadata.RebootRequired = parameter.rebootRequired ? 'True' : 'False' + } + if (parameter.min !== undefined || parameter.max !== undefined) { + metadata.Range = { + ...parameter.min !== undefined && { low: parameter.min.toString() }, + ...parameter.max !== undefined && { high: parameter.max.toString() }, + } + } + if (parameter.values !== undefined) metadata.Values = valuesToRecord(parameter.values) + if (parameter.bitmask !== undefined) metadata.Bitmask = bitmaskToRecord(parameter.bitmask) + return metadata +} + +export function flattenArduPilotMetadata(metadata: ArduPilotMetadataFile): ParameterMetadataTable { + const flattened: ParameterMetadataTable = {} + for (const category of Object.values(metadata)) { + for (const [name, parameter] of Object.entries(category)) { + if (typeof parameter === 'number') continue + flattened[name] = parameter + } + } + return flattened +} + +export function normalizeComponentMetadata(metadata: unknown): ParameterMetadataTable { + const normalized: ParameterMetadataTable = {} + for (const parameter of parseComponentMetadata(metadata).parameters) { + normalized[parameter.name] = normalizeComponentParameter(parameter) + } + return normalized +} + +export function updateComponentMetadataState( + current: ComponentMetadataState, + status: number, + document?: unknown, + etag?: string, +): ComponentMetadataStateUpdate { + if (status === 304) return { ...current, changed: false } + if (status === 204) { + const changed = current.etag !== undefined || Object.keys(current.metadata).length > 0 + return { metadata: {}, etag: undefined, changed } + } + if (status !== 200) throw new Error(`Unsupported parameter metadata response status ${status}`) + if (etag !== undefined && etag === current.etag) return { ...current, changed: false } + return { metadata: normalizeComponentMetadata(document), etag, changed: true } +} + +function cloneMetadata(metadata: ParameterMetadata): ParameterMetadata { + return { + ...metadata, + ...metadata.Range && { Range: { ...metadata.Range } }, + ...metadata.Values && { Values: { ...metadata.Values } }, + ...metadata.Bitmask && { Bitmask: { ...metadata.Bitmask } }, + } +} + +function templatePattern(name: string): RegExp | undefined { + if (!name.includes('{n}')) return undefined + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + return new RegExp(`^${escaped.replace(/\\\{n\\\}/g, '\\d+')}$`) +} + +export function metadataForParameter( + metadata: ParameterMetadataTable, + parameterName: string, +): ParameterMetadata | undefined { + if (metadata[parameterName] !== undefined) return metadata[parameterName] + return Object.entries(metadata).find(([name]) => templatePattern(name)?.test(parameterName))?.[1] +} + +export function mergedMetadataForParameter( + bundled: ParameterMetadataTable, + component: ParameterMetadataTable, + parameterName: string, +): ParameterMetadata | undefined { + const bundledMetadata = metadataForParameter(bundled, parameterName) + const componentMetadata = metadataForParameter(component, parameterName) + if (bundledMetadata === undefined && componentMetadata === undefined) return undefined + return mergeMetadata(bundledMetadata, componentMetadata) +} + +function mergeMetadata( + bundled: ParameterMetadata | undefined, + component: ParameterMetadata | undefined, +): ParameterMetadata { + return { + ...bundled && cloneMetadata(bundled), + ...component && cloneMetadata(component), + ...component?.Range && { Range: { ...bundled?.Range, ...component.Range } }, + } +} + +export function mergeParameterMetadata( + bundled: ParameterMetadataTable, + component: ParameterMetadataTable, +): ParameterMetadataTable { + const merged = Object.fromEntries( + Object.entries(bundled).map(([name, metadata]) => [name, cloneMetadata(metadata)]), + ) + + for (const [name, metadata] of Object.entries(component)) { + merged[name] = mergeMetadata(merged[name], metadata) + } + return merged +} diff --git a/core/frontend/src/types/autopilot/parameter-table.ts b/core/frontend/src/types/autopilot/parameter-table.ts index 05d0a1f35f..28e5de63f0 100644 --- a/core/frontend/src/types/autopilot/parameter-table.ts +++ b/core/frontend/src/types/autopilot/parameter-table.ts @@ -1,44 +1,30 @@ -import { isNumber } from 'lodash' +import axios from 'axios' +// eslint-disable-next-line import/no-unresolved import ardupilotParamPaths from 'virtual:ardupilot-param-index' -import { fetchVehicleType } from '@/components/autopilot/AutopilotManagerUpdater' +import { fetchFirmwareVehicleType, fetchVehicleType } from '@/components/autopilot/AutopilotManagerUpdater' import { MavAutopilot } from '@/libs/MAVLink2Rest/mavlink2rest-ts/messages/mavlink2rest-enum' import autopilot_data from '@/store/autopilot' import autopilot from '@/store/autopilot_manager' +import { FirmwareVehicleType } from '@/types/autopilot' import { Dictionary } from '@/types/common' import Parameter from './parameter' +import { + ArduPilotMetadataFile, + flattenArduPilotMetadata, + mergedMetadataForParameter, + mergeParameterMetadata, + ParameterMetadata, + ParameterMetadataTable, + selectArduPilotMetadataPath, + updateComponentMetadataState, +} from './parameter-metadata' import { fetchPX4Metadata, PX4ParametersMetadata } from './px4/metadata-fetcher' -import { fetchFirmwareVehicleType } from '@/components/autopilot/AutopilotManagerUpdater' -import { FirmwareVehicleType } from '@/types/autopilot' -import axios from 'axios' - -// Parameter metadata as in the JSON files -interface Metadata { - Description?: string - DisplayName: string - Increment?: string - Range?: { - high: string - low: string - } - RebootRequired?: string, - ReadOnly?: string, - Bitmask?: {[key:number] : string} - Values?: {[key:number] : string} - User?: string - Units?: string - Default?: string -} -interface MetadataCategory { - [key: string]: Metadata | number; // number deals with a sneaky {"json": 0} entry -} -interface MetadataFile { - [key: string]: MetadataCategory; -} +const COMPONENT_PARAMETER_METADATA_URL = '/ardupilot-manager/v1.0/parameter_metadata' -function fromPX4toArduPilotParametersMetadata(parameters: PX4ParametersMetadata[]): Record { +function fromPX4toArduPilotParametersMetadata(parameters: PX4ParametersMetadata[]): ParameterMetadataTable { return parameters.reduce((acc, param) => { acc[param.name] = { User: param.category, @@ -80,7 +66,7 @@ function fromPX4toArduPilotParametersMetadata(parameters: PX4ParametersMetadata[ } return acc - }, {} as Record) + }, {} as ParameterMetadataTable) } export default class ParametersTable { @@ -88,7 +74,17 @@ export default class ParametersTable { metadata_loaded = false - metadata = {} as Dictionary + metadata = {} as Dictionary + + bundled_metadata: ParameterMetadataTable = {} + + component_metadata: ParameterMetadataTable = {} + + component_metadata_etag: string | undefined + + component_metadata_request: Promise | null = null + + metadata_generation = 0 constructor() { this.fetchMetadata() @@ -96,21 +92,30 @@ export default class ParametersTable { reset(): void { this.parametersDict = {} - this.fetchMetadata() + this.metadata = {} + this.bundled_metadata = {} + this.component_metadata = {} + this.component_metadata_etag = undefined + this.metadata_loaded = false + this.metadata_generation += 1 + this.fetchMetadata(this.metadata_generation) } - async fetchArduPilotMetadata(): Promise { + // This owns the static BlueOS metadata lookup; it does not depend on table instance state. + // eslint-disable-next-line class-methods-use-this + async fetchArduPilotMetadata(): Promise { try { const json_metadata_override = '/userdata/metadata_override.json' - const metadata = await axios.get(json_metadata_override).then(response => response.data as MetadataFile) + const metadata = await axios.get(json_metadata_override) + .then((response) => response.data as ArduPilotMetadataFile) console.info(`Using metadata override from ${json_metadata_override}`) return metadata } catch (error) { - console.debug(`Metadata override not present`) + console.debug('Metadata override not present') } await fetchFirmwareVehicleType() // required to populate autopilot.vehicle_type const jsons = ardupilotParamPaths - let folder = "Copter" + let folder = 'Copter' switch (autopilot.firmware_vehicle_type) { case FirmwareVehicleType.ArduSub: folder = 'Sub' @@ -120,52 +125,94 @@ export default class ParametersTable { break case FirmwareVehicleType.ArduPlane: folder = 'Plane' + break + default: + break } const major = autopilot.firmware_info?.version.major - let minor = autopilot.firmware_info?.version.minor ?? 0 - while (minor >= 0) { - // go down one minor version at a time until we find a match - const metadata_path = `/assets/ArduPilot-Parameter-Repository/${folder}-${major}.${minor}/apm.pdef.json` - if (jsons.includes(`/public${metadata_path}`)) { - return await axios.get(metadata_path).then(response => response.data as MetadataFile) - } - minor -= 1; + const minor = autopilot.firmware_info?.version.minor ?? 0 + const metadataPath = selectArduPilotMetadataPath(jsons, folder, major, minor) + const requestedPath = `/assets/ArduPilot-Parameter-Repository/${folder}-${major}.${minor}/apm.pdef.json` + if (major === undefined || metadataPath !== requestedPath) { + console.warn( + `Could not find exact metadata for ${folder}-${major ?? 'unknown'}.${minor}. ` + + `Falling back to ${metadataPath}`, + ) } - // if we didn't find a match, just return the first one. that must be better than nothing, right? - const fallback = jsons[0].replace('/public', '') - console.warn(`Could not find metadata for ${folder}-${major}.X. Falling back to ${fallback}`) - return axios.get(fallback).then(response => response.data as MetadataFile) + return axios.get(metadataPath).then((response) => response.data as ArduPilotMetadataFile) } - - async fetchMetadata(): Promise { + async fetchMetadata(generation = this.metadata_generation): Promise { if (autopilot.vehicle_type === null) { // Check again later if we have a vehicle type identified fetchVehicleType() - setTimeout(() => { this.fetchMetadata() }, 1000) + setTimeout(() => { + if (generation === this.metadata_generation) this.fetchMetadata(generation) + }, 1000) return } + let bundledMetadata: ParameterMetadataTable if (autopilot_data.autopilot_type === MavAutopilot.MAV_AUTOPILOT_PX4) { - this.metadata = fromPX4toArduPilotParametersMetadata(await fetchPX4Metadata()) + bundledMetadata = fromPX4toArduPilotParametersMetadata(await fetchPX4Metadata()) } else { - let metadata = await this.fetchArduPilotMetadata() - - for (const category of Object.values(metadata)) { - for (const [name, parameter] of Object.entries(category)) { - if (isNumber(parameter)) { // ignore "json" entry - console.log(`ignoring ${name} : ${parameter}`) - continue - } - this.metadata[name] = parameter - } - } + bundledMetadata = flattenArduPilotMetadata(await this.fetchArduPilotMetadata()) } + if (generation !== this.metadata_generation) return + this.bundled_metadata = bundledMetadata + this.rebuildMetadata() + await this.refreshComponentMetadata() + if (generation !== this.metadata_generation) return this.updateParameters() this.metadata_loaded = true } + rebuildMetadata(): void { + this.metadata = mergeParameterMetadata(this.bundled_metadata, this.component_metadata) + } + + async fetchComponentMetadata(generation: number): Promise { + try { + const response = await axios.get(COMPONENT_PARAMETER_METADATA_URL, { + headers: this.component_metadata_etag ? { 'If-None-Match': this.component_metadata_etag } : undefined, + validateStatus: (status) => [200, 204, 304].includes(status), + }) + if (generation !== this.metadata_generation) return false + + const etag = response.headers.etag as string | undefined + const update = updateComponentMetadataState( + { metadata: this.component_metadata, etag: this.component_metadata_etag }, + response.status, + response.data, + etag, + ) + if (!update.changed) return false + + this.component_metadata = update.metadata + this.component_metadata_etag = update.etag + this.rebuildMetadata() + this.updateParameters() + return true + } catch (error) { + console.debug('Vehicle parameter metadata is unavailable; using the last valid metadata snapshot.', error) + return false + } + } + + async refreshComponentMetadata(): Promise { + if (this.component_metadata_request !== null) return this.component_metadata_request + + const generation = this.metadata_generation + const request = this.fetchComponentMetadata(generation) + this.component_metadata_request = request + try { + return await request + } finally { + if (this.component_metadata_request === request) this.component_metadata_request = null + } + } + updateParameters(): void { for (const parameter of Object.values(this.parametersDict)) { this.addParam(parameter) @@ -173,22 +220,38 @@ export default class ParametersTable { } addParam(param: Parameter): void { - if (param.name in this.metadata) { - param.description = this.metadata[param.name].Description?.toTitle() ?? '' - param.shortDescription = this.metadata[param.name].DisplayName - param.units = this.metadata[param.name].Units + const updatedParam: Parameter = { + ...param, + description: '', + shortDescription: '', + units: undefined, + options: undefined, + bitmask: undefined, + readonly: false, + increment: undefined, + rebootRequired: false, + range: undefined, + default: undefined, + } + const metadata = mergedMetadataForParameter(this.bundled_metadata, this.component_metadata, param.name) + if (metadata !== undefined) { + updatedParam.description = metadata.Description?.toTitle() ?? '' + updatedParam.shortDescription = metadata.DisplayName ?? param.name + updatedParam.units = metadata.Units const { Values, Bitmask, ReadOnly, Increment, RebootRequired, Range, Default, - } = this.metadata[param.name] - param.options = Values - param.bitmask = Bitmask - param.readonly = ReadOnly === 'True' - param.increment = Increment ? parseFloat(Increment) : undefined - param.rebootRequired = RebootRequired === 'True' - param.range = Range ? { high: parseFloat(Range.high), low: parseFloat(Range.low) } : undefined - param.default = Default ? parseFloat(Default) : undefined - } - this.parametersDict[param.id] = param + } = metadata + updatedParam.options = Values + updatedParam.bitmask = Bitmask + updatedParam.readonly = ReadOnly === 'True' + updatedParam.increment = Increment !== undefined ? parseFloat(Increment) : undefined + updatedParam.rebootRequired = RebootRequired === 'True' + if (Range?.high !== undefined && Range?.low !== undefined) { + updatedParam.range = { high: parseFloat(Range.high), low: parseFloat(Range.low) } + } + updatedParam.default = Default !== undefined ? parseFloat(Default) : undefined + } + this.parametersDict[param.id] = updatedParam } updateParam(param_name: string, param_value: number): void { diff --git a/core/frontend/tests/parameter-metadata.spec.ts b/core/frontend/tests/parameter-metadata.spec.ts new file mode 100644 index 0000000000..344c355825 --- /dev/null +++ b/core/frontend/tests/parameter-metadata.spec.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + mergedMetadataForParameter, + mergeParameterMetadata, + normalizeComponentMetadata, + selectArduPilotMetadataPath, + updateComponentMetadataState, +} from '../src/types/autopilot/parameter-metadata' + +const metadataPaths = [ + '/public/assets/ArduPilot-Parameter-Repository/AP_Periph-1.3/apm.pdef.json', + '/public/assets/ArduPilot-Parameter-Repository/Sub-4.7/apm.pdef.json', + '/public/assets/ArduPilot-Parameter-Repository/Sub-4.8/apm.pdef.json', + '/public/assets/ArduPilot-Parameter-Repository/Sub-4.10/apm.pdef.json', +] + +test('falls back to the newest metadata for the detected vehicle family', () => { + assert.equal( + selectArduPilotMetadataPath(metadataPaths, 'Sub', 0, 1), + '/assets/ArduPilot-Parameter-Repository/Sub-4.10/apm.pdef.json', + ) +}) + +test('prefers an exact metadata match before the vehicle-family fallback', () => { + assert.equal( + selectArduPilotMetadataPath(metadataPaths, 'Sub', 4, 8), + '/assets/ArduPilot-Parameter-Repository/Sub-4.8/apm.pdef.json', + ) +}) + +test('normalizes standard MAVLink parameter metadata', () => { + const normalized = normalizeComponentMetadata({ + version: 3, + parameters: [{ + name: 'SOURCE_TARGET', + shortDesc: 'Selected target', + longDesc: 'Runtime-discovered source', + default: 0, + min: 0, + max: 16777215, + increment: 1, + readOnly: true, + rebootRequired: true, + values: [ + { value: 0, description: 'None' }, + { value: 65538, description: 'MAVLink 1.2' }, + ], + }], + }) + + assert.deepEqual(normalized.SOURCE_TARGET, { + Default: '0', + Description: 'Runtime-discovered source', + DisplayName: 'Selected target', + Increment: '1', + Range: { high: '16777215', low: '0' }, + ReadOnly: 'True', + RebootRequired: 'True', + Values: { 0: 'None', 65538: 'MAVLink 1.2' }, + }) +}) + +test('component metadata overlays fields without discarding bundled definitions', () => { + const bundled = { + SOURCE_TARGET: { + Description: 'Bundled description', + DisplayName: 'Bundled title', + Range: { low: '0', high: '100' }, + Units: 'id', + Values: { 0: 'None', 1: 'Old target' }, + }, + UNCHANGED: { DisplayName: 'Unchanged' }, + } + const component = normalizeComponentMetadata({ + version: 3, + parameters: [{ + name: 'SOURCE_TARGET', + max: 200, + values: [{ value: 2, description: 'Detected target' }], + }], + }) + + const merged = mergeParameterMetadata(bundled, component) + assert.deepEqual(merged.SOURCE_TARGET, { + Description: 'Bundled description', + DisplayName: 'Bundled title', + Range: { low: '0', high: '200' }, + Units: 'id', + Values: { 2: 'Detected target' }, + }) + assert.deepEqual(merged.UNCHANGED, bundled.UNCHANGED) + assert.notEqual(merged.UNCHANGED, bundled.UNCHANGED) +}) + +test('rejects malformed snapshots instead of partially replacing metadata', () => { + assert.throws( + () => normalizeComponentMetadata({ version: 3, parameters: [{ name: 'BROKEN', values: 'invalid' }] }), + /values must be an array/, + ) +}) + +test('204 clears a prior runtime snapshot so bundled metadata becomes authoritative', () => { + const loaded = updateComponentMetadataState( + { metadata: {} }, + 200, + { + version: 3, + parameters: [{ + name: 'SOURCE_TARGET', + values: [{ value: 42, description: 'Detected target' }], + }], + }, + '"1234"', + ) + assert.equal(loaded.changed, true) + assert.deepEqual(loaded.metadata.SOURCE_TARGET.Values, { 42: 'Detected target' }) + + const cleared = updateComponentMetadataState(loaded, 204) + assert.deepEqual(cleared, { metadata: {}, etag: undefined, changed: true }) + + const bundled = { SOURCE_TARGET: { Values: { 0: 'None' } } } + assert.deepEqual(mergeParameterMetadata(bundled, cleared.metadata), bundled) +}) + +test('accepts integer metadata versions 3 and newer and rejects older or fractional versions', () => { + assert.deepEqual(normalizeComponentMetadata({ version: 3, parameters: [] }), {}) + assert.deepEqual(normalizeComponentMetadata({ version: 4, parameters: [] }), {}) + assert.throws(() => normalizeComponentMetadata({ version: 2, parameters: [] }), /document is invalid/) + assert.throws(() => normalizeComponentMetadata({ version: 3.5, parameters: [] }), /document is invalid/) +}) + +test('component templates overlay only matching loaded parameter names', () => { + const bundled = { + FOO1_BAR: { Description: 'First bundled', Units: 'm' }, + FOO2_BAR: { Description: 'Second bundled', Units: 'm' }, + OTHER: { Description: 'Unrelated' }, + } + const component = { + 'FOO{n}_BAR': { DisplayName: 'Detected source', Values: { 7: 'Target seven' } }, + } + + assert.deepEqual(mergedMetadataForParameter(bundled, component, 'FOO1_BAR'), { + Description: 'First bundled', + DisplayName: 'Detected source', + Units: 'm', + Values: { 7: 'Target seven' }, + }) + assert.deepEqual(mergedMetadataForParameter(bundled, component, 'FOO2_BAR'), { + Description: 'Second bundled', + DisplayName: 'Detected source', + Units: 'm', + Values: { 7: 'Target seven' }, + }) + assert.deepEqual(mergedMetadataForParameter(bundled, component, 'OTHER'), bundled.OTHER) + assert.equal(mergedMetadataForParameter(bundled, component, 'FOOX_BAR'), undefined) +}) diff --git a/core/frontend/vite.config.js b/core/frontend/vite.config.js index 2fc103124d..32dbbf93bc 100644 --- a/core/frontend/vite.config.js +++ b/core/frontend/vite.config.js @@ -55,6 +55,7 @@ export default defineConfig(({ command, mode }) => { }), sentryVitePlugin({ authToken: process.env.SENTRY_AUTH_TOKEN, + disable: !process.env.SENTRY_AUTH_TOKEN, org: "blue-robotics-c7", project: "blueos", }), diff --git a/core/services/ardupilot_manager/api/v1/routers/index.py b/core/services/ardupilot_manager/api/v1/routers/index.py index ea7566653e..b9077443b5 100644 --- a/core/services/ardupilot_manager/api/v1/routers/index.py +++ b/core/services/ardupilot_manager/api/v1/routers/index.py @@ -15,8 +15,17 @@ from commonwealth.utils.apis import StackedHTTPException from commonwealth.utils.decorators import single_threaded from exceptions import InvalidFirmwareFile, NoDefaultFirmwareAvailable -from fastapi import APIRouter, Body, File, HTTPException, UploadFile, status -from fastapi.responses import PlainTextResponse +from fastapi import ( + APIRouter, + Body, + File, + HTTPException, + Request, + Response, + UploadFile, + status, +) +from fastapi.responses import JSONResponse, PlainTextResponse from fastapi_versioning import versioned_api_route from loguru import logger from typedefs import ( @@ -130,6 +139,22 @@ async def get_vehicle_type() -> Any: return PlainTextResponse(f"Failed to get vehicle type: {error}", status_code=500) +@index_router_v1.get("/parameter_metadata", summary="Get vehicle-provided MAVLink parameter metadata.") +async def get_parameter_metadata(request: Request) -> Response: + manager = autopilot.parameter_metadata_manager + if manager is not None: + try: + await manager.refresh_from_vehicle() + except Exception as error: + logger.warning(f"Failed to refresh vehicle parameter metadata: {error}") + snapshot = manager.snapshot if manager is not None else None + if snapshot is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + if request.headers.get("if-none-match") == snapshot.etag: + return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": snapshot.etag}) + return JSONResponse(content=snapshot.document, headers={"ETag": snapshot.etag}) + + @index_router_v1.post("/sitl_frame", summary="Set SITL Frame type.") @index_to_http_exception async def set_sitl_frame(frame: SITLFrame) -> Any: diff --git a/core/services/ardupilot_manager/autopilot_manager.py b/core/services/ardupilot_manager/autopilot_manager.py index 5124d5762f..056d2f866b 100644 --- a/core/services/ardupilot_manager/autopilot_manager.py +++ b/core/services/ardupilot_manager/autopilot_manager.py @@ -23,6 +23,7 @@ from mavlink_proxy.Endpoint import Endpoint, EndpointType from mavlink_proxy.exceptions import EndpointAlreadyExists from mavlink_proxy.Manager import Manager as MavlinkManager +from parameter_metadata import ParameterMetadataManager from settings import Settings from typedefs import ( Firmware, @@ -45,6 +46,7 @@ def __init__(self) -> None: self._current_board: Optional[FlightController] = None self.should_be_running = False self.mavlink_manager = MavlinkManager() + self.parameter_metadata_manager: Optional[ParameterMetadataManager] = None # Load settings and do the initial configuration if self.settings.load(): @@ -159,6 +161,7 @@ async def setup(self) -> None: self.settings.firmware_folder, self.settings.defaults_folder, self.settings.user_firmware_folder ) self.vehicle_manager = VehicleManager() + self.parameter_metadata_manager = ParameterMetadataManager(self.vehicle_manager) self._heartbeat_fail_count = 0 # Consecutive heartbeat failures self._max_heartbeat_failures = 10 # Threshold for restarting Ardupilot after consecutive heartbeat failures @@ -622,6 +625,8 @@ async def prune_ardupilot_processes(self) -> None: async def kill_ardupilot(self) -> None: self.should_be_running = False + if self.parameter_metadata_manager is not None: + self.parameter_metadata_manager.reset_for_boot() if not self.current_board or self.current_board.platform != Platform.SITL: try: logger.info("Disarming vehicle.") @@ -681,12 +686,24 @@ async def start_ardupilot(self) -> None: self.should_be_running = True async def restart_ardupilot(self) -> None: + if self.parameter_metadata_manager is not None: + self.parameter_metadata_manager.reset_for_boot() if self.current_board is None or self.current_board.type in [PlatformType.SITL, PlatformType.Linux]: await self.kill_ardupilot() await self.start_ardupilot() return await self.vehicle_manager.reboot_vehicle() + async def update_parameter_metadata(self) -> None: + while True: + manager = self.parameter_metadata_manager + if manager is not None: + try: + await manager.refresh(allow_request=self.should_be_running) + except Exception as error: + logger.warning(f"Parameter metadata update failed: {error}") + await asyncio.sleep(1.0) + def _get_configuration_endpoints(self) -> Set[Endpoint]: return {Endpoint(**endpoint) for endpoint in self.configuration.get("endpoints") or []} diff --git a/core/services/ardupilot_manager/main.py b/core/services/ardupilot_manager/main.py index 6e539a6e9e..6d2c5f25db 100755 --- a/core/services/ardupilot_manager/main.py +++ b/core/services/ardupilot_manager/main.py @@ -45,13 +45,19 @@ async def main() -> None: except Exception as start_error: logger.exception(start_error) else: + await autopilot.setup() logger.info("Autopilot was stopped by the user, skipping auto-start on boot.") asyncio.create_task(autopilot.auto_restart_ardupilot()) asyncio.create_task(autopilot.start_mavlink_manager_watchdog()) - - await server.serve() - await autopilot.kill_ardupilot() + parameter_metadata_task = asyncio.create_task(autopilot.update_parameter_metadata()) + + try: + await server.serve() + finally: + parameter_metadata_task.cancel() + await asyncio.gather(parameter_metadata_task, return_exceptions=True) + await autopilot.kill_ardupilot() if __name__ == "__main__": diff --git a/core/services/ardupilot_manager/parameter_metadata.py b/core/services/ardupilot_manager/parameter_metadata.py new file mode 100644 index 0000000000..090ddf345d --- /dev/null +++ b/core/services/ardupilot_manager/parameter_metadata.py @@ -0,0 +1,465 @@ +import asyncio +import json +import lzma +import time +import zlib +from dataclasses import dataclass +from typing import Any, Dict, Optional + +import aiohttp +from commonwealth.mavlink_comm.VehicleManager import VehicleManager +from loguru import logger + +COMPONENT_METADATA_MESSAGE_ID = 397 +PARAMETER_METADATA_TYPE = 1 +MAVFTP_ACK = 128 +MAVFTP_NACK = 129 +MAVFTP_OPEN_FILE_READ_ONLY = 4 +MAVFTP_READ_FILE = 5 +MAVFTP_TERMINATE_SESSION = 1 +MAVFTP_PAYLOAD_SIZE = 251 +MAVFTP_DATA_SIZE = 239 +MAX_METADATA_FILE_SIZE = 2 * 1024 * 1024 + + +class ParameterMetadataError(RuntimeError): + pass + + +@dataclass(frozen=True) +class ParameterMetadataSnapshot: + document: Dict[str, Any] + file_crc: int + + @property + def etag(self) -> str: + return f'"{self.file_crc}"' + + +@dataclass +class AdvertisementState: + counter: int = -1 + key: Optional[tuple[int, str]] = None + baseline_initialized: bool = False + failed_key: Optional[tuple[int, str]] = None + failed_attempts: int = 0 + retry_at: float = 0.0 + + +@dataclass +class RequestState: + attempts: int = 0 + next_at: float = 0.0 + + +@dataclass(frozen=True) +class MavlinkFtpReply: + session: int + data: bytes + + +class MavlinkFtpReader: + def __init__(self, vehicle_manager: VehicleManager) -> None: + self._vehicle_manager = vehicle_manager + self._lock = asyncio.Lock() + self._sequence = 0 + self._target_component = vehicle_manager.target_component + + async def download(self, uri: str) -> bytes: + path, target_component = self._path_from_uri(uri) + async with self._lock: + self._target_component = target_component or self._vehicle_manager.target_component + try: + return await asyncio.wait_for(self._download(path), timeout=45.0) + finally: + self._target_component = self._vehicle_manager.target_component + + async def _download(self, path: str) -> bytes: + session = 0 + opened = False + try: + open_reply = await self._request(session, MAVFTP_OPEN_FILE_READ_ONLY, 0, path.encode("utf-8")) + opened = True + session = open_reply.session + if len(open_reply.data) < 4: + raise ParameterMetadataError("MAVFTP open response did not include a file size") + file_size = int.from_bytes(open_reply.data[:4], byteorder="little") + if file_size > MAX_METADATA_FILE_SIZE: + raise ParameterMetadataError(f"metadata file is too large: {file_size} bytes") + + content = bytearray() + while len(content) < file_size: + read_size = min(MAVFTP_DATA_SIZE, file_size - len(content)) + read_reply = await self._request(session, MAVFTP_READ_FILE, len(content), read_size) + if read_reply.session != session: + raise ParameterMetadataError("MAVFTP session changed during download") + if not read_reply.data or len(read_reply.data) > read_size: + raise ParameterMetadataError("MAVFTP returned an invalid read size") + content.extend(read_reply.data) + if len(content) != file_size: + raise ParameterMetadataError("MAVFTP returned an unexpected file size") + return bytes(content) + finally: + if opened: + try: + await self._request(session, MAVFTP_TERMINATE_SESSION, 0) + except Exception as error: # A failed close must not replace the download result. + logger.warning(f"Failed to close MAVFTP metadata session: {error}") + + async def _request( + self, + session: int, + opcode: int, + offset: int, + data_or_size: bytes | int = b"", + ) -> MavlinkFtpReply: + data = data_or_size if isinstance(data_or_size, bytes) else b"" + size = len(data) if isinstance(data_or_size, bytes) else data_or_size + if len(data) > MAVFTP_DATA_SIZE: + raise ParameterMetadataError("MAVFTP request data is too large") + payload = [0] * MAVFTP_PAYLOAD_SIZE + payload[0:2] = self._sequence.to_bytes(2, byteorder="little") + payload[2] = session + payload[3] = opcode + payload[4] = size + payload[8:12] = offset.to_bytes(4, byteorder="little") + payload[12 : 12 + len(data)] = data + + baseline_counter = await self._reply_counter() + message = { + "type": "FILE_TRANSFER_PROTOCOL", + "target_network": 0, + "target_system": self._vehicle_manager.target_system, + "target_component": self._target_component, + "payload": payload, + } + for attempt in range(3): + await self._vehicle_manager.mavlink2rest.send_mavlink_message(message) + try: + return await self._wait_for_reply(baseline_counter, opcode) + except asyncio.TimeoutError: + if attempt == 2: + raise ParameterMetadataError("MAVFTP request timed out") from None + raise ParameterMetadataError("MAVFTP request failed") + + async def _reply_counter(self) -> int: + try: + response = await self._vehicle_manager.mavlink2rest.get_mavlink_message( + "FILE_TRANSFER_PROTOCOL", + self._vehicle_manager.target_system, + self._target_component, + ) + return int(response["status"]["time"]["counter"]) + except Exception: + return -1 + + async def _wait_for_reply(self, baseline_counter: int, request_opcode: int) -> MavlinkFtpReply: + deadline = time.monotonic() + 0.75 + expected_sequence = (self._sequence + 1) & 0xFFFF + while time.monotonic() < deadline: + try: + response = await self._vehicle_manager.mavlink2rest.get_mavlink_message( + "FILE_TRANSFER_PROTOCOL", + self._vehicle_manager.target_system, + self._target_component, + ) + counter = int(response["status"]["time"]["counter"]) + payload = response["message"]["payload"] + reply_sequence = int.from_bytes(bytes(payload[0:2]), byteorder="little") + if counter != baseline_counter and reply_sequence == expected_sequence and payload[5] == request_opcode: + self._sequence = reply_sequence + if payload[3] == MAVFTP_NACK: + error = payload[12] if payload[4] else "unknown" + raise ParameterMetadataError(f"MAVFTP request was rejected: {error}") + if payload[3] != MAVFTP_ACK: + raise ParameterMetadataError(f"unexpected MAVFTP opcode: {payload[3]}") + return MavlinkFtpReply(session=payload[2], data=bytes(payload[12 : 12 + payload[4]])) + except ParameterMetadataError: + raise + except Exception: + pass + await asyncio.sleep(0.02) + raise asyncio.TimeoutError + + @staticmethod + def _path_from_uri(uri: str) -> tuple[str, Optional[int]]: + if not uri.startswith("mftp://") or "?" in uri or "#" in uri: + raise ParameterMetadataError(f"unsupported metadata URI: {uri}") + path = uri.removeprefix("mftp://") + target_component = None + if path.startswith("comp="): + selector, separator, path = path.partition(":") + component_text = selector.removeprefix("comp=") + if not separator or not component_text.isdigit(): + raise ParameterMetadataError(f"invalid MAVFTP component selector: {uri}") + target_component = int(component_text) + if not 1 <= target_component <= 255: + raise ParameterMetadataError(f"invalid MAVFTP component selector: {uri}") + + relative_parts = path.removeprefix("/").split("/") + if ( + not path + or any(part in ("", ".", "..") for part in relative_parts) + or len(path.encode("utf-8")) > MAVFTP_DATA_SIZE + ): + raise ParameterMetadataError(f"invalid MAVFTP metadata path: {path}") + return path, target_component + + +class MetadataFileReader: + def __init__(self, vehicle_manager: VehicleManager) -> None: + self._mavftp = MavlinkFtpReader(vehicle_manager) + + async def download(self, uri: str) -> bytes: + if uri.startswith("mftp://"): + return await self._mavftp.download(uri) + if uri.startswith("https://"): + return await self._download_https(uri) + raise ParameterMetadataError(f"unsupported metadata URI: {uri}") + + @staticmethod + async def _download_https(uri: str) -> bytes: + timeout = aiohttp.ClientTimeout(total=15.0, connect=3.0, sock_read=5.0) + async with aiohttp.ClientSession(timeout=timeout) as session: + async with session.get(uri, allow_redirects=True) as response: + response.raise_for_status() + if response.url.scheme != "https": + raise ParameterMetadataError("metadata HTTPS redirect changed scheme") + content_length = response.headers.get("Content-Length") + if content_length is not None and int(content_length) > MAX_METADATA_FILE_SIZE: + raise ParameterMetadataError("metadata HTTPS file is too large") + content = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + content.extend(chunk) + if len(content) > MAX_METADATA_FILE_SIZE: + raise ParameterMetadataError("metadata HTTPS file is too large") + return bytes(content) + + +class ParameterMetadataManager: + def __init__( + self, + vehicle_manager: VehicleManager, + file_reader: Optional[MetadataFileReader] = None, + ) -> None: + self._vehicle_manager = vehicle_manager + self._file_reader = file_reader or MetadataFileReader(vehicle_manager) + self._refresh_lock = asyncio.Lock() + self._advertisement = AdvertisementState() + self._request = RequestState() + self._snapshot: Optional[ParameterMetadataSnapshot] = None + self._generation = 0 + + @property + def snapshot(self) -> Optional[ParameterMetadataSnapshot]: + return self._snapshot + + def reset_for_boot(self) -> None: + self._generation += 1 + self._advertisement = AdvertisementState() + self._request = RequestState() + self._snapshot = None + + async def refresh_from_vehicle(self) -> None: + async with self._refresh_lock: + baseline = await self._latest_advertisement() + baseline_counter = baseline[0] if baseline is not None else -1 + await self._vehicle_manager.request_message(COMPONENT_METADATA_MESSAGE_ID) + + deadline = time.monotonic() + 1.0 + while time.monotonic() < deadline: + advertisement = await self._latest_advertisement() + if advertisement is not None and advertisement[0] != baseline_counter: + self._advertisement.counter = advertisement[0] + self._advertisement.baseline_initialized = True + await self._update_from_advertisement(advertisement[1], time.monotonic()) + return + await asyncio.sleep(0.02) + + async def refresh(self, allow_request: bool = True) -> None: + if self._refresh_lock.locked(): + return + async with self._refresh_lock: + baseline = await self._latest_advertisement() + if not self._advertisement.baseline_initialized: + self._advertisement.counter = baseline[0] if allow_request and baseline is not None else -1 + self._advertisement.baseline_initialized = True + + now = time.monotonic() + if self._request.attempts < 3 and now >= self._request.next_at and self._snapshot is None and allow_request: + try: + await self._vehicle_manager.request_message(COMPONENT_METADATA_MESSAGE_ID) + self._request.attempts += 1 + self._request.next_at = now + 2.0 + except Exception as error: + self._request.attempts += 1 + self._request.next_at = now + 2.0 + logger.warning(f"Failed to request parameter metadata advertisement: {error}") + return + + advertisement = await self._latest_advertisement() + if advertisement is None: + return + if advertisement[0] == self._advertisement.counter and self._advertisement.failed_key is None: + return + if advertisement[0] != self._advertisement.counter and self._advertisement.failed_key is not None: + self._advertisement.failed_key = None + self._advertisement.failed_attempts = 0 + self._advertisement.retry_at = 0.0 + self._advertisement.counter = advertisement[0] + await self._update_from_advertisement(advertisement[1], now) + + async def _update_from_advertisement(self, advertisement: Dict[str, Any], now: float) -> None: + try: + advertisement_key = ( + self._require_crc(advertisement.get("file_crc")), + self._decode_uri(advertisement.get("uri")), + ) + except ParameterMetadataError as error: + logger.warning(f"Failed to update parameter metadata: {error}") + return + if advertisement_key == self._advertisement.key: + return + if advertisement_key != self._advertisement.failed_key: + self._advertisement.failed_key = None + self._advertisement.failed_attempts = 0 + self._advertisement.retry_at = 0.0 + elif self._advertisement.failed_attempts >= 3 or now < self._advertisement.retry_at: + return + generation = self._generation + try: + snapshot = await self._load_snapshot(advertisement) + except Exception as error: + self._advertisement.failed_key = advertisement_key + self._advertisement.failed_attempts += 1 + self._advertisement.retry_at = now + 2 ** (self._advertisement.failed_attempts - 1) + logger.warning(f"Failed to update parameter metadata: {error}") + return + if generation == self._generation: + self._snapshot = snapshot + self._advertisement.key = advertisement_key + self._advertisement.failed_key = None + self._advertisement.failed_attempts = 0 + self._advertisement.retry_at = 0.0 + + async def _latest_advertisement(self) -> Optional[tuple[int, Dict[str, Any]]]: + try: + response = await self._vehicle_manager.mavlink2rest.get_mavlink_message( + "COMPONENT_METADATA", + self._vehicle_manager.target_system, + self._vehicle_manager.target_component, + ) + return int(response["status"]["time"]["counter"]), response["message"] + except Exception: + return None + + async def _load_snapshot(self, advertisement: Dict[str, Any]) -> ParameterMetadataSnapshot: + general_crc = self._require_crc(advertisement.get("file_crc")) + general_uri = self._decode_uri(advertisement.get("uri")) + if not general_uri.startswith("mftp://"): + raise ParameterMetadataError("general metadata must use MAVFTP") + general_file = await self._file_reader.download(general_uri) + self._validate_crc(general_file, general_crc) + general = self._decode_document(self._decompress_if_needed(general_uri, general_file)) + + metadata_types = general.get("metadataTypes") + general_version = general.get("version") + if not isinstance(general_version, int) or general_version < 1: + raise ParameterMetadataError("general metadata version must be at least 1") + if not isinstance(metadata_types, list): + raise ParameterMetadataError("general metadata has no metadataTypes list") + parameter_metadata = next( + ( + metadata + for metadata in metadata_types + if isinstance(metadata, dict) and metadata.get("type") == PARAMETER_METADATA_TYPE + ), + None, + ) + if parameter_metadata is None: + raise ParameterMetadataError("general metadata has no parameter metadata entry") + + parameter_crc = self._require_crc(parameter_metadata.get("fileCrc")) + parameter_uri = parameter_metadata.get("uri") + if not isinstance(parameter_uri, str): + raise ParameterMetadataError("parameter metadata URI is invalid") + parameter_file = await self._file_reader.download(parameter_uri) + self._validate_crc(parameter_file, parameter_crc) + document = self._decode_document(self._decompress_if_needed(parameter_uri, parameter_file)) + self._validate_parameter_document(document) + return ParameterMetadataSnapshot(document=document, file_crc=parameter_crc) + + @staticmethod + def _decode_uri(value: Any) -> str: + if isinstance(value, str): + return value.rstrip("\x00") + if isinstance(value, list) and all(isinstance(item, str) and len(item) == 1 for item in value): + return "".join(value).split("\x00", maxsplit=1)[0] + if not isinstance(value, list) or not all(isinstance(item, int) and 0 <= item <= 255 for item in value): + raise ParameterMetadataError("component metadata URI is invalid") + try: + return bytes(value).split(b"\x00", maxsplit=1)[0].decode("utf-8") + except UnicodeDecodeError as error: + raise ParameterMetadataError("component metadata URI is not UTF-8") from error + + @staticmethod + def _decode_document(content: bytes) -> Dict[str, Any]: + try: + document = json.loads(content) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ParameterMetadataError("metadata file is not valid JSON") from error + if not isinstance(document, dict): + raise ParameterMetadataError("metadata document must be an object") + return document + + @staticmethod + def _decompress_if_needed(uri: str, content: bytes) -> bytes: + if not uri.split("?", maxsplit=1)[0].lower().endswith(".xz"): + return content + decompressor = lzma.LZMADecompressor(format=lzma.FORMAT_XZ, memlimit=32 * 1024 * 1024) + output = bytearray() + pending = content + try: + while True: + size_before = len(output) + remaining = MAX_METADATA_FILE_SIZE + 1 - size_before + output.extend(decompressor.decompress(pending, max_length=remaining)) + pending = b"" + if len(output) > MAX_METADATA_FILE_SIZE: + raise ParameterMetadataError("decompressed metadata file is too large") + if bool(getattr(decompressor, "eof")): + if getattr(decompressor, "unused_data"): + raise ParameterMetadataError("metadata XZ file has trailing data") + return bytes(output) + if bool(getattr(decompressor, "needs_input")): + raise ParameterMetadataError("metadata XZ file is truncated") + if len(output) == size_before: + raise ParameterMetadataError("metadata XZ file is invalid") + except lzma.LZMAError as error: + raise ParameterMetadataError("metadata XZ file is invalid") from error + + @staticmethod + def _require_crc(value: Any) -> int: + if not isinstance(value, int) or not 0 <= value <= 0xFFFFFFFF: + raise ParameterMetadataError("metadata CRC is invalid") + return value + + @staticmethod + def _validate_crc(content: bytes, expected: int) -> None: + actual = zlib.crc32(content, 0xFFFFFFFF) ^ 0xFFFFFFFF + if actual != expected: + raise ParameterMetadataError(f"metadata CRC mismatch: expected {expected}, got {actual}") + + @staticmethod + def _validate_parameter_document(document: Dict[str, Any]) -> None: + version = document.get("version") + if not isinstance(version, int) or version < 3 or not isinstance(document.get("parameters"), list): + raise ParameterMetadataError("parameter metadata version must be at least 3") + names = set() + for parameter in document["parameters"]: + if not isinstance(parameter, dict) or not isinstance(parameter.get("name"), str): + raise ParameterMetadataError("parameter metadata contains an invalid entry") + name = parameter["name"] + if not name or name in names: + raise ParameterMetadataError(f"parameter metadata contains an invalid name: {name}") + names.add(name) diff --git a/core/services/ardupilot_manager/test_parameter_metadata.py b/core/services/ardupilot_manager/test_parameter_metadata.py new file mode 100644 index 0000000000..c060071c02 --- /dev/null +++ b/core/services/ardupilot_manager/test_parameter_metadata.py @@ -0,0 +1,469 @@ +import asyncio +import json +import lzma +import zlib +from types import SimpleNamespace +from typing import Any, Dict, Optional, cast +from unittest.mock import patch + +from parameter_metadata import ( + MAX_METADATA_FILE_SIZE, + MavlinkFtpReader, + MetadataFileReader, + ParameterMetadataError, + ParameterMetadataManager, +) + + +def metadata_crc(content: bytes) -> int: + return zlib.crc32(content, 0xFFFFFFFF) ^ 0xFFFFFFFF + + +def test_decode_uri_accepts_mavlink2rest_character_array() -> None: + uri = list("mftp://@META/general.json") + ["\x00"] * 74 + assert ParameterMetadataManager._decode_uri(uri) == "mftp://@META/general.json" + + +class FakeMavlinkMessenger: + def __init__(self) -> None: + self.advertisement: Optional[Dict[str, Any]] = None + + async def get_mavlink_message(self, message_name: str, vehicle: int, component: int) -> Dict[str, Any]: + del vehicle, component + if message_name != "COMPONENT_METADATA" or self.advertisement is None: + raise RuntimeError("message unavailable") + return self.advertisement + + +class FakeFtpMessenger: + def __init__(self, content: bytes) -> None: + self.content = content + self.counter = 0 + self.response: Optional[Dict[str, Any]] = None + self.sessions: list[int] = [] + self.components: list[int] = [] + + async def get_mavlink_message(self, message_name: str, vehicle: int, component: int) -> Dict[str, Any]: + del vehicle, component + if message_name != "FILE_TRANSFER_PROTOCOL" or self.response is None: + raise RuntimeError("message unavailable") + return self.response + + async def send_mavlink_message(self, message: Dict[str, Any]) -> None: + self.components.append(message["target_component"]) + request = message["payload"] + request_sequence = int.from_bytes(bytes(request[0:2]), byteorder="little") + request_session = request[2] + opcode = request[3] + self.sessions.append(request_session) + + if opcode == 4: + response_session = 7 + data = len(self.content).to_bytes(4, byteorder="little") + elif opcode == 5: + assert request_session == 7 + offset = int.from_bytes(bytes(request[8:12]), byteorder="little") + data = self.content[offset : offset + request[4]] + response_session = request_session + else: + assert opcode == 1 + assert request_session == 7 + data = b"" + response_session = request_session + + payload = [0] * 251 + payload[0:2] = ((request_sequence + 1) & 0xFFFF).to_bytes(2, byteorder="little") + payload[2] = response_session + payload[3] = 128 + payload[4] = len(data) + payload[5] = opcode + payload[12 : 12 + len(data)] = data + self.counter += 1 + self.response = { + "status": {"time": {"counter": self.counter}}, + "message": {"payload": payload}, + } + + +class FakeFtpVehicleManager: + def __init__(self, content: bytes) -> None: + self.target_system = 1 + self.target_component = 1 + self.mavlink2rest = FakeFtpMessenger(content) + + +class FakeVehicleManager: + def __init__(self) -> None: + self.target_system = 1 + self.target_component = 1 + self.mavlink2rest = FakeMavlinkMessenger() + self.request_count = 0 + self.advertisement_on_request: Optional[Dict[str, Any]] = None + + async def request_message(self, message_id: int) -> None: + assert message_id == 397 + self.request_count += 1 + if self.advertisement_on_request is not None: + self.mavlink2rest.advertisement = self.advertisement_on_request + + +class FakeFtpReader: + def __init__(self, files: Dict[str, bytes]) -> None: + self.files = files + self.downloads: list[str] = [] + + async def download(self, uri: str) -> bytes: + self.downloads.append(uri) + return self.files[uri] + + +class FailingFileReader(FakeFtpReader): + def __init__(self, files: Dict[str, bytes]) -> None: + super().__init__(files) + self.failures_remaining = 0 + self.attempt_count = 0 + + async def download(self, uri: str) -> bytes: + self.attempt_count += 1 + if self.failures_remaining: + self.failures_remaining -= 1 + raise ParameterMetadataError("temporary transfer failure") + return await super().download(uri) + + +def documents(parameter_names: list[str], version: int = 3) -> tuple[bytes, bytes]: + parameters = json.dumps( + { + "version": version, + "parameters": [{"name": name, "type": "Int32", "default": 0} for name in parameter_names], + }, + separators=(",", ":"), + ).encode() + general = json.dumps( + { + "version": 1, + "metadataTypes": [ + { + "type": 1, + "uri": "mftp://@META/parameters.json", + "fileCrc": metadata_crc(parameters), + "uriFallback": "", + "fileCrcFallback": 0, + } + ], + }, + separators=(",", ":"), + ).encode() + return general, parameters + + +def advertisement(counter: int, general: bytes) -> Dict[str, Any]: + uri = list(b"mftp://@META/general.json\x00") + return { + "status": {"time": {"counter": counter}}, + "message": {"file_crc": metadata_crc(general), "uri": uri}, + } + + +def test_initial_hotswap_duplicate_and_invalid_metadata() -> None: + async def run() -> None: + vehicle = FakeVehicleManager() + first_general, first_parameters = documents(["EK3_SRC1_TARGET"]) + files = { + "mftp://@META/general.json": first_general, + "mftp://@META/parameters.json": first_parameters, + } + ftp = FakeFtpReader(files) + manager = ParameterMetadataManager(cast(Any, vehicle), cast(Any, ftp)) + + await manager.refresh() + assert vehicle.request_count == 1 + assert manager.__dict__["_snapshot"] is None + + vehicle.mavlink2rest.advertisement = advertisement(1, first_general) + await manager.refresh() + first_snapshot = manager.snapshot + assert first_snapshot is not None + assert first_snapshot.document["parameters"][0]["name"] == "EK3_SRC1_TARGET" + assert first_snapshot.etag == f'"{metadata_crc(first_parameters)}"' + assert ftp.downloads == [ + "mftp://@META/general.json", + "mftp://@META/parameters.json", + ] + + vehicle.mavlink2rest.advertisement = advertisement(2, first_general) + await manager.refresh() + assert len(ftp.downloads) == 2 + + second_general, second_parameters = documents(["EK3_SRC1_TARGET", "EK3_SRC2_TARGET"]) + files["mftp://@META/general.json"] = second_general + files["mftp://@META/parameters.json"] = second_parameters + vehicle.mavlink2rest.advertisement = advertisement(3, second_general) + await manager.refresh() + assert manager.snapshot is not None + assert len(manager.snapshot.document["parameters"]) == 2 + + stale_snapshot = manager.snapshot + bad_advertisement = advertisement(4, second_general) + bad_advertisement["message"]["file_crc"] += 1 + vehicle.mavlink2rest.advertisement = bad_advertisement + await manager.refresh() + assert manager.snapshot is stale_snapshot + + manager.reset_for_boot() + assert manager.__dict__["_snapshot"] is None + await manager.refresh() + assert vehicle.request_count == 2 + + asyncio.run(run()) + + +def test_passive_refresh_ingests_existing_advertisement_without_requesting() -> None: + async def run() -> None: + vehicle = FakeVehicleManager() + general, parameters = documents(["EK3_SRC1_TARGET"]) + reader = FakeFtpReader( + { + "mftp://@META/general.json": general, + "mftp://@META/parameters.json": parameters, + } + ) + vehicle.mavlink2rest.advertisement = advertisement(1, general) + manager = ParameterMetadataManager(cast(Any, vehicle), cast(Any, reader)) + + await manager.refresh(allow_request=False) + + assert vehicle.request_count == 0 + assert manager._request.attempts == 0 + assert manager.snapshot is not None + assert manager.snapshot.document["parameters"][0]["name"] == "EK3_SRC1_TARGET" + + asyncio.run(run()) + + +def test_explicit_refresh_requests_current_metadata_from_vehicle() -> None: + async def run() -> None: + vehicle = FakeVehicleManager() + first_general, first_parameters = documents(["EK3_SRC1_TARGET"]) + files = { + "mftp://@META/general.json": first_general, + "mftp://@META/parameters.json": first_parameters, + } + reader = FakeFtpReader(files) + manager = ParameterMetadataManager(cast(Any, vehicle), cast(Any, reader)) + + vehicle.mavlink2rest.advertisement = advertisement(1, first_general) + await manager.refresh(allow_request=False) + assert manager.snapshot is not None + assert len(manager.snapshot.document["parameters"]) == 1 + + second_general, second_parameters = documents(["EK3_SRC1_TARGET", "EK3_SRC2_TARGET"]) + files["mftp://@META/general.json"] = second_general + files["mftp://@META/parameters.json"] = second_parameters + vehicle.advertisement_on_request = advertisement(2, second_general) + + await manager.refresh_from_vehicle() + + assert vehicle.request_count == 1 + assert manager.snapshot is not None + assert len(manager.snapshot.document["parameters"]) == 2 + + asyncio.run(run()) + + +def test_metadata_validation_and_mavftp_uri_boundaries() -> None: + valid = b'{"version":3,"parameters":[]}' + assert metadata_crc(b"123456789") == 0x2DFD2D88 + ParameterMetadataManager._validate_crc(valid, metadata_crc(valid)) + assert MavlinkFtpReader._path_from_uri("mftp://@META/parameters.json") == ( + "@META/parameters.json", + None, + ) + assert MavlinkFtpReader._path_from_uri("mftp:///absolute/parameters.json") == ( + "/absolute/parameters.json", + None, + ) + assert MavlinkFtpReader._path_from_uri("mftp://comp=42:@META/parameters.json") == ( + "@META/parameters.json", + 42, + ) + + for uri in ( + "https://example.com/parameters.json", + "mftp://@META/../parameters.json", + ): + try: + MavlinkFtpReader._path_from_uri(uri) + except ParameterMetadataError: + continue + raise AssertionError(f"invalid URI was accepted: {uri}") + + try: + ParameterMetadataManager._validate_crc(valid, 0) + except ParameterMetadataError: + pass + else: + raise AssertionError("invalid CRC was accepted") + + +def test_mavftp_download_uses_server_assigned_session() -> None: + async def run() -> None: + content = bytes(index % 251 for index in range(500)) + vehicle = FakeFtpVehicleManager(content) + reader = MavlinkFtpReader(cast(Any, vehicle)) + assert await reader.download("mftp://comp=7:@META/parameters.json") == content + assert vehicle.mavlink2rest.sessions[0] == 0 + assert all(session == 7 for session in vehicle.mavlink2rest.sessions[1:]) + assert all(component == 7 for component in vehicle.mavlink2rest.components) + + asyncio.run(run()) + + +def test_failed_hotswap_retries_same_advertisement_and_keeps_stale_snapshot() -> None: + async def run() -> None: + vehicle = FakeVehicleManager() + first_general, first_parameters = documents(["EK3_SRC1_TARGET"]) + files = { + "mftp://@META/general.json": first_general, + "mftp://@META/parameters.json": first_parameters, + } + reader = FailingFileReader(files) + manager = ParameterMetadataManager(cast(Any, vehicle), cast(Any, reader)) + await manager.refresh() + vehicle.mavlink2rest.advertisement = advertisement(1, first_general) + await manager.refresh() + original = manager.snapshot + assert original is not None + + second_general, second_parameters = documents(["EK3_SRC1_TARGET", "EK3_SRC2_TARGET"]) + files["mftp://@META/general.json"] = second_general + files["mftp://@META/parameters.json"] = second_parameters + reader.failures_remaining = 2 + vehicle.mavlink2rest.advertisement = advertisement(2, second_general) + await manager.refresh() + assert manager.snapshot is original + + manager._advertisement.retry_at = 0 + await manager.refresh() + assert manager.snapshot is original + + manager._advertisement.retry_at = 0 + await manager.refresh() + assert manager.snapshot is not None + assert len(manager.snapshot.document["parameters"]) == 2 + assert manager._advertisement.failed_attempts == 0 + + accepted_snapshot = manager.snapshot + third_general, third_parameters = documents(["EK3_SRC1_TARGET", "EK3_SRC2_TARGET", "EK3_SRC3_TARGET"]) + files["mftp://@META/general.json"] = third_general + files["mftp://@META/parameters.json"] = third_parameters + reader.failures_remaining = 4 + attempts_before = reader.attempt_count + vehicle.mavlink2rest.advertisement = advertisement(3, third_general) + for _ in range(4): + manager._advertisement.retry_at = 0 + await manager.refresh() + assert reader.attempt_count - attempts_before == 3 + assert manager.snapshot is accepted_snapshot + + reader.failures_remaining = 0 + vehicle.mavlink2rest.advertisement = advertisement(4, third_general) + await manager.refresh() + assert manager.snapshot is not None + assert len(manager.snapshot.document["parameters"]) == 3 + assert manager._advertisement.failed_attempts == 0 + + asyncio.run(run()) + + +def test_xz_crc_is_checked_before_decompression_and_version_four_is_accepted() -> None: + async def run() -> None: + vehicle = FakeVehicleManager() + general, parameters = documents(["EK3_SRC1_TARGET"], version=4) + compressed_parameters = lzma.compress(parameters, format=lzma.FORMAT_XZ) + general_document = json.loads(general) + general_document["metadataTypes"][0]["uri"] = "https://metadata.example/parameters.json.xz" + general_document["metadataTypes"][0]["fileCrc"] = metadata_crc(compressed_parameters) + compressed_general = lzma.compress( + json.dumps(general_document, separators=(",", ":")).encode(), + format=lzma.FORMAT_XZ, + ) + files = { + "mftp://@META/general.json.xz": compressed_general, + "https://metadata.example/parameters.json.xz": compressed_parameters, + } + reader = FakeFtpReader(files) + manager = ParameterMetadataManager(cast(Any, vehicle), cast(Any, reader)) + await manager.refresh() + uri = list(b"mftp://@META/general.json.xz\x00") + vehicle.mavlink2rest.advertisement = { + "status": {"time": {"counter": 1}}, + "message": {"file_crc": metadata_crc(compressed_general), "uri": uri}, + } + await manager.refresh() + assert manager.snapshot is not None + assert manager.snapshot.document["version"] == 4 + assert manager.snapshot.file_crc == metadata_crc(compressed_parameters) + + asyncio.run(run()) + + +class FakeHttpContent: + def __init__(self, content: bytes) -> None: + self._content = content + + async def iter_chunked(self, size: int) -> Any: + for offset in range(0, len(self._content), size): + yield self._content[offset : offset + size] + + +class FakeHttpResponse: + def __init__(self, content: bytes) -> None: + self.url = SimpleNamespace(scheme="https") + self.headers = {"Content-Length": str(len(content))} + self.content = FakeHttpContent(content) + + async def __aenter__(self) -> "FakeHttpResponse": + return self + + async def __aexit__(self, *args: Any) -> None: + del args + + def raise_for_status(self) -> None: + return None + + +class FakeHttpSession: + def __init__(self, content: bytes) -> None: + self._response = FakeHttpResponse(content) + + async def __aenter__(self) -> "FakeHttpSession": + return self + + async def __aexit__(self, *args: Any) -> None: + del args + + def get(self, uri: str, allow_redirects: bool) -> FakeHttpResponse: + assert uri.startswith("https://") + assert allow_redirects + return self._response + + +def test_https_download_is_bounded_without_new_dependencies() -> None: + async def run() -> None: + content = b'{"version":4,"parameters":[]}' + with patch( + "parameter_metadata.aiohttp.ClientSession", + return_value=FakeHttpSession(content), + ): + assert await MetadataFileReader._download_https("https://metadata.example/parameters.json") == content + + oversized = lzma.compress(b"x" * (MAX_METADATA_FILE_SIZE + 1)) + try: + ParameterMetadataManager._decompress_if_needed("metadata.json.xz", oversized) + except ParameterMetadataError: + pass + else: + raise AssertionError("oversized decompressed metadata was accepted") + + asyncio.run(run()) diff --git a/core/tools/mavlink2rest/bootstrap.sh b/core/tools/mavlink2rest/bootstrap.sh index 08184dfda6..9dba15eec4 100755 --- a/core/tools/mavlink2rest/bootstrap.sh +++ b/core/tools/mavlink2rest/bootstrap.sh @@ -3,7 +3,7 @@ # Immediately exit on errors set -e -VERSION="t0.11.25" +VERSION="0.11.26" REPOSITORY_ORG="mavlink" REPOSITORY_NAME="mavlink2rest" PROJECT_NAME="$REPOSITORY_NAME"