1
0
Fork 0
mirror of https://github.com/betaflight/betaflight-configurator.git synced 2025-07-15 12:25:15 +03:00

Added Virtual Mode

This commit is contained in:
visdauas 2020-12-28 17:24:34 +01:00
parent 09663e386e
commit 030a75a89e
15 changed files with 518 additions and 52 deletions

View file

@ -52,6 +52,13 @@
"portsSelectManual": {
"message": "Manual Selection"
},
"portsSelectVirtual": {
"message": "Virtual Mode (Experimental)",
"description": "Configure a Virtual Flight Controller without the need of a physical FC."
},
"virtualMSPVersion": {
"message": "Virtual Firmware Version"
},
"portOverrideText": {
"message": "Port:"
},

View file

@ -224,6 +224,13 @@ input[type="number"]::-webkit-inner-spin-button {
color: var(--subtleAccent);
}
#firmware-virtual-option {
height: 76px;
width: 180px;
margin-right: 15px;
display: none;
}
#port-override-option {
height: 76px;
margin-top: 7px;

212
src/js/VirtualFC.js Normal file
View file

@ -0,0 +1,212 @@
'use strict';
const VirtualFC = {
// these values are manufactured to unlock all the functionality of the configurator, they dont represent actual hardware
setVirtualConfig() {
const virtualFC = FC;
virtualFC.resetState();
virtualFC.CONFIG.flightControllerVersion = "4.2.4";
virtualFC.CONFIG.apiVersion = CONFIGURATOR.virtualApiVersion;
virtualFC.FEATURE_CONFIG.features = new Features(FC.CONFIG);
virtualFC.FEATURE_CONFIG.features.setMask(0);
virtualFC.BEEPER_CONFIG.beepers = new Beepers(FC.CONFIG);
virtualFC.BEEPER_CONFIG.dshotBeaconConditions = new Beepers(FC.CONFIG, [ "RX_LOST", "RX_SET" ]);
virtualFC.MIXER_CONFIG.mixer = 3;
virtualFC.MOTOR_DATA = Array.from({length: 8});
virtualFC.MOTOR_3D_CONFIG = true;
virtualFC.MOTOR_CONFIG = {
minthrottle: 1070,
maxthrottle: 2000,
mincommand: 1000,
motor_count: 4,
motor_poles: 14,
use_dshot_telemetry: true,
use_esc_sensor: false,
};
virtualFC.SERVO_CONFIG = Array.from({length: 8});
for (let i = 0; i < virtualFC.SERVO_CONFIG.length; i++) {
virtualFC.SERVO_CONFIG[i] = {
middle: 1500,
min: 1000,
max: 2000,
indexOfChannelToForward: 255,
rate: 100,
reversedInputSources: 0,
};
}
virtualFC.ADJUSTMENT_RANGES = Array.from({length: 16});
for (let i = 0; i < virtualFC.ADJUSTMENT_RANGES.length; i++) {
virtualFC.ADJUSTMENT_RANGES[i] = {
slotIndex: 0,
auxChannelIndex: 0,
range: {
start: 900,
end: 900,
},
adjustmentFunction: 0,
auxSwitchChannelIndex: 0,
};
}
virtualFC.SERIAL_CONFIG.ports = Array.from({length: 6});
virtualFC.SERIAL_CONFIG.ports[0] = {
identifier: 20,
auxChannelIndex: 0,
functions: ["MSP"],
msp_baudrate: 115200,
gps_baudrate: 57600,
telemetry_baudrate: "AUTO",
blackbox_baudrate: 115200,
};
for (let i = 1; i < virtualFC.SERIAL_CONFIG.ports.length; i++) {
virtualFC.SERIAL_CONFIG.ports[i] = {
identifier: i-1,
auxChannelIndex: 0,
functions: [],
msp_baudrate: 115200,
gps_baudrate: 57600,
telemetry_baudrate: "AUTO",
blackbox_baudrate: 115200,
};
}
virtualFC.LED_STRIP = Array.from({length: 256});
for (let i = 0; i < virtualFC.LED_STRIP.length; i++) {
virtualFC.LED_STRIP[i] = {
x: 0,
y: 0,
functions: ["c"],
color: 0,
directions: [],
parameters: 0,
};
}
virtualFC.ANALOG = {
voltage: 12,
mAhdrawn: 1200,
rssi: 100,
amperage: 3,
};
virtualFC.CONFIG.sampleRateHz = 12000;
virtualFC.PID_ADVANCED_CONFIG.pid_process_denom = 2;
virtualFC.BLACKBOX.supported = true;
virtualFC.VTX_CONFIG.vtx_type = 1;
virtualFC.BATTERY_CONFIG = {
vbatmincellvoltage: 1,
vbatmaxcellvoltage: 4,
vbatwarningcellvoltage: 3,
capacity: 10000,
voltageMeterSource: 1,
currentMeterSource: 1,
};
virtualFC.BATTERY_STATE = {
cellCount: 10,
voltage: 20,
mAhDrawn: 1000,
amperage: 3,
};
virtualFC.DATAFLASH = {
ready: true,
supported: true,
sectors: 1024,
totalSize: 40000,
usedSize: 10000,
};
virtualFC.SDCARD = {
supported: true,
state: 1,
freeSizeKB: 1024,
totalSizeKB: 2048,
};
virtualFC.SENSOR_CONFIG = {
acc_hardware: 1,
baro_hardware: 1,
mag_hardware: 1,
};
virtualFC.RC = {
channels: Array.from({length: 16}),
active_channels: 16,
};
for (let i = 0; i < virtualFC.RC.channels.length; i++) {
virtualFC.RC.channels[i] = 1500;
}
// from https://github.com/betaflight/betaflight/blob/master/docs/Modes.md
virtualFC.AUX_CONFIG = ["ARM","ANGLE","HORIZON","ANTI GRAVITY","MAG","HEADFREE","HEADADJ","CAMSTAB","PASSTHRU","BEEPERON","LEDLOW","CALIB",
"OSD","TELEMETRY","SERVO1","SERVO2","SERVO3","BLACKBOX","FAILSAFE","AIRMODE","3D","FPV ANGLE MIX","BLACKBOX ERASE","CAMERA CONTROL 1",
"CAMERA CONTROL 2","CAMERA CONTROL 3","FLIP OVER AFTER CRASH","BOXPREARM","BEEP GPS SATELLITE COUNT","VTX PIT MODE","USER1","USER2",
"USER3","USER4","PID AUDIO","PARALYZE","GPS RESCUE","ACRO TRAINER","DISABLE VTX CONTROL","LAUNCH CONTROL"];
FC.AUX_CONFIG_IDS = [0,1,2,4,5,6,7,8,12,13,15,17,19,20,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,39,40,41,42,43,44,45,46,47,48,49];
for (let i = 0; i < 16; i++) {
virtualFC.RXFAIL_CONFIG[i] = {
mode: 1,
value: 1500,
};
}
// 11 1111 (pass bitchecks)
virtualFC.CONFIG.activeSensors = 63;
},
setupVirtualOSD(){
const virtualOSD = OSD;
virtualOSD.data.video_system = 1;
virtualOSD.data.unit_mode = 1;
virtualOSD.virtualMode = {
itemPositions: Array.from({length: 60}),
statisticsState: [],
warningFlags: 0,
timerData: [],
};
virtualOSD.data.state = {
haveMax7456Configured: true,
haveOsdFeature: true,
haveMax7456FontDeviceConfigured: true,
isMax7456FontDeviceDetected: true,
haveSomeOsd: true,
};
virtualOSD.data.parameters = {
cameraFrameWidth: 30,
cameraFrameHeight: 30,
};
virtualOSD.data.osd_profiles = {
number: 3,
selected: 0,
};
virtualOSD.data.alarms = {
rssi: { display_name: i18n.getMessage('osdTimerAlarmOptionRssi'), value: 0 },
cap: { display_name: i18n.getMessage('osdTimerAlarmOptionCapacity'), value: 0 },
alt: { display_name: i18n.getMessage('osdTimerAlarmOptionAltitude'), value: 0 },
time: { display_name: 'Minutes', value: 0 },
};
},
};

