// // Created by Lenn on 2025/10/14. // #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "component.hh" #include "cpstream_core.hh" #include "base/globalhelper.hh" #include "creeper-qt/utility/theme/theme.hh" #include "creeper-qt/utility/wrapper/layout.hh" #include "creeper-qt/utility/wrapper/widget.hh" #include "components/charts/heatmap.hh" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "components/ffmsep/presist/presist.hh" #include "components/ffmsep/tactile/tacdec.hh" #include #define DEBUG 0 using namespace creeper; namespace capro = card::pro; namespace lnpro = linear::pro; namespace impro = image::pro; namespace ibpro = icon_button::pro; namespace fbpro = filled_button::pro; namespace dmpro = dropdown_menu::pro; namespace pwpro = plot_widget::pro; namespace { constexpr std::array kSlaveRequestCommand{ 0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00, 0x7A }; QVector make_flat_points(const QSize& size, double value = 0.0) { const int width = std::max(size.width(), 1); const int height = std::max(size.height(), 1); QVector points; points.reserve(static_cast(width * height)); for (int y = 0; y < height; ++y) { for (int x = 0; x < width; ++x) { points.append(PointData{ static_cast(x), static_cast(y), value }); } } return points; } std::once_flag& codec_registration_flag() { static std::once_flag flag; return flag; } class SensorStreamController: public QObject { public: SensorStreamController(std::shared_ptr>> heatmap_data, std::shared_ptr> matrix_context, QObject* parent = nullptr): QObject(parent), heatmap_data_(std::move(heatmap_data)), matrix_context_(std::move(matrix_context)) { std::call_once(codec_registration_flag(), [] { ffmsep::tactile::register_tactile_codec(); }); } ~SensorStreamController() override { reset_core(); } bool start(const QString& requested_port, std::uint32_t baudrate) { if (is_connected()) { return true; } const auto ports = ffmsep::CPStreamCore::list_available_ports(); std::string port_utf8; if (!requested_port.isEmpty()) { port_utf8 = requested_port.toStdString(); const auto it = std::find_if( ports.begin(), ports.end(), [&](const serial::PortInfo& info) { return info.port == port_utf8; }); if (it == ports.end()) { if (ports.empty()) { #if DEBUG std::cerr << "SensorStreamController: requested port '" << port_utf8 << "' not available and no other ports detected.\n"; #endif last_error_ = QString::fromUtf8("未检测到串口"); return false; } #if DEBUG std::cerr << "SensorStreamController: requested port '" << port_utf8 << "' not available, falling back to first detected port.\n"; #endif port_utf8 = ports.front().port; } } else if (!ports.empty()) { port_utf8 = ports.front().port; } else { #if DEBUG std::cerr << "SensorStreamController: no serial ports available\n"; #endif last_error_ = QString::fromUtf8("未检测到串口"); return false; } const std::uint32_t baud = baudrate == 0U ? 115200U : baudrate; ffmsep::CPStreamConfig cfg; cfg.port = port_utf8; cfg.baudrate = baud; cfg.codec_id = ffmsep::CPCodecID::Tactile; cfg.read_chunk_size = 256; cfg.packet_queue_capacity = 128; cfg.frame_queue_capacity = 32; cfg.slave_request_command.assign(kSlaveRequestCommand.begin(), kSlaveRequestCommand.end()); cfg.slave_request_interval = 3ms; reset_core(); core_ = std::make_unique(); if (!core_->open(cfg)) { last_error_ = QString::fromStdString(core_->last_error()); std::cerr << "SensorStreamController: open failed - " << core_->last_error() << "\n"; reset_core(); return false; } core_->set_frame_callback([this](std::shared_ptr frame) { handle_frame(frame); }); if (!core_->start()) { last_error_ = QString::fromStdString(core_->last_error()); std::cerr << "SensorStreamController: start failed - " << core_->last_error() << "\n"; reset_core(); return false; } active_port_ = QString::fromStdString(cfg.port); last_error_.clear(); connected_ = true; return true; } void stop() { if (!core_) { active_port_.clear(); if (heatmap_data_ && matrix_context_) { heatmap_data_->set(make_flat_points(matrix_context_->get())); } 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 { return core_ && core_->is_running(); } [[nodiscard]] bool is_connected() const noexcept { return connected_; } [[nodiscard]] QString active_port() const { return active_port_; } [[nodiscard]] QString last_error() const { return last_error_; } std::future 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: void reset_core() { connected_ = false; if (!core_) { return; } core_->set_frame_callback({}); if (core_->is_running()) { core_->stop(); } if (core_->is_open()) { core_->close(); } core_.reset(); } static QSize to_qsize(const ffmsep::tactile::MatrixSize& m) { return QSize{ static_cast(m.long_edge), static_cast(m.short_edge) }; } void handle_frame(std::shared_ptr frame) { if (!frame->tactile || frame->tactile_pressures.empty()) { return; } auto pressures = frame->tactile_pressures; auto frame_bytes = frame->frame.data; std::vector raw_payload; if (frame->tactile) { raw_payload = frame->tactile->payload; } QMetaObject::invokeMethod( this, [this, pressures = std::move(pressures), frame_bytes = std::move(frame_bytes), raw_payload = std::move(raw_payload)]() mutable { const auto format_raw = [](const std::vector& data) -> std::string { if (data.empty()) { return "[]"; } std::ostringstream oss; oss << '['; oss << std::uppercase << std::setfill('0'); for (std::size_t idx = 0; idx < data.size(); ++idx) { if (idx != 0U) { oss << ' '; } oss << std::setw(2) << std::hex << static_cast(data[idx]); } oss << ']'; return oss.str(); }; std::cout << "[Sensor] frame=" << format_raw(frame_bytes); std::cout << " payload=" << format_raw(raw_payload); std::cout << " received " << pressures.size() << " pressure values"; const std::size_t preview = std::min(pressures.size(), 12); if (preview > 0) { std::cout << " values=["; for (std::size_t idx = 0; idx < preview; ++idx) { if (idx != 0U) { std::cout << ", "; } std::cout << pressures[idx]; } if (preview < pressures.size()) { std::cout << ", ..."; } std::cout << "]"; } std::cout << std::endl; auto matrix = matrix_context_->get(); const auto cells_exp = static_cast(std::max(1, matrix.width()) * std::max(1, matrix.height())); if (cells_exp == 0) return; if (pressures.size() > cells_exp) { pressures.resize(cells_exp); } else if (pressures.size() < cells_exp) { pressures.resize(cells_exp, 0); } QVector points; points.reserve(matrix.width() * matrix.height()); for (int y = 0; y < matrix.height(); ++y) { for (int x = 0; x < matrix.width(); ++x) { const int idx = y * matrix.width() + x; if (idx >= static_cast(pressures.size())) { break; } const auto value = static_cast(pressures[static_cast(idx)]); points.append(PointData{ static_cast(x), static_cast(y), value }); } } matrix_context_->set(matrix); heatmap_data_->set(std::move(points)); }, Qt::QueuedConnection); } [[nodiscard]] QSize normalize_matrix(QSize candidate, std::size_t value_count) const { if (value_count == 0U) { return QSize{}; } const auto adapt_from = [value_count](const QSize& hint) -> std::optional { if (hint.width() <= 0 && hint.height() <= 0) { return std::nullopt; } if (hint.width() > 0 && hint.height() > 0) { const auto cells = static_cast(hint.width()) * static_cast(hint.height()); if (cells == value_count) { return hint; } } if (hint.width() > 0) { const auto width = static_cast(hint.width()); if (width != 0U && (value_count % width) == 0U) { const auto height = static_cast(value_count / width); return QSize{ hint.width(), height }; } } if (hint.height() > 0) { const auto height = static_cast(hint.height()); if (height != 0U && (value_count % height) == 0U) { const auto width = static_cast(value_count / height); return QSize{ width, hint.height() }; } } return std::nullopt; }; if (auto adjusted = adapt_from(candidate)) { return *adjusted; } if (auto adjusted = adapt_from(matrix_context_->get())) { return *adjusted; } const auto root = static_cast(std::sqrt(static_cast(value_count))); for (int width = root; width >= 1; --width) { const auto divisor = static_cast(width); if (divisor == 0U) { continue; } if ((value_count % divisor) == 0U) { const auto height = static_cast(value_count / divisor); return QSize{ width, height }; } } return QSize{ static_cast(value_count), 1 }; } std::shared_ptr>> heatmap_data_; std::shared_ptr> matrix_context_; std::unique_ptr core_; QString active_port_; QString last_error_; bool connected_ = false; static std::future make_failed_future( const QString& path, std::string message) { std::promise 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 { std::shared_ptr> link_icon = std::make_shared>(QString::fromLatin1(material::icon::kAddLink)); std::shared_ptr>> heatmap_data = std::make_shared>>(); std::shared_ptr> heatmap_matrix = std::make_shared>(); std::shared_ptr>> heatmap_range = std::make_shared>>(QPair{0, 300}); std::shared_ptr> port_items = std::make_shared>(); std::shared_ptr> profile_items = std::make_shared>(); QString selected_port; QString selected_profile; std::uint32_t selected_baud = 115200; std::unique_ptr controller; SensorUiState() { const QSize size{ 3, 4 }; heatmap_matrix->set_silent(size); heatmap_data->set_silent(make_flat_points(size)); // 初始化串口列表 QStringList ports_list; const auto ports = ffmsep::CPStreamCore::list_available_ports(); ports_list.reserve(static_cast(ports.size())); for (const auto& info: ports) { ports_list.emplace_back(QString::fromStdString(info.port)); } port_items->set_silent(ports_list); if (selected_port.isEmpty() && !ports_list.isEmpty()) { selected_port = ports_list.front(); } GlobalHelper::instance().reload_profiles(); QStringList profile_list; const auto& profiles = GlobalHelper::instance().get_all_profile(); profile_list.reserve(static_cast(profiles.size())); for (const auto& p : profiles) { profile_list << p.name; } profile_items->set_silent(profile_list); if (selected_profile.isEmpty() && !profile_list.isEmpty()) { selected_profile = profile_list.front(); if (!profiles.empty()) { const auto size = QSize { std::max(1, profiles.front().matrix_width), std::max(1, profiles.front().matrix_height) }; selected_baud = profiles.front().baud_rate == 0 ? 115200U : static_cast(profiles.front().baud_rate); heatmap_matrix->set_silent(size); heatmap_data->set_silent(make_flat_points(size)); const int range_min = profiles.front().range_left; const int range_max = (profiles.front().range_right == profiles.front().range_left) ? profiles.front().range_left + 1 : profiles.front().range_right; heatmap_range->set_silent(QPair{range_min, range_max}); } } controller = std::make_unique(heatmap_data, heatmap_matrix); } }; SensorUiState& sensor_state() { static SensorUiState state; return state; } } // namespace void RefreshProfilesForView() { auto& sensor = sensor_state(); GlobalHelper::instance().reload_profiles(); QStringList profile_list; const auto& profiles = GlobalHelper::instance().get_all_profile(); profile_list.reserve(static_cast(profiles.size())); for (const auto& p : profiles) { profile_list << p.name; } if (!sensor.selected_profile.isEmpty()) { const bool exists = profile_list.contains(sensor.selected_profile); if (!exists) { sensor.selected_profile = profile_list.isEmpty() ? QString{} : profile_list.front(); } } else if (!profile_list.isEmpty()) { sensor.selected_profile = profile_list.front(); } if (!sensor.selected_profile.isEmpty()) { const auto it = std::find_if(profiles.begin(), profiles.end(), [&](const ConfigProfile& p) { return p.name == sensor.selected_profile; }); if (it != profiles.end()) { sensor.selected_baud = it->baud_rate == 0 ? 115200U : static_cast(it->baud_rate); const auto size = QSize { std::max(1, it->matrix_width), std::max(1, it->matrix_height) }; sensor.heatmap_matrix->set(size); sensor.heatmap_data->set(make_flat_points(size)); const int range_min = it->range_left; const int range_max = (it->range_right == it->range_left) ? it->range_left + 1 : it->range_right; sensor.heatmap_range->set(QPair{range_min, range_max}); } } sensor.profile_items->set(std::move(profile_list)); } static auto ComConfigComponent(ThemeManager& manager) { auto& sensor = sensor_state(); auto link_icon_context = sensor.link_icon; if (sensor.selected_port.isEmpty() && !sensor.port_items->get().isEmpty()) { sensor.selected_port = sensor.port_items->get().front(); } if (sensor.selected_baud == 0U) { sensor.selected_baud = 115200U; } const auto row = new Row{ // lnpro::Item { // text_field::pro::ThemeManager {manager}, // text_field::pro::LeadingIcon {material::icon::kSearch, material::regular::font}, // MutableForward { // text_field::pro::LabelText {}, // slogen_context, // }, // }, lnpro::Item{ dmpro::ThemeManager{ manager }, dmpro::LeadingIcon{ material::icon::kArrowDropDown, material::regular::font }, dmpro::TextChanged{ [sensor_ptr = &sensor](QString text) { // const auto text = self.currentText(); if (!text.isEmpty()) { sensor_ptr->selected_port = text; } } }, dmpro::LabelText{ "COM" }, MutableForward{ dmpro::Items{}, sensor.port_items, }, }, lnpro::Item{ dmpro::ThemeManager{ manager }, dmpro::LeadingIcon{ material::icon::kArrowDropDown, material::regular::font }, dmpro::TextChanged{ [sensor_ptr = &sensor](QString text) { if (!text.isEmpty()) { sensor_ptr->selected_profile = text; const auto& profiles = GlobalHelper::instance().get_all_profile(); const auto it = std::find_if(profiles.begin(), profiles.end(), [&text](const ConfigProfile& p) { return p.name == text; }); if (it != profiles.end()) { const auto baud = it->baud_rate == 0 ? 115200U : static_cast(it->baud_rate); sensor_ptr->selected_baud = baud; const auto size = QSize { std::max(1, it->matrix_width), std::max(1, it->matrix_height) }; sensor_ptr->heatmap_matrix->set(size); sensor_ptr->heatmap_data->set(make_flat_points(size)); const int range_min = it->range_left; const int range_max = (it->range_right == it->range_left) ? it->range_left + 1 : it->range_right; sensor_ptr->heatmap_range->set(QPair{range_min, range_max}); } } } }, dmpro::LabelText{ "Profile" }, MutableForward{ dmpro::Items{}, sensor.profile_items, }, }, lnpro::SpacingItem{ 20 }, lnpro::Item{ ibpro::ThemeManager{ manager }, ibpro::FixedSize{ 40, 40 }, ibpro::Color{ IconButton::Color::TONAL }, ibpro::Font{ material::kRegularExtraSmallFont }, MutableForward{ icon_button::pro::FontIcon{}, link_icon_context, }, ibpro::Clickable{ [sensor_ptr = &sensor, link_icon_context] { auto& sensor = *sensor_ptr; if (!sensor.controller) { return; } if (sensor.controller->is_connected()) { sensor.controller->stop(); link_icon_context->set(QString::fromLatin1(material::icon::kAddLink)); } else { const auto port = sensor.selected_port; const auto baud = sensor.selected_baud == 0U ? 115200U : sensor.selected_baud; if (sensor.controller->start(port, baud)) { sensor.selected_port = sensor.controller->active_port(); link_icon_context->set(QString::fromLatin1(material::icon::kLinkOff)); } else { std::cerr << "Failed to start sensor stream: " << sensor.controller->last_error().toStdString() << "\n"; } } } } }, lnpro::Item{ ibpro::ThemeManager{ manager }, ibpro::FixedSize{ 40, 40 }, ibpro::Color{ IconButton::Color::TONAL }, ibpro::Font{ material::kRegularExtraSmallFont }, ibpro::FontIcon{ material::icon::kRefresh }, ibpro::Clickable{ [&sensor] { QStringList ports_list; const auto ports = ffmsep::CPStreamCore::list_available_ports(); ports_list.reserve(static_cast(ports.size())); for (const auto& info: ports) { ports_list.emplace_back(QString::fromStdString(info.port)); } if (!sensor.selected_port.isEmpty()) { const bool exists = ports_list.contains(sensor.selected_port); if (!exists) { sensor.selected_port = ports_list.isEmpty() ? QString{} : ports_list.front(); } } else if (!ports_list.isEmpty()) { sensor.selected_port = ports_list.front(); } sensor.port_items->set(std::move(ports_list)); RefreshProfilesForView(); } }, }, lnpro::Item{ fbpro::ThemeManager{ manager }, fbpro::FixedSize{ 40, 40 }, fbpro::Radius{ 8.0 }, // fbpro::Color { IconButton::Color::TONAL }, fbpro::Font{ material::kRegularExtraSmallFont }, fbpro::Text{ "drive_file_move" }, fbpro::Clickable{ [&sensor] { 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{ widget::pro::Layout{ row }, }; } static auto DisplayComponent(ThemeManager& manager, int /*index*/ = 0) noexcept { auto& sensor = sensor_state(); const auto row = new Row{ lnpro::Item{ plot_widget::pro::SizePolicy{ QSizePolicy::Expanding, }, plot_widget::pro::ThemeManager{ manager }, MutableForward{ plot_widget::pro::PlotData{}, sensor.heatmap_data, }, pwpro::MatrixSize{ sensor.heatmap_matrix->get() }, MutableTransform{ [](auto& widget, const QSize& size) { pwpro::MatrixSize{ size }.apply(widget); }, sensor.heatmap_matrix }, MutableTransform{ [](auto& widget, const QPair& range) { const double min = static_cast(range.first); const double max = static_cast(range.second); widget.set_color_gradient_range(min, max); }, sensor.heatmap_range }, }, }; return new Widget{ widget::pro::Layout{ row }, }; } auto ViewComponent(ViewComponentState& state) noexcept -> raw_pointer { return new FilledCard{ capro::ThemeManager{ state.manager }, capro::SizePolicy{ QSizePolicy::Expanding }, capro::Layout{ lnpro::Alignment{ Qt::AlignTop }, // lnpro::Margin {10}, // lnpro::Spacing {10}, lnpro::Item{ ComConfigComponent(state.manager), }, lnpro::Item{ lnpro::Item{ DisplayComponent(state.manager), }, lnpro::Item{ DisplayComponent(state.manager), }, }, }, }; }