diff --git a/README.md b/README.md index 05752dd..390cb8e 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,11 @@ make docker-run ARGS='-e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx # TUI mode - remote CAN interface via SSH (no data if will ask password - use public key access or sshpass utility) make docker-run ARGS='-e "ssh user@remote candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx' -# Headless mode - create report about collected PGNs and SPNs -make docker-run ARGS='-hl -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx -of output.json' +# Discover mode - find out what PGNs/SPNs are on the bus +make docker-run ARGS='-discover -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx' + +# Headless mode - stream decoded values +make docker-run ARGS='-hl -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx' ``` ### Cross-compile for arm64 @@ -85,28 +88,37 @@ Requires Docker. SSH keys from `~/.ssh` and `/etc/hosts` are forwarded into the ## Usage +All operating modes are mutually exclusive. If none is specified, TUI mode is used. + ```bash -# TUI mode (default) +# TUI mode (default) — interactive terminal interface canscope -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx -# Headless - JSON to stdout +# Discover mode — output PGN/SPN structure (no values) to stdout or file +canscope -discover -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx +canscope -discover -of discovered.json -e "candump can0" -j1939-csv thirdparty/j1939da_2018.csv + +# Headless mode — stream all decoded values (NDJSON) to stdout canscope -hl -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx -# Headless - JSON to file -canscope -hl -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx -of output.json - -# Read from stdin (pipe) -candump can0 | canscope -j1939-xlsx thirdparty/j1939da_2018.xlsx - -# Record to SQLite database +# Record mode — write all decoded values + timestamps to SQLite canscope -rec -db recording.db -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx -# Record + TUI -canscope -rec -db recording.db -tui -e "candump can0" -j1939-xlsx thirdparty/j1939da_2018.xlsx +# Read from stdin (pipe) +candump can0 | canscope -j1939-csv thirdparty/j1939da_2018.csv ``` > **Note:** J1939 decoding has only been tested with the Digital Annex 2018 edition. Other editions may work but are not guaranteed. +### Modes + +| Mode | Flag | Description | +|------|------|-------------| +| TUI | *(default)* | Interactive full-screen terminal UI | +| Discover | `-discover` | Output PGN/SPN structure (no values) to stdout or file (`-of`) | +| Headless | `-hl` | Stream all decoded PGN/SPN values as NDJSON to stdout | +| Record | `-rec` | Write all decoded PGN/SPN values + timestamps to SQLite (`-db`) | + ### CLI flags | Flag | Long form | Description | @@ -114,11 +126,11 @@ canscope -rec -db recording.db -tui -e "candump can0" -j1939-xlsx thirdparty/j19 | `-j1939-xlsx` | | J1939 Digital Annex xlsx file | | `-j1939-csv` | | J1939 Digital Annex csv file (faster parsing) | | `-e` | `--execute-command` | Command to read CAN frames from (e.g. `"candump can0"`) | -| `-hl` | `--headless` | Headless mode (no TUI) | -| `-of` | `--output-file` | Output file path (headless mode) | -| `-rec` | `--record` | Record decoded values to SQLite | +| `-discover` | | Discover mode | +| `-hl` | `--headless` | Headless mode | +| `-rec` | `--record` | Record mode | +| `-of` | `--output-file` | Output file path (used with `-discover`) | | `-db` | `--database` | SQLite database path (required with `-rec`) | -| `-tui` | | Show TUI alongside recording | | `-h` | `--help` | Show help | ## Roadmap diff --git a/src/can_data.hpp b/src/can_data.hpp index d6bc599..fdc8525 100644 --- a/src/can_data.hpp +++ b/src/can_data.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -10,9 +11,14 @@ // Mutex protecting the J1939 SQLite database from concurrent access extern std::mutex g_j1939_db_mtx; +// Count of CAN error frames seen on the bus (SocketCAN ERRORFRAME markers) +extern std::atomic g_error_frame_count; + struct can_frame_data_s { std::vector payload; int32_t size = 0; + bool is_error_frame = false; + bool is_remote_request = false; }; struct can_frame_diff_s { diff --git a/src/can_frame.cpp b/src/can_frame.cpp index a485937..5cc327b 100644 --- a/src/can_frame.cpp +++ b/src/can_frame.cpp @@ -11,6 +11,7 @@ std::pair processFrame(sqlite::database &db, const std::string &iface, const std::string &canid, const std::vector &data) { nlohmann::json verbose, brief; + db << R"(SELECT pgn, pg_label, pg_acronym, pg_descr, edp, dp, pf, ps, pg_datalen, pg_priority FROM pgns WHERE pgn = ?;)" << [&]() -> int32_t { int32_t ret; @@ -29,11 +30,12 @@ std::pair processFrame(sqlite::database &db, con // Get SPNs nlohmann::json::array_t spns_array; - db << R"(SELECT spn, spn_name, spn_pos, spn_length, resolution, offset, data_range, min_value, max_value, units, slot_id, slot_name, spn_type FROM spns WHERE pgn = ?;)" + db << R"(SELECT spn, spn_name, spn_pos, spn_length, resolution, offset, data_range, min_value, max_value, units, slot_id, slot_name, spn_type, value_encoding FROM spns WHERE pgn = ?;)" << pgn_int >> [&](int32_t spn, const std::string &spn_name, const std::string &spn_pos, int32_t spn_length, double resolution, int32_t offset, const std::string &data_range, double min, double max, const std::string &unit, - const std::string &slot_id, const std::string &slot_name, const std::string &spn_type) { + const std::string &slot_id, const std::string &slot_name, const std::string &spn_type, + const std::string &value_encoding) { nlohmann::json spn_json = { {"SPN (integer)", spn}, {"SPN name", spn_name}, @@ -46,15 +48,33 @@ std::pair processFrame(sqlite::database &db, con {"Unit", unit}, {"SLOT id", slot_id}, {"SPN type", spn_type}, + {"Encoding", value_encoding}, }; // Get parts size_t result = 0u, iter = 0u, total_size_bits = 0u; + std::vector ascii_bytes; nlohmann::json::array_t parts_array; + db << "SELECT byte_offset,bit_offset,size FROM spn_fragments WHERE spn = ? AND pgn = ?" << spn << pgn >> [&, byte_array = data](int32_t byte_offset, int32_t bit_offset, int32_t size_bits) { + if (value_encoding == "ascii") { + // ASCII SPNs are byte-aligned; collect raw bytes directly — size may exceed 64 bits. + const size_t nbytes = size_bits / UINT8_WIDTH; + + for (size_t i = 0; i < nbytes && (byte_offset + i) < byte_array.size(); ++i) { + ascii_bytes.push_back(byte_array[byte_offset + i]); + } + + parts_array.push_back(nlohmann::json::parse(fmt::format( + R"({{"{}":{{"byte_offset":{},"bit_offset":{},"size_bits":{},"parse_result":"ascii"}}}})", + fmt::format("Fragment#{}", iter++), byte_offset, bit_offset, size_bits))); + return; + } + result <<= size_bits; total_size_bits += size_bits; + for (uint32_t i = 0; i < ((size_bits / UINT8_WIDTH) + (size_bits % UINT8_WIDTH ? 1 : 0)); ++i) { const uint8_t &byte = byte_array[byte_offset + i]; result |= (((byte << (i * UINT8_WIDTH)) & @@ -72,9 +92,20 @@ std::pair processFrame(sqlite::database &db, con fmt::format("{:#x}", result)))); }; - double result_real = result * resolution + offset; spn_json["Fragments"] = parts_array; - spn_json["Value"] = result_real; + if (value_encoding == "ascii") { + std::string text; + text.reserve(ascii_bytes.size()); + + for (uint8_t b : ascii_bytes) { + text += (b >= 0x20 && b < 0x7f) ? static_cast(b) : '.'; + } + + spn_json["Value"] = text; + } else { + spn_json["Value"] = result * resolution + offset; + } + spns_array.push_back(spn_json); }; @@ -90,8 +121,17 @@ std::pair processFrame(sqlite::database &db, con nlohmann::json::array_t spns_array; for (const auto &spn : verbose["SPNs"]) { - spns_array.push_back(fmt::format("{}: {:.6g} {}", spn["SPN name"].get(), spn["Value"].get(), - spn["Unit"].get())); + const auto name = spn["SPN name"].get(); + const auto unit = spn["Unit"].get(); + const auto encoding = spn.value("Encoding", std::string{"numeric"}); + + if (encoding == "ascii") { + spns_array.push_back(fmt::format("{}: \"{}\"", name, spn["Value"].get())); + } else if (encoding == "binary") { + spns_array.push_back(fmt::format("{}: {}", name, spn["Value"].get() != 0.0 ? "yes" : "no")); + } else { + spns_array.push_back(fmt::format("{}: {:.6g} {}", name, spn["Value"].get(), unit)); + } } brief["SPNs"] = spns_array; @@ -103,26 +143,25 @@ std::pair processFrame(sqlite::database &db, con nlohmann::json verboseToExportJson(const nlohmann::json &verbose) { nlohmann::json::array_t spns; - if (verbose.is_null() || !verbose.contains("SPNs")) return spns; + if (verbose.is_null() || !verbose.contains("SPNs")) + return spns; std::string pgn = verbose.contains("PGN") ? verbose["PGN"].get() : ""; for (const auto &v : verbose["SPNs"]) { nlohmann::json spn = { - {"name", v.value("SPN name", "")}, - {"offset", v.value("Offset", 0)}, - {"resolution", v.value("Resolution", 0.0)}, - {"max", v.value("Maximum value", 0.0)}, - {"min", v.value("Minimum value", 0.0)}, - {"pgn", pgn}, - {"value", v.value("Value", 0.0)}, - {"unit", v.value("Unit", "")}, + {"name", v.value("SPN name", "")}, {"offset", v.value("Offset", 0)}, + {"resolution", v.value("Resolution", 0.0)}, {"max", v.value("Maximum value", 0.0)}, + {"min", v.value("Minimum value", 0.0)}, {"pgn", pgn}, + {"value", v.value("Value", 0.0)}, {"unit", v.value("Unit", "")}, }; nlohmann::json::array_t frags; + if (v.contains("Fragments")) { for (const auto &[k, frag] : v["Fragments"].items()) { auto frag_key = fmt::format("Fragment#{}", k); + if (frag.contains(frag_key)) { frags.push_back({{ fmt::format("fragment#{}", k), diff --git a/src/headless.cpp b/src/headless.cpp index b3d0b02..359b7ad 100644 --- a/src/headless.cpp +++ b/src/headless.cpp @@ -8,24 +8,23 @@ #include #include -HeadlessHandler::HeadlessHandler(const std::string &output_file) - : output_file_(output_file) {} - -void HeadlessHandler::onDatabaseReady(sqlite::database &db) { - database_ = &db; -} +HeadlessHandler::HeadlessHandler(const std::string &output_file) : output_file_(output_file) {} +void HeadlessHandler::onDatabaseReady(sqlite::database &db) { database_ = &db; } void HeadlessHandler::onBatch(const std::vector &batch) { - if (!database_) return; + if (!database_) + return; - extern std::pair processFrame(sqlite::database &db, const std::string &iface, const std::string &canid, const std::vector &data); + extern std::pair processFrame( + sqlite::database & db, const std::string &iface, const std::string &canid, const std::vector &data); for (const auto &entry : batch) { const auto &iface = entry.iface; const auto &canid = entry.canid; const auto &frame_data = entry.data; - if (configuration_map_.contains(canid)) continue; + if (configuration_map_.contains(canid)) + continue; auto [verbose, brief] = processFrame(*database_, iface, canid, frame_data.payload); configuration_map_.insert({ diff --git a/src/j1939_db.cpp b/src/j1939_db.cpp index fc08171..566fa3d 100644 --- a/src/j1939_db.cpp +++ b/src/j1939_db.cpp @@ -39,7 +39,8 @@ void initJ1939Database(sqlite::database &db) { units TEXT, slot_id TEXT, slot_name TEXT, - spn_type TEXT + spn_type TEXT, + value_encoding TEXT ); )"; @@ -67,19 +68,26 @@ std::string buildPgnInsertSql() { const auto &pgn_mapping = J1939MappingTables::pgn(); std::string cols, placeholders; for (const auto &[k, v] : pgn_mapping) { - if (!cols.empty()) { cols += ", "; placeholders += ", "; } + if (!cols.empty()) { + cols += ", "; + placeholders += ", "; + } + cols += std::get<1u>(v); placeholders += "?"; } + return fmt::format("INSERT OR REPLACE INTO pgns ({}) VALUES ({})", cols, placeholders); } std::string buildSpnInsertSql() { const auto &spn_mapping = J1939MappingTables::spn(); std::string cols = "pgn", placeholders = "?"; + for (const auto &[k, v] : spn_mapping) { cols += ", "; placeholders += ", "; + if (std::get<1u>(v) == "data_range") { cols += "min_value, max_value"; placeholders += "?, ?"; @@ -88,6 +96,10 @@ std::string buildSpnInsertSql() { placeholders += "?"; } } + + // value_encoding is derived from Resolution format, not from a mapped CSV column. + cols += ", value_encoding"; + placeholders += ", ?"; return fmt::format("INSERT OR REPLACE INTO spns ({}) VALUES ({})", cols, placeholders); } @@ -102,9 +114,12 @@ void insertJ1939Row(sqlite::database &db, const std::string &pgn_insert_sql, con for (const auto &[k, v] : pgn_mapping) { ps << pgn_row_map[std::get<1u>(v).data()]; } + ps.execute(); } catch (const sqlite::sqlite_exception &e) { - if (e.get_extended_code() != SQLITE_CONSTRAINT_UNIQUE) throw; + if (e.get_extended_code() != SQLITE_CONSTRAINT_UNIQUE) { + throw; + } } // Insert SPN @@ -112,6 +127,7 @@ void insertJ1939Row(sqlite::database &db, const std::string &pgn_insert_sql, con bool parts_inserted_flag = false; double min = 0.0, max = 0.0; size_t size_bits = 0u; + parsers::resolution_s::type_e encoding = parsers::resolution_s::type_e::numeric; } calc; // Pre-compute size_bits @@ -131,6 +147,7 @@ void insertJ1939Row(sqlite::database &db, const std::string &pgn_insert_sql, con for (const auto &[k, v] : spn_mapping) { if (std::get<1u>(v) == "data_range") { auto range = parsers::parseSpnDataRange(spn_row_map[std::get<1u>(v).data()]); + if (range.has_value()) { calc.min = range.value().min; calc.max = range.value().max; @@ -139,18 +156,23 @@ void insertJ1939Row(sqlite::database &db, const std::string &pgn_insert_sql, con ps << nullptr << nullptr; } } else if (std::get<1u>(v) == "offset") { + auto offset = parsers::parseSpnOffset(spn_row_map[std::get<1u>(v).data()]); ps << (offset.has_value() ? offset.value().offset : 0.0); } else if (std::get<1u>(v) == "spn_length") { + auto size = parsers::parseSpnSize(spn_row_map[std::get<1u>(v).data()]); calc.size_bits = size.has_value() ? size.value().size_bits : 0u; ps << calc.size_bits; } else if (std::get<1u>(v) == "resolution") { + + auto resolution = parsers::parseSpnResolution(spn_row_map[std::get<1u>(v).data()]); + calc.encoding = resolution.has_value() ? resolution.value().type : parsers::resolution_s::type_e::numeric; double calculated = (calc.max - calc.min) / (std::pow(2.0, calc.size_bits) - 1.0); + if (std::fabs(calculated - 1.0) < 1e-9) { ps << calculated; } else { - auto resolution = parsers::parseSpnResolution(spn_row_map[std::get<1u>(v).data()]); ps << (resolution.has_value() ? resolution.value().resolution : 1.0); } } else { @@ -158,26 +180,33 @@ void insertJ1939Row(sqlite::database &db, const std::string &pgn_insert_sql, con } } + ps << parsers::resolutionTypeName(calc.encoding); ps.execute(); // Insert SPN fragments if (!calc.parts_inserted_flag) { auto size = parsers::parseSpnSize(spn_row_map[std::get<1u>(spn_mapping.at("SPN Length")).data()]); + if (size.has_value()) { auto spn_fragments = parsers::parseSpnPosition( size.value().size_bits, spn_row_map[std::get<1u>(spn_mapping.at("SPN Position in PG")).data()]); + if (spn_fragments.has_value()) { auto spn = std::stoll(spn_row_map["spn"]); + for (const auto &part : spn_fragments.value().spn_fragments) { db << R"(INSERT OR REPLACE INTO spn_fragments (spn, pgn, byte_offset, bit_offset, size) VALUES (?, ?, ?, ?, ?);)" << spn << std::stoll(pgn_row_map["pgn"]) << part.byte_offset << part.bit_offset << part.size; } + calc.parts_inserted_flag = true; } } } } catch (const sqlite::sqlite_exception &e) { - if (e.get_extended_code() != SQLITE_CONSTRAINT_UNIQUE) throw; + if (e.get_extended_code() != SQLITE_CONSTRAINT_UNIQUE) { + throw; + } } } } diff --git a/src/main.cpp b/src/main.cpp index 4db8bb1..6729b0e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include #include @@ -7,7 +8,7 @@ #include #include #include -#include +#include #include #include #include @@ -31,12 +32,14 @@ #include "ftxui/component/screen_interactive.hpp" #include "ftxui/dom/elements.hpp" #include "headless.hpp" +#include "headless_streamer.hpp" #include "process.hpp" #include "recorder.hpp" #include "signals.hpp" #include std::mutex g_j1939_db_mtx; +std::atomic g_error_frame_count{0}; int32_t main(int32_t argc, char *argv[]) { static auto screen = ftxui::ScreenInteractive::Fullscreen(); @@ -52,10 +55,13 @@ int32_t main(int32_t argc, char *argv[]) { static std::unique_ptr j1939_db_owner; static std::unique_ptr recorder; static std::unique_ptr headless_handler; + static std::unique_ptr headless_streamer; + + enum class Mode { tui, discover, record, headless } mode = Mode::tui; static struct { std::string xlsx_file, csv_file, command = "", output_file = "", record_db_path = ""; - bool show_help = false, headless_mode = false, sync_to_server = false, record_mode = false, tui_mode = false; + bool show_help = false; } cli_opts; // Parse cli options @@ -66,42 +72,39 @@ int32_t main(int32_t argc, char *argv[]) { auto cli = ( - clipp::option("-hl", "--headless") - .doc("Headless mode, write configuration to stdout if output file is not specified") - .set(cli_opts.headless_mode) - .call([]() { fmt::println("Headless mode"); }), + clipp::option("-dscvr, --discovery-mode") + .doc("Discover mode: output PGN/SPN structure (only first received falue) to stdout or file") + .call([&]() { mode = Mode::discover; }), - clipp::option("-of", "--output-file") & - clipp::value("Output file to write configuration", cli_opts.output_file) - .call([&]() { fmt::println("Output file is: {}", cli_opts.output_file); }) - .doc("Specify output file to write configuration"), + clipp::option("-hl", "--headless") + .doc("Headless mode: stream all decoded PGN/SPN values to stdout") + .call([&]() { mode = Mode::headless; }), clipp::option("-rec", "--record") - .doc("Record mode: collect decoded J1939 SPN values to SQLite DB") - .set(cli_opts.record_mode) - .call([]() { fmt::println("Record mode"); }), + .doc("Record mode: write all decoded PGN/SPN values + timestamps to SQLite DB") + .call([&]() { mode = Mode::record; }), - clipp::option("-db", "--database") & - clipp::value("SQLite output database path", cli_opts.record_db_path) - .call([&]() { fmt::println("Record DB: {}", cli_opts.record_db_path); }) - .doc("Path for the output SQLite database (used with -rec)"), + clipp::option("-of", "--output-file") & + clipp::value("Output file", cli_opts.output_file).doc("Output file path (used with -discover)"), - clipp::option("-tui").doc("Enable TUI mode (use with -rec to show UI while recording)").set(cli_opts.tui_mode), + clipp::option("-db", "--database") & clipp::value("SQLite output database path", cli_opts.record_db_path) + .doc("SQLite database path (used with -rec)"), clipp::option("-e", "--execute-command") & clipp::value("command", cli_opts.command).call([&]() {}).doc("execute cli command to read its output"), (clipp::option("-j1939-xlsx") & - clipp::value("J1939 XLSX file", cli_opts.xlsx_file) - .call([&]() { - j1939_parser_task = std::async(std::launch::async, [&]() { - j1939_db_owner = parseXlsx(cli_opts.xlsx_file); - j1939_db.store(j1939_db_owner.get()); - signals.map.get("j1939_database_ready")->operator()(*j1939_db_owner); - }); - }) - .doc("J1939 Digital Annex .xlsx file")) | - (clipp::option("-j1939-csv") & + clipp::value("J1939 XLSX file", cli_opts.xlsx_file) + .call([&]() { + j1939_parser_task = std::async(std::launch::async, [&]() { + j1939_db_owner = parseXlsx(cli_opts.xlsx_file); + j1939_db.store(j1939_db_owner.get()); + signals.map.get("j1939_database_ready")->operator()(*j1939_db_owner); + }); + }) + .doc("J1939 Digital Annex .xlsx file")) | + + (clipp::option("-j1939-csv") & clipp::value("J1939 CSV file", cli_opts.csv_file) .call([&]() { j1939_parser_task = std::async(std::launch::async, [&]() { @@ -122,7 +125,7 @@ int32_t main(int32_t argc, char *argv[]) { return -1; } - if (cli_opts.record_mode && cli_opts.record_db_path.empty()) { + if (mode == Mode::record && cli_opts.record_db_path.empty()) { fmt::println(stderr, "Error: -rec requires -db "); return -1; } @@ -130,44 +133,105 @@ int32_t main(int32_t argc, char *argv[]) { // Parse a single candump line and aggregate it static const auto parse_candump_line = [](const std::string &line) { - if (line.empty()) + enum class field_e : size_t { + INTERFACE = 0, + CANID = 1, + DLC = 2, + PAYLOAD_BEGIN = 3, + }; + + constexpr auto idx = [](enum field_e f) consteval { return static_cast(f); }; + if (line.empty()) { return; + } - std::vector words; - std::istringstream iss(line); - std::string s; - - while (std::getline(iss, s, ' ')) { - if (!s.empty()) { - words.push_back(s); + std::vector words; + for (auto part : std::string_view(line) | std::views::split(' ')) { + if (!part.empty()) { + words.emplace_back(part.begin(), part.end()); } } - if (words.size() >= 4u) { // 0 - interface, 1 - canid, 2 - size, 3 - payload (1 byte minimum) - if (words[0].starts_with("can") || words[0].starts_with("vcan")) { - can_frame_data_s entry; + if (words.size() > idx(field_e::PAYLOAD_BEGIN)) { + auto &iface = words[idx(field_e::INTERFACE)]; + can_frame_data_s entry; + auto &canid = words[idx(field_e::CANID)]; - // Parse DLC - { - auto sv = std::string_view(words[2]).substr(1, words[2].size() - 2); - auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), entry.size); - if (ec != std::errc{}) { - entry.size = 0; + // Validate CAN ID: 3 hex digits (SFF, 11-bit) or 8 (EFF, 29-bit) + { + constexpr auto sff_length_bytes = 3u, eff_length_bytes = 8u; + + // Check CAN_ID length + if (canid.size() != sff_length_bytes && canid.size() != eff_length_bytes) { + return; + } + + // Check CAN_ID symbols + for (const char &c : canid) { + if (!std::isxdigit(static_cast(c))) { + return; } } + } - // Parse payload bytes directly - entry.payload.reserve(words.size() - 3); - for (size_t i = 3; i < words.size(); ++i) { - uint8_t byte = 0; - std::from_chars(words[i].data(), words[i].data() + words[i].size(), byte, 16); - entry.payload.push_back(byte); + // Parse DLC + { + auto &dlc = words[idx(field_e::DLC)]; + if (dlc.size() < 3 /* [${size}] format */ || dlc.front() != '[' || dlc.back() != ']') { + return; } - { - std::lock_guard lock(rw_mtx); - aggregated[words[0]][words[1]] = std::make_shared(std::move(entry)); + auto sv = dlc.substr(1, dlc.size() - 2); + if (auto [ptr, ec] = std::from_chars(sv.data(), sv.data() + sv.size(), entry.size); + ec != std::errc{} || ptr != sv.data() + sv.size()) { + return; } + + // Max payload per CAN frame: 8 (Classic CAN) / 64 (CAN FD). Use 64 as upper bound. + constexpr int32_t max_payload_bytes = 64; + if (entry.size < 0 || entry.size > max_payload_bytes) { + return; + } + } + + // Detect ERRORFRAME marker (SocketCAN diagnostic pseudo-frame): count and drop + if (words.back() == "ERRORFRAME") { + g_error_frame_count.fetch_add(1, std::memory_order_relaxed); + return; + } + + // Detect RTR: candump prints "remote request" in place of payload bytes. Drop silently. + if (words[idx(field_e::PAYLOAD_BEGIN)] == "remote") { + return; + } + + // Parse payload bytes directly + entry.payload.reserve(words.size() - idx(field_e::PAYLOAD_BEGIN)); + + for (size_t i = idx(field_e::PAYLOAD_BEGIN); i < words.size(); ++i) { + if (words[i].size() != 2) { // each byte must be exactly 2 hex digits + return; + } + + uint8_t byte = 0; + auto *first = words[i].data(); + auto *last = first + words[i].size(); + if (auto [ptr, ec] = std::from_chars(first, last, byte, 16 /* HEX format */); + ec != std::errc{} || ptr != last) { + return; + } + + entry.payload.push_back(byte); + } + + // DLC must match the actual number of payload bytes + if (entry.payload.size() != entry.size) { + return; + } + + { + std::lock_guard lock(rw_mtx); + aggregated[std::string(iface)][std::string(canid)] = std::make_shared(std::move(entry)); } } }; @@ -353,14 +417,13 @@ int32_t main(int32_t argc, char *argv[]) { ::signal(SIGINT, signal_handler); } - if (cli_opts.record_mode) { - bool rec_console = !cli_opts.tui_mode && !cli_opts.headless_mode; - recorder = std::make_unique(cli_opts.record_db_path, rec_console); + if (mode == Mode::record) { + recorder = std::make_unique(cli_opts.record_db_path, true); signals.map.get &)>("new_entries_batch") ->connect([](const std::vector &batch) { recorder->onBatch(batch); }); } - if (cli_opts.headless_mode) { + if (mode == Mode::discover) { headless_handler = std::make_unique(cli_opts.output_file); signals.map.get("j1939_database_ready")->connect([](sqlite::database &db) { @@ -371,8 +434,13 @@ int32_t main(int32_t argc, char *argv[]) { ->connect([](const std::vector &batch) { headless_handler->onBatch(batch); }); } - // Determine if TUI should run: default on, off if headless, off if rec-only (no -tui) - bool run_tui = !cli_opts.headless_mode && (!cli_opts.record_mode || cli_opts.tui_mode); + if (mode == Mode::headless) { + headless_streamer = std::make_unique(); + signals.map.get &)>("new_entries_batch") + ->connect([](const std::vector &batch) { headless_streamer->onBatch(batch); }); + } + + bool run_tui = (mode == Mode::tui); if (run_tui) { extern ftxui::Component makeMainForm(ftxui::ScreenInteractive * screen, signals_map_t & smap); diff --git a/src/mainform.cpp b/src/mainform.cpp index e93569d..1f98848 100644 --- a/src/mainform.cpp +++ b/src/mainform.cpp @@ -188,6 +188,15 @@ ftxui::Component makeMainForm(ftxui::ScreenInteractive *screen, signals_map_t &s fmt::format(" Uptime: {} ", fmt::format("{:02}:{:02}:{:02}", hours, minutes, seconds)))}); }), + ftxui::Renderer([]() { + const auto errors = g_error_frame_count.load(std::memory_order_relaxed); + return ftxui::hbox({ + ftxui::text(" Errors: "), + ftxui::text(fmt::format("{} ", errors)) | + ftxui::color(errors ? ftxui::Color::Red : ftxui::Color::GrayDark), + }); + }), + ftxui::Renderer([]() { return ftxui::separator(); }), ftxui::Container::Horizontal({ ftxui::Input({ diff --git a/src/parsers.cpp b/src/parsers.cpp index 86efa84..492e90a 100644 --- a/src/parsers.cpp +++ b/src/parsers.cpp @@ -4,30 +4,40 @@ namespace parsers { std::optional parseSpnDataRange(const std::string &str) { struct range_s result; auto begin = str.begin(), end = str.end(); - return phrase_parse(begin, end, range::range_parser_s{}, ascii::space, result) ? std::optional(result) : std::nullopt; + return phrase_parse(begin, end, range::range_parser_s{}, ascii::space, result) + ? std::optional(result) + : std::nullopt; } std::optional parseSpnSize(const std::string &str) { struct size_s result; auto begin = str.begin(), end = str.end(); - return phrase_parse(begin, end, size::size_parser_s{}, ascii::space, result) ? std::optional(result) : std::nullopt; + return phrase_parse(begin, end, size::size_parser_s{}, ascii::space, result) + ? std::optional(result) + : std::nullopt; } std::optional parseSpnOffset(const std::string &str) { struct offset_s result; auto begin = str.begin(), end = str.end(); - return phrase_parse(begin, end, offset::offset_parser_s{}, ascii::space, result) ? std::optional(result) : std::nullopt; + return phrase_parse(begin, end, offset::offset_parser_s{}, ascii::space, result) + ? std::optional(result) + : std::nullopt; } std::optional parseSpnResolution(const std::string &str) { struct resolution_s result; auto begin = str.begin(), end = str.end(); - return phrase_parse(begin, end, resolution::resolution_parser_s{}, ascii::space, result) ? std::optional(result) : std::nullopt; + return phrase_parse(begin, end, resolution::resolution_parser_s{}, ascii::space, result) + ? std::optional(result) + : std::nullopt; } std::optional parseSpnPosition(size_t spn_size_bits, const std::string &str) { struct spn_fragments_s result; auto begin = str.begin(), end = str.end(); - return phrase_parse(begin, end, position::position_parser_s(spn_size_bits), ascii::space, result) ? std::optional(result) : std::nullopt; + return phrase_parse(begin, end, position::position_parser_s(spn_size_bits), ascii::space, result) + ? std::optional(result) + : std::nullopt; } }; // namespace parsers diff --git a/src/parsers.hpp b/src/parsers.hpp index bf333e4..63632f5 100644 --- a/src/parsers.hpp +++ b/src/parsers.hpp @@ -1,11 +1,11 @@ #pragma once -#include #include #include #include #include #include +#include #include #include @@ -43,9 +43,26 @@ struct spn_fragments_s { // Parse resolution string to this struct struct resolution_s { - double resolution; + enum class type_e { numeric, enum_states, binary, ascii }; + double resolution = 1.0; + type_e type = type_e::numeric; }; +// Map resolution_s::type_e to a lowercase string tag used in SQLite / JSON +inline const char *resolutionTypeName(resolution_s::type_e t) { + switch (t) { + case resolution_s::type_e::numeric: + return "numeric"; + case resolution_s::type_e::enum_states: + return "enum"; + case resolution_s::type_e::binary: + return "binary"; + case resolution_s::type_e::ascii: + return "ascii"; + } + return "numeric"; +} + namespace _detail { template struct parser_s : qi::grammar { parser_s() : parser_s::base_type(rule) {} @@ -107,8 +124,9 @@ template struct size_parser_s : _detail::parser_s { } }; - this->rule = ((qi::uint_ >> "byte") | (qi::uint_ >> "bytes"))[_val = boost::phoenix::function{}(qi::_1)] | - ((qi::uint_ >> "bit") | (qi::uint_ >> "bits"))[_val = boost::phoenix::function{}(qi::_1)]; + this->rule = + ((qi::uint_ >> "byte") | (qi::uint_ >> "bytes"))[_val = boost::phoenix::function{}(qi::_1)] | + ((qi::uint_ >> "bit") | (qi::uint_ >> "bits"))[_val = boost::phoenix::function{}(qi::_1)]; } }; }; // namespace size @@ -215,7 +233,8 @@ template struct position_parser_s : _detail::parser_s struct position_parser_s : _detail::parser_s struct position_parser_s : _detail::parser_s{}(qi::_1)]; - position_rule_v1 = (qi::uint_ >> '.' >> qi::uint_)[qi::_val = boost::phoenix::function{}(size_bits, qi::_1, qi::_2)]; - position_rule_v2 = (qi::uint_ >> '-' >> qi::uint_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2)]; - position_rule_v3 = (qi::uint_ >> ',' >> qi::uint_ >> '.' >> qi::uint_)[qi::_val = boost::phoenix::function{}(size_bits, qi::_1, qi::_2, qi::_3)]; - position_rule_v4 = (qi::uint_ >> '.' >> qi::uint_ >> ',' >> qi::uint_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2, qi::_3)]; + position_rule_v1 = (qi::uint_ >> '.' >> + qi::uint_)[qi::_val = boost::phoenix::function{}(size_bits, qi::_1, qi::_2)]; + position_rule_v2 = + (qi::uint_ >> '-' >> qi::uint_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2)]; + position_rule_v3 = + (qi::uint_ >> ',' >> qi::uint_ >> '.' >> + qi::uint_)[qi::_val = boost::phoenix::function{}(size_bits, qi::_1, qi::_2, qi::_3)]; + position_rule_v4 = (qi::uint_ >> '.' >> qi::uint_ >> ',' >> + qi::uint_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2, qi::_3)]; position_rule_v5 = (qi::uint_ >> '-' >> qi::uint_ >> ',' >> qi::uint_ >> '.' >> - qi::uint_)[qi::_val = boost::phoenix::function{}(size_bits, qi::_1, qi::_2, qi::_3, qi::_4)]; + qi::uint_)[qi::_val = boost::phoenix::function{}(size_bits, qi::_1, qi::_2, + qi::_3, qi::_4)]; position_rule_v6 = - (qi::uint_ >> '.' >> qi::uint_ >> ',' >> qi::uint_ >> '-' >> qi::uint_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2, qi::_3, qi::_4)]; + (qi::uint_ >> '.' >> qi::uint_ >> ',' >> qi::uint_ >> '-' >> + qi::uint_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2, qi::_3, qi::_4)]; // If one of rules works - this->rule = position_rule_v6 | position_rule_v5 | position_rule_v4 | position_rule_v3 | position_rule_v2 | position_rule_v1 | position_rule_v0; + this->rule = position_rule_v6 | position_rule_v5 | position_rule_v4 | position_rule_v3 | position_rule_v2 | + position_rule_v1 | position_rule_v0; } - qi::rule position_rule_v0, position_rule_v1, position_rule_v2, position_rule_v3, position_rule_v4, position_rule_v5, position_rule_v6; + qi::rule position_rule_v0, position_rule_v1, position_rule_v2, + position_rule_v3, position_rule_v4, position_rule_v5, position_rule_v6; }; }; // namespace position @@ -294,30 +323,51 @@ template struct resolution_parser_s : _detail::parser_s() { struct resolution_rule_v0_handler_s { resolution_s operator()(float number) const { - return { - .resolution = number, - }; + return {.resolution = number, .type = resolution_s::type_e::numeric}; } }; struct resolution_rule_v1_handler_s { resolution_s operator()(double first, double second) const { - return { - .resolution = first / second, - }; + return {.resolution = first / second, .type = resolution_s::type_e::numeric}; } }; + // `N states/M bit` — enum SPN: raw value is the state index, no scaling. + struct resolution_rule_v2_handler_s { + resolution_s operator()() const { return {.resolution = 1.0, .type = resolution_s::type_e::enum_states}; } + }; + + // `Binary` — single-bit boolean flag. + struct resolution_rule_binary_handler_s { + resolution_s operator()() const { return {.resolution = 1.0, .type = resolution_s::type_e::binary}; } + }; + + // `ASCII` — variable/fixed byte sequence to be rendered as text. + struct resolution_rule_ascii_handler_s { + resolution_s operator()() const { return {.resolution = 1.0, .type = resolution_s::type_e::ascii}; } + }; + this->strnum = lexeme[-(qi::char_('+') | qi::char_('-')) >> +(qi::digit | qi::char_(',') | qi::char_('.'))]; this->num = this->strnum[_val = boost::phoenix::function<_detail::string_to_double_s>{}(qi::_1)]; - resolution_rule_v0 = (this->num >> *qi::char_)[qi::_val = boost::phoenix::function{}(qi::_1)]; - resolution_rule_v1 = (qi::uint_ >> '/' >> qi::uint_ >> *qi::char_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2)]; + resolution_rule_v0 = + (this->num >> *qi::char_)[qi::_val = boost::phoenix::function{}(qi::_1)]; + resolution_rule_v1 = + (qi::uint_ >> '/' >> qi::uint_ >> + *qi::char_)[qi::_val = boost::phoenix::function{}(qi::_1, qi::_2)]; + resolution_rule_v2 = + (this->num >> "states" >> *qi::char_)[qi::_val = boost::phoenix::function{}()]; + resolution_rule_binary = + qi::lit("Binary")[qi::_val = boost::phoenix::function{}()]; + resolution_rule_ascii = qi::lit("ASCII")[qi::_val = boost::phoenix::function{}()]; - this->rule = resolution_rule_v1 | resolution_rule_v0; + this->rule = + resolution_rule_binary | resolution_rule_ascii | resolution_rule_v2 | resolution_rule_v1 | resolution_rule_v0; } - qi::rule resolution_rule_v0, resolution_rule_v1; + qi::rule resolution_rule_v0, resolution_rule_v1, resolution_rule_v2, + resolution_rule_binary, resolution_rule_ascii; }; }; // namespace resolution @@ -330,7 +380,9 @@ std::optional parseSpnResolution(const std::string &str); BOOST_FUSION_ADAPT_STRUCT(parsers::range_s, (double, min)(double, max)(std::string, other)); BOOST_FUSION_ADAPT_STRUCT(parsers::size_s, (size_t, size_bytes)(size_t, size_bits)); -BOOST_FUSION_ADAPT_STRUCT(parsers::spn_fragments_s::spn_part_s, (size_t, byte_offset)(size_t, bit_offset)(size_t, size)); -BOOST_FUSION_ADAPT_STRUCT(parsers::spn_fragments_s, (std::vector, spn_fragments)); +BOOST_FUSION_ADAPT_STRUCT(parsers::spn_fragments_s::spn_part_s, + (size_t, byte_offset)(size_t, bit_offset)(size_t, size)); +BOOST_FUSION_ADAPT_STRUCT(parsers::spn_fragments_s, + (std::vector, spn_fragments)); BOOST_FUSION_ADAPT_STRUCT(parsers::offset_s, (double, offset)); BOOST_FUSION_ADAPT_STRUCT(parsers::resolution_s, (double, resolution));