// SPDX-License-Identifier: MIT // SPDX-FileCopyrightText: 2026 Martino Ferrari #include "event_store.hpp" #include #include #include #include #include #include #include #include "layout_store.hpp" namespace udpscope { namespace { /// One directory under the cache holds *every* source's events, and each source gets a folder inside /// it: `/udpscope/events//`. The other way round -- a folder per source with an /// `events` inside -- spreads one feature over as many directories as there are sources, and turns /// "where are my events" into a question about which host you last used. constexpr const char* kEventsSubdir = "/events"; /// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are /// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite /// the event it collided with. std::string event_stem(double wall_time, std::uint64_t seq) { const std::time_t t = static_cast(wall_time); std::tm tm{}; localtime_r(&t, &tm); char buf[64]; std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm); char stem[96]; std::snprintf(stem, sizeof(stem), "%s-%04llu", buf, static_cast(seq % 10000ull)); return stem; } /// `a.npy` -> `a`; anything else -> "". std::string stem_of(const std::string& file, const char* ext) { const std::size_t n = std::strlen(ext); if (file.size() <= n || file.compare(file.size() - n, n, ext) != 0) return {}; return file.substr(0, file.size() - n); } /// The event's picture, drawn from its samples: one horizontal strip per signal, min/max per pixel /// column, the trigger edge as a brighter line. A 24-bit BMP -- the header is nine words and the rest /// is the pixels, so it needs no encoder, and a person can open it with anything. /// /// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at /// a glance is one *event* from another, not one signal from another (for which the names are on the /// row above). bool write_preview(const ExportTable& table, const std::string& path, int width = 480, int height = 160) { if (table.empty() || table.columns.empty()) return false; const std::size_t rows = table.rows(); const std::size_t strips = table.columns.size(); const int strip_h = std::max(6, height / static_cast(strips)); const int total_h = strip_h * static_cast(strips); static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u, 0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u}; // One byte per pixel per channel, bottom-up (BMP's own order), padded to a 4-byte row. const int row_bytes = ((width * 3) + 3) & ~3; std::vector pixels(std::size_t(row_bytes) * total_h, 0); const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) { if (x < 0 || y < 0 || x >= width || y >= total_h) return; unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3]; const double a = std::clamp(alpha, 0.0, 1.0); p[0] = static_cast(p[0] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a); p[1] = static_cast(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a); p[2] = static_cast(p[2] * (1.0 - a) + (rgb & 0xFF) * a); }; // The background: a dark field, so the traces read the way they do on screen. for (int y = 0; y < total_h; ++y) { for (int x = 0; x < width; ++x) put(x, y, 0x101418u, 1.0); } for (std::size_t c = 0; c < strips; ++c) { const std::vector& values = table.columns[c].values; if (values.empty()) continue; double lo = values[0]; double hi = values[0]; for (f32 v : values) { if (!std::isfinite(v)) continue; lo = std::min(lo, double(v)); hi = std::max(hi, double(v)); } if (!(hi > lo)) { hi = lo + 1.0; } const int base = static_cast(c) * strip_h; const std::uint32_t colour = kPalette[c % (sizeof(kPalette) / sizeof(kPalette[0]))]; for (int x = 0; x < width; ++x) { const std::size_t from = rows * std::size_t(x) / std::size_t(width); const std::size_t to = std::max(from + 1, rows * std::size_t(x + 1) / std::size_t(width)); double vmin = 1e300; double vmax = -1e300; for (std::size_t i = from; i < to && i < rows; ++i) { const double v = values[i]; if (!std::isfinite(v)) continue; vmin = std::min(vmin, v); vmax = std::max(vmax, v); } if (vmin > vmax) continue; const auto to_y = [&](double v) { const double frac = (v - lo) / (hi - lo); return base + strip_h - 1 - static_cast(frac * (strip_h - 1)); }; const int y0 = std::clamp(to_y(vmax), base, base + strip_h - 1); const int y1 = std::clamp(to_y(vmin), base, base + strip_h - 1); for (int y = y0; y <= y1; ++y) put(x, y, colour, 1.0); } // The strip's own baseline, so an empty stretch reads as a flat trace and not as a gap. for (int x = 0; x < width; ++x) { const int y = base + strip_h - 1; put(x, y, 0x303840u, 1.0); } } // The trigger edge: the snapshot's x = 0, which is at the same fraction of every event's window. if (!table.time.empty()) { const double t0 = table.time.front(); const double t1 = table.time.back(); if (t1 > t0) { const int x = static_cast((0.0 - t0) / (t1 - t0) * (width - 1)); for (int y = 0; y < total_h; ++y) put(x, y, 0xFFE9A0u, 0.85); } } std::FILE* f = std::fopen(path.c_str(), "wb"); if (f == nullptr) return false; const std::uint32_t image_bytes = std::uint32_t(row_bytes) * std::uint32_t(total_h); const std::uint32_t file_bytes = 54u + image_bytes; unsigned char header[54] = {}; header[0] = 'B'; header[1] = 'M'; const auto put32 = [&](int at, std::uint32_t v) { header[at] = static_cast(v & 0xFF); header[at + 1] = static_cast((v >> 8) & 0xFF); header[at + 2] = static_cast((v >> 16) & 0xFF); header[at + 3] = static_cast((v >> 24) & 0xFF); }; put32(2, file_bytes); put32(10, 54u); put32(14, 40u); put32(18, std::uint32_t(width)); put32(22, std::uint32_t(total_h)); header[26] = 1; // planes header[28] = 24; // bits per pixel put32(34, image_bytes); put32(38, 2835u); // 72 dpi, in pixels per metre put32(42, 2835u); std::fwrite(header, 1, sizeof(header), f); std::fwrite(pixels.data(), 1, pixels.size(), f); std::fclose(f); return true; } } // namespace std::string event_meta_text(const EventRecord& r) { char buf[512]; std::string out; std::snprintf(buf, sizeof(buf), "wall_time = %.6f\n", r.wall_time); out += buf; std::snprintf(buf, sizeof(buf), "duration_s = %.6f\n", r.duration_s); out += buf; std::snprintf(buf, sizeof(buf), "pre_s = %.6f\n", r.pre_s); out += buf; std::snprintf(buf, sizeof(buf), "post_s = %.6f\n", r.post_s); out += buf; std::snprintf(buf, sizeof(buf), "edge = %.6f\n", r.edge); out += buf; std::snprintf(buf, sizeof(buf), "rate_hz = %.6g\n", r.rate_hz); out += buf; std::snprintf(buf, sizeof(buf), "signals = %zu\n", r.signals); out += buf; std::snprintf(buf, sizeof(buf), "samples = %zu\n", r.samples); out += buf; std::snprintf(buf, sizeof(buf), "seq = %llu\n", static_cast(r.seq)); out += buf; return out; } bool event_meta_parse(const std::string& text, EventRecord* out) { if (out == nullptr) return false; bool any = false; std::size_t i = 0; while (i < text.size()) { const std::size_t end = text.find('\n', i); const std::string line = text.substr(i, end == std::string::npos ? std::string::npos : end - i); i = end == std::string::npos ? text.size() : end + 1; const std::size_t eq = line.find('='); if (eq == std::string::npos) continue; std::string key = line.substr(0, eq); std::string value = line.substr(eq + 1); while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back(); while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(0, 1); const double number = std::atof(value.c_str()); if (key == "wall_time") { out->wall_time = number; any = true; } else if (key == "duration_s") { out->duration_s = number; any = true; } else if (key == "pre_s") { out->pre_s = number; any = true; } else if (key == "post_s") { out->post_s = number; any = true; } else if (key == "edge") { out->edge = number; any = true; } else if (key == "rate_hz") { out->rate_hz = number; any = true; } else if (key == "signals") { out->signals = static_cast(std::atoll(value.c_str())); any = true; } else if (key == "samples") { out->samples = static_cast(std::atoll(value.c_str())); any = true; } else if (key == "seq") { out->seq = static_cast(std::strtoull(value.c_str(), nullptr, 10)); any = true; } } return any; } std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; } EventStore::EventStore(std::string source, std::string base_dir) { if (source.empty()) return; std::string safe; for (char c : source) { const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; safe.push_back(ok ? c : '_'); } dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe; } std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) { if (dir_.empty() || table.empty()) return {}; mkdirs(dir_); const std::string stem = event_stem(meta.wall_time > 0.0 ? meta.wall_time : double(std::time(nullptr)), meta.seq); const std::string samples = dir_ + "/" + stem + ".npy"; std::string error; if (!write_npy(samples, table, error)) { std::fprintf(stderr, "[udpscope] event cache: %s\n", error.c_str()); std::fflush(stderr); return {}; } EventRecord record = meta; record.stem = stem; std::string text = event_meta_text(record); if (std::FILE* f = std::fopen((dir_ + "/" + stem + ".txt").c_str(), "wb"); f != nullptr) { std::fwrite(text.data(), 1, text.size(), f); std::fclose(f); } write_preview(table, dir_ + "/" + stem + ".bmp"); prune(keep); return samples; } std::vector EventStore::list() const { std::vector out; if (dir_.empty()) return out; DIR* d = ::opendir(dir_.c_str()); if (d == nullptr) return out; while (const dirent* e = ::readdir(d)) { const std::string stem = stem_of(e->d_name, ".txt"); if (stem.empty()) continue; std::FILE* f = std::fopen((dir_ + "/" + e->d_name).c_str(), "rb"); if (f == nullptr) continue; std::string text; char buf[512]; std::size_t n = 0; while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n); std::fclose(f); EventRecord r; if (!event_meta_parse(text, &r)) continue; r.stem = stem; r.file = dir_ + "/" + stem + ".npy"; if (::access(r.file.c_str(), R_OK) != 0) continue; // a sidecar without its samples struct stat sb {}; if (::stat((dir_ + "/" + stem + ".bmp").c_str(), &sb) == 0) { r.thumbnail = dir_ + "/" + stem + ".bmp"; } out.push_back(std::move(r)); } ::closedir(d); // Newest first by the *record's* time, not the file's: a copy or a touch changes the mtime, and the // list is about when the event happened. std::stable_sort(out.begin(), out.end(), [](const EventRecord& a, const EventRecord& b) { if (a.wall_time != b.wall_time) return a.wall_time > b.wall_time; return a.seq > b.seq; }); return out; } bool EventStore::forget(const std::string& stem) { if (dir_.empty() || stem.empty()) return false; const std::string base = dir_ + "/" + stem; const bool gone = ::unlink((base + ".npy").c_str()) == 0; ::unlink((base + ".txt").c_str()); ::unlink((base + ".bmp").c_str()); return gone; } void EventStore::prune(std::size_t keep) const { const std::vector all = list(); for (std::size_t i = keep; i < all.size(); ++i) { const std::string base = dir_ + "/" + all[i].stem; ::unlink((base + ".npy").c_str()); ::unlink((base + ".txt").c_str()); ::unlink((base + ".bmp").c_str()); } } } // namespace udpscope