feat:export data while running or close

This commit is contained in:
2025-11-05 10:42:43 +08:00
parent 7517f79c07
commit b2350a3b35
3 changed files with 210 additions and 23 deletions

View File

@@ -6,6 +6,7 @@
#include "components/ffmsep/cpstream_core.hh" #include "components/ffmsep/cpstream_core.hh"
#include <algorithm>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <fstream> #include <fstream>
@@ -19,6 +20,73 @@ namespace {
using nlohmann::json; using nlohmann::json;
bool is_simple_array(const json& value) {
if (!value.is_array()) {
return false;
}
return std::all_of(value.begin(), value.end(), [](const json& item) {
return item.is_primitive();
});
}
void dump_compact_json(std::ostream& out,
const json& value,
int indent = 0,
int indent_step = 2) {
const auto indent_str = std::string(static_cast<std::size_t>(indent), ' ');
const auto child_indent = indent + indent_step;
const auto child_indent_str = std::string(static_cast<std::size_t>(child_indent), ' ');
if (value.is_object()) {
out << "{\n";
bool first = true;
for (auto it = value.begin(); it != value.end(); ++it) {
if (!first) {
out << ",\n";
}
first = false;
out << child_indent_str << json(it.key()).dump() << ": ";
dump_compact_json(out, it.value(), child_indent, indent_step);
}
out << '\n' << indent_str << '}';
return;
}
if (value.is_array()) {
if (value.empty()) {
out << "[]";
return;
}
if (is_simple_array(value)) {
out << '[';
for (std::size_t idx = 0; idx < value.size(); ++idx) {
if (idx != 0U) {
out << ", ";
}
out << value[static_cast<json::size_type>(idx)].dump();
}
out << ']';
return;
}
out << "[\n";
bool first = true;
for (const auto& item : value) {
if (!first) {
out << ",\n";
}
first = false;
out << child_indent_str;
dump_compact_json(out, item, child_indent, indent_step);
}
out << '\n' << indent_str << ']';
return;
}
out << value.dump();
}
json serialize_tactile_frame(const DecodedFrame& frame) { json serialize_tactile_frame(const DecodedFrame& frame) {
json result = { json result = {
{"pts", frame.pts}, {"pts", frame.pts},
@@ -165,7 +233,8 @@ WriteResult JsonWritter::write_once(const std::string& path,
return {false, "failed to open export file", path}; return {false, "failed to open export file", path};
} }
stream << std::setw(2) << root; dump_compact_json(stream, root);
stream << '\n';
stream.flush(); stream.flush();
if (!stream.good()) { if (!stream.good()) {
return {false, "failed to write export file", path}; return {false, "failed to write export file", path};

View File

@@ -6,13 +6,22 @@
#include <array> #include <array>
#include <cmath> #include <cmath>
#include <cstdint> #include <cstdint>
#include <exception>
#include <future>
#include <memory> #include <memory>
#include <mutex> #include <mutex>
#include <thread>
#include <QString> #include <QString>
#include <QObject> #include <QObject>
#include <QMetaObject> #include <QMetaObject>
#include <QStringList> #include <QStringList>
#include <QtCore/Qt> #include <QtCore/Qt>
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFileDialog>
#include <QMessageBox>
#include <QStandardPaths>
#include <optional> #include <optional>
#include <string> #include <string>
#include <qsize.h> #include <qsize.h>
@@ -42,6 +51,7 @@
#include <modern-qt/widget/text-fields.hh> #include <modern-qt/widget/text-fields.hh>
#include <modern-qt/widget/text.hh> #include <modern-qt/widget/text.hh>
#include <modern-qt/widget/select.hh> #include <modern-qt/widget/select.hh>
#include "components/ffmsep/presist/presist.hh"
#include "components/ffmsep/tactile/tacdec.hh" #include "components/ffmsep/tactile/tacdec.hh"
#define DEBUG 0 #define DEBUG 0
@@ -146,7 +156,7 @@ public:
cfg.packet_queue_capacity = 128; cfg.packet_queue_capacity = 128;
cfg.frame_queue_capacity = 32; cfg.frame_queue_capacity = 32;
cfg.slave_request_command.assign(kSlaveRequestCommand.begin(), kSlaveRequestCommand.end()); cfg.slave_request_command.assign(kSlaveRequestCommand.begin(), kSlaveRequestCommand.end());
cfg.slave_request_interval = std::chrono::milliseconds{200}; cfg.slave_request_interval = 3ms;
reset_core(); reset_core();
core_ = std::make_unique<ffmsep::CPStreamCore>(); core_ = std::make_unique<ffmsep::CPStreamCore>();
@@ -175,14 +185,29 @@ public:
return true; return true;
} }
void stop() { void stop() {
reset_core(); if (!core_) {
active_port_.clear(); active_port_.clear();
if (heatmap_data_ && matrix_context_) { if (heatmap_data_ && matrix_context_) {
heatmap_data_->set(make_flat_points(matrix_context_->get())); heatmap_data_->set(make_flat_points(matrix_context_->get()));
} }
connected_ = false; connected_ = false;
} return;
}
core_->set_frame_callback({});
if (core_->is_running()) {
core_->stop();
}
core_->clear_frames();
connected_ = false;
active_port_.clear();
if (heatmap_data_ && matrix_context_) {
heatmap_data_->set(make_flat_points(matrix_context_->get()));
}
}
[[nodiscard]] bool is_running() const noexcept { [[nodiscard]] bool is_running() const noexcept {
return core_ && core_->is_running(); return core_ && core_->is_running();
@@ -200,6 +225,21 @@ public:
return last_error_; return last_error_;
} }
std::future<ffmsep::persist::WriteResult> export_frames(const QString& path,
bool clear_after_export) {
if (path.isEmpty()) {
return make_failed_future(path, "export path is empty");
}
if (!core_) {
return make_failed_future(path, "stream is not active");
}
if (core_->recorded_frame_count() == 0U) {
return make_failed_future(path, "no tactile frames recorded");
}
const auto normalized = QDir::toNativeSeparators(path);
return core_->export_recorded_frames(normalized.toStdString(), clear_after_export);
}
private: private:
void reset_core() { void reset_core() {
connected_ = false; connected_ = false;
@@ -380,6 +420,20 @@ private:
QString active_port_; QString active_port_;
QString last_error_; QString last_error_;
bool connected_ = false; bool connected_ = false;
static std::future<ffmsep::persist::WriteResult> make_failed_future(
const QString& path,
std::string message) {
std::promise<ffmsep::persist::WriteResult> promise;
auto future = promise.get_future();
ffmsep::persist::WriteResult result{
false,
std::move(message),
path.toStdString()
};
promise.set_value(std::move(result));
return future;
}
}; };
struct SensorUiState { struct SensorUiState {
@@ -482,7 +536,7 @@ static auto ComConfigComponent(ThemeManager& manager) {
ibpro::ThemeManager {manager}, ibpro::ThemeManager {manager},
ibpro::FixedSize {40, 40}, ibpro::FixedSize {40, 40},
ibpro::Color { IconButton::Color::TONAL }, ibpro::Color { IconButton::Color::TONAL },
ibpro::Font { material::kRoundSmallFont }, ibpro::Font { material::kRegularExtraSmallFont },
MutableForward { MutableForward {
icon_button::pro::FontIcon {}, icon_button::pro::FontIcon {},
link_icon_context, link_icon_context,
@@ -513,18 +567,15 @@ static auto ComConfigComponent(ThemeManager& manager) {
ibpro::ThemeManager { manager }, ibpro::ThemeManager { manager },
ibpro::FixedSize { 40, 40 }, ibpro::FixedSize { 40, 40 },
ibpro::Color { IconButton::Color::TONAL }, ibpro::Color { IconButton::Color::TONAL },
ibpro::Font { material::kRoundSmallFont }, ibpro::Font { material::kRegularExtraSmallFont },
ibpro::FontIcon { material::icon::kRefresh }, ibpro::FontIcon { material::icon::kRefresh },
ibpro::Clickable {[&sensor] { ibpro::Clickable {[&sensor] {
// 刷新串口列表
QStringList ports_list; QStringList ports_list;
const auto ports = ffmsep::CPStreamCore::list_available_ports(); const auto ports = ffmsep::CPStreamCore::list_available_ports();
ports_list.reserve(static_cast<qsizetype>(ports.size())); ports_list.reserve(static_cast<qsizetype>(ports.size()));
for (const auto& info : ports) { for (const auto& info : ports) {
ports_list.emplace_back(QString::fromStdString(info.port)); ports_list.emplace_back(QString::fromStdString(info.port));
} }
// 保持原选择(若仍然存在)
if (!sensor.selected_port.isEmpty()) { if (!sensor.selected_port.isEmpty()) {
const bool exists = ports_list.contains(sensor.selected_port); const bool exists = ports_list.contains(sensor.selected_port);
if (!exists) { if (!exists) {
@@ -539,15 +590,74 @@ static auto ComConfigComponent(ThemeManager& manager) {
}, },
lnpro::Item<FilledButton> { lnpro::Item<FilledButton> {
fbpro::ThemeManager {manager}, fbpro::ThemeManager {manager},
fbpro::FixedSize {40, 40}, fbpro::FixedSize {40, 40},
fbpro::Color {FilledButton::Color::TONAL}, fbpro::Radius {8.0},
fbpro::Font {material::kRoundSmallFont}, // fbpro::Color { IconButton::Color::TONAL },
fbpro::FontIcon {material::icon::kRefresh}, fbpro::Font { material::kRegularExtraSmallFont },
fbpro::Clickable { fbpro::Text { "drive_file_move" },
[] { fbpro::Clickable {[&sensor] {
qDebug() << "export"; auto* controller = sensor.controller.get();
if (!controller) {
QMessageBox::warning(nullptr,
QStringLiteral("导出失败"),
QStringLiteral("当前串流尚未初始化。"));
return;
} }
}
const auto documents = QStandardPaths::writableLocation(QStandardPaths::DocumentsLocation);
const auto timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss"));
QString suggested_name = QStringLiteral("touchsensor_%1.json").arg(timestamp);
QString initial_path = documents.isEmpty()
? suggested_name
: QDir(documents).filePath(suggested_name);
const QString chosen_path = QFileDialog::getSaveFileName(
nullptr,
QStringLiteral("导出触觉帧"),
initial_path,
QStringLiteral("JSON 文件 (*.json)"));
if (chosen_path.isEmpty()) {
return;
}
auto future = controller->export_frames(chosen_path, false);
std::thread([future = std::move(future)]() mutable {
ffmsep::persist::WriteResult result{};
try {
result = future.get();
} catch (const std::exception& ex) {
result.ok = false;
result.error = ex.what();
} catch (...) {
result.ok = false;
result.error = "unknown export failure";
}
if (auto* app = QCoreApplication::instance()) {
QMetaObject::invokeMethod(app,
[res = std::move(result)]() {
if (res.ok) {
QMessageBox::information(
nullptr,
QStringLiteral("导出成功"),
QStringLiteral("触觉帧已导出至:\n%1")
.arg(QString::fromStdString(res.path)));
} else {
const auto error = QString::fromStdString(
res.error.empty() ? std::string{"unknown error"} : res.error);
const auto target = QString::fromStdString(res.path);
const auto message = target.isEmpty()
? QStringLiteral("原因:%1").arg(error)
: QStringLiteral("原因:%1\n目标:%2").arg(error, target);
QMessageBox::warning(nullptr,
QStringLiteral("导出失败"),
message);
}
},
Qt::QueuedConnection);
}
}).detach();
}}
} }
}; };
return new Widget { return new Widget {

View File

@@ -96,6 +96,13 @@ namespace material {
inline const auto kOutlinedLargeFont = outlined::font_3; inline const auto kOutlinedLargeFont = outlined::font_3;
inline const auto kOutlinedExtraLargeFont = outlined::font_4; inline const auto kOutlinedExtraLargeFont = outlined::font_4;
constexpr auto kRegularFontName = regular::font;
inline const auto kRegularExtraSmallFont = regular::font_0;
inline const auto kRegularSmallFont = regular::font_1;
inline const auto kRegularMediumFont = regular::font_2;
inline const auto kRegularLargeFont = regular::font_3;
inline const auto kRegularExtraLargeFont = regular::font_4;
namespace icon { namespace icon {
// Function // Function
@@ -140,6 +147,7 @@ namespace material {
constexpr auto kLogout = "logout"; constexpr auto kLogout = "logout";
constexpr auto kRoutine = "routine"; constexpr auto kRoutine = "routine";
constexpr auto kDarkMode = "dark_mode"; constexpr auto kDarkMode = "dark_mode";
constexpr auto kFileExport = "file_export";
// File // File
constexpr auto kFolder = "folder"; constexpr auto kFolder = "folder";