mirror of
https://github.com/betaflight/betaflight-configurator.git
synced 2025-07-24 08:45:28 +03:00
* Add serial facade * Fix websocket connection * Refactor * Fix unplug * Fix reboot / unplug reconnect issue * The real deal (detail has no value)
561 lines
18 KiB
JavaScript
561 lines
18 KiB
JavaScript
import { i18n } from "../localization";
|
|
import BFClipboard from "../Clipboard";
|
|
import { generateFilename } from "../utils/generate_filename";
|
|
import GUI, { TABS } from "../gui";
|
|
import BuildApi from "../BuildApi";
|
|
import { tracking } from "../Analytics";
|
|
import { reinitializeConnection } from "../serial_backend";
|
|
import CONFIGURATOR from "../data_storage";
|
|
import CliAutoComplete from "../CliAutoComplete";
|
|
import { gui_log } from "../gui_log";
|
|
import jBox from "jbox";
|
|
import $ from "jquery";
|
|
import { serial } from "../serial";
|
|
import FileSystem from "../FileSystem";
|
|
import { ispConnected } from "../utils/connection";
|
|
|
|
const cli = {
|
|
lineDelayMs: 5,
|
|
profileSwitchDelayMs: 100,
|
|
outputHistory: "",
|
|
cliBuffer: "",
|
|
startProcessing: false,
|
|
GUI: {
|
|
snippetPreviewWindow: null,
|
|
copyButton: null,
|
|
windowWrapper: null,
|
|
},
|
|
lastArrival: 0,
|
|
};
|
|
|
|
function removePromptHash(promptText) {
|
|
return promptText.replace(/^# /, "");
|
|
}
|
|
|
|
function cliBufferCharsToDelete(command, buffer) {
|
|
let commonChars = 0;
|
|
for (let i = 0; i < buffer.length; i++) {
|
|
if (command[i] === buffer[i]) {
|
|
commonChars++;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
return buffer.length - commonChars;
|
|
}
|
|
|
|
function commandWithBackSpaces(command, buffer, noOfCharsToDelete) {
|
|
const backspace = String.fromCharCode(127);
|
|
return backspace.repeat(noOfCharsToDelete) + command.substring(buffer.length - noOfCharsToDelete, command.length);
|
|
}
|
|
|
|
function getCliCommand(command, cliBuffer) {
|
|
const buffer = removePromptHash(cliBuffer);
|
|
const bufferRegex = new RegExp(`^${buffer}`, "g");
|
|
if (command.match(bufferRegex)) {
|
|
return command.replace(bufferRegex, "");
|
|
}
|
|
|
|
const noOfCharsToDelete = cliBufferCharsToDelete(command, buffer);
|
|
|
|
return commandWithBackSpaces(command, buffer, noOfCharsToDelete);
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
function onCopySuccessful() {
|
|
tracking.sendEvent(tracking.EVENT_CATEGORIES.FLIGHT_CONTROLLER, "CliCopyToClipboard", { length: text.length });
|
|
const button = TABS.cli.GUI.copyButton;
|
|
const origText = button.text();
|
|
const origWidth = button.css("width");
|
|
button.text(i18n.getMessage("cliCopySuccessful"));
|
|
button.css({
|
|
width: origWidth,
|
|
textAlign: "center",
|
|
});
|
|
setTimeout(() => {
|
|
button.text(origText);
|
|
button.css({
|
|
width: "",
|
|
textAlign: "",
|
|
});
|
|
}, 1500);
|
|
}
|
|
|
|
function onCopyFailed(ex) {
|
|
console.warn(ex);
|
|
}
|
|
|
|
BFClipboard.writeText(text, onCopySuccessful, onCopyFailed);
|
|
}
|
|
|
|
cli.initialize = function (callback) {
|
|
const self = this;
|
|
|
|
if (GUI.active_tab !== "cli") {
|
|
GUI.active_tab = "cli";
|
|
}
|
|
|
|
self.outputHistory = "";
|
|
self.cliBuffer = "";
|
|
self.startProcessing = false;
|
|
|
|
const enterKeyCode = 13;
|
|
|
|
function clearHistory() {
|
|
self.outputHistory = "";
|
|
self.GUI.windowWrapper.empty();
|
|
}
|
|
|
|
async function executeCommands(outString) {
|
|
self.history.add(outString.trim());
|
|
|
|
function sendCommandIterative(commandArray) {
|
|
const command = commandArray.shift();
|
|
|
|
let line = command.trim();
|
|
let processingDelay = self.lineDelayMs;
|
|
if (line.toLowerCase().startsWith("profile")) {
|
|
processingDelay = self.profileSwitchDelayMs;
|
|
}
|
|
const isLastCommand = outputArray.length === 0;
|
|
if (isLastCommand && self.cliBuffer) {
|
|
line = getCliCommand(line, self.cliBuffer);
|
|
}
|
|
|
|
self.sendLine(line);
|
|
|
|
if (!isLastCommand) {
|
|
GUI.timeout_add(
|
|
"CLI_send_slowly",
|
|
function () {
|
|
sendCommandIterative(commandArray);
|
|
},
|
|
processingDelay,
|
|
);
|
|
}
|
|
}
|
|
|
|
const outputArray = outString.split("\n");
|
|
sendCommandIterative(outputArray);
|
|
}
|
|
|
|
async function loadFile() {
|
|
const previewArea = $("#snippetpreviewcontent textarea#preview");
|
|
|
|
function executeSnippet(fileName) {
|
|
const commands = previewArea.val();
|
|
|
|
tracking.sendEvent(tracking.EVENT_CATEGORIES.FLIGHT_CONTROLLER, "CliExecuteFromFile", {
|
|
filename: fileName,
|
|
});
|
|
|
|
executeCommands(commands);
|
|
self.GUI.snippetPreviewWindow.close();
|
|
}
|
|
|
|
function previewCommands(result, fileName) {
|
|
if (!self.GUI.snippetPreviewWindow) {
|
|
self.GUI.snippetPreviewWindow = new jBox("Modal", {
|
|
id: "snippetPreviewWindow",
|
|
width: "auto",
|
|
height: "auto",
|
|
closeButton: "title",
|
|
animation: false,
|
|
isolateScroll: false,
|
|
title: i18n.getMessage("cliConfirmSnippetDialogTitle", { fileName: fileName }),
|
|
content: $("#snippetpreviewcontent"),
|
|
onCreated: () => $("#snippetpreviewcontent a.confirm").click(() => executeSnippet(fileName)),
|
|
});
|
|
}
|
|
previewArea.val(result);
|
|
self.GUI.snippetPreviewWindow.open();
|
|
}
|
|
|
|
const file = await FileSystem.pickOpenFile(i18n.getMessage("fileSystemPickerFiles", { typeof: "TXT" }), ".txt");
|
|
const contents = await FileSystem.readFile(file);
|
|
previewCommands(contents, file.name);
|
|
}
|
|
|
|
async function saveFile(filename, content) {
|
|
const file = await FileSystem.pickSaveFile(
|
|
filename,
|
|
i18n.getMessage("fileSystemPickerFiles", { typeof: "TXT" }),
|
|
".txt",
|
|
);
|
|
await FileSystem.writeFile(file, content);
|
|
}
|
|
|
|
$("#content").load("./tabs/cli.html", function () {
|
|
// translate to user-selected language
|
|
i18n.localizePage();
|
|
|
|
TABS.cli.adaptPhones();
|
|
|
|
CONFIGURATOR.cliActive = true;
|
|
|
|
self.GUI.copyButton = $("a.copy");
|
|
self.GUI.windowWrapper = $(".tab-cli .window .wrapper");
|
|
|
|
const textarea = $('.tab-cli textarea[name="commands"]');
|
|
|
|
CliAutoComplete.initialize(textarea, self.sendLine.bind(self), writeToOutput);
|
|
$(CliAutoComplete).on("build:start", function () {
|
|
textarea.val("").attr("placeholder", i18n.getMessage("cliInputPlaceholderBuilding")).prop("disabled", true);
|
|
});
|
|
$(CliAutoComplete).on("build:stop", function () {
|
|
textarea.attr("placeholder", i18n.getMessage("cliInputPlaceholder")).prop("disabled", false).focus();
|
|
});
|
|
|
|
$("a.save").on("click", function () {
|
|
const filename = generateFilename("cli", "txt");
|
|
|
|
saveFile(filename, self.outputHistory);
|
|
});
|
|
|
|
$("a.clear").click(function () {
|
|
clearHistory();
|
|
});
|
|
|
|
self.GUI.copyButton.click(function () {
|
|
copyToClipboard(self.outputHistory);
|
|
});
|
|
|
|
$("a.load").on("click", function () {
|
|
loadFile();
|
|
});
|
|
|
|
$("a.support")
|
|
.toggle(ispConnected())
|
|
.on("click", function () {
|
|
function submitSupportData(data) {
|
|
clearHistory();
|
|
const api = new BuildApi();
|
|
|
|
api.getSupportCommands(async (commands) => {
|
|
commands = [`###\n# Problem description\n# ${data}\n###`, ...commands];
|
|
await executeCommands(commands.join("\n"));
|
|
const delay = setInterval(() => {
|
|
const time = new Date().getTime();
|
|
if (self.lastArrival < time - 250) {
|
|
clearInterval(delay);
|
|
const text = self.outputHistory;
|
|
api.submitSupportData(text, (key) => {
|
|
writeToOutput(i18n.getMessage("buildServerSupportRequestSubmission", [key]));
|
|
});
|
|
}
|
|
}, 250);
|
|
});
|
|
}
|
|
|
|
self.supportWarningDialog(submitSupportData);
|
|
});
|
|
|
|
// Tab key detection must be on keydown,
|
|
// `keypress`/`keyup` happens too late, as `textarea` will have already lost focus.
|
|
textarea.keydown(function (event) {
|
|
const tabKeyCode = 9;
|
|
if (event.which === tabKeyCode) {
|
|
// prevent default tabbing behaviour
|
|
event.preventDefault();
|
|
|
|
if (!CliAutoComplete.isEnabled()) {
|
|
// Native FC autoComplete
|
|
const outString = textarea.val();
|
|
const lastCommand = outString.split("\n").pop();
|
|
const command = getCliCommand(lastCommand, self.cliBuffer);
|
|
if (command) {
|
|
self.sendNativeAutoComplete(command);
|
|
textarea.val("");
|
|
}
|
|
} else if (!CliAutoComplete.isOpen() && !CliAutoComplete.isBuilding()) {
|
|
// force show autocomplete on Tab
|
|
CliAutoComplete.openLater(true);
|
|
}
|
|
}
|
|
});
|
|
|
|
textarea.keypress(function (event) {
|
|
if (event.which === enterKeyCode) {
|
|
event.preventDefault(); // prevent the adding of new line
|
|
|
|
if (CliAutoComplete.isBuilding()) {
|
|
return; // silently ignore commands if autocomplete is still building
|
|
}
|
|
|
|
const outString = textarea.val();
|
|
executeCommands(outString);
|
|
textarea.val("");
|
|
}
|
|
});
|
|
|
|
textarea.keyup(function (event) {
|
|
const keyUp = { 38: true };
|
|
const keyDown = { 40: true };
|
|
|
|
if (CliAutoComplete.isOpen()) {
|
|
return; // disable history keys if autocomplete is open
|
|
}
|
|
|
|
if (event.keyCode in keyUp) {
|
|
textarea.val(self.history.prev());
|
|
}
|
|
|
|
if (event.keyCode in keyDown) {
|
|
textarea.val(self.history.next());
|
|
}
|
|
});
|
|
|
|
// give input element user focus
|
|
textarea.focus();
|
|
|
|
GUI.timeout_add(
|
|
"enter_cli",
|
|
function enter_cli() {
|
|
// Enter CLI mode
|
|
const bufferOut = new ArrayBuffer(1);
|
|
const bufView = new Uint8Array(bufferOut);
|
|
|
|
bufView[0] = 0x23; // #
|
|
|
|
serial.send(bufferOut);
|
|
},
|
|
250,
|
|
);
|
|
|
|
GUI.content_ready(callback);
|
|
});
|
|
};
|
|
|
|
cli.adaptPhones = function () {
|
|
if ($(window).width() < 575) {
|
|
const backdropHeight = $(".note").height() + 22 + 38;
|
|
$(".backdrop").css("height", `calc(100% - ${backdropHeight}px)`);
|
|
}
|
|
};
|
|
|
|
cli.history = {
|
|
history: [],
|
|
index: 0,
|
|
};
|
|
|
|
cli.history.add = function (str) {
|
|
this.history.push(str);
|
|
this.index = this.history.length;
|
|
};
|
|
|
|
cli.history.prev = function () {
|
|
if (this.index > 0) {
|
|
this.index -= 1;
|
|
}
|
|
return this.history[this.index];
|
|
};
|
|
|
|
cli.history.next = function () {
|
|
if (this.index < this.history.length) {
|
|
this.index += 1;
|
|
}
|
|
return this.history[this.index - 1];
|
|
};
|
|
|
|
const backspaceCode = 8;
|
|
const lineFeedCode = 10;
|
|
const carriageReturnCode = 13;
|
|
|
|
function writeToOutput(text) {
|
|
TABS.cli.GUI.windowWrapper.append(text);
|
|
const cliWindow = $(".tab-cli .window");
|
|
cliWindow.scrollTop(cliWindow.prop("scrollHeight"));
|
|
}
|
|
|
|
function writeLineToOutput(text) {
|
|
if (CliAutoComplete.isBuilding()) {
|
|
CliAutoComplete.builderParseLine(text);
|
|
return; // suppress output if in building state
|
|
}
|
|
|
|
if (text.startsWith("###ERROR")) {
|
|
writeToOutput(`<span class="error_message">${text}</span><br>`);
|
|
} else {
|
|
writeToOutput(`${text}<br>`);
|
|
}
|
|
}
|
|
|
|
function setPrompt(text) {
|
|
$(".tab-cli textarea").val(text);
|
|
}
|
|
|
|
cli.read = function (readInfo) {
|
|
/* Some info about handling line feeds and carriage return
|
|
|
|
line feed = LF = \n = 0x0A = 10
|
|
carriage return = CR = \r = 0x0D = 13
|
|
|
|
MAC only understands CR
|
|
Linux and Unix only understand LF
|
|
Windows understands (both) CRLF
|
|
Chrome OS currently unknown
|
|
*/
|
|
const data = new Uint8Array(readInfo.data ?? readInfo);
|
|
let validateText = "";
|
|
let sequenceCharsToSkip = 0;
|
|
|
|
for (let i = 0; i < data.length; i++) {
|
|
const currentChar = String.fromCharCode(data[i]);
|
|
const isCRLF = currentChar.charCodeAt() === lineFeedCode || currentChar.charCodeAt() === carriageReturnCode;
|
|
|
|
if (!CONFIGURATOR.cliValid && (isCRLF || this.startProcessing)) {
|
|
// try to catch part of valid CLI enter message (firmware message starts with CRLF)
|
|
this.startProcessing = true;
|
|
validateText += currentChar;
|
|
writeToOutput(currentChar);
|
|
continue;
|
|
}
|
|
|
|
const escapeSequenceCode = 27;
|
|
const escapeSequenceCharLength = 3;
|
|
if (data[i] === escapeSequenceCode && !sequenceCharsToSkip) {
|
|
// ESC + other
|
|
sequenceCharsToSkip = escapeSequenceCharLength;
|
|
}
|
|
|
|
if (sequenceCharsToSkip) {
|
|
sequenceCharsToSkip--;
|
|
continue;
|
|
}
|
|
|
|
if (CONFIGURATOR.cliValid) {
|
|
switch (data[i]) {
|
|
case lineFeedCode:
|
|
if (GUI.operating_system === "Windows") {
|
|
writeLineToOutput(this.cliBuffer);
|
|
this.cliBuffer = "";
|
|
}
|
|
break;
|
|
case carriageReturnCode:
|
|
if (GUI.operating_system !== "Windows") {
|
|
writeLineToOutput(this.cliBuffer);
|
|
this.cliBuffer = "";
|
|
}
|
|
break;
|
|
case 60:
|
|
this.cliBuffer += "<";
|
|
break;
|
|
case 62:
|
|
this.cliBuffer += ">";
|
|
break;
|
|
case backspaceCode:
|
|
this.cliBuffer = this.cliBuffer.slice(0, -1);
|
|
this.outputHistory = this.outputHistory.slice(0, -1);
|
|
continue;
|
|
|
|
default:
|
|
this.cliBuffer += currentChar;
|
|
}
|
|
}
|
|
|
|
if (!CliAutoComplete.isBuilding()) {
|
|
// do not include the building dialog into the history
|
|
this.outputHistory += currentChar;
|
|
}
|
|
|
|
if (this.cliBuffer === "Rebooting") {
|
|
CONFIGURATOR.cliActive = false;
|
|
CONFIGURATOR.cliValid = false;
|
|
gui_log(i18n.getMessage("cliReboot"));
|
|
reinitializeConnection();
|
|
}
|
|
}
|
|
|
|
this.lastArrival = new Date().getTime();
|
|
|
|
if (!CONFIGURATOR.cliValid && validateText.indexOf("CLI") !== -1) {
|
|
gui_log(i18n.getMessage("cliEnter"));
|
|
CONFIGURATOR.cliValid = true;
|
|
// begin output history with the prompt (last line of welcome message)
|
|
// this is to match the content of the history with what the user sees on this tab
|
|
const lastLine = validateText.split("\n").pop();
|
|
this.outputHistory = lastLine;
|
|
|
|
if (CliAutoComplete.isEnabled() && !CliAutoComplete.isBuilding()) {
|
|
// start building autoComplete
|
|
CliAutoComplete.builderStart();
|
|
}
|
|
}
|
|
|
|
// fallback to native autocomplete
|
|
if (!CliAutoComplete.isEnabled()) {
|
|
setPrompt(removePromptHash(this.cliBuffer));
|
|
}
|
|
};
|
|
|
|
cli.sendLine = function (line, callback) {
|
|
this.send(`${line}\n`, callback);
|
|
};
|
|
|
|
cli.sendNativeAutoComplete = function (line, callback) {
|
|
this.send(`${line}\t`, callback);
|
|
};
|
|
|
|
cli.send = function (line, callback) {
|
|
const bufferOut = new ArrayBuffer(line.length);
|
|
const bufView = new Uint8Array(bufferOut);
|
|
|
|
for (let cKey = 0; cKey < line.length; cKey++) {
|
|
bufView[cKey] = line.charCodeAt(cKey);
|
|
}
|
|
|
|
serial.send(bufferOut, callback);
|
|
};
|
|
|
|
cli.supportWarningDialog = function (onAccept) {
|
|
const supportWarningDialog = $(".supportWarningDialog")[0];
|
|
const supportWarningDialogTextArea = $('.tab-cli textarea[name="supportWarningDialogInput"]');
|
|
|
|
if (!supportWarningDialog.hasAttribute("open")) {
|
|
supportWarningDialog.showModal();
|
|
|
|
$(".cancel").on("click", function () {
|
|
supportWarningDialog.close();
|
|
});
|
|
|
|
$(".submit").on("click", function () {
|
|
supportWarningDialog.close();
|
|
onAccept(supportWarningDialogTextArea.val());
|
|
supportWarningDialogTextArea.val("");
|
|
});
|
|
}
|
|
};
|
|
|
|
cli.cleanup = function (callback) {
|
|
if (TABS.cli.GUI.snippetPreviewWindow) {
|
|
TABS.cli.GUI.snippetPreviewWindow.destroy();
|
|
TABS.cli.GUI.snippetPreviewWindow = null;
|
|
}
|
|
if (!(CONFIGURATOR.connectionValid && CONFIGURATOR.cliValid && CONFIGURATOR.cliActive)) {
|
|
if (callback) {
|
|
callback();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this.send(getCliCommand("exit\r", this.cliBuffer), function () {
|
|
// we could handle this "nicely", but this will do for now
|
|
// (another approach is however much more complicated):
|
|
// we can setup an interval asking for data lets say every 200ms, when data arrives, callback will be triggered and tab switched
|
|
// we could probably implement this someday
|
|
reinitializeConnection(function () {
|
|
GUI.timeout_add("tab_change_callback", callback, 500);
|
|
});
|
|
});
|
|
|
|
CONFIGURATOR.cliActive = false;
|
|
CONFIGURATOR.cliValid = false;
|
|
|
|
CliAutoComplete.cleanup();
|
|
$(CliAutoComplete).off();
|
|
};
|
|
|
|
TABS.cli = cli;
|
|
export { cli };
|