376 lines
21 KiB
C++
376 lines
21 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Private per-device JSON process adapter. Every wire operation is upstream.
|
|
#include "config_export.h"
|
|
#include <QEventLoop>
|
|
#include <QElapsedTimer>
|
|
#include <QSocketNotifier>
|
|
#include <QSerialPort>
|
|
#include <QTimer>
|
|
#include <QFileInfo>
|
|
#include <cmath>
|
|
#include <functional>
|
|
#include <fcntl.h>
|
|
#include <sys/file.h>
|
|
#include <sys/ioctl.h>
|
|
#include <sys/stat.h>
|
|
#include <sys/sysmacros.h>
|
|
#include <termios.h>
|
|
#include <unistd.h>
|
|
|
|
class ExchangeFailure : public std::runtime_error {
|
|
public:
|
|
QJsonObject diagnostics;
|
|
explicit ExchangeFailure(const QJsonObject &value)
|
|
: std::runtime_error("Native query timed out or disconnected"), diagnostics(value) {}
|
|
};
|
|
|
|
class Engine {
|
|
public:
|
|
VescInterface vesc;
|
|
Packet *packet;
|
|
FW_RX_PARAMS identity;
|
|
bool procedureRunning = false;
|
|
bool procedureUncertain = false;
|
|
QJsonObject procedure;
|
|
bool queryRunning = false;
|
|
bool allowHardware = false;
|
|
QByteArray lastMotor, lastApplication, lastHall;
|
|
QTimer outputWatchdog;
|
|
|
|
Engine() {
|
|
outputWatchdog.setSingleShot(true);
|
|
QObject::connect(&outputWatchdog, &QTimer::timeout, [&] {
|
|
if (allowHardware && vesc.isPortConnected() && !procedureRunning) {
|
|
vesc.commands()->setCurrent(0);
|
|
try { flush(); } catch (...) {}
|
|
}
|
|
});
|
|
require(Utility::configLoadLatest(&vesc), "Upstream resources missing");
|
|
packet = vesc.findChild<Packet *>();
|
|
require(packet, "Upstream packet transport missing");
|
|
QObject::connect(vesc.commands(), &Commands::fwVersionReceived,
|
|
[&](FW_RX_PARAMS value) { identity = value; });
|
|
QObject::connect(packet, &Packet::packetReceived, [&](QByteArray &raw) {
|
|
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_MCCONF) lastMotor = raw;
|
|
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_APPCONF) lastApplication = raw;
|
|
if (!raw.isEmpty() && quint8(raw[0]) == COMM_DETECT_HALL_FOC) lastHall = raw;
|
|
});
|
|
}
|
|
|
|
void open(const QString &port) {
|
|
require(QRegularExpression("^/dev/ttyACM[0-9]+$").match(port).hasMatch(), "Unsupported USB path");
|
|
struct stat st;
|
|
require(lstat(port.toLocal8Bit(), &st) == 0 && S_ISCHR(st.st_mode) && major(st.st_rdev) == 166,
|
|
"Not a CDC ACM device");
|
|
require(vesc.connectSerial(port, 115200), "Native serial connection failed");
|
|
auto serial = vesc.findChild<QSerialPort *>();
|
|
require(serial && serial->isOpen() && "/dev/" + serial->portName() == port,
|
|
"Native serial path mismatch");
|
|
require(flock(serial->handle(), LOCK_EX | LOCK_NB) == 0 && ioctl(serial->handle(), TIOCEXCL) == 0,
|
|
"Serial port is already owned");
|
|
allowHardware = true;
|
|
query(COMM_FW_VERSION, 3000);
|
|
require(identity.major == 5 && identity.minor == 2 && identity.hwType == HW_TYPE_VESC
|
|
&& identity.isTestFw == 0 && identity.customConfigNum == 0,
|
|
"Native hardware acceptance currently admits stable FW 5.02 only");
|
|
}
|
|
|
|
QByteArray exchange(int command, int timeoutMs, const std::function<void()> &send) {
|
|
require(allowHardware && vesc.isPortConnected(), "Device disconnected");
|
|
require(!queryRunning, "A native query is already pending");
|
|
queryRunning = true;
|
|
struct PendingReset { bool &pending; ~PendingReset() { pending = false; } } reset{queryRunning};
|
|
QEventLoop loop;
|
|
QTimer timeout; timeout.setSingleShot(true);
|
|
QObject observer;
|
|
QElapsedTimer elapsed; elapsed.start();
|
|
QJsonArray events;
|
|
bool emitted = false;
|
|
int packetsSent = 0, packetsReceived = 0;
|
|
qint64 bytesWritten = 0;
|
|
auto serial = vesc.findChild<QSerialPort *>();
|
|
auto record = [&](const QString &kind, int code, qint64 bytes) {
|
|
if (events.size() == 24) events.removeFirst();
|
|
events.append(QJsonObject{{"event", kind}, {"command", code},
|
|
{"bytes", double(bytes)}, {"at_ms", elapsed.nsecsElapsed() / 1e6}});
|
|
};
|
|
QObject::connect(vesc.commands(), &Commands::dataToSend, &observer, [&](QByteArray &raw) {
|
|
const int code = raw.isEmpty() ? -1 : quint8(raw[0]);
|
|
if (code == command) emitted = true;
|
|
record("command_emitted", code, raw.size());
|
|
});
|
|
QObject::connect(packet, &Packet::dataToSend, &observer, [&](QByteArray &raw) {
|
|
++packetsSent; record("packet_sent", -1, raw.size());
|
|
});
|
|
if (serial) {
|
|
QObject::connect(serial, &QSerialPort::bytesWritten, &observer, [&](qint64 bytes) {
|
|
bytesWritten += bytes; record("serial_written", -1, bytes);
|
|
});
|
|
QObject::connect(serial, &QSerialPort::errorOccurred, &observer, [&](QSerialPort::SerialPortError error) {
|
|
if (error != QSerialPort::NoError) record("serial_error", int(error), 0);
|
|
});
|
|
}
|
|
QByteArray answer;
|
|
QObject::connect(packet, &Packet::packetReceived, &observer, [&](QByteArray &raw) {
|
|
++packetsReceived;
|
|
record("packet_received", raw.isEmpty() ? -1 : quint8(raw[0]), raw.size());
|
|
if (!raw.isEmpty() && quint8(raw[0]) == command) { answer = raw; loop.quit(); }
|
|
});
|
|
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
|
|
timeout.start(timeoutMs);
|
|
send();
|
|
if (answer.isEmpty()) loop.exec();
|
|
if (answer.isEmpty() || !vesc.isPortConnected()) {
|
|
throw ExchangeFailure({{"requested_command", command}, {"timeout_ms", timeoutMs},
|
|
{"elapsed_ms", elapsed.nsecsElapsed() / 1e6}, {"request_emitted", emitted},
|
|
{"packets_sent", packetsSent}, {"packets_received", packetsReceived},
|
|
{"serial_bytes_written", double(bytesWritten)}, {"port_connected", vesc.isPortConnected()},
|
|
{"serial_open", serial && serial->isOpen()}, {"serial_error", serial ? int(serial->error()) : -1},
|
|
{"serial_bytes_pending", serial ? double(serial->bytesToWrite()) : -1}, {"events", events}});
|
|
}
|
|
return answer;
|
|
}
|
|
|
|
QByteArray query(int code, int timeoutMs, bool internal = false) {
|
|
auto cmd = vesc.commands();
|
|
require(internal || !procedureRunning || code == COMM_GET_VALUES || code == COMM_GET_DECODED_PPM,
|
|
"Configuration reads are unavailable during native measurement");
|
|
std::function<void()> send;
|
|
switch (code) {
|
|
case COMM_FW_VERSION: send = [=] { cmd->getFwVersion(); }; break;
|
|
case COMM_GET_VALUES: send = [=] { cmd->getValues(); }; break;
|
|
case COMM_GET_MCCONF: send = [=] { cmd->getMcconf(); }; break;
|
|
case COMM_GET_APPCONF: send = [=] { cmd->getAppConf(); }; break;
|
|
case COMM_GET_DECODED_PPM: send = [=] { cmd->getDecodedPpm(); }; break;
|
|
case COMM_PING_CAN: send = [=] { cmd->pingCan(); }; break;
|
|
default: throw std::runtime_error("Native read command is not admitted");
|
|
}
|
|
auto raw = exchange(code, timeoutMs, send);
|
|
if (code == COMM_GET_MCCONF || code == COMM_GET_APPCONF) {
|
|
VByteArray serialized;
|
|
auto config = code == COMM_GET_MCCONF ? vesc.mcConfig() : vesc.appConfig();
|
|
config->serialize(serialized);
|
|
require(serialized == raw.mid(1), "Native configuration decode is not byte-exact");
|
|
}
|
|
return raw;
|
|
}
|
|
|
|
QByteArray configurationPacket(ConfigParams *config, int code) {
|
|
VByteArray raw; raw.vbAppendInt8(code); config->serialize(raw); return raw;
|
|
}
|
|
|
|
void calibrate(double loss) {
|
|
ConfigParams beforeMotor, beforeApp;
|
|
beforeMotor = *vesc.mcConfig(); beforeApp = *vesc.appConfig();
|
|
bool received = false, validated = false;
|
|
int code = -1000;
|
|
QString report, error;
|
|
QJsonArray changed;
|
|
auto connection = QObject::connect(vesc.commands(), &Commands::detectAllFocReceived,
|
|
[&](int result) { received = true; code = result; });
|
|
try {
|
|
// The actual upstream wizard motor procedure, including its FW 5.02
|
|
// power-loss correction. Do not infer a battery profile from Ah/voltage.
|
|
report = Utility::detectAllFoc(&vesc, false, loss,
|
|
beforeMotor.getParamDouble("l_in_current_min"), beforeMotor.getParamDouble("l_in_current_max"),
|
|
beforeMotor.getParamDouble("foc_openloop_rpm"), beforeMotor.getParamDouble("foc_sl_erpm"));
|
|
require(received, "Native calibration completion was not received");
|
|
query(COMM_GET_MCCONF, 3000, true);
|
|
query(COMM_GET_APPCONF, 3000, true);
|
|
auto mc = vesc.mcConfig(); auto app = vesc.appConfig();
|
|
const QStringList admitted = {"l_current_max", "l_current_min", "motor_type", "foc_motor_r",
|
|
"foc_motor_l", "foc_motor_flux_linkage", "foc_current_kp", "foc_current_ki", "foc_observer_gain",
|
|
"foc_sensor_mode", "m_sensor_port_mode", "foc_encoder_offset", "foc_encoder_ratio", "foc_encoder_inverted",
|
|
"foc_hall_table__0", "foc_hall_table__1", "foc_hall_table__2", "foc_hall_table__3",
|
|
"foc_hall_table__4", "foc_hall_table__5", "foc_hall_table__6", "foc_hall_table__7"};
|
|
for (const auto &key : beforeMotor.checkDifference(mc)) {
|
|
require(admitted.contains(key), "Calibration changed a protected motor parameter");
|
|
changed.append(key);
|
|
}
|
|
for (const auto &key : beforeApp.checkDifference(app))
|
|
require(key == "send_can_status", "Calibration changed a protected receiver parameter");
|
|
// Native FW 5.02 enables CAN status as a side effect. Restore the
|
|
// original application exactly; retain existing PPM and CAN identity.
|
|
if (!beforeApp.checkDifference(app).isEmpty()) {
|
|
*app = beforeApp;
|
|
const auto expected = configurationPacket(app, COMM_GET_APPCONF);
|
|
require(exchange(COMM_SET_APPCONF, 3000, [&] { vesc.commands()->setAppConf(); }).size() == 1,
|
|
"Application write ACK invalid");
|
|
require(query(COMM_GET_APPCONF, 3000, true) == expected, "Application restore not byte-exact");
|
|
}
|
|
if (code < 0) {
|
|
// A completed failed detection can leave partial RAM changes.
|
|
// Only the known calibration fields passed the guard above.
|
|
*mc = beforeMotor;
|
|
} else {
|
|
for (const auto &key : {"foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
|
require(std::isfinite(mc->getParamDouble(key)) && mc->getParamDouble(key) > 0,
|
|
"Invalid detected motor parameter");
|
|
require(mc->getParamDouble("l_current_max") > 0 && mc->getParamDouble("l_current_min") < 0,
|
|
"Invalid detected current limits");
|
|
// Calibration must not silently increase the owner's existing
|
|
// current limits. The separate spin test applies its own 30 A cap.
|
|
mc->updateParamDouble("l_current_max", qMin(mc->getParamDouble("l_current_max"), beforeMotor.getParamDouble("l_current_max")));
|
|
mc->updateParamDouble("l_current_min", qMax(mc->getParamDouble("l_current_min"), beforeMotor.getParamDouble("l_current_min")));
|
|
}
|
|
const auto expected = configurationPacket(mc, COMM_GET_MCCONF);
|
|
if (expected != lastMotor) {
|
|
require(exchange(COMM_SET_MCCONF, 3000, [&] { vesc.commands()->setMcconf(false); }).size() == 1,
|
|
"Motor write ACK invalid");
|
|
require(query(COMM_GET_MCCONF, 3000, true) == expected, "Motor write not byte-exact");
|
|
}
|
|
validated = true;
|
|
} catch (const std::exception &e) { error = e.what(); }
|
|
QObject::disconnect(connection);
|
|
QJsonObject parameters;
|
|
for (const auto &key : {"l_current_max", "l_current_min", "foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
|
parameters.insert(key, vesc.mcConfig()->getParamDouble(key));
|
|
procedure = {{"kind", "foc"}, {"completed", received}, {"success", received && code >= 0 && validated},
|
|
{"validated", validated}, {"code", code}, {"report", report}, {"error", error},
|
|
{"sensor_mode", vesc.mcConfig()->getParamEnum("foc_sensor_mode")}, {"parameters", parameters},
|
|
{"changed", changed}, {"upstream", "Utility::detectAllFoc"}};
|
|
procedureUncertain = !validated;
|
|
procedureRunning = false;
|
|
}
|
|
|
|
double number(const QJsonObject &request, const QString &name, double min, double max) {
|
|
auto v = request.value(name);
|
|
require(v.isDouble() && std::isfinite(v.toDouble()) && v.toDouble() >= min && v.toDouble() <= max,
|
|
"Numeric argument is outside operation bounds");
|
|
return v.toDouble();
|
|
}
|
|
|
|
void flush() {
|
|
auto serial = vesc.findChild<QSerialPort *>();
|
|
require(serial && serial->isOpen(), "Serial transport closed");
|
|
serial->flush();
|
|
if (serial->bytesToWrite() > 0) require(serial->waitForBytesWritten(40), "Serial write not confirmed");
|
|
require(serial->bytesToWrite() == 0, "Serial write is incomplete");
|
|
}
|
|
|
|
QJsonObject dispatch(const QJsonObject &request) {
|
|
const auto method = request.value("method").toString();
|
|
if (method == "engine") return {{"version", "7.00"}, {"commit", "01d5f10901116c311e3fb84d5a1541f663d3ce20"},
|
|
{"connected", vesc.isPortConnected()}, {"hardware_enabled", allowHardware},
|
|
{"legacy_power_loss_correction", vesc.commands()->getMaxPowerLossBug()}};
|
|
require(allowHardware && vesc.isPortConnected(), "Native device is not connected");
|
|
if (method == "query") return {{"payload", QString::fromLatin1(query(
|
|
int(number(request, "command", 0, 255)), int(number(request, "timeout_ms", 20, 8000))).toBase64())}};
|
|
if (method == "procedure_result") return {{"running", procedureRunning}, {"uncertain", procedureUncertain}, {"result", procedure}};
|
|
if (method == "lease") {
|
|
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
|
vesc.commands()->disableAppOutput(250, false); flush(); outputWatchdog.start(200); return {};
|
|
}
|
|
if (method == "release") { vesc.commands()->setCurrent(0); flush(); return {}; }
|
|
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
|
if (method == "current") {
|
|
require(outputWatchdog.isActive(), "Output lease expired");
|
|
auto current = number(request, "current_a", 0, 30);
|
|
require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded");
|
|
vesc.commands()->setCurrent(current); flush(); return {};
|
|
}
|
|
if (method == "rpm") { require(outputWatchdog.isActive(), "Output lease expired"); vesc.commands()->setRpm(int(number(request, "erpm", -3000, 3000))); flush(); return {}; }
|
|
if (method == "limits") {
|
|
auto p = request.value("parameters").toObject();
|
|
MCCONF_TEMP conf;
|
|
conf.current_min_scale = number(p, "l_current_min_scale", 0, 1);
|
|
conf.current_max_scale = number(p, "l_current_max_scale", 0, 1);
|
|
// Restore/application may only change current scales. All other
|
|
// values must equal the last native read, including battery limits.
|
|
auto mc = vesc.mcConfig();
|
|
for (const auto &key : {"l_min_erpm", "l_max_erpm", "l_min_duty", "l_max_duty", "l_watt_min", "l_watt_max", "l_in_current_min", "l_in_current_max"})
|
|
require(p.value(key).isDouble() && p.value(key).toDouble() == mc->getParamDouble(key), "Only volatile current scales may change");
|
|
conf.erpm_or_speed_min = mc->getParamDouble("l_min_erpm");
|
|
conf.erpm_or_speed_max = mc->getParamDouble("l_max_erpm");
|
|
conf.duty_min = mc->getParamDouble("l_min_duty"); conf.duty_max = mc->getParamDouble("l_max_duty");
|
|
conf.watt_min = mc->getParamDouble("l_watt_min"); conf.watt_max = mc->getParamDouble("l_watt_max");
|
|
auto ack = exchange(COMM_SET_MCCONF_TEMP, 2000, [&] {
|
|
vesc.commands()->setMcconfTemp(conf, false, false, false, false, true);
|
|
});
|
|
require(ack.size() == 1, "Invalid native limits ACK"); return {};
|
|
}
|
|
if (method == "configuration") {
|
|
auto motor = query(COMM_GET_MCCONF, 2000);
|
|
auto application = query(COMM_GET_APPCONF, 2000);
|
|
return {{"motor", exportConfig(vesc.mcConfig(), motor, "MCConfiguration")},
|
|
{"application", exportConfig(vesc.appConfig(), application, "APPConfiguration")}};
|
|
}
|
|
if (method == "foc_start") {
|
|
const auto loss = number(request, "max_power_loss_w", 10, 150);
|
|
require(!lastMotor.isEmpty() && !lastApplication.isEmpty(), "Read configurations before calibration");
|
|
for (const auto &key : {"l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm"})
|
|
require(std::isfinite(vesc.mcConfig()->getParamDouble(key)) && std::abs(vesc.mcConfig()->getParamDouble(key)) > 0.001,
|
|
"Zero-valued detection inputs require an explicit equipment profile");
|
|
procedureRunning = true; procedure = {{"kind", "foc"}};
|
|
QTimer::singleShot(0, [this, loss] { calibrate(loss); });
|
|
return {{"started", true}, {"interruptible", false}};
|
|
}
|
|
if (method == "hall_start") {
|
|
require(request.value("current_a") == 5, "This Hall profile uses 5 A");
|
|
procedureRunning = true; procedure = {}; lastHall.clear();
|
|
QTimer::singleShot(0, [&] {
|
|
auto measured = Utility::measureHallFocBlocking(&vesc, 5.0);
|
|
QJsonArray table;
|
|
for (int i = 1; i < measured.size(); ++i) table.append(measured[i]);
|
|
const bool completed = measured.size() == 9 && measured.first() != -10;
|
|
procedure = {{"kind", "hall"}, {"completed", completed},
|
|
{"status", measured.isEmpty() ? -10 : measured.first()}, {"table", table},
|
|
{"payload", QString::fromLatin1(lastHall.toBase64())},
|
|
{"upstream", "Utility::measureHallFocBlocking"}};
|
|
procedureUncertain = !completed;
|
|
procedureRunning = false;
|
|
});
|
|
return {{"started", true}, {"interruptible", false}};
|
|
}
|
|
throw std::runtime_error("Native operation is not admitted");
|
|
}
|
|
};
|
|
|
|
int main(int argc, char **argv) {
|
|
qputenv("QT_QPA_PLATFORM", "offscreen");
|
|
QApplication application(argc, argv);
|
|
QCoreApplication::setOrganizationName("MissionCore");
|
|
QCoreApplication::setApplicationName("VescToolEngine");
|
|
try {
|
|
require(argc == 2, "One exact serial port or --offline argument is required");
|
|
Engine engine;
|
|
const QString port = QString::fromLocal8Bit(argv[1]);
|
|
if (port != "--offline") engine.open(port);
|
|
QFile output; output.open(stdout, QIODevice::WriteOnly);
|
|
auto write = [&](const QJsonObject &value) {
|
|
output.write(QJsonDocument(value).toJson(QJsonDocument::Compact) + '\n'); output.flush();
|
|
};
|
|
write({{"ready", true}, {"engine", engine.dispatch({{"method", "engine"}})}});
|
|
QByteArray buffer;
|
|
fcntl(STDIN_FILENO, F_SETFL, fcntl(STDIN_FILENO, F_GETFL) | O_NONBLOCK);
|
|
QSocketNotifier input(STDIN_FILENO, QSocketNotifier::Read);
|
|
QObject::connect(&input, &QSocketNotifier::activated, [&] {
|
|
char chunk[4096]; const auto size = ::read(STDIN_FILENO, chunk, sizeof(chunk));
|
|
if (size == 0) { application.quit(); return; }
|
|
if (size < 0) return;
|
|
buffer.append(chunk, int(size));
|
|
if (buffer.size() > 65536) { application.exit(2); return; }
|
|
int end;
|
|
while ((end = buffer.indexOf('\n')) >= 0) {
|
|
auto raw = buffer.left(end); buffer.remove(0, end + 1);
|
|
QJsonParseError error;
|
|
auto document = QJsonDocument::fromJson(raw, &error);
|
|
auto request = document.object();
|
|
QJsonObject response{{"id", request.value("id")}};
|
|
try {
|
|
require(error.error == QJsonParseError::NoError && document.isObject(), "Invalid JSON request");
|
|
response.insert("result", engine.dispatch(request)); response.insert("ok", true);
|
|
} catch (const ExchangeFailure &e) {
|
|
response.insert("ok", false); response.insert("error", e.what());
|
|
response.insert("diagnostics", e.diagnostics);
|
|
} catch (const std::exception &e) { response.insert("ok", false); response.insert("error", e.what()); }
|
|
write(response);
|
|
}
|
|
});
|
|
return application.exec();
|
|
} catch (const std::exception &error) {
|
|
fprintf(stderr, "Native engine startup failed: %s\n", error.what());
|
|
return 1;
|
|
}
|
|
}
|