capkit 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {capkit-0.1.0 → capkit-0.2.0}/.github/workflows/ci.yml +2 -0
- {capkit-0.1.0 → capkit-0.2.0}/CHANGELOG.md +17 -1
- {capkit-0.1.0 → capkit-0.2.0}/PKG-INFO +14 -7
- {capkit-0.1.0 → capkit-0.2.0}/README.md +12 -6
- capkit-0.2.0/ROADMAP.md +45 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/model/frame.py +2 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/readers/__init__.py +70 -8
- capkit-0.2.0/capkit/readers/candump.py +98 -0
- capkit-0.2.0/capkit/readers/vector_asc.py +300 -0
- {capkit-0.1.0 → capkit-0.2.0}/docs/api-reference.md +49 -10
- capkit-0.2.0/docs/format-support.md +117 -0
- {capkit-0.1.0 → capkit-0.2.0}/docs/releasing.md +7 -6
- {capkit-0.1.0 → capkit-0.2.0}/pyproject.toml +4 -1
- capkit-0.2.0/tests/fixtures/README.md +17 -0
- capkit-0.2.0/tests/fixtures/candump/candump.log +300 -0
- capkit-0.2.0/tests/fixtures/vector_asc/LICENSE.python-can.txt +165 -0
- capkit-0.2.0/tests/fixtures/vector_asc/python_can_logfile.asc +39 -0
- capkit-0.2.0/tests/test_candump.py +138 -0
- {capkit-0.1.0 → capkit-0.2.0}/tests/test_integration_dbckit.py +28 -0
- {capkit-0.1.0 → capkit-0.2.0}/tests/test_io.py +6 -2
- {capkit-0.1.0 → capkit-0.2.0}/tests/test_model.py +9 -0
- capkit-0.2.0/tests/test_reader_entry_points.py +165 -0
- capkit-0.2.0/tests/test_vector_asc.py +165 -0
- capkit-0.2.0/tests/test_vector_asc_crosscheck.py +41 -0
- capkit-0.1.0/ROADMAP.md +0 -71
- capkit-0.1.0/docs/format-support.md +0 -54
- {capkit-0.1.0 → capkit-0.2.0}/.github/workflows/release.yml +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/.gitignore +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/LICENSE +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/__init__.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/integration.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/io.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/model/__init__.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/operations/__init__.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/py.typed +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/readers/base.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/readers/kvaser_txt.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/capkit/writers/__init__.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/docs/recipes.md +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/tests/__init__.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/tests/fixtures/kvaser/kvaser.txt +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/tests/test_contract.py +0 -0
- {capkit-0.1.0 → capkit-0.2.0}/tests/test_kvaser_txt.py +0 -0
|
@@ -60,4 +60,6 @@ jobs:
|
|
|
60
60
|
entry=next(name for name in names if name.endswith('entry_points.txt'));
|
|
61
61
|
text=archive.read(entry).decode();
|
|
62
62
|
assert '[dbckit.readers]' in text;
|
|
63
|
+
assert 'asc = capkit.readers.vector_asc:VectorAscReader' in text;
|
|
64
|
+
assert 'log = capkit.integration:DispatchReader' in text;
|
|
63
65
|
assert 'txt = capkit.integration:DispatchReader' in text"
|
|
@@ -7,7 +7,23 @@ Versioning will follow [Semantic Versioning](https://semver.org/) from 1.0.0 onw
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
## [
|
|
10
|
+
## [0.2.0] — 2026-07-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- can-utils `candump -L` reader (`candump`) with classic, remote, and CAN FD support,
|
|
15
|
+
pinned by a 300-frame fixture.
|
|
16
|
+
- Vector CANalyzer/CANoe ASC reader (`vector-asc`) covering classic, remote, error,
|
|
17
|
+
and CAN FD rows plus header-derived capture start times.
|
|
18
|
+
- `Frame.bitrate_switch` and `Frame.error_state_indicator` CAN FD flags.
|
|
19
|
+
- Lazy, cached discovery of third-party readers through the `capkit.readers`
|
|
20
|
+
entry-point group, including validated conflict and failure reporting.
|
|
21
|
+
- python-can reference writer/reader cross-checks in the development test suite.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- dbckit integration now handles `.log` through the sniffing dispatcher and
|
|
26
|
+
supersedes dbckit's built-in `.asc` reader with capkit's richer implementation.
|
|
11
27
|
|
|
12
28
|
## [0.1.0] — 2026-07-16
|
|
13
29
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: capkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Read CAN bus capture logs in different formats into one common frame stream
|
|
5
5
|
Project-URL: Homepage, https://canforge.io/capkit
|
|
6
6
|
Project-URL: Repository, https://github.com/canforge/capkit
|
|
@@ -44,6 +44,7 @@ Requires-Dist: dbckit>=1.0; extra == 'dev'
|
|
|
44
44
|
Requires-Dist: mypy; extra == 'dev'
|
|
45
45
|
Requires-Dist: pytest-cov; extra == 'dev'
|
|
46
46
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
47
|
+
Requires-Dist: python-can>=4.6; extra == 'dev'
|
|
47
48
|
Requires-Dist: ruff; extra == 'dev'
|
|
48
49
|
Description-Content-Type: text/markdown
|
|
49
50
|
|
|
@@ -70,8 +71,8 @@ Use it to:
|
|
|
70
71
|
| Format | Reader name | Extensions | Status | Dependency |
|
|
71
72
|
|---|---|---|---|---|
|
|
72
73
|
| Kvaser CanKing TXT | `kvaser-txt` | `.txt` | Supported | none |
|
|
73
|
-
| candump text | `candump` | `.log` |
|
|
74
|
-
| Vector ASC | `vector-asc` | `.asc` |
|
|
74
|
+
| candump text | `candump` | `.log` | Supported | none |
|
|
75
|
+
| Vector ASC | `vector-asc` | `.asc` | Supported | none |
|
|
75
76
|
| PCAN TRC | `pcan-trc` | `.trc` | Planned | none |
|
|
76
77
|
| Generic CSV | `csv-table` | `.csv` | Planned | none |
|
|
77
78
|
| Vector BLF | `vector-blf` | `.blf` | Planned adapter | `python-can` |
|
|
@@ -112,7 +113,7 @@ meta = capkit.probe("trace.txt")
|
|
|
112
113
|
print(meta.format, meta.start_time)
|
|
113
114
|
|
|
114
115
|
# registered reader names
|
|
115
|
-
print(capkit.available_formats()) # ['kvaser-txt']
|
|
116
|
+
print(capkit.available_formats()) # ['candump', 'kvaser-txt', 'vector-asc']
|
|
116
117
|
```
|
|
117
118
|
|
|
118
119
|
The public API is six names: `read`, `probe`, `available_formats`,
|
|
@@ -165,7 +166,9 @@ class MyReader:
|
|
|
165
166
|
capkit.register_reader(MyReader)
|
|
166
167
|
```
|
|
167
168
|
|
|
168
|
-
Registration is process-global
|
|
169
|
+
Registration is process-global. Installed packages can also advertise reader
|
|
170
|
+
classes through the `capkit.readers` entry-point group; capkit discovers and
|
|
171
|
+
caches them on the first `read()`, `probe()`, or `available_formats()` call.
|
|
169
172
|
dbckit's `.txt` entry point sniffs among all registered readers, so a reader
|
|
170
173
|
whose `sniff()` uniquely matches the content of a `.txt` log is used there
|
|
171
174
|
too, regardless of the extensions it claims.
|
|
@@ -200,8 +203,8 @@ for decoded in dbckit.decode_frames(db, capkit.read("trace.txt")):
|
|
|
200
203
|
```
|
|
201
204
|
|
|
202
205
|
capkit also registers its readers in dbckit's `dbckit.readers` entry-point
|
|
203
|
-
group. With both packages installed, `dbckit.decode_log()` reads `.txt
|
|
204
|
-
through capkit directly:
|
|
206
|
+
group. With both packages installed, `dbckit.decode_log()` reads `.txt`,
|
|
207
|
+
`.log`, and `.asc` logs through capkit directly:
|
|
205
208
|
|
|
206
209
|
```python
|
|
207
210
|
for decoded in dbckit.decode_log(db, "trace.txt"):
|
|
@@ -212,6 +215,10 @@ for decoded in dbckit.decode_log(db, "trace.txt"):
|
|
|
212
215
|
|
|
213
216
|
- Kvaser dialects with absolute start-time headers are not supported;
|
|
214
217
|
`probe()` returns `start_time=None` for `kvaser-txt`.
|
|
218
|
+
- candump error-flag records are skipped by default and rejected in strict
|
|
219
|
+
mode; decoding them as CAN error frames is not claimed.
|
|
220
|
+
- Vector ASC relative timestamp directives and non-English month names are
|
|
221
|
+
rejected instead of being interpreted approximately.
|
|
215
222
|
- capkit reads frames only: no DBC or signal awareness (that is dbckit's job),
|
|
216
223
|
no hardware I/O, no log writing, no dataframe export, no CLI.
|
|
217
224
|
|
|
@@ -21,8 +21,8 @@ Use it to:
|
|
|
21
21
|
| Format | Reader name | Extensions | Status | Dependency |
|
|
22
22
|
|---|---|---|---|---|
|
|
23
23
|
| Kvaser CanKing TXT | `kvaser-txt` | `.txt` | Supported | none |
|
|
24
|
-
| candump text | `candump` | `.log` |
|
|
25
|
-
| Vector ASC | `vector-asc` | `.asc` |
|
|
24
|
+
| candump text | `candump` | `.log` | Supported | none |
|
|
25
|
+
| Vector ASC | `vector-asc` | `.asc` | Supported | none |
|
|
26
26
|
| PCAN TRC | `pcan-trc` | `.trc` | Planned | none |
|
|
27
27
|
| Generic CSV | `csv-table` | `.csv` | Planned | none |
|
|
28
28
|
| Vector BLF | `vector-blf` | `.blf` | Planned adapter | `python-can` |
|
|
@@ -63,7 +63,7 @@ meta = capkit.probe("trace.txt")
|
|
|
63
63
|
print(meta.format, meta.start_time)
|
|
64
64
|
|
|
65
65
|
# registered reader names
|
|
66
|
-
print(capkit.available_formats()) # ['kvaser-txt']
|
|
66
|
+
print(capkit.available_formats()) # ['candump', 'kvaser-txt', 'vector-asc']
|
|
67
67
|
```
|
|
68
68
|
|
|
69
69
|
The public API is six names: `read`, `probe`, `available_formats`,
|
|
@@ -116,7 +116,9 @@ class MyReader:
|
|
|
116
116
|
capkit.register_reader(MyReader)
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
-
Registration is process-global
|
|
119
|
+
Registration is process-global. Installed packages can also advertise reader
|
|
120
|
+
classes through the `capkit.readers` entry-point group; capkit discovers and
|
|
121
|
+
caches them on the first `read()`, `probe()`, or `available_formats()` call.
|
|
120
122
|
dbckit's `.txt` entry point sniffs among all registered readers, so a reader
|
|
121
123
|
whose `sniff()` uniquely matches the content of a `.txt` log is used there
|
|
122
124
|
too, regardless of the extensions it claims.
|
|
@@ -151,8 +153,8 @@ for decoded in dbckit.decode_frames(db, capkit.read("trace.txt")):
|
|
|
151
153
|
```
|
|
152
154
|
|
|
153
155
|
capkit also registers its readers in dbckit's `dbckit.readers` entry-point
|
|
154
|
-
group. With both packages installed, `dbckit.decode_log()` reads `.txt
|
|
155
|
-
through capkit directly:
|
|
156
|
+
group. With both packages installed, `dbckit.decode_log()` reads `.txt`,
|
|
157
|
+
`.log`, and `.asc` logs through capkit directly:
|
|
156
158
|
|
|
157
159
|
```python
|
|
158
160
|
for decoded in dbckit.decode_log(db, "trace.txt"):
|
|
@@ -163,6 +165,10 @@ for decoded in dbckit.decode_log(db, "trace.txt"):
|
|
|
163
165
|
|
|
164
166
|
- Kvaser dialects with absolute start-time headers are not supported;
|
|
165
167
|
`probe()` returns `start_time=None` for `kvaser-txt`.
|
|
168
|
+
- candump error-flag records are skipped by default and rejected in strict
|
|
169
|
+
mode; decoding them as CAN error frames is not claimed.
|
|
170
|
+
- Vector ASC relative timestamp directives and non-English month names are
|
|
171
|
+
rejected instead of being interpreted approximately.
|
|
166
172
|
- capkit reads frames only: no DBC or signal awareness (that is dbckit's job),
|
|
167
173
|
no hardware I/O, no log writing, no dataframe export, no CLI.
|
|
168
174
|
|
capkit-0.2.0/ROADMAP.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
capkit 0.2.0 is [on PyPI](https://pypi.org/project/capkit/); the release checklist is complete
|
|
4
|
+
and retired (the release process lives in [docs/releasing.md](docs/releasing.md)). Readers are
|
|
5
|
+
fixture-first: a format lands only with a real capture under `tests/fixtures/<format>/`.
|
|
6
|
+
|
|
7
|
+
## Planned readers
|
|
8
|
+
|
|
9
|
+
- [ ] PCAN TRC (`.trc`)
|
|
10
|
+
- [ ] Configurable generic CSV (`.csv`)
|
|
11
|
+
- [ ] SocketCAN pcap/pcapng (`.pcap`, `.pcapng`)
|
|
12
|
+
- [ ] Vector BLF (`.blf`) and ASAM MF4 (`.mf4`) through optional adapters
|
|
13
|
+
- [ ] Additional Kvaser dialects (absolute start-time headers) as real fixtures arrive
|
|
14
|
+
|
|
15
|
+
## Reading pipeline
|
|
16
|
+
|
|
17
|
+
- [ ] File-object and stdin input alongside paths, so live captures pipe straight in
|
|
18
|
+
(`candump can0 | ...`)
|
|
19
|
+
- [ ] Transparent reading of gzip-compressed logs (stdlib `gzip`; captures are large)
|
|
20
|
+
- [ ] Collected-error mode between the two current extremes: skip unrecognized lines but
|
|
21
|
+
report them (count and line numbers) instead of skipping silently or raising on the
|
|
22
|
+
first
|
|
23
|
+
|
|
24
|
+
## Stream operations
|
|
25
|
+
|
|
26
|
+
- [ ] Filters: by ID set, channel, and time window
|
|
27
|
+
- [ ] Merge several logs into one time-ordered stream (multi-bus and multi-file captures)
|
|
28
|
+
- [ ] Explicit, opt-in timestamp rebasing helper — `read()` itself keeps returning
|
|
29
|
+
timestamps exactly as recorded
|
|
30
|
+
- [ ] J1939 arbitration-ID decomposition (priority, PGN, source address): pure per-frame
|
|
31
|
+
arithmetic with no DBC awareness, which is where dbckit's J1939 helpers deliberately
|
|
32
|
+
stop
|
|
33
|
+
|
|
34
|
+
## CLI (`capkit[cli]`)
|
|
35
|
+
|
|
36
|
+
- [ ] `probe`, `head`, and `stats` commands (unique IDs, per-ID counts, time span)
|
|
37
|
+
- [ ] `convert` between supported formats once writers exist
|
|
38
|
+
|
|
39
|
+
## Deferred additions
|
|
40
|
+
|
|
41
|
+
- [ ] Writers and format conversion
|
|
42
|
+
- [ ] Dataframe export (pandas/polars) — recipes first, optional helpers only if the
|
|
43
|
+
recipes prove insufficient
|
|
44
|
+
- [ ] python-can `Message` interop helpers
|
|
45
|
+
- [ ] Benchmark suite pinning parse throughput on large logs
|
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
"""Reader registry and format-resolution logic."""
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
|
+
from importlib import metadata
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import cast
|
|
6
7
|
|
|
7
8
|
from capkit.readers.base import Reader, read_sample
|
|
9
|
+
from capkit.readers.candump import CandumpReader
|
|
8
10
|
from capkit.readers.kvaser_txt import KvaserTxtReader
|
|
11
|
+
from capkit.readers.vector_asc import VectorAscReader
|
|
9
12
|
|
|
10
13
|
_READERS: dict[str, type[Reader]] = {
|
|
14
|
+
CandumpReader.name: cast(type[Reader], CandumpReader),
|
|
11
15
|
KvaserTxtReader.name: cast(type[Reader], KvaserTxtReader),
|
|
16
|
+
VectorAscReader.name: cast(type[Reader], VectorAscReader),
|
|
12
17
|
}
|
|
18
|
+
_ENTRY_POINTS_LOADED = False
|
|
13
19
|
|
|
14
20
|
|
|
15
|
-
def
|
|
16
|
-
"""Register a zero-argument reader class for process-wide format resolution."""
|
|
21
|
+
def _validate_reader(reader_type: object) -> tuple[str, type[Reader]]:
|
|
17
22
|
if not isinstance(reader_type, type):
|
|
18
23
|
raise TypeError("register_reader() requires a reader class, not an instance.")
|
|
19
24
|
|
|
@@ -22,9 +27,6 @@ def register_reader(reader_type: type[Reader]) -> None:
|
|
|
22
27
|
raise TypeError("Reader type must define a non-empty string 'name'.")
|
|
23
28
|
|
|
24
29
|
normalized = name.strip().lower()
|
|
25
|
-
if normalized in _READERS:
|
|
26
|
-
raise ValueError(f"Log format '{normalized}' is already registered.")
|
|
27
|
-
|
|
28
30
|
extensions = getattr(reader_type, "extensions", None)
|
|
29
31
|
if (
|
|
30
32
|
not isinstance(extensions, tuple)
|
|
@@ -38,16 +40,75 @@ def register_reader(reader_type: type[Reader]) -> None:
|
|
|
38
40
|
try:
|
|
39
41
|
reader = reader_type()
|
|
40
42
|
except Exception as error:
|
|
41
|
-
raise TypeError(
|
|
43
|
+
raise TypeError(
|
|
44
|
+
f"Reader type '{normalized}' must be zero-argument constructible: {error}"
|
|
45
|
+
) from error
|
|
42
46
|
|
|
43
47
|
if any(not callable(getattr(reader, method, None)) for method in ("sniff", "probe", "read")):
|
|
44
48
|
raise TypeError(f"Reader type '{normalized}' must define callable sniff(), probe(), and read() methods.")
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
return normalized, cast(type[Reader], reader_type)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register_reader(reader_type: type[Reader]) -> None:
|
|
54
|
+
"""Register a zero-argument reader class for process-wide format resolution."""
|
|
55
|
+
normalized, validated_type = _validate_reader(reader_type)
|
|
56
|
+
if normalized in _READERS:
|
|
57
|
+
raise ValueError(f"Log format '{normalized}' is already registered.")
|
|
58
|
+
_READERS[normalized] = validated_type
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _distribution_name(entry_point: metadata.EntryPoint) -> str:
|
|
62
|
+
distribution = getattr(entry_point, "dist", None)
|
|
63
|
+
name = getattr(distribution, "name", None)
|
|
64
|
+
if isinstance(name, str) and name:
|
|
65
|
+
return name
|
|
66
|
+
distribution_metadata = getattr(distribution, "metadata", None)
|
|
67
|
+
if distribution_metadata is not None:
|
|
68
|
+
try:
|
|
69
|
+
metadata_name = distribution_metadata["Name"]
|
|
70
|
+
except (KeyError, TypeError):
|
|
71
|
+
pass
|
|
72
|
+
else:
|
|
73
|
+
if isinstance(metadata_name, str) and metadata_name:
|
|
74
|
+
return metadata_name
|
|
75
|
+
return "unknown"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _entry_point_context(entry_point: metadata.EntryPoint) -> str:
|
|
79
|
+
return (
|
|
80
|
+
f"capkit.readers entry point '{entry_point.name}' "
|
|
81
|
+
f"from distribution '{_distribution_name(entry_point)}'"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_entry_point_readers() -> None:
|
|
86
|
+
global _ENTRY_POINTS_LOADED
|
|
87
|
+
if _ENTRY_POINTS_LOADED:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
discovered: dict[str, type[Reader]] = {}
|
|
91
|
+
for entry_point in metadata.entry_points(group="capkit.readers"):
|
|
92
|
+
context = _entry_point_context(entry_point)
|
|
93
|
+
try:
|
|
94
|
+
loaded = entry_point.load()
|
|
95
|
+
normalized, reader_type = _validate_reader(loaded)
|
|
96
|
+
except Exception as error:
|
|
97
|
+
raise RuntimeError(f"Failed to load {context}: {error}") from error
|
|
98
|
+
|
|
99
|
+
if normalized in _READERS or normalized in discovered:
|
|
100
|
+
raise RuntimeError(
|
|
101
|
+
f"Reader {context} conflicts with already registered log format '{normalized}'."
|
|
102
|
+
)
|
|
103
|
+
discovered[normalized] = reader_type
|
|
104
|
+
|
|
105
|
+
_READERS.update(discovered)
|
|
106
|
+
_ENTRY_POINTS_LOADED = True
|
|
47
107
|
|
|
48
108
|
|
|
49
109
|
def registered_formats() -> list[str]:
|
|
50
110
|
"""Return sorted registered reader names."""
|
|
111
|
+
_load_entry_point_readers()
|
|
51
112
|
return sorted(_READERS)
|
|
52
113
|
|
|
53
114
|
|
|
@@ -61,6 +122,7 @@ def _reader_type_for(
|
|
|
61
122
|
format: str | None = None,
|
|
62
123
|
sniff_only: bool = False,
|
|
63
124
|
) -> type[Reader]:
|
|
125
|
+
_load_entry_point_readers()
|
|
64
126
|
if not _READERS:
|
|
65
127
|
raise ValueError("No log formats are registered.")
|
|
66
128
|
|
|
@@ -101,4 +163,4 @@ def create_reader(
|
|
|
101
163
|
return cast(Reader, reader_type(**(options or {})))
|
|
102
164
|
|
|
103
165
|
|
|
104
|
-
__all__ = ["KvaserTxtReader", "Reader", "register_reader"]
|
|
166
|
+
__all__ = ["CandumpReader", "KvaserTxtReader", "Reader", "VectorAscReader", "register_reader"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Reader for the can-utils ``candump -L`` text format."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from re import Match
|
|
8
|
+
|
|
9
|
+
from capkit.model import Frame, LogMeta
|
|
10
|
+
from capkit.readers.base import read_sample
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CandumpReader:
|
|
14
|
+
"""Read the can-utils log format emitted by ``candump -L``."""
|
|
15
|
+
|
|
16
|
+
name = "candump"
|
|
17
|
+
extensions = (".log",)
|
|
18
|
+
|
|
19
|
+
_ERROR_FLAG = 0x20000000
|
|
20
|
+
_FRAME_RE = re.compile(
|
|
21
|
+
r"^\s*"
|
|
22
|
+
r"\((?P<timestamp>\d+(?:\.\d+)?)\)"
|
|
23
|
+
r"\s+(?P<interface>\S+)"
|
|
24
|
+
r"\s+(?P<identifier>[0-9A-Fa-f]{3}|[0-9A-Fa-f]{8})"
|
|
25
|
+
r"(?:"
|
|
26
|
+
r"##(?P<fd_flags>[0-9A-Fa-f])(?P<fd_data>(?:[0-9A-Fa-f]{2})*)"
|
|
27
|
+
r"|#(?:[Rr](?P<remote_dlc>[0-9A-Fa-f]?)|(?P<data>(?:[0-9A-Fa-f]{2})*))"
|
|
28
|
+
r")"
|
|
29
|
+
r"(?:\s+(?P<direction>[RrTt]))?"
|
|
30
|
+
r"\s*$"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def __init__(self, *, strict: bool = False) -> None:
|
|
34
|
+
self.strict = strict
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def _channel(interface: str) -> int | None:
|
|
38
|
+
match = re.search(r"(\d+)$", interface)
|
|
39
|
+
return int(match.group(1)) if match is not None else None
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def _is_rx(direction: str | None) -> bool | None:
|
|
43
|
+
if direction is None:
|
|
44
|
+
return None
|
|
45
|
+
return direction.upper() == "R"
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def _is_error_frame(cls, match: Match[str]) -> bool:
|
|
49
|
+
return bool(int(match.group("identifier"), 16) & cls._ERROR_FLAG)
|
|
50
|
+
|
|
51
|
+
def sniff(self, sample: str) -> bool:
|
|
52
|
+
"""Recognize a complete can-utils log record."""
|
|
53
|
+
return any(self._FRAME_RE.fullmatch(line) is not None for line in sample.splitlines())
|
|
54
|
+
|
|
55
|
+
def probe(self, path: Path) -> LogMeta:
|
|
56
|
+
"""Return candump metadata after validating a bounded sample."""
|
|
57
|
+
if not self.sniff(read_sample(path)):
|
|
58
|
+
raise ValueError(f"File '{path.name}' is not a candump log.")
|
|
59
|
+
return LogMeta(format=self.name)
|
|
60
|
+
|
|
61
|
+
def _frame(self, match: Match[str]) -> Frame:
|
|
62
|
+
identifier = match.group("identifier")
|
|
63
|
+
fd_flags = match.group("fd_flags")
|
|
64
|
+
remote_dlc = match.group("remote_dlc")
|
|
65
|
+
is_fd = fd_flags is not None
|
|
66
|
+
is_remote = remote_dlc is not None
|
|
67
|
+
flags = int(fd_flags, 16) if fd_flags is not None else 0
|
|
68
|
+
data_hex = match.group("fd_data") if is_fd else match.group("data")
|
|
69
|
+
|
|
70
|
+
return Frame(
|
|
71
|
+
timestamp=float(match.group("timestamp")),
|
|
72
|
+
arbitration_id=int(identifier, 16),
|
|
73
|
+
data=bytes.fromhex(data_hex or ""),
|
|
74
|
+
channel=self._channel(match.group("interface")),
|
|
75
|
+
is_extended_frame=len(identifier) == 8,
|
|
76
|
+
is_fd=is_fd,
|
|
77
|
+
is_remote_frame=is_remote,
|
|
78
|
+
is_rx=self._is_rx(match.group("direction")),
|
|
79
|
+
dlc=int(remote_dlc, 16) if remote_dlc else None,
|
|
80
|
+
bitrate_switch=bool(flags & 0x1),
|
|
81
|
+
error_state_indicator=bool(flags & 0x2),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def read(self, path: Path) -> Iterator[Frame]:
|
|
85
|
+
"""Lazily parse can-utils frame records from *path*."""
|
|
86
|
+
with path.open(encoding="latin-1") as stream:
|
|
87
|
+
for line_number, raw_line in enumerate(stream, start=1):
|
|
88
|
+
line = raw_line.rstrip("\r\n")
|
|
89
|
+
if not line.strip():
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
match = self._FRAME_RE.fullmatch(line)
|
|
93
|
+
if match is None or self._is_error_frame(match):
|
|
94
|
+
if self.strict:
|
|
95
|
+
raise ValueError(f"Unrecognized candump line {line_number}: {line!r}.")
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
yield self._frame(match)
|