diff --git a/js/configurator_main.js b/js/configurator_main.js index 2660e1d69..8e2319f9a 100644 --- a/js/configurator_main.js +++ b/js/configurator_main.js @@ -598,7 +598,7 @@ $(function() { $('#languageOption').val(i18n.getCurrentLanguage()); $('#languageOption').on('change', () => { - i18n.changeLanguage($('#languageOption').val()); + i18n.changeLanguage($('#languageOption').val()).then(GUI.updateProfileNames); }); // Set the value of the unit type diff --git a/js/fc.js b/js/fc.js index 8a43bcb94..c4a2089af 100644 --- a/js/fc.js +++ b/js/fc.js @@ -47,6 +47,7 @@ var FC = { SENSOR_DATA: null, MOTOR_DATA: null, SERVO_DATA: null, + PROFILE_NAMES: null, GPS_DATA: null, ADSB_VEHICLES: null, ADSB_LIMITS: null, @@ -294,6 +295,7 @@ var FC = { this.MOTOR_DATA = new Array(8); this.SERVO_DATA = new Array(16); + this.PROFILE_NAMES = null; this.GPS_DATA = { fix: 0, diff --git a/js/gui.js b/js/gui.js index 887ecf429..eeebbf482 100644 --- a/js/gui.js +++ b/js/gui.js @@ -4,7 +4,8 @@ import MSP from './msp'; import FC from './fc'; import interval from './intervals'; import { scaleRangeInt } from './helpers'; -import i18n from './localization'; +import i18n from './localization'; +import { profileOptionLabel } from './profileNames'; import mspDeduplicationQueue from "./msp/mspDeduplicationQueue"; import mspQueue from './serial_queue'; @@ -242,6 +243,21 @@ GUI_control.prototype.updateProfileChange = function(refresh) { } }; +// Label the header profile dropdowns with the user-defined profile names (FC.PROFILE_NAMES), +// falling back to the plain translated labels when the firmware has none. +GUI_control.prototype.updateProfileNames = function () { + const names = FC.PROFILE_NAMES; + const relabel = function ($select, messageKey, list) { + $select.find('option').each(function (index) { + $(this).text(profileOptionLabel(i18n.getMessage(messageKey + (index + 1)), list ? list[index] : '')); + }); + }; + + relabel($('#profilechange'), 'sensorProfile', names ? names.control : null); + relabel($('#batteryprofilechange'), 'sensorBatteryProfile', names ? names.battery : null); + relabel($('#mixerprofilechange'), 'mixerProfile', names ? names.mixer : null); +}; + GUI_control.prototype.fillSelect = function ($element, values, currentValue, unit) { if (unit == null) { unit = ''; diff --git a/js/localization.js b/js/localization.js index 2c89dca62..9c13ef268 100644 --- a/js/localization.js +++ b/js/localization.js @@ -119,7 +119,7 @@ i18n.getLanguages = function() { i18n.changeLanguage = function(languageSelected) { store.set('userLanguage', languageSelected); - i18next.changeLanguage(i18n.getValidLocale(languageSelected)); + return i18next.changeLanguage(i18n.getValidLocale(languageSelected)); }; i18n.localize = function (reTranslate = false) { diff --git a/js/msp/MSPCodes.js b/js/msp/MSPCodes.js index df7cc7006..d05e3c559 100644 --- a/js/msp/MSPCodes.js +++ b/js/msp/MSPCodes.js @@ -241,6 +241,7 @@ var MSPCodes = { MSP2_INAV_EZ_TUNE_SET: 0x2071, MSP2_INAV_SELECT_MIXER_PROFILE: 0x2080, + MSP2_INAV_PROFILE_NAMES: 0x2082, MSP2_ADSB_VEHICLE_LIST: 0x2090, MSP2_ADSB_LIMITS: 0x2091, diff --git a/js/msp/MSPHelper.js b/js/msp/MSPHelper.js index 40a6cd935..0a53ac4b7 100644 --- a/js/msp/MSPHelper.js +++ b/js/msp/MSPHelper.js @@ -28,6 +28,7 @@ import Waypoint from './../waypoint'; import mspDeduplicationQueue from './mspDeduplicationQueue'; import mspStatistics from './mspStatistics'; import settingsCache from './../settingsCache'; +import { parseProfileNames } from './../profileNames'; import {Geozone, GeozoneVertex, GeozoneShapes } from './../geozone'; import { parseDronecanAsyncRequestResponse } from './../dronecanAsyncRequestParse'; @@ -165,6 +166,10 @@ var mspHelper = (function () { if (profile_changed > 0 || wasUninitialized) { GUI.updateProfileChange(profile_changed); } + if (wasUninitialized) { + // first status of this connection: fetch the profile names for the header dropdowns + MSP.send_message(MSPCodes.MSP2_INAV_PROFILE_NAMES, false, false); + } break; case MSPCodes.MSP_ACTIVEBOXES: @@ -721,6 +726,15 @@ var mspHelper = (function () { break; case MSPCodes.MSP_EEPROM_WRITE: console.log('Settings Saved in EEPROM'); + // a save may have renamed the active profile + MSP.send_message(MSPCodes.MSP2_INAV_PROFILE_NAMES, false, false); + break; + + case MSPCodes.MSP2_INAV_PROFILE_NAMES: + // null = firmware without the message (unsupported reply) or a malformed payload + FC.PROFILE_NAMES = dataHandler.unsupported ? null : parseProfileNames(data); + GUI.updateProfileNames(); + GUI.active_tab?.onProfileNamesChanged?.(); break; case MSPCodes.MSP_DEBUGMSG: for (var ii = 0; ii < data.byteLength; ii++) { @@ -3866,6 +3880,10 @@ var mspHelper = (function () { MSP.send_message(MSPCodes.MSP2_INAV_MIXER, false, false, callback); }; + self.loadProfileNames = function (callback) { + MSP.send_message(MSPCodes.MSP2_INAV_PROFILE_NAMES, false, false, callback); + }; + self.saveMixerConfig = function (callback) { MSP.send_message(MSPCodes.MSP2_INAV_SET_MIXER, mspHelper.crunch(MSPCodes.MSP2_INAV_SET_MIXER), false, callback); }; diff --git a/js/profileNames.js b/js/profileNames.js new file mode 100644 index 000000000..4a110cf1b --- /dev/null +++ b/js/profileNames.js @@ -0,0 +1,68 @@ +'use strict'; + +// Decoder for MSP2_INAV_PROFILE_NAMES: the user-defined names of every +// control, battery and mixer profile slot in one reply. +// +// uint8 maxNameLength (MAX_PROFILE_NAME_LENGTH in the firmware) +// uint8 controlProfileCount, then per slot: uint8 length + that many bytes +// uint8 batteryProfileCount, then per slot as above +// uint8 mixerProfileCount, then per slot as above +// +// An unnamed slot has length 0 and decodes to an empty string. + +function readNameList(data, cursor) { + if (cursor.offset >= data.byteLength) { + return null; + } + + const count = data.getUint8(cursor.offset++); + const names = []; + + for (let i = 0; i < count; i++) { + if (cursor.offset >= data.byteLength) { + return null; + } + const length = data.getUint8(cursor.offset++); + if (cursor.offset + length > data.byteLength) { + return null; + } + let name = ''; + for (let c = 0; c < length; c++) { + name += String.fromCodePoint(data.getUint8(cursor.offset++)); + } + names.push(name); + } + + return names; +} + +/** + * @param {DataView} data payload of an MSP2_INAV_PROFILE_NAMES reply + * @returns {{maxLength: number, control: string[], battery: string[], mixer: string[]}|null} + * null when the payload is truncated or malformed + */ +export function parseProfileNames(data) { + if (!data || data.byteLength < 4) { + return null; + } + + const cursor = { offset: 0 }; + const maxLength = data.getUint8(cursor.offset++); + const control = readNameList(data, cursor); + const battery = readNameList(data, cursor); + const mixer = readNameList(data, cursor); + + if (control === null || battery === null || mixer === null || cursor.offset !== data.byteLength) { + return null; + } + + return { maxLength: maxLength, control: control, battery: battery, mixer: mixer }; +} + +/** + * Label for a profile selector entry: the translated base label, plus the + * user's name when the slot has one. + */ +export function profileOptionLabel(baseLabel, name) { + return name ? baseLabel + ': ' + name : baseLabel; +} diff --git a/locale/en/messages.json b/locale/en/messages.json index 5cbde6fde..24237e7c4 100644 --- a/locale/en/messages.json +++ b/locale/en/messages.json @@ -2135,6 +2135,18 @@ "setMixerProfile": { "message": "Setting Mixer Profile: $1" }, + "controlProfileName": { + "message": "Control profile name" + }, + "batteryProfileName": { + "message": "Battery profile name" + }, + "mixerProfileName": { + "message": "Mixer profile name" + }, + "profileNameHelp": { + "message": "Optional name for this profile slot, shown next to its number in the profile dropdowns at the top. Up to 12 characters. Needs INAV 10 firmware." + }, "setBatteryProfile": { "message": "Setting Battery Profile: $1" }, @@ -4877,6 +4889,15 @@ "osdElement_ACTIVE_PROFILE": { "message": "Show the active profile" }, + "osdElement_CONTROL_PROFILE_NAME": { + "message": "Name of the active control profile (slot symbol and number when unnamed)" + }, + "osdElement_BATTERY_PROFILE_NAME": { + "message": "Name of the active battery profile (battery symbol and number when unnamed)" + }, + "osdElement_MIXER_PROFILE_NAME": { + "message": "Name of the active mixer profile (M and number when unnamed)" + }, "osdElement_LEVEL_PIDS": { "message": "Level PIDs" }, diff --git a/tabs/configuration.html b/tabs/configuration.html index 0ec2a2a20..1fb65048a 100644 --- a/tabs/configuration.html +++ b/tabs/configuration.html @@ -77,6 +77,13 @@
+
+ + +
+
diff --git a/tabs/mixer.html b/tabs/mixer.html index 372187d05..5d7787f16 100644 --- a/tabs/mixer.html +++ b/tabs/mixer.html @@ -9,6 +9,13 @@
+
+ + +
+
+
+
+ + +
+
+
diff --git a/tests/profile-names.test.mjs b/tests/profile-names.test.mjs new file mode 100644 index 000000000..6f2c301d4 --- /dev/null +++ b/tests/profile-names.test.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +/** + * Tests for parseProfileNames() / profileOptionLabel() (js/profileNames.js), + * the decoder for MSP2_INAV_PROFILE_NAMES used to label the header profile + * dropdowns. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseProfileNames, profileOptionLabel } from '../js/profileNames.js'; + +function buildPayload(maxLength, lists) { + const bytes = [maxLength]; + for (const list of lists) { + bytes.push(list.length); + for (const name of list) { + bytes.push(name.length); + for (const ch of name) { + bytes.push(ch.charCodeAt(0)); + } + } + } + return new DataView(Uint8Array.from(bytes).buffer); +} + +test('decodes three name lists with empty slots', () => { + const view = buildPayload(12, [['Cruise', '', 'Sport'], ['4S', '6S HV', ''], ['Quad', 'Plane']]); + + assert.deepEqual(parseProfileNames(view), { + maxLength: 12, + control: ['Cruise', '', 'Sport'], + battery: ['4S', '6S HV', ''], + mixer: ['Quad', 'Plane'], + }); +}); + +test('handles a single mixer profile and all-empty names', () => { + const view = buildPayload(12, [['', '', ''], ['', '', ''], ['']]); + const names = parseProfileNames(view); + + assert.equal(names.mixer.length, 1); + assert.deepEqual(names.control, ['', '', '']); +}); + +test('rejects truncated, trailing and empty payloads', () => { + const good = buildPayload(12, [['A'], ['B'], ['C']]); + + assert.equal(parseProfileNames(new DataView(good.buffer.slice(0, good.byteLength - 1))), null); + + const trailing = new Uint8Array(good.byteLength + 1); + trailing.set(new Uint8Array(good.buffer)); + assert.equal(parseProfileNames(new DataView(trailing.buffer)), null); + + assert.equal(parseProfileNames(new DataView(new ArrayBuffer(0))), null); + assert.equal(parseProfileNames(null), null); +}); + +test('option label appends the name only when there is one', () => { + assert.equal(profileOptionLabel('Control Profile 1', 'Cruise'), 'Control Profile 1: Cruise'); + assert.equal(profileOptionLabel('Control Profile 2', ''), 'Control Profile 2'); + assert.equal(profileOptionLabel('Control Profile 3', undefined), 'Control Profile 3'); +});