14 Commits

Author SHA1 Message Date
eeeck 663d75174e Remove J1939 stub for data calculation
- To be done separately
2026-07-13 14:17:12 +02:00
eeeck 324aa5652c Use AGPLv3 license 2026-07-11 09:52:41 +02:00
eeeck e27351aa28 Delete src/analyzer.py 2026-07-10 01:27:54 +02:00
eeeck 6e386fcb4b Add stub to decode J1939 and calculate useful values 2026-07-10 01:27:21 +02:00
eeeck ca670c9bb7 Fix issues with metadata not correctly computed 2026-07-10 00:46:42 +02:00
eeeck 725c9ccbc1 Use more clear function name 2026-07-10 00:03:47 +02:00
eeeck 9d5cd74ef7 Decode J1939 metadata for compliant frames and add a struct with J1939
metadata
2026-07-10 00:03:04 +02:00
eeeck 6ef0571e4e Add J1939 decoder 2026-07-09 23:57:37 +02:00
eeeck c758aca6c6 Drop data column, keeping only the 8 bytes 2026-07-09 23:45:45 +02:00
eeeck 21172e020b Keep parquet data in hexadecimal format 2026-07-09 23:39:55 +02:00
eeeck ce20587591 Move to /src 2026-07-09 23:29:25 +02:00
eeeck 2fcaef5571 Remove file meant to be local only 2026-07-06 18:26:28 +02:00
eeeck ecef35918e Add parquet optimizer for faster data analysis in the future 2026-07-06 18:24:33 +02:00
eeeck 86b14af3c7 Add python project related files 2026-07-06 18:08:53 +02:00
6 changed files with 381 additions and 264 deletions
+1
View File
@@ -0,0 +1 @@
3.14
+2 -2
View File
@@ -633,8 +633,8 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
-81
View File
@@ -1,81 +0,0 @@
import re
import csv
from pathlib import Path
def parse_can_log(input_path: str | Path, out_bus1: str | Path, out_bus2: str | Path) -> None:
"""
Parses a CAN bus log file from CANdigger and saves valid frames to separate CSV files for Bus 1 and Bus 2.
Corrupted, incomplete, or debug frames are silently discarded.
"""
input_file = Path(input_path)
out1_file = Path(out_bus1)
out2_file = Path(out_bus2)
# Group 1: Bus (C1 or C2)
# Group 2: ID (1 to 8 hex chars)
# Group 3: DLC (1 to 2 hex chars)
start_pattern = re.compile(r'(C[12]):([0-9A-Fa-f]{1,8})\s+([0-9A-Fa-f]{1,2})\s+')
byte_pattern = re.compile(r'^[0-9A-Fa-f]{2}$')
with input_file.open('r', encoding='utf-8') as f_in, \
out1_file.open('w', newline='', encoding='utf-8') as f_out1, \
out2_file.open('w', newline='', encoding='utf-8') as f_out2:
writer1 = csv.writer(f_out1)
writer2 = csv.writer(f_out2)
writer1.writerow(['ID', 'DLC', 'Data'])
writer2.writerow(['ID', 'DLC', 'Data'])
for line in f_in:
for match in start_pattern.finditer(line):
bus = match.group(1)
can_id = match.group(2).upper()
dlc_str = match.group(3)
try:
dlc = int(dlc_str, 16)
except ValueError:
continue
if dlc > 8:
continue
remaining = line[match.end():]
tokens = remaining.split()
data_bytes = []
for token in tokens:
if byte_pattern.match(token):
data_bytes.append(token.upper())
else:
break
if len(data_bytes) == dlc:
data_str = ' '.join(data_bytes)
row = [can_id, dlc, data_str]
if bus == 'C1':
writer1.writerow(row)
elif bus == 'C2':
writer2.writerow(row)
if __name__ == '__main__':
# Example usage:
# parse_can_log('can_traffic.txt', 'bus1_output.csv', 'bus2_output.csv')
# or on CLI:
# python3 can_parser.py can_traffic.txt' bus1_output.csv bus2_output.csv
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="Parse CAN bus logs to separate CSV files")
parser.add_argument("input", help="Path to the input .txt log file")
parser.add_argument("out_bus1", help="Output CSV filename for Bus 1 (C1)")
parser.add_argument("out_bus2", help="Output CSV filename for Bus 2 (C2)")
args = parser.parse_args()
parse_can_log(args.input, args.out_bus1, args.out_bus2)
pass
+7
View File
@@ -0,0 +1,7 @@
[project]
name = "CANveyor"
version = "0.1.0"
description = "J1939 CAN bus parser that works in pair with CANdigger"
readme = "README.md"
requires-python = ">=3.14"
dependencies = ["polars", "pathlib", "typing"]
+75
View File
@@ -0,0 +1,75 @@
import argparse
import polars as pl
def get_j1939_mask() -> pl.Expr:
"""
Returns a Polars expression representing the strict J1939 filtering rules.
"""
id_int = pl.col("ID").str.to_integer(base=16).cast(pl.UInt32)
return (
(id_int > 0x7FF) &
((id_int % 33554432 // 16777216) == 0) &
(pl.col("DLC") <= 8)
)
def decode_j1939_metadata(lf: pl.LazyFrame) -> pl.LazyFrame:
"""
Decodes J1939 fields and bundles them into a Struct column.
"""
id_int = pl.col("ID").str.to_integer(base=16).cast(pl.UInt32)
id_shifted_8 = id_int // 256
id_shifted_16 = id_int // 65536
priority = ((id_int // 67108864) % 8).cast(pl.UInt8)
pf = (id_shifted_16 % 256).cast(pl.UInt8)
ps = (id_shifted_8 % 256).cast(pl.UInt8)
sa = (id_int % 256).cast(pl.UInt8)
da = pl.when(pf < 240).then(ps).otherwise(pl.lit(255, dtype=pl.UInt8))
pgn = pl.when(pf < 240).then(id_shifted_8 % 65536).otherwise(id_shifted_8 % 262144).cast(pl.UInt32)
return lf.with_columns(
pl.struct([
priority.alias("Priority"),
pf.alias("PF"),
ps.alias("PS"),
sa.alias("SA"),
da.alias("DA"),
pgn.alias("PGN")
]).alias("j1939_metadata")
)
def decode_j1939_frames(df: pl.DataFrame) -> pl.DataFrame:
id_int = pl.col("ID").str.to_integer(base=16).cast(pl.UInt32)
is_j1939 = (id_int > 0x7FF) & ((id_int % 33554432 // 16777216) == 0) & (pl.col("DLC") <= 8)
priority = (id_int // 67108864) % 8
pf = (id_int // 65536) % 256
ps = (id_int // 256) % 256
sa = id_int % 256
da = pl.when(pf < 240).then(ps).otherwise(255)
pgn = pl.when(pf < 240).then((id_int // 256) % 65536).otherwise((id_int // 256) % 262144)
j1939_meta = pl.when(is_j1939).then(
pl.struct([
priority.cast(pl.UInt8).alias("Priority"),
pf.cast(pl.UInt8).alias("PF"),
ps.cast(pl.UInt8).alias("PS"),
sa.cast(pl.UInt8).alias("SA"),
da.cast(pl.UInt8).alias("DA"),
pgn.cast(pl.UInt32).alias("PGN")
])
).otherwise(None)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="J1939 decoder")
parser.add_argument("input_parquet", help="Path to the raw .parquet file")
parser.add_argument("output_parquet", help="Path to save the decoded .parquet file")
args = parser.parse_args()
df = pl.scan_parquet(args.input_parquet).collect()
decoded_df = decode_j1939_frames(df)
decoded_df.write_parquet(args.output_parquet)
+115
View File
@@ -0,0 +1,115 @@
import re
import csv
import polars as pl
from pathlib import Path
from typing import Union
PathLike = Union[str, Path]
def parse_log(input_path: PathLike, out_bus1: PathLike, out_bus2: PathLike) -> None:
"""
Parses a raw CAN bus log from CANdigger using regex and saves valid frames to separate CSV.
Corrupted, incomplete, or debug frames are silently discarded.
"""
input_file = Path(input_path)
out1_file = Path(out_bus1)
out2_file = Path(out_bus2)
start_pattern = re.compile(r'(C[12]):([0-9A-Fa-f]{1,8})\s+([0-9A-Fa-f]{1,2})\s+')
byte_pattern = re.compile(r'^[0-9A-Fa-f]{2}$')
with input_file.open('r', encoding='utf-8') as f_in, \
out1_file.open('w', newline='', encoding='utf-8') as f_out1, \
out2_file.open('w', newline='', encoding='utf-8') as f_out2:
writer1 = csv.writer(f_out1)
writer2 = csv.writer(f_out2)
writer1.writerow(['ID', 'DLC', 'Data'])
writer2.writerow(['ID', 'DLC', 'Data'])
for line in f_in:
for match in start_pattern.finditer(line):
bus = match.group(1)
can_id = match.group(2).upper()
dlc_str = match.group(3)
try:
dlc = int(dlc_str, 16)
except ValueError:
continue
if dlc > 8:
continue
remaining = line[match.end():]
tokens = remaining.split()
data_bytes = []
for token in tokens:
if byte_pattern.match(token):
data_bytes.append(token.upper())
else:
break
if len(data_bytes) == dlc:
data_str = ' '.join(data_bytes)
row = [can_id, dlc, data_str]
if bus == 'C1':
writer1.writerow(row)
elif bus == 'C2':
writer2.writerow(row)
def parse_csv(csv_path: PathLike) -> pl.LazyFrame:
"""
Ingests a parsed CSV file, unpacks hex strings into 8 hex columns,
generates sequential timestamps if missing, and returns a Polars LazyFrame.
"""
lf = pl.scan_csv(csv_path, schema_overrides={"ID": pl.String, "Data": pl.String})
if "Timestamp" not in lf.columns:
lf = lf.with_row_index("Timestamp")
byte_exprs = []
for i in range(8):
expr = (
pl.col("Data").str.strip_chars().str.split(" ")
.list.get(i, null_on_oob=True)
.alias(f"b{i}")
)
byte_exprs.append(expr)
lf = lf.with_columns(byte_exprs).drop("Data")
return lf.with_columns([
pl.col("DLC").cast(pl.UInt8),
pl.col("Timestamp").cast(pl.Float64)
])
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="CAN Bus Data Engine & Parser")
subparsers = parser.add_subparsers(dest="command", required=True, help="Available commands")
parser_csv = subparsers.add_parser("csv", help="Parse raw text log into Bus 1 and Bus 2 CSVs")
parser_csv.add_argument("input", help="Path to the .txt log file from CANdigger")
parser_csv.add_argument("out_bus1", help="Output CSV filename for Bus 1 (C1)")
parser_csv.add_argument("out_bus2", help="Output CSV filename for Bus 2 (C2)")
parser_parquet = subparsers.add_parser("parquet", help="Convert a parsed CSV into an optimized Parquet file")
parser_parquet.add_argument("input_csv", help="Path to the input .csv file")
parser_parquet.add_argument("output_parquet", help="Path to the output .parquet file")
args = parser.parse_args()
if args.command == "csv":
parse_log(args.input, args.out_bus1, args.out_bus2)
print(f"[+] Saved csv file to {args.out_bus1} and {args.out_bus2}")
elif args.command == "parquet":
print(f"[*] Processing {args.input_csv}...")
lf = parse_csv(args.input_csv)
lf.sink_parquet(args.output_parquet)
print(f"[+] Saved parquet file to {args.output_parquet}")