diff --git a/tabs/configuration.js b/tabs/configuration.js
index 07b70bbbc..ff5f2db7c 100644
--- a/tabs/configuration.js
+++ b/tabs/configuration.js
@@ -14,6 +14,14 @@ import features from './../js/feature_framework';
const configurationTab = {};
+// vbat_meter_type and current_meter_type are separate firmware settings tables
+// (voltage_sensor / current_sensor) with different enum values, so "CAN" isn't
+// at the same ordinal in each - comparing the rendered option label instead of
+// a hardcoded index avoids two different magic numbers that would silently go
+// stale if either table is ever reordered in firmware. Safe today: no locale
+// file defines an i18n key literally named "CAN" (checked all 5 locales).
+const CAN_SOURCE_LABEL = 'CAN';
+
configurationTab.initialize = function (callback, scrollPosition) {
if (GUI.active_tab !== this) {
@@ -261,9 +269,21 @@ configurationTab.initialize = function (callback, scrollPosition) {
});
+ function updateBatterySourceFields() {
+ const vbatIsCan = $('#vbat_meter_type option:selected').text() === CAN_SOURCE_LABEL;
+ const currentIsCan = $('#current_meter_type option:selected').text() === CAN_SOURCE_LABEL;
+ $('#voltagescale-row').toggle(!vbatIsCan);
+ $('#currentscale-row').toggle(!currentIsCan);
+ $('#currentoffset-row').toggle(!currentIsCan);
+ $('#dronecan-battery-id-row').toggle(vbatIsCan || currentIsCan);
+ }
+
+ $('#vbat_meter_type, #current_meter_type').on('change', updateBatterySourceFields);
+
// Wait for settings to load before triggering change event
settingsPromise.then(function() {
$i2cSpeed.trigger('change');
+ updateBatterySourceFields();
}).catch(function(error) {
console.error('Settings load failed, I2C speed change not triggered:', error);
});
@@ -278,7 +298,9 @@ configurationTab.initialize = function (callback, scrollPosition) {
FC.MISC.vbatmincellvoltage = parseFloat($('#mincellvoltage').val());
FC.MISC.vbatmaxcellvoltage = parseFloat($('#maxcellvoltage').val());
FC.MISC.vbatwarningcellvoltage = parseFloat($('#warningcellvoltage').val());
- FC.MISC.vbatscale = parseInt($('#voltagescale').val());
+ if ($('#vbat_meter_type option:selected').text() !== CAN_SOURCE_LABEL) {
+ FC.MISC.vbatscale = parseInt($('#voltagescale').val());
+ }
FC.MISC.battery_capacity = parseInt($('#battery_capacity').val());
FC.MISC.battery_capacity_warning = parseInt($('#battery_capacity_warning').val() * FC.MISC.battery_capacity / 100);
@@ -288,8 +310,10 @@ configurationTab.initialize = function (callback, scrollPosition) {
features.reset();
features.fromUI($('.tab-configuration'));
features.execute(function () {
- FC.CURRENT_METER_CONFIG.scale = parseInt($('#currentscale').val());
- FC.CURRENT_METER_CONFIG.offset = Math.round(parseFloat($('#currentoffset').val()) * 10);
+ if ($('#current_meter_type option:selected').text() !== CAN_SOURCE_LABEL) {
+ FC.CURRENT_METER_CONFIG.scale = parseInt($('#currentscale').val());
+ FC.CURRENT_METER_CONFIG.offset = Math.round(parseFloat($('#currentoffset').val()) * 10);
+ }
saveChainer.execute();
});
});
diff --git a/tabs/dronecan.html b/tabs/dronecan.html
new file mode 100644
index 000000000..6c65e6444
--- /dev/null
+++ b/tabs/dronecan.html
@@ -0,0 +1,78 @@
+
diff --git a/tabs/dronecan.js b/tabs/dronecan.js
new file mode 100644
index 000000000..c7a955704
--- /dev/null
+++ b/tabs/dronecan.js
@@ -0,0 +1,468 @@
+'use strict';
+
+import MSPCodes from './../js/msp/MSPCodes';
+import MSP from './../js/msp';
+import mspHelper from './../js/msp/MSPHelper';
+import GUI from './../js/gui';
+import FC from './../js/fc';
+import i18n from './../js/localization';
+import interval from './../js/intervals';
+
+const HEALTH_LABELS = ['OK', 'WARNING', 'ERROR', 'CRITICAL'];
+const HEALTH_CLASSES = ['health-ok', 'health-warning', 'health-error', 'health-critical'];
+const esc = s => String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
+const POLL_INTERVAL_MS = 75;
+const POLL_MAX_ATTEMPTS = 34; // 34 polls × 75 ms ≈ 2.5 s total window
+const MODE_LABELS = ['OPERATIONAL', 'INITIALIZATION', 'MAINTENANCE', 'SOFTWARE_UPDATE', 'UNKNOWN_4', 'UNKNOWN_5', 'UNKNOWN_6', 'OFFLINE']; // modes 0-3 per DroneCAN NodeStatus; 4-6 reserved by spec
+const PARAM_TYPE_INT = 1;
+const PARAM_TYPE_FLOAT = 2;
+const PARAM_TYPE_BOOL = 3;
+const PARAM_TYPE_STRING = 4;
+const DRONECAN_SERVICE_GETNODEINFO = 1;
+const DRONECAN_SERVICE_RESTART_NODE = 5;
+const DRONECAN_SERVICE_EXECUTE_OPCODE = 10;
+const DRONECAN_SERVICE_PARAM_GETSET = 11;
+const DRONECAN_ASYNC_STATE_READY = 2;
+const DRONECAN_ASYNC_STATE_ERROR = 3;
+const DRONECAN_ASYNC_REQUEST_STATUS_OK = 0;
+const DRONECAN_ASYNC_REQUEST_STATUS_BUSY = 1;
+const DRONECAN_EXECUTE_OPCODE_SAVE = 0;
+
+function getModeLabel(mode) {
+ return (mode < MODE_LABELS.length && MODE_LABELS[mode]) ? MODE_LABELS[mode] : `MODE_${mode}`;
+}
+
+const dronecanTab = {};
+let nameCache = {};
+let currentDetailNodeId = null;
+
+function encodeParamValueBytes(value_type, value) {
+ switch (value_type) {
+ case PARAM_TYPE_INT: {
+ const v = typeof value === 'bigint' ? value : BigInt(Math.trunc(value));
+ const bytes = [];
+ for (let i = 0; i < 8; i++) bytes.push(Number((v >> BigInt(i * 8)) & 0xFFn));
+ return bytes;
+ }
+ case PARAM_TYPE_FLOAT:
+ return Array.from(new Uint8Array(new Float32Array([value]).buffer));
+ case PARAM_TYPE_BOOL:
+ return [value ? 1 : 0];
+ case PARAM_TYPE_STRING: {
+ const enc = new TextEncoder().encode(String(value).slice(0, 63));
+ return [enc.length, ...enc];
+ }
+ default:
+ return [];
+ }
+}
+
+function buildParamGetSetPayload(params) {
+ const payload = [params.index & 0xFF, (params.index >> 8) & 0xFF, params.is_write ? 1 : 0];
+ if (!params.is_write) return payload;
+
+ payload.push(params.value_type, ...encodeParamValueBytes(params.value_type, params.value));
+ const nameEnc = new TextEncoder().encode(String(params.name || '').slice(0, 92));
+ payload.push(nameEnc.length, ...nameEnc);
+ return payload;
+}
+
+function buildAsyncRequestPayload(service_id, node_id, params) {
+ const payload = [service_id & 0xFF, (service_id >> 8) & 0xFF, node_id];
+ if (service_id === DRONECAN_SERVICE_PARAM_GETSET && params) {
+ payload.push(...buildParamGetSetPayload(params));
+ } else if (service_id === DRONECAN_SERVICE_EXECUTE_OPCODE && params) {
+ payload.push(params.opcode);
+ }
+ return payload;
+}
+
+function dronecanAsyncPoll(service_id, node_id, params, onDone) {
+ const reqPayload = buildAsyncRequestPayload(service_id, node_id, params);
+
+ MSP.send_message(MSPCodes.MSP2_INAV_DRONECAN_ASYNC_REQUEST, reqPayload, false, () => {
+ const req = FC.DRONECAN_ASYNC_REQUEST;
+ if (req?.status !== DRONECAN_ASYNC_REQUEST_STATUS_OK) {
+ onDone(req?.status === DRONECAN_ASYNC_REQUEST_STATUS_BUSY ? new Error('busy') : new Error('not_ready'), null);
+ return;
+ }
+ const expectedSeq = req.seq;
+ let attempts = 0;
+ const poll = () => {
+ MSP.send_message(MSPCodes.MSP2_INAV_DRONECAN_ASYNC_RESULT, false, false, () => {
+ const r = FC.DRONECAN_ASYNC_RESULT;
+ if (!r || r.seq !== expectedSeq ||
+ r.service_id !== service_id || r.node_id !== node_id) {
+ if (++attempts < POLL_MAX_ATTEMPTS) { setTimeout(poll, POLL_INTERVAL_MS); }
+ else { onDone(new Error('stale'), null); }
+ return;
+ }
+ if (r.state === DRONECAN_ASYNC_STATE_READY) { onDone(null, r); }
+ else if (r.state === DRONECAN_ASYNC_STATE_ERROR) { onDone(new Error('error'), null); }
+ else if (++attempts < POLL_MAX_ATTEMPTS) { setTimeout(poll, POLL_INTERVAL_MS); }
+ else { onDone(new Error('timeout'), null); }
+ });
+ };
+ setTimeout(poll, POLL_INTERVAL_MS);
+ });
+}
+
+function fetchNamesSequentially(nodes, index, tbody) {
+ if (index >= nodes.length) return;
+ const node = nodes[index];
+ dronecanAsyncPoll(DRONECAN_SERVICE_GETNODEINFO, node.nodeID, null, (err, result) => {
+ if (!err && result) {
+ nameCache[node.nodeID] = result.name;
+ const r = tbody.querySelector(`tr[data-node-id="${node.nodeID}"]`);
+ if (r) r.cells[1].textContent = result.name;
+ }
+ fetchNamesSequentially(nodes, index + 1, tbody);
+ });
+}
+
+dronecanTab.initialize = function (callback) {
+ GUI.active_tab = this;
+
+ import('./dronecan.html?raw').then(({default: html}) => {
+ GUI.load(html, () => {
+ i18n.localize();
+ mspHelper.getSetting('dronecan_bitrate_kbps').then(data => {
+ if (data) {
+ const val = data.setting.table ? data.setting.table.values[data.value] : data.value;
+ $('#dronecan-bitrate').val(val);
+ }
+ return mspHelper.getSetting('dronecan_node_id');
+ }).then(data => {
+ if (data) $('#dronecan-node-id').val(data.value);
+ });
+ $('#dronecan-save').on('click', dronecanTab.saveConfig);
+ dronecanTab.refresh();
+ interval.add('dronecan_refresh', () => dronecanTab.refresh(), 2000);
+ GUI.content_ready(callback);
+ });
+ });
+};
+
+dronecanTab.refresh = function () {
+ MSP.send_message(MSPCodes.MSP2_INAV_DRONECAN_NODES, false, false, () => {
+ dronecanTab.render();
+ });
+};
+
+dronecanTab.render = function () {
+ const nodes = FC.DRONECAN_NODES;
+ const status = document.getElementById('dronecan-status');
+ const table = document.getElementById('dronecan-node-wrapper');
+ const tbody = document.getElementById('dronecan-node-tbody');
+
+ if (!nodes || nodes.length === 0) {
+ status.style.display = '';
+ table.style.display = 'none';
+ document.getElementById('dronecan-node-detail').style.display = 'none';
+ currentDetailNodeId = null;
+
+ return;
+ }
+
+ status.style.display = 'none';
+ table.style.display = '';
+ tbody.innerHTML = '';
+
+ const nodesToFetch = [];
+ nodes.forEach(node => {
+ const health = node.health < HEALTH_LABELS.length ? node.health : 3;
+ const modeLabel = getModeLabel(node.mode);
+ const lastSeen = i18n.getMessage('dronecanSecondsAgo', [(node.last_seen_ms / 1000).toFixed(1)]);
+
+ const row = document.createElement('tr');
+ row.dataset.nodeId = node.nodeID;
+ row.innerHTML = `
+
${esc(node.nodeID)} |
+
— |
+
${HEALTH_LABELS[health]} |
+
${esc(modeLabel)} |
+
${lastSeen} |
+ `;
+ if (nameCache[node.nodeID] !== undefined) {
+ row.cells[1].textContent = nameCache[node.nodeID];
+ } else {
+ nodesToFetch.push(node);
+ }
+
+ row.addEventListener('click', () => dronecanTab.showDetail(node.nodeID));
+ tbody.appendChild(row);
+ });
+
+ fetchNamesSequentially(nodesToFetch, 0, tbody);
+ if (currentDetailNodeId !== null) {
+ const liveNode = nodes.find(n => n.nodeID === currentDetailNodeId);
+ if (liveNode) {
+ const detail = document.getElementById('dronecan-node-detail');
+ const health = liveNode.health < HEALTH_LABELS.length ? liveNode.health : 3;
+ const uptime = `${Math.floor(liveNode.uptime_sec / 3600)}h ${Math.floor((liveNode.uptime_sec % 3600) / 60)}m ${liveNode.uptime_sec % 60}s`;
+ const modeLabel = getModeLabel(liveNode.mode);
+ const set = (attr, val) => { const el = detail.querySelector(`[data-detail="${attr}"]`); if (el) el.textContent = val; };
+ set('health', HEALTH_LABELS[health]);
+ set('mode', modeLabel);
+ set('last-seen', i18n.getMessage('dronecanSecondsAgo', [(liveNode.last_seen_ms / 1000).toFixed(1)]));
+ set('uptime', uptime);
+ set('vendor-status', liveNode.vendor_status_code);
+ } else {
+ document.getElementById('dronecan-node-detail').style.display = 'none';
+ currentDetailNodeId = null;
+ }
+ }
+};
+
+dronecanTab.showDetail = function (nodeId) {
+ currentDetailNodeId = nodeId;
+ const node = FC.DRONECAN_NODES.find(n => n.nodeID === nodeId);
+ if (!node) return;
+
+ dronecanAsyncPoll(DRONECAN_SERVICE_GETNODEINFO, nodeId, null, (err, result) => {
+ if (nodeId !== currentDetailNodeId) return;
+ const detail = document.getElementById('dronecan-node-detail');
+ const tbody = document.getElementById('dronecan-detail-tbody');
+ const health = node.health < HEALTH_LABELS.length ? node.health : 3;
+ const uptime = result ? `${Math.floor(node.uptime_sec / 3600)}h ${Math.floor((node.uptime_sec % 3600) / 60)}m ${node.uptime_sec % 60}s` : '—';
+ const modeLabel = getModeLabel(node.mode);
+
+ let nameCell = '—';
+ if (result) {
+ nameCell = esc(result.name);
+ } else if (err) {
+ nameCell = i18n.getMessage('dronecanNameError');
+ }
+
+ let versionRows = '';
+ if (result) {
+ const swCommitSuffix = (result.sw_optional_field_flags & 1)
+ ? ` (${result.sw_vcs_commit.toString(16).padStart(8, '0')})`
+ : '';
+ versionRows = `
+
| SW Version | ${esc(result.sw_major)}.${esc(result.sw_minor)}${swCommitSuffix} |
|---|
+
| HW Version | ${esc(result.hw_major)}.${esc(result.hw_minor)} |
|---|
+
| Unique ID | ${Array.from(result.hw_unique_id).map(b => b.toString(16).padStart(2, '0')).join(':')} |
|---|
+ `;
+ }
+
+ tbody.innerHTML = `
+
| Node ID | ${esc(nodeId)} |
|---|
+
| Name | ${nameCell} |
|---|
+
| Health | ${HEALTH_LABELS[health]} |
|---|
+
| Mode | ${esc(modeLabel)} |
|---|
+
| Last Seen | ${i18n.getMessage('dronecanSecondsAgo', [(node.last_seen_ms / 1000).toFixed(1)])} |
|---|
+
| Uptime | ${uptime} |
|---|
+
| Vendor Status | ${esc(node.vendor_status_code)} |
|---|
+ ${versionRows}
+ `;
+ detail.style.display = '';
+ dronecanTab.showParams(nodeId);
+ });
+};
+
+function paramValueToString(value_type, value) {
+ if (value_type === PARAM_TYPE_BOOL) {
+ return value ? 'true' : 'false';
+ }
+ return String(value);
+}
+
+function convertParamValue(value_type, writeValue) {
+ switch (value_type) {
+ case PARAM_TYPE_INT:
+ try { return BigInt(writeValue); } catch { return Number.NaN; }
+ case PARAM_TYPE_FLOAT: return Number.parseFloat(writeValue);
+ case PARAM_TYPE_BOOL: return writeValue === 'true' || writeValue === '1';
+ case PARAM_TYPE_STRING:
+ default: return writeValue;
+ }
+}
+
+function validateNumericParam(param, value) {
+ const isInt = param.value_type === PARAM_TYPE_INT;
+ if (isInt ? typeof value !== 'bigint' : Number.isNaN(value)) {
+ return { ok: false, message: i18n.getMessage('dronecanParamOutOfRange') };
+ }
+ const toBig = b => typeof b === 'bigint' ? b : BigInt(Math.round(b));
+ const tooLow = param.min !== undefined && (isInt ? value < toBig(param.min) : value < Number(param.min));
+ const tooHigh = param.max !== undefined && (isInt ? value > toBig(param.max) : value > Number(param.max));
+ if (tooLow || tooHigh) {
+ const lo = param.min !== undefined ? param.min : '—';
+ const hi = param.max !== undefined ? param.max : '—';
+ return { ok: false, message: i18n.getMessage('dronecanParamRangeMustBeBetween', [lo, hi]) };
+ }
+ return { ok: true };
+}
+
+function flagParamInputError(input, btn, message) {
+ input.style.outline = '2px solid #cc0000';
+ input.title = message;
+ btn.textContent = i18n.getMessage('dronecanParamOutOfRange');
+ setTimeout(() => { btn.textContent = i18n.getMessage('dronecanParamWrite'); }, 2000);
+}
+
+function submitParamWrite(btn, nodeId, payload, param, input) {
+ btn.disabled = true;
+ btn.textContent = '...';
+ dronecanAsyncPoll(DRONECAN_SERVICE_PARAM_GETSET, nodeId, payload, (err, result) => {
+ if (!err && result) {
+ param.value = result.value;
+ input.value = paramValueToString(param.value_type, result.value);
+ btn.textContent = i18n.getMessage('OK');
+ } else {
+ btn.textContent = i18n.getMessage('dronecanParamError');
+ }
+ btn.disabled = false;
+ setTimeout(() => { btn.textContent = i18n.getMessage('dronecanParamWrite'); }, 2000);
+ });
+}
+
+function handleParamWriteClick(btn, container, params, nodeId) {
+ const idx = Number.parseInt(btn.dataset.index, 10);
+ const input = container.querySelector(`.param-input[data-index="${idx}"]`);
+ const param = params.find(p => p.index === idx);
+ if (!input || !param) return;
+
+ const payload = {
+ index: idx, is_write: true, value_type: param.value_type, name: param.name,
+ value: convertParamValue(param.value_type, input.value),
+ };
+
+ if (param.value_type === PARAM_TYPE_INT || param.value_type === PARAM_TYPE_FLOAT) {
+ const validation = validateNumericParam(param, payload.value);
+ if (!validation.ok) {
+ flagParamInputError(input, btn, validation.message);
+ return;
+ }
+ }
+
+ input.style.outline = '';
+ input.title = '';
+ submitParamWrite(btn, nodeId, payload, param, input);
+}
+
+function handleSaveToEeprom(btn, nodeId) {
+ btn.disabled = true;
+ btn.textContent = '...';
+ dronecanAsyncPoll(DRONECAN_SERVICE_EXECUTE_OPCODE, nodeId, { opcode: DRONECAN_EXECUTE_OPCODE_SAVE }, (err, result) => {
+ btn.textContent = (!err && result?.ok) ? i18n.getMessage('dronecanSaved') : i18n.getMessage('dronecanFailed');
+ btn.disabled = false;
+ setTimeout(() => { btn.textContent = i18n.getMessage('dronecanSaveToEeprom'); }, 2000);
+ });
+}
+
+function handleRestartNode(btn, nodeId) {
+ btn.disabled = true;
+ btn.textContent = '...';
+ dronecanAsyncPoll(DRONECAN_SERVICE_RESTART_NODE, nodeId, null, (err, result) => {
+ btn.textContent = (!err && result?.ok) ? i18n.getMessage('dronecanRestarting') : i18n.getMessage('dronecanFailed');
+ btn.disabled = false;
+ setTimeout(() => { btn.textContent = i18n.getMessage('dronecanRestartNode'); }, 3000);
+ });
+}
+
+dronecanTab.showParams = function (nodeId) {
+ const container = document.getElementById('dronecan-params');
+ if (!container) return;
+ container.innerHTML = `
${i18n.getMessage('dronecanParamsLoading')}
`;
+
+ const params = [];
+
+ function fetchParam(index) {
+ if (index > 8191) { renderParams(); return; } // UAVCAN v0 GetSet.Request uses a uint13 index (max 8191); firmware reads uint16
+ if (nodeId !== currentDetailNodeId) return;
+ dronecanAsyncPoll(DRONECAN_SERVICE_PARAM_GETSET, nodeId, { index, is_write: false }, (err, result) => {
+ if (nodeId !== currentDetailNodeId) return;
+ if (err || !result?.name) {
+ renderParams();
+ return;
+ }
+ params.push({ index, name: result.name, value_type: result.value_type, value: result.value,
+ min: result.min, max: result.max });
+ fetchParam(index + 1);
+ });
+ }
+
+ function renderParams() {
+ if (params.length === 0) {
+ container.innerHTML = `
${i18n.getMessage('dronecanNoParams')}
`;
+ return;
+ }
+ const TYPE_LABELS = ['', 'INT', 'FLOAT', 'BOOL', 'STRING'];
+ const fmtNumeric = (v, type) => {
+ if (v === undefined) return '—';
+ return type === PARAM_TYPE_FLOAT ? Number(v).toFixed(3) : String(v);
+ };
+ const lblWrite = i18n.getMessage('dronecanParamWrite');
+ const lblSaveEeprom = i18n.getMessage('dronecanSaveToEeprom');
+ const lblRestartNode = i18n.getMessage('dronecanRestartNode');
+ const thIndex = i18n.getMessage('dronecanParamColIndex');
+ const thName = i18n.getMessage('dronecanParamColName');
+ const thType = i18n.getMessage('dronecanParamColType');
+ const thValue = i18n.getMessage('dronecanParamColValue');
+ const thRange = i18n.getMessage('dronecanParamColRange');
+ let html = `
';
+ html += `
`;
+ container.innerHTML = html;
+
+ const saveBtn = container.querySelector('.param-save-eeprom');
+ saveBtn.addEventListener('click', () => handleSaveToEeprom(saveBtn, nodeId));
+
+ const restartBtn = container.querySelector('.param-restart-node');
+ restartBtn.addEventListener('click', () => handleRestartNode(restartBtn, nodeId));
+
+ container.querySelectorAll('.param-write').forEach(btn => {
+ btn.addEventListener('click', () => handleParamWriteClick(btn, container, params, nodeId));
+ });
+ }
+
+ fetchParam(0);
+};
+
+function finishSaveConfigAndReboot() {
+ GUI.log(i18n.getMessage('configurationEepromSaved'));
+ MSP.send_message(MSPCodes.MSP_SET_REBOOT, false, false, () => {
+ GUI.log(i18n.getMessage('deviceRebooting'));
+ GUI.handleReconnect($('.tab_dronecan a'));
+ });
+}
+
+function saveNodeIdAndReboot(nodeId) {
+ mspHelper.setSetting('dronecan_node_id', nodeId, () => {
+ mspHelper.saveToEeprom(finishSaveConfigAndReboot);
+ });
+}
+
+dronecanTab.saveConfig = function () {
+ const bitrate = $('#dronecan-bitrate').val();
+ const nodeId = Number.parseInt($('#dronecan-node-id').val(), 10);
+ if (Number.isNaN(nodeId) || nodeId < 1 || nodeId > 127) return;
+ if (nodeId >= 126 && !confirm(i18n.getMessage('dronecanNodeIdReservedWarning'))) return;
+ mspHelper.setSetting('dronecan_bitrate_kbps', bitrate, () => saveNodeIdAndReboot(nodeId));
+};
+
+dronecanTab.cleanup = function (callback) {
+ interval.remove('dronecan_refresh');
+ currentDetailNodeId = null;
+ nameCache = {}; // prevent stale names from a previous vehicle appearing on reconnect
+ if (callback) callback();
+};
+
+export default dronecanTab;
diff --git a/tabs/gps.html b/tabs/gps.html
index a81c76e78..90164330a 100644
--- a/tabs/gps.html
+++ b/tabs/gps.html
@@ -14,6 +14,15 @@
-
-
+