#include "capture_file_writer.h" #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr std::size_t kLivePlotMaxColumns = 800; constexpr uint32_t kCsvSpoolMagic = 0x4C564353U; // "SVCL" constexpr uint32_t kCsvSpoolVersion = 1U; struct CsvSpoolFileHeader { uint32_t magic = kCsvSpoolMagic; uint32_t version = kCsvSpoolVersion; uint32_t channel_count = 0; uint32_t reserved = 0; }; struct CsvSpoolPacketHeader { uint64_t packet_index = 0; uint64_t channel_count = 0; uint64_t frame_count = 0; }; struct PlotPoint { std::size_t sample_index = 0; double value = 0.0; }; std::size_t packet_channel_count(const CapturePacket& packet) { return (packet.channel_count <= 1U) ? 1U : 2U; } bool packet_has_ch2(const CapturePacket& packet) { return packet_channel_count(packet) >= 2U; } std::size_t packet_frame_count(const CapturePacket& packet) { return packet_has_ch2(packet) ? std::min(packet.ch1.size(), packet.ch2.size()) : packet.ch1.size(); } std::vector build_min_max_trace(const std::vector& data, std::size_t max_columns) { std::vector result; if (data.empty()) { return result; } const std::size_t bucket_size = std::max(1, (data.size() + max_columns - 1) / max_columns); result.reserve(max_columns * 2); for (std::size_t begin = 0; begin < data.size(); begin += bucket_size) { const std::size_t end = std::min(begin + bucket_size, data.size()); std::size_t min_index = begin; std::size_t max_index = begin; for (std::size_t i = begin + 1; i < end; ++i) { if (data[i] < data[min_index]) { min_index = i; } if (data[i] > data[max_index]) { max_index = i; } } if (min_index <= max_index) { result.push_back({min_index, data[min_index]}); if (max_index != min_index) { result.push_back({max_index, data[max_index]}); } } else { result.push_back({max_index, data[max_index]}); result.push_back({min_index, data[min_index]}); } } if (result.back().sample_index != (data.size() - 1)) { result.push_back({data.size() - 1, data.back()}); } return result; } std::string polyline_points(const std::vector& trace, std::size_t packet_start_index, std::size_t total_frames, double y_min, double y_max, double left, double top, double width, double height) { std::ostringstream out; out << std::fixed << std::setprecision(3); const double y_span = std::max(1e-12, y_max - y_min); const double x_span = (total_frames > 1U) ? static_cast(total_frames - 1U) : 1.0; for (const auto& point : trace) { const double global_index = static_cast(packet_start_index + point.sample_index); const double x = left + (global_index / x_span) * width; const double y = top + height - ((point.value - y_min) / y_span) * height; out << x << "," << y << " "; } return out.str(); } std::string json_points(const std::vector& trace, double frame_freq_hz) { std::ostringstream out; out << std::fixed << std::setprecision(9); out << "["; bool first = true; for (const auto& point : trace) { if (!first) { out << ","; } first = false; out << "[" << (static_cast(point.sample_index) / frame_freq_hz) << "," << point.value << "]"; } out << "]"; return out.str(); } [[noreturn]] void fail_write(const std::string& message); std::string replace_extension(const std::string& path, const std::string& new_extension) { const std::size_t slash_pos = path.find_last_of("/\\"); const std::size_t dot_pos = path.find_last_of('.'); if ((dot_pos == std::string::npos) || ((slash_pos != std::string::npos) && (dot_pos < slash_pos))) { return path + new_extension; } return path.substr(0, dot_pos) + new_extension; } std::string path_to_script_url(std::string path) { for (char& ch : path) { if (ch == '\\') { ch = '/'; } } std::string encoded; encoded.reserve(path.size() + 8); for (char ch : path) { switch (ch) { case ' ': encoded += "%20"; break; case '#': encoded += "%23"; break; case '%': encoded += "%25"; break; case '?': encoded += "%3F"; break; default: encoded.push_back(ch); break; } } if ((encoded.size() >= 2U) && std::isalpha(static_cast(encoded[0])) && (encoded[1] == ':')) { return "file:///" + encoded; } return encoded; } std::string csv_spool_path(const std::string& csv_path) { return replace_extension(csv_path, ".capture_spool.bin"); } void write_text_file(const std::string& path, const std::string& text) { std::ofstream file(path, std::ios::binary); if (!file) { fail_write("Cannot open file for writing: " + path); } file << text; } void write_live_data_script(const std::string& path, const std::string& data_json) { write_text_file(path, "window.e502LiveData = " + data_json + ";\n"); } void write_live_html_document(const std::string& path, const std::string& data_script_url) { std::ofstream html(path, std::ios::binary); if (!html) { fail_write("Cannot open live HTML for writing: " + path); } html << "\n" << "\n" << "\n" << " \n" << " \n" << " E-502 Live Plot\n" << " \n" << "\n" << "\n" << "
\n" << "
\n" << "
\n" << "
\n" << "
E-502 Live Packet Plot
\n" << "
\n" << " Waiting for packets...\n" << " \n" << " \n" << " \n" << " \n" << "
\n" << "
\n" << "
\n" << " Auto-refresh every 500 ms\n" << "
\n" << "
\n" << "
Open this page once and leave it open. It refreshes its data script to pick up new packets.
\n" << "
\n" << " \n" << " \n" << " \n" << " \n" << " \n" << " \n" << " Wheel: X zoom, Shift+wheel: Y zoom, double-click: reset\n" << "
\n" << " \n" << "
\n" << " CH1\n" << " CH2\n" << "
\n" << "
\n" << "
\n" << " \n" << "\n" << "\n"; } [[noreturn]] void fail_write(const std::string& message) { throw std::runtime_error(message); } } // namespace CaptureFileWriter::CaptureFileWriter(std::string csv_path, std::string svg_path, std::string live_html_path, std::string live_json_path) : csv_path_(std::move(csv_path)), svg_path_(std::move(svg_path)), live_html_path_(std::move(live_html_path)), live_json_path_(std::move(live_json_path)) {} const std::string& CaptureFileWriter::live_html_path() const { return live_html_path_; } const std::string& CaptureFileWriter::live_json_path() const { return live_json_path_; } void CaptureFileWriter::write(const std::vector& packets, double frame_freq_hz, double nominal_range_v) const { finalize_csv_from_spool(frame_freq_hz); write_svg(packets, frame_freq_hz, nominal_range_v); } void CaptureFileWriter::initialize_live_plot() const { const std::string waiting_json = "{\n" " \"status\": \"waiting\",\n" " \"message\": \"Waiting for first packet...\"\n" "}\n"; const std::string live_script_path = replace_extension(live_json_path_, ".js"); write_text_file(live_json_path_, waiting_json); write_live_data_script(live_script_path, waiting_json); write_live_html_document(live_html_path_, path_to_script_url(live_script_path)); } void CaptureFileWriter::initialize_csv(std::size_t channel_count) const { (void) channel_count; std::ofstream csv_file(csv_path_, std::ios::binary | std::ios::trunc); if (!csv_file) { fail_write("Cannot prepare CSV output file: " + csv_path_); } csv_file << ""; const std::string spool_path = csv_spool_path(csv_path_); std::ofstream spool(spool_path, std::ios::binary | std::ios::trunc); if (!spool) { fail_write("Cannot open CSV spool for writing: " + spool_path); } const CsvSpoolFileHeader header{kCsvSpoolMagic, kCsvSpoolVersion, static_cast(channel_count), 0U}; spool.write(reinterpret_cast(&header), sizeof(header)); if (!spool) { fail_write("Cannot initialize CSV spool: " + spool_path); } } void CaptureFileWriter::append_csv_packet(const CapturePacket& packet, double frame_freq_hz, std::size_t& global_frame_index) const { (void) frame_freq_hz; (void) global_frame_index; const std::string spool_path = csv_spool_path(csv_path_); std::ofstream spool(spool_path, std::ios::binary | std::ios::app); if (!spool) { fail_write("Cannot open CSV spool for appending: " + spool_path); } const std::size_t frames = packet_frame_count(packet); const std::size_t channel_count = packet_channel_count(packet); const CsvSpoolPacketHeader header{ static_cast(packet.packet_index), static_cast(channel_count), static_cast(frames) }; spool.write(reinterpret_cast(&header), sizeof(header)); if (frames != 0U) { spool.write(reinterpret_cast(packet.ch1.data()), static_cast(frames * sizeof(double))); if (channel_count >= 2U) { spool.write(reinterpret_cast(packet.ch2.data()), static_cast(frames * sizeof(double))); } } if (!spool) { fail_write("Cannot append packet to CSV spool: " + spool_path); } } void CaptureFileWriter::update_live_plot(const CapturePacket& packet, std::size_t packets_seen, double packets_per_second, double frame_freq_hz, const std::string& close_reason, std::size_t zeroed_samples, std::size_t stored_samples) const { const std::size_t frames = packet_frame_count(packet); const double duration_ms = (frames == 0U) ? 0.0 : (1000.0 * static_cast(frames) / frame_freq_hz); const double zeroed_percent = (stored_samples == 0U) ? 0.0 : (100.0 * static_cast(zeroed_samples) / static_cast(stored_samples)); const auto trace1 = build_min_max_trace(packet.ch1, kLivePlotMaxColumns); const auto trace2 = build_min_max_trace(packet.ch2, kLivePlotMaxColumns); std::ostringstream json; json << std::fixed << std::setprecision(9); json << "{\n" << " \"status\": \"packet\",\n" << " \"packetIndex\": " << packet.packet_index << ",\n" << " \"channelCount\": " << packet_channel_count(packet) << ",\n" << " \"packetsSeen\": " << packets_seen << ",\n" << " \"packetsPerSecond\": " << packets_per_second << ",\n" << " \"framesPerChannel\": " << frames << ",\n" << " \"frameFreqHz\": " << frame_freq_hz << ",\n" << " \"durationMs\": " << duration_ms << ",\n" << " \"closeReason\": \"" << close_reason << "\",\n" << " \"zeroedSamples\": " << zeroed_samples << ",\n" << " \"storedSamples\": " << stored_samples << ",\n" << " \"zeroedPercent\": " << zeroed_percent << ",\n" << " \"updatedAt\": \"packet " << packet.packet_index << "\",\n" << " \"ch1\": " << json_points(trace1, frame_freq_hz) << ",\n" << " \"ch2\": " << json_points(trace2, frame_freq_hz) << "\n" << "}\n"; const std::string json_text = json.str(); write_text_file(live_json_path_, json_text); write_live_data_script(replace_extension(live_json_path_, ".js"), json_text); } void CaptureFileWriter::finalize_csv_from_spool(double frame_freq_hz) const { const std::string spool_path = csv_spool_path(csv_path_); std::ifstream spool(spool_path, std::ios::binary); if (!spool) { fail_write("Cannot open CSV spool for reading: " + spool_path); } CsvSpoolFileHeader file_header{}; spool.read(reinterpret_cast(&file_header), sizeof(file_header)); if (!spool || (file_header.magic != kCsvSpoolMagic) || (file_header.version != kCsvSpoolVersion)) { fail_write("Invalid CSV spool format: " + spool_path); } std::ofstream csv(csv_path_, std::ios::binary | std::ios::trunc); if (!csv) { fail_write("Cannot open CSV for final writing: " + csv_path_); } bool header_written = false; std::size_t global_frame_index = 0; while (true) { CsvSpoolPacketHeader packet_header{}; spool.read(reinterpret_cast(&packet_header), sizeof(packet_header)); if (!spool) { if (spool.eof()) { break; } fail_write("Cannot read packet header from CSV spool: " + spool_path); } const std::size_t packet_index = static_cast(packet_header.packet_index); const std::size_t channel_count = static_cast(packet_header.channel_count); const std::size_t frames = static_cast(packet_header.frame_count); if (!header_written) { csv << "packet_index,frame_index,global_frame_index,time_s,packet_time_s,ch1_v"; if (channel_count >= 2U) { csv << ",ch2_v"; } csv << "\n"; header_written = true; } std::vector ch1(frames); std::vector ch2((channel_count >= 2U) ? frames : 0U); if (frames != 0U) { spool.read(reinterpret_cast(ch1.data()), static_cast(frames * sizeof(double))); if (!spool) { fail_write("Cannot read CH1 data from CSV spool: " + spool_path); } if (channel_count >= 2U) { spool.read(reinterpret_cast(ch2.data()), static_cast(frames * sizeof(double))); if (!spool) { fail_write("Cannot read CH2 data from CSV spool: " + spool_path); } } } std::ostringstream out; out << std::fixed << std::setprecision(9); for (std::size_t i = 0; i < frames; ++i) { const double time_s = static_cast(global_frame_index) / frame_freq_hz; const double packet_time_s = static_cast(i) / frame_freq_hz; out << packet_index << "," << i << "," << global_frame_index << "," << time_s << "," << packet_time_s << "," << ch1[i]; if (channel_count >= 2U) { out << "," << ch2[i]; } out << "\n"; ++global_frame_index; } csv << out.str(); } if (!header_written) { csv << "packet_index,frame_index,global_frame_index,time_s,packet_time_s,ch1_v"; if (file_header.channel_count >= 2U) { csv << ",ch2_v"; } csv << "\n"; } csv.flush(); std::remove(spool_path.c_str()); } void CaptureFileWriter::write_svg(const std::vector& packets, double frame_freq_hz, double nominal_range_v) const { std::ofstream file(svg_path_, std::ios::binary); if (!file) { fail_write("Cannot open SVG for writing: " + svg_path_); } std::size_t total_frames = 0; double min_y = std::numeric_limits::infinity(); double max_y = -std::numeric_limits::infinity(); const std::size_t channel_count = packets.empty() ? 2U : packet_channel_count(packets.front()); for (const auto& packet : packets) { const std::size_t frames = packet_frame_count(packet); total_frames += frames; for (std::size_t i = 0; i < frames; ++i) { min_y = std::min(min_y, packet.ch1[i]); max_y = std::max(max_y, packet.ch1[i]); if (packet_has_ch2(packet)) { min_y = std::min(min_y, packet.ch2[i]); max_y = std::max(max_y, packet.ch2[i]); } } } if (!std::isfinite(min_y) || !std::isfinite(max_y) || (min_y == max_y)) { min_y = -nominal_range_v; max_y = nominal_range_v; } else { const double pad = std::max(0.1, (max_y - min_y) * 0.08); min_y -= pad; max_y += pad; } const double total_time_s = (total_frames > 1U) ? (static_cast(total_frames - 1U) / frame_freq_hz) : 0.0; const double width = 1400.0; const double height = 800.0; const double left = 90.0; const double right = 40.0; const double top = 40.0; const double bottom = 80.0; const double plot_w = width - left - right; const double plot_h = height - top - bottom; const double zero_y = top + plot_h - ((0.0 - min_y) / std::max(1e-12, max_y - min_y)) * plot_h; file << "\n"; file << " \n"; file << " \n"; for (int i = 0; i <= 10; ++i) { const double x = left + (plot_w * i / 10.0); const double y = top + (plot_h * i / 10.0); file << " \n"; file << " \n"; } if ((0.0 >= min_y) && (0.0 <= max_y)) { file << " \n"; } std::size_t frame_offset = 0; for (std::size_t packet_idx = 0; packet_idx < packets.size(); ++packet_idx) { const auto& packet = packets[packet_idx]; const std::size_t frames = packet_frame_count(packet); if (frames == 0U) { continue; } const double x0 = left + ((total_frames > 1U) ? (static_cast(frame_offset) / static_cast(total_frames - 1U)) * plot_w : 0.0); const double x1 = left + ((total_frames > 1U) ? (static_cast(frame_offset + frames - 1U) / static_cast(total_frames - 1U)) * plot_w : plot_w); const char* packet_fill = (packet_idx % 2U == 0U) ? "#f7fafc" : "#fdfefe"; file << " \n"; if (packet_idx != 0U) { file << " \n"; } const auto trace1 = build_min_max_trace(packet.ch1, 1200); const auto trace2 = build_min_max_trace(packet.ch2, 1200); file << " \n"; if (packet_has_ch2(packet)) { file << " \n"; } if (packets.size() <= 24U) { file << " P" << packet.packet_index << "\n"; } frame_offset += frames; } file << " E-502 capture: " << packets.size() << " packet(s), " << ((channel_count >= 2U) ? "CH1 and CH2" : "CH1 only") << "\n"; file << " time, s\n"; file << " V\n"; file << std::fixed << std::setprecision(6); for (int i = 0; i <= 10; ++i) { const double x = left + (plot_w * i / 10.0); const double t = total_time_s * i / 10.0; file << " " << t << "\n"; const double y = top + plot_h - (plot_h * i / 10.0); const double v = min_y + (max_y - min_y) * i / 10.0; file << " " << v << "\n"; } const double legend_y = height - 48.0; file << " \n"; file << " CH1\n"; if (channel_count >= 2U) { file << " \n"; file << " CH2\n"; } file << "\n"; }