View file

@ -928,6 +928,14 @@ function configuration_restore(callback) {
}
}
if (CONFIGURATOR.virtualMode) {
FC.resetState();
FC.CONFIG.apiVersion = CONFIGURATOR.virtualApiVersion;
sensor_status(FC.CONFIG.activeSensors);
update_dataflash_global();
}
upload();
}
}

View file

@ -25,6 +25,8 @@ var CONFIGURATOR = {
connectionValid: false,
connectionValidCliOnly: false,
virtualMode: false,
virtualApiVersion: '0.0.1',
cliActive: false,
cliValid: false,
gitChangesetId: 'unknown',

View file

@ -98,7 +98,7 @@ function closeSerial() {
// automatically close the port when application closes
const connectionId = serial.connectionId;
if (connectionId && CONFIGURATOR.connectionValid) {
if (connectionId && CONFIGURATOR.connectionValid && !CONFIGURATOR.virtualMode) {
// code below is handmade MSP message (without pretty JS wrapper), it behaves exactly like MSP.send_message
// sending exit command just in case the cli tab was open.
// reset motors to default (mincommand)

View file

@ -56,6 +56,10 @@ const MSP = {
JUMBO_FRAME_SIZE_LIMIT: 255,
read: function (readInfo) {
if (CONFIGURATOR.virtualMode) {
return;
}
const data = new Uint8Array(readInfo.data);
for (const chunk of data) {
@ -310,6 +314,13 @@ const MSP = {
return bufferOut;
},
send_message: function (code, data, callback_sent, callback_msp, doCallbackOnError) {
if (CONFIGURATOR.virtualMode) {
if (callback_msp) {
callback_msp();
}
return;
}
if (code === undefined) {
return;
}

View file

@ -17,6 +17,9 @@ const PortHandler = new function () {
PortHandler.initialize = function () {
this.portPickerElement = $('div#port-picker #port');
// fill dropdown with version numbers
generateVirtualApiVersions();
// start listening, check after TIMEOUT_CHECK ms
this.check();
};
@ -72,6 +75,12 @@ PortHandler.check_usb_devices = function (callback) {
data: {isDFU: true},
}));
self.portPickerElement.append($('<option/>', {
value: 'virtual',
text: i18n.getMessage('portsSelectVirtual'),
data: {isVirtual: true},
}));
self.portPickerElement.append($('<option/>', {
value: 'manual',
text: i18n.getMessage('portsSelectManual'),
@ -213,6 +222,12 @@ PortHandler.updatePortSelect = function (ports) {
}));
}
this.portPickerElement.append($("<option/>", {
value: 'virtual',
text: i18n.getMessage('portsSelectVirtual'),
data: {isVirtual: true},
}));
this.portPickerElement.append($("<option/>", {
value: 'manual',
text: i18n.getMessage('portsSelectManual'),

View file

@ -8,7 +8,7 @@ const serial = {
bytesReceived: 0,
bytesSent: 0,
failed: 0,
connectionType: 'serial', // 'serial' or 'tcp'
connectionType: 'serial', // 'serial' or 'tcp' or 'virtual'
connectionIP: '127.0.0.1',
connectionPort: 5761,
@ -20,6 +20,8 @@ const serial = {
const testUrl = path.match(/^tcp:\/\/([A-Za-z0-9\.-]+)(?:\:(\d+))?$/);
if (testUrl) {
self.connectTcp(testUrl[1], testUrl[2], options, callback);
} else if (path === 'virtual') {
self.connectVirtual(callback);
} else {
self.connectSerial(path, options, callback);
}
@ -189,6 +191,21 @@ const serial = {
}
});
},
connectVirtual: function (callback) {
const self = this;
self.connectionType = 'virtual';
if (!self.openCanceled) {
self.connected = true;
self.connectionId = 'virtual';
self.bitrate = 115200;
self.bytesReceived = 0;
self.bytesSent = 0;
self.failed = 0;
callback();
}
},
disconnect: function (callback) {
const self = this;
self.connected = false;
@ -203,26 +220,32 @@ const serial = {
for (let i = (self.onReceiveError.listeners.length - 1); i >= 0; i--) {
self.onReceiveError.removeListener(self.onReceiveError.listeners[i]);
}
if (self.connectionType !== 'virtual') {
if (self.connectionType === 'tcp') {
chrome.sockets.tcp.disconnect(self.connectionId, function () {
checkChromeRuntimeError();
console.log(`${self.connectionType}: disconnecting socket.`);
});
}
if (self.connectionType === 'tcp') {
chrome.sockets.tcp.disconnect(self.connectionId, function () {
const disconnectFn = (self.connectionType === 'serial') ? chrome.serial.disconnect : chrome.sockets.tcp.close;
disconnectFn(self.connectionId, function (result) {
checkChromeRuntimeError();
console.log(`${self.connectionType}: disconnecting socket.`);
result = result || self.connectionType === 'tcp';
console.log(`${self.connectionType}: ${result ? 'closed' : 'failed to close'} connection with ID: ${self.connectionId}, Sent: ${self.bytesSent} bytes, Received: ${self.bytesReceived} bytes`);
self.connectionId = false;
self.bitrate = 0;
if (callback) callback(result);
});
}
const disconnectFn = (self.connectionType === 'serial') ? chrome.serial.disconnect : chrome.sockets.tcp.close;
disconnectFn(self.connectionId, function (result) {
checkChromeRuntimeError();
result = result || self.connectionType === 'tcp';
console.log(`${self.connectionType}: ${result ? 'closed' : 'failed to close'} connection with ID: ${self.connectionId}, Sent: ${self.bytesSent} bytes, Received: ${self.bytesReceived} bytes`);
} else {
self.connectionId = false;
self.bitrate = 0;
if (callback) callback(result);
});
if (callback) {
callback();
}
}
} else {
// connection wasn't opened, so we won't try to close anything
// instead we will rise canceled flag which will prevent connect from continueing further after being canceled

View file

@ -12,6 +12,12 @@ function initializeSerialBackend() {
else {
$('#port-override-option').hide();
}
if (selected_port.data().isVirtual) {
$('#firmware-virtual-option').show();
}
else {
$('#firmware-virtual-option').hide();
}
if (selected_port.data().isDFU) {
$('select#baud').hide();
}
@ -67,7 +73,14 @@ function initializeSerialBackend() {
$('div#port-picker #port, div#port-picker #baud, div#port-picker #delay').prop('disabled', true);
$('div.connect_controls div.connect_state').text(i18n.getMessage('connecting'));
serial.connect(portName, {bitrate: selected_baud}, onOpen);
if (selectedPort.data().isVirtual) {
CONFIGURATOR.virtualMode = true;
CONFIGURATOR.virtualApiVersion = $('#firmware-version-dropdown :selected').val();
serial.connect('virtual', {}, onOpenVirtual);
} else {
serial.connect(portName, {bitrate: selected_baud}, onOpen);
}
toggleStatus();
} else {
@ -206,6 +219,8 @@ function setConnectionTimeout() {
function onOpen(openInfo) {
if (openInfo) {
CONFIGURATOR.virtualMode = false;
// update connected_to
GUI.connected_to = GUI.connecting_to;
@ -297,6 +312,23 @@ function onOpen(openInfo) {
}
}
function onOpenVirtual() {
GUI.connected_to = GUI.connecting_to;
GUI.connecting_to = false;
CONFIGURATOR.connectionValid = true;
mspHelper = new MspHelper();
VirtualFC.setVirtualConfig();
processBoardInfo();
update_dataflash_global();
sensor_status(FC.CONFIG.activeSensors);
updateTabList(FC.FEATURE_CONFIG.features);
}
function abortConnect() {
$('div#connectbutton div.connect_state').text(i18n.getMessage('connect'));
$('div#connectbutton a.connect').removeClass('active');

View file

@ -102,6 +102,8 @@ TABS.gps.initialize = function (callback) {
}
}
let frame = document.getElementById('map');
// enable data pulling
GUI.interval_add('gps_pull', function gps_update() {
// avoid usage of the GPS commands until a GPS sensor is detected for targets that are compiled without GPS support.
@ -137,8 +139,6 @@ TABS.gps.initialize = function (callback) {
}
});
let frame = document.getElementById('map');
$('#zoom_in').click(function() {
console.log('zoom in');
const message = {

View file

@ -1867,6 +1867,11 @@ OSD.msp = {
warningFlags |= (1 << i);
}
}
if (CONFIGURATOR.virtualMode) {
OSD.virtualMode.warningFlags = warningFlags;
}
console.log(warningFlags);
result.push16(warningFlags);
if (semver.gte(FC.CONFIG.apiVersion, API_VERSION_1_41)) {
@ -1882,17 +1887,24 @@ OSD.msp = {
result.push8(OSD.data.parameters.cameraFrameHeight);
}
}
}
return result;
},
encodeLayout(displayItem) {
if (CONFIGURATOR.virtualMode) {
OSD.virtualMode.itemPositions[displayItem.index] = this.helpers.pack.position(displayItem);
}
const buffer = [];
buffer.push8(displayItem.index);
buffer.push16(this.helpers.pack.position(displayItem));
return buffer;
},
encodeStatistics(statItem) {
if (CONFIGURATOR.virtualMode) {
OSD.virtualMode.statisticsState[statItem.index] = statItem.enabled;
}
const buffer = [];
buffer.push8(statItem.index);
buffer.push16(statItem.enabled);
@ -1900,10 +1912,49 @@ OSD.msp = {
return buffer;
},
encodeTimer(timer) {
if (CONFIGURATOR.virtualMode) {
OSD.virtualMode.timerData[timer.index] = {};
OSD.virtualMode.timerData[timer.index].src = timer.src;
OSD.virtualMode.timerData[timer.index].precision = timer.precision;
OSD.virtualMode.timerData[timer.index].alarm = timer.alarm;
}
const buffer = [-2, timer.index];
buffer.push16(this.helpers.pack.timer(timer));
return buffer;
},
processOsdElements(data, itemPositions){
// Now we have the number of profiles, process the OSD elements
for (const item of itemPositions) {
const j = data.displayItems.length;
let c;
let suffix;
let ignoreSize = false;
if (data.displayItems.length < OSD.constants.DISPLAY_FIELDS.length) {
c = OSD.constants.DISPLAY_FIELDS[j];
} else {
c = OSD.constants.UNKNOWN_DISPLAY_FIELD;
suffix = (1 + data.displayItems.length - OSD.constants.DISPLAY_FIELDS.length).toString();
ignoreSize = true;
}
data.displayItems.push($.extend({
name: c.name,
text: suffix ? [c.text, suffix] : c.text,
desc: c.desc,
index: j,
draw_order: c.draw_order,
preview: suffix ? c.preview + suffix : c.preview,
ignoreSize,
}, this.helpers.unpack.position(item, c)));
}
// Generate OSD element previews and positionable that are defined by a function
for (const item of data.displayItems) {
if (typeof (item.preview) === 'function') {
item.preview = item.preview(data);
}
}
},
// Currently only parses MSP_MAX_OSD responses, add a switch on payload.code if more codes are handled
decode(payload) {
const view = payload.data;
@ -2068,36 +2119,82 @@ OSD.msp = {
d.parameters.cameraFrameHeight = view.readU8();
}
// Now we have the number of profiles, process the OSD elements
for (const item of itemsPositionsRead) {
const j = d.displayItems.length;
let c;
let suffix;
let ignoreSize = false;
if (d.displayItems.length < OSD.constants.DISPLAY_FIELDS.length) {
c = OSD.constants.DISPLAY_FIELDS[j];
} else {
c = OSD.constants.UNKNOWN_DISPLAY_FIELD;
suffix = (1 + d.displayItems.length - OSD.constants.DISPLAY_FIELDS.length).toString();
ignoreSize = true;
this.processOsdElements(d, itemsPositionsRead);
OSD.updateDisplaySize();
},
decodeVirtual() {
const d = OSD.data;
d.displayItems = [];
d.statItems = [];
d.warnings = [];
d.timers = [];
if (semver.gte(FC.CONFIG.apiVersion, API_VERSION_1_36)) {
// Parse statistics display enable
const expectedStatsCount = OSD.constants.STATISTIC_FIELDS.length;
for (let i = 0; i < expectedStatsCount; i++) {
const v = OSD.virtualMode.statisticsState[i] ? 1 : 0;
// Known statistics field
if (i < expectedStatsCount) {
const c = OSD.constants.STATISTIC_FIELDS[i];
d.statItems.push({
name: c.name,
text: c.text,
desc: c.desc,
index: i,
enabled: v === 1,
});
// Read all the data for any statistics we don't know about
} else {
const statisticNumber = i - expectedStatsCount + 1;
d.statItems.push({
name: 'UNKNOWN',
text: ['osdTextStatUnknown', statisticNumber],
desc: 'osdDescStatUnknown',
index: i,
enabled: v === 1,
});
}
}
// Parse configurable timers
const expectedTimersCount = 3;
for (let i = 0; i < expectedTimersCount; i++) {
d.timers.push($.extend({
index: i,
}, OSD.virtualMode.timerData[i]));
}
// Parse enabled warnings
const warningCount = OSD.constants.WARNINGS.length;
const warningFlags = OSD.virtualMode.warningFlags;
for (let i = 0; i < warningCount; i++) {
const enabled = (warningFlags & (1 << i)) !== 0;
// Known warning field
if (i < warningCount) {
d.warnings.push($.extend(OSD.constants.WARNINGS[i], { enabled }));
// Push Unknown Warning field
} else {
const warningNumber = i - warningCount + 1;
d.warnings.push({
name: 'UNKNOWN',
text: ['osdWarningTextUnknown', warningNumber],
desc: 'osdWarningUnknown',
enabled,
});
}
}
d.displayItems.push($.extend({
name: c.name,
text: suffix ? [c.text, suffix] : c.text,
desc: c.desc,
index: j,
draw_order: c.draw_order,
preview: suffix ? c.preview + suffix : c.preview,
ignoreSize,
}, this.helpers.unpack.position(item, c)));
}
// Generate OSD element previews and positionable that are defined by a function
for (const item of d.displayItems) {
if (typeof (item.preview) === 'function') {
item.preview = item.preview(d);
}
}
this.processOsdElements(OSD.data, OSD.virtualMode.itemPositions);
OSD.updateDisplaySize();
},
@ -2235,6 +2332,10 @@ TABS.osd.initialize = function(callback) {
GUI.active_tab = 'osd';
}
if (CONFIGURATOR.virtualMode) {
VirtualFC.setupVirtualOSD();
}
$('#content').load("./tabs/osd.html", function() {
// Prepare symbols depending on the version
SYM.loadSymbols();
@ -2325,7 +2426,11 @@ TABS.osd.initialize = function(callback) {
OSD.chooseFields();
OSD.msp.decode(info);
if (CONFIGURATOR.virtualMode) {
OSD.msp.decodeVirtual();
} else {
OSD.msp.decode(info);
}
if (OSD.data.state.haveMax7456FontDeviceConfigured && !OSD.data.state.isMax7456FontDeviceDetected) {
$('.noOsdChipDetect').show();

View file

@ -29,13 +29,20 @@ TABS.setup.initialize = function (callback) {
// translate to user-selected language
i18n.localizePage();
const backupButton = $('#content .backup');
if (semver.lt(FC.CONFIG.apiVersion, CONFIGURATOR.API_VERSION_MIN_SUPPORTED_BACKUP_RESTORE)) {
$('#content .backup').addClass('disabled');
backupButton.addClass('disabled');
$('#content .restore').addClass('disabled');
GUI.log(i18n.getMessage('initialSetupBackupAndRestoreApiVersion', [FC.CONFIG.apiVersion, CONFIGURATOR.API_VERSION_MIN_SUPPORTED_BACKUP_RESTORE]));
}
// saving and uploading an imaginary config to hardware is a bad idea
if (CONFIGURATOR.virtualMode) {
backupButton.addClass('disabled');
}
// initialize 3D Model
self.initModel();
@ -157,7 +164,7 @@ TABS.setup.initialize = function (callback) {
console.log(`YAW reset to 0 deg, fix: ${self.yaw_fix} deg`);
});
$('#content .backup').click(function () {
backupButton.click(function () {
if ($(this).hasClass('disabled')) {
return;
}

View file

@ -42,3 +42,34 @@ function bytesToSize(bytes) {
}
return false;
}
const majorFirmwareVersions = {
'1.43': '4.2.*',
'1.42': '4.1.*',
'1.41': '4.0.*',
'1.40': '3.5.*',
'1.39': '3.4.*',
'1.37': '3.3.0',
'1.36': '3.2.*',
'1.31': '3.1.0',
};
function generateVirtualApiVersions() {
const firmwareVersionDropdown = document.getElementById('firmware-version-dropdown');
const max = semver.minor(CONFIGURATOR.API_VERSION_MAX_SUPPORTED);
for (let i = max; i > 0; i--) {
const option = document.createElement("option");
const verNum = `1.${i}`;
option.value = `${verNum}.0`;
option.text = `MSP: ${verNum} `;
if (majorFirmwareVersions.hasOwnProperty(verNum)) {
option.text += ` | Firmware: ${majorFirmwareVersions[verNum]}`;
} else if (i === max) {
option.text += ` | Latest Firmware`;
}
firmwareVersionDropdown.appendChild(option);
}
}

View file

@ -76,6 +76,7 @@
<script type="text/javascript" src="./js/ConfigStorage.js"></script>
<script type="text/javascript" src="./js/data_storage.js"></script>
<script type="text/javascript" src="./js/fc.js"></script>
<script type="text/javascript" src="./js/VirtualFC.js"></script>
<script type="text/javascript" src="./js/port_handler.js"></script>
<script type="text/javascript" src="./js/port_usage.js"></script>
<script type="text/javascript" src="./js/serial.js"></script>
@ -165,6 +166,11 @@
<input id="port-override" type="text" value="/dev/rfcomm0"/>
</label>
</div>
<div id="firmware-virtual-option">
<div class="dropdown dropdown-dark">
<select id="firmware-version-dropdown" class="dropdown-select" i18n_title="virtualMSPVersion"></select>
</div>
</div>
<div id="portsinput">
<div class="dropdown dropdown-dark">
<select class="dropdown-select" id="port" i18n_title="firmwareFlasherManualPort">