pycan-plot 0.0.1__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.
- pycan_plot-0.0.1/.gitignore +5 -0
- pycan_plot-0.0.1/PKG-INFO +87 -0
- pycan_plot-0.0.1/README.md +74 -0
- pycan_plot-0.0.1/hatch_build.py +23 -0
- pycan_plot-0.0.1/pyproject.toml +49 -0
- pycan_plot-0.0.1/src/pycan_plot/__about__.py +1 -0
- pycan_plot-0.0.1/src/pycan_plot/__init__.py +0 -0
- pycan_plot-0.0.1/src/pycan_plot/_completion.py +168 -0
- pycan_plot-0.0.1/src/pycan_plot/_fish.py +45 -0
- pycan_plot-0.0.1/src/pycan_plot/candump.py +62 -0
- pycan_plot-0.0.1/src/pycan_plot/errors.py +25 -0
- pycan_plot-0.0.1/src/pycan_plot/extract.py +104 -0
- pycan_plot-0.0.1/src/pycan_plot/main.py +144 -0
- pycan_plot-0.0.1/src/pycan_plot/render.py +99 -0
- pycan_plot-0.0.1/src/pycan_plot/selection.py +64 -0
- pycan_plot-0.0.1/src/pycan_plot/timespec.py +92 -0
- pycan_plot-0.0.1/tests/conftest.py +20 -0
- pycan_plot-0.0.1/tests/data/fixture.kcd +33 -0
- pycan_plot-0.0.1/tests/test_candump.py +59 -0
- pycan_plot-0.0.1/tests/test_cli.py +121 -0
- pycan_plot-0.0.1/tests/test_completion.py +165 -0
- pycan_plot-0.0.1/tests/test_extract.py +123 -0
- pycan_plot-0.0.1/tests/test_render.py +99 -0
- pycan_plot-0.0.1/tests/test_selection.py +56 -0
- pycan_plot-0.0.1/tests/test_timespec.py +74 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pycan-plot
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Plot CAN signals from a candump log using a KCD database
|
|
5
|
+
Author-email: Alexander Becker <nabla@radiation.systems>
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: cantools
|
|
9
|
+
Requires-Dist: click
|
|
10
|
+
Requires-Dist: matplotlib
|
|
11
|
+
Requires-Dist: rich
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# pycan-plot
|
|
15
|
+
|
|
16
|
+
Graph CAN signals from a recorded `candump` log, decoded through a KCD
|
|
17
|
+
database.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```console
|
|
22
|
+
pip install -e .
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
pycan-plot capture.log -d database.kcd -s Status.Voltage -s Status.Mode -o plot.png
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Signals are named `Message.Signal`, exactly as they appear in the KCD.
|
|
32
|
+
With no `-o`, an interactive window opens instead.
|
|
33
|
+
|
|
34
|
+
| Option | Meaning |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `-d`, `--kcd` | KCD database (required) |
|
|
37
|
+
| `-s`, `--signal` | `Message.Signal` to plot; repeatable |
|
|
38
|
+
| `-p`, `--prefix` | Hex stack-position prefix ORed onto each message ID (default `0x0`) |
|
|
39
|
+
| `-t`, `--time-window` | `START..END`; each end is relative seconds (`12.5`) or a clock time (`08:25:40`), and either may be omitted |
|
|
40
|
+
| `-o`, `--output` | Output file; format taken from the extension (`.png`, `.svg`, `.pdf`) |
|
|
41
|
+
| `--show` | Open an interactive window; implied when `-o` is absent |
|
|
42
|
+
| `--split` | One subplot per signal instead of overlaying them |
|
|
43
|
+
| `--line` | Interpolate between samples instead of holding each value |
|
|
44
|
+
|
|
45
|
+
Signals are drawn as steps by default, because a CAN signal holds its last
|
|
46
|
+
received value until the next frame arrives. `--line` overrides that.
|
|
47
|
+
|
|
48
|
+
Examples:
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
# One module in stack position 2, clipped to twenty seconds of the capture
|
|
52
|
+
pycan-plot capture.log -d db.kcd -s Status.Voltage -p 0x02000000 -t 10..30 -o v.png
|
|
53
|
+
|
|
54
|
+
# Two signals on separate stacked axes, clipped by wall-clock time
|
|
55
|
+
pycan-plot capture.log -d db.kcd -s Status.Voltage -s Status.Mode \
|
|
56
|
+
--split -t 08:25:40..08:26:10 -o both.svg
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Exit codes
|
|
60
|
+
|
|
61
|
+
| Code | Meaning |
|
|
62
|
+
|---|---|
|
|
63
|
+
| 0 | success |
|
|
64
|
+
| 1 | log, KCD, or output file could not be read or written |
|
|
65
|
+
| 2 | CLI usage error (unknown message or signal, bad `-t`, bad `-p`) |
|
|
66
|
+
| 3 | no samples found for any requested signal |
|
|
67
|
+
|
|
68
|
+
Exit code 3 reports which stack-position prefixes *were* present for the
|
|
69
|
+
requested messages — usually the fastest way to spot a wrong `-p`.
|
|
70
|
+
|
|
71
|
+
## Fish tab completion
|
|
72
|
+
|
|
73
|
+
An editable install writes the completion script automatically. To do it by
|
|
74
|
+
hand:
|
|
75
|
+
|
|
76
|
+
```fish
|
|
77
|
+
_PYCAN_PLOT_COMPLETE=fish_source pycan-plot | source # current shell
|
|
78
|
+
_PYCAN_PLOT_COMPLETE=fish_source pycan-plot > ~/.config/fish/completions/pycan-plot.fish # persistent
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Completion covers log files, KCD files, message and signal names pulled live
|
|
82
|
+
from the KCD on the command line, and — once the log file is given — the
|
|
83
|
+
stack-position prefixes actually present in that log.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
`pycan-plot` is distributed under the terms of the GPL-3.0-or-later license.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# pycan-plot
|
|
2
|
+
|
|
3
|
+
Graph CAN signals from a recorded `candump` log, decoded through a KCD
|
|
4
|
+
database.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```console
|
|
9
|
+
pip install -e .
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
```console
|
|
15
|
+
pycan-plot capture.log -d database.kcd -s Status.Voltage -s Status.Mode -o plot.png
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Signals are named `Message.Signal`, exactly as they appear in the KCD.
|
|
19
|
+
With no `-o`, an interactive window opens instead.
|
|
20
|
+
|
|
21
|
+
| Option | Meaning |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `-d`, `--kcd` | KCD database (required) |
|
|
24
|
+
| `-s`, `--signal` | `Message.Signal` to plot; repeatable |
|
|
25
|
+
| `-p`, `--prefix` | Hex stack-position prefix ORed onto each message ID (default `0x0`) |
|
|
26
|
+
| `-t`, `--time-window` | `START..END`; each end is relative seconds (`12.5`) or a clock time (`08:25:40`), and either may be omitted |
|
|
27
|
+
| `-o`, `--output` | Output file; format taken from the extension (`.png`, `.svg`, `.pdf`) |
|
|
28
|
+
| `--show` | Open an interactive window; implied when `-o` is absent |
|
|
29
|
+
| `--split` | One subplot per signal instead of overlaying them |
|
|
30
|
+
| `--line` | Interpolate between samples instead of holding each value |
|
|
31
|
+
|
|
32
|
+
Signals are drawn as steps by default, because a CAN signal holds its last
|
|
33
|
+
received value until the next frame arrives. `--line` overrides that.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
# One module in stack position 2, clipped to twenty seconds of the capture
|
|
39
|
+
pycan-plot capture.log -d db.kcd -s Status.Voltage -p 0x02000000 -t 10..30 -o v.png
|
|
40
|
+
|
|
41
|
+
# Two signals on separate stacked axes, clipped by wall-clock time
|
|
42
|
+
pycan-plot capture.log -d db.kcd -s Status.Voltage -s Status.Mode \
|
|
43
|
+
--split -t 08:25:40..08:26:10 -o both.svg
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Exit codes
|
|
47
|
+
|
|
48
|
+
| Code | Meaning |
|
|
49
|
+
|---|---|
|
|
50
|
+
| 0 | success |
|
|
51
|
+
| 1 | log, KCD, or output file could not be read or written |
|
|
52
|
+
| 2 | CLI usage error (unknown message or signal, bad `-t`, bad `-p`) |
|
|
53
|
+
| 3 | no samples found for any requested signal |
|
|
54
|
+
|
|
55
|
+
Exit code 3 reports which stack-position prefixes *were* present for the
|
|
56
|
+
requested messages — usually the fastest way to spot a wrong `-p`.
|
|
57
|
+
|
|
58
|
+
## Fish tab completion
|
|
59
|
+
|
|
60
|
+
An editable install writes the completion script automatically. To do it by
|
|
61
|
+
hand:
|
|
62
|
+
|
|
63
|
+
```fish
|
|
64
|
+
_PYCAN_PLOT_COMPLETE=fish_source pycan-plot | source # current shell
|
|
65
|
+
_PYCAN_PLOT_COMPLETE=fish_source pycan-plot > ~/.config/fish/completions/pycan-plot.fish # persistent
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Completion covers log files, KCD files, message and signal names pulled live
|
|
69
|
+
from the KCD on the command line, and — once the log file is given — the
|
|
70
|
+
stack-position prefixes actually present in that log.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
`pycan-plot` is distributed under the terms of the GPL-3.0-or-later license.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Hatchling build hook — installs the fish completion on editable installs.
|
|
2
|
+
|
|
3
|
+
The hook runs inside an isolated build environment where neither this package
|
|
4
|
+
nor its dependencies are importable, so it reaches into `src/` for the
|
|
5
|
+
dependency-free `_fish` module rather than importing `pycan_plot` proper.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CustomBuildHook(BuildHookInterface):
|
|
14
|
+
def initialize(self, version: str, build_data: dict) -> None:
|
|
15
|
+
if version != "editable":
|
|
16
|
+
return
|
|
17
|
+
try:
|
|
18
|
+
sys.path.insert(0, os.path.join(self.root, "src"))
|
|
19
|
+
from pycan_plot._fish import install_fish_script
|
|
20
|
+
|
|
21
|
+
install_fish_script(os.path.expanduser("~"))
|
|
22
|
+
except Exception:
|
|
23
|
+
pass # never block an install over completions
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pycan-plot"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Plot CAN signals from a candump log using a KCD database"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "GPL-3.0-or-later"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Alexander Becker", email = "nabla@radiation.systems" },
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"click",
|
|
17
|
+
"cantools",
|
|
18
|
+
"matplotlib",
|
|
19
|
+
"rich",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
pycan-plot = "pycan_plot.main:main"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.version]
|
|
26
|
+
path = "src/pycan_plot/__about__.py"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.hooks.custom]
|
|
29
|
+
path = "hatch_build.py"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/pycan_plot"]
|
|
33
|
+
|
|
34
|
+
[tool.hatch.envs.test]
|
|
35
|
+
dependencies = [
|
|
36
|
+
"pytest",
|
|
37
|
+
]
|
|
38
|
+
[tool.hatch.envs.test.scripts]
|
|
39
|
+
run = "pytest {args:tests}"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.envs.types]
|
|
42
|
+
extra-dependencies = [
|
|
43
|
+
"mypy>=1.0.0",
|
|
44
|
+
]
|
|
45
|
+
[tool.hatch.envs.types.scripts]
|
|
46
|
+
check = "mypy --install-types --non-interactive {args:src/pycan_plot tests}"
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Click shell-completion callbacks.
|
|
2
|
+
|
|
3
|
+
Completions are computed live from the KCD named on the command line, so
|
|
4
|
+
there is no generated table to keep in sync with the database. Every
|
|
5
|
+
callback swallows exceptions: a malformed KCD must never break the user's
|
|
6
|
+
shell.
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
import cantools
|
|
13
|
+
from click.shell_completion import CompletionItem
|
|
14
|
+
|
|
15
|
+
_ID_LINE = re.compile(r"^\(\d+(?:\.\d+)?\)\s+\S+\s+([0-9A-Fa-f]+)#")
|
|
16
|
+
_PREFIX_MASK = 0x1F000000
|
|
17
|
+
#: How many lines of the log to sample when completing -p.
|
|
18
|
+
_PREFIX_SCAN_LINES = 50_000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _complete_paths(incomplete: str, suffix: str) -> List[CompletionItem]:
|
|
22
|
+
"""Offer directories plus files ending in `suffix`, keeping the token's
|
|
23
|
+
user-visible form (`~/foo`, `./foo`, `foo`) rather than absolutising it."""
|
|
24
|
+
raw = incomplete or ""
|
|
25
|
+
expanded = Path(raw).expanduser() if raw else Path(".")
|
|
26
|
+
if raw.endswith("/") and expanded.is_dir():
|
|
27
|
+
directory, prefix = expanded, ""
|
|
28
|
+
else:
|
|
29
|
+
directory, prefix = expanded.parent, expanded.name
|
|
30
|
+
if not directory.exists():
|
|
31
|
+
return []
|
|
32
|
+
|
|
33
|
+
if raw.startswith("~"):
|
|
34
|
+
home = Path.home()
|
|
35
|
+
|
|
36
|
+
def display(p: Path) -> str:
|
|
37
|
+
try:
|
|
38
|
+
return "~/" + str(p.relative_to(home))
|
|
39
|
+
except ValueError:
|
|
40
|
+
return str(p)
|
|
41
|
+
else:
|
|
42
|
+
def display(p: Path) -> str:
|
|
43
|
+
return str(p)
|
|
44
|
+
|
|
45
|
+
items = []
|
|
46
|
+
for entry in sorted(directory.iterdir()):
|
|
47
|
+
if not entry.name.startswith(prefix):
|
|
48
|
+
continue
|
|
49
|
+
shown = display(entry)
|
|
50
|
+
if entry.is_dir():
|
|
51
|
+
items.append(CompletionItem(shown + "/", type="plain"))
|
|
52
|
+
elif entry.suffix == suffix:
|
|
53
|
+
items.append(CompletionItem(shown))
|
|
54
|
+
return items
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _kcd_from_ctx(ctx) -> Optional[str]:
|
|
58
|
+
"""During completion the -d value may still be sitting in ctx.args."""
|
|
59
|
+
kcd = ctx.params.get("kcd_file")
|
|
60
|
+
if kcd:
|
|
61
|
+
return kcd
|
|
62
|
+
for arg in ctx.args:
|
|
63
|
+
if str(arg).endswith(".kcd"):
|
|
64
|
+
return str(arg)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _logfile_from_ctx(ctx) -> Optional[str]:
|
|
69
|
+
logfile = ctx.params.get("logfile")
|
|
70
|
+
if logfile:
|
|
71
|
+
return logfile
|
|
72
|
+
for arg in ctx.args:
|
|
73
|
+
if str(arg).endswith(".log"):
|
|
74
|
+
return str(arg)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_db(ctx):
|
|
79
|
+
kcd = _kcd_from_ctx(ctx)
|
|
80
|
+
if not kcd:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return cantools.database.load_file(str(Path(kcd).expanduser()))
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def complete_kcd(ctx, param, incomplete) -> List[CompletionItem]:
|
|
89
|
+
try:
|
|
90
|
+
return _complete_paths(incomplete, ".kcd")
|
|
91
|
+
except Exception:
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def complete_logfile(ctx, param, incomplete) -> List[CompletionItem]:
|
|
96
|
+
try:
|
|
97
|
+
return _complete_paths(incomplete, ".log")
|
|
98
|
+
except Exception:
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def complete_signal(ctx, param, incomplete) -> List[CompletionItem]:
|
|
103
|
+
"""`Message.Signal` is a single token, so complete it in two phases:
|
|
104
|
+
message names (with a trailing dot) until the dot is typed, then that
|
|
105
|
+
message's signals as full `Message.Signal` strings."""
|
|
106
|
+
try:
|
|
107
|
+
db = _load_db(ctx)
|
|
108
|
+
if db is None:
|
|
109
|
+
return []
|
|
110
|
+
if "." not in incomplete:
|
|
111
|
+
return [
|
|
112
|
+
CompletionItem(m.name + ".")
|
|
113
|
+
for m in db.messages
|
|
114
|
+
if m.name.startswith(incomplete)
|
|
115
|
+
]
|
|
116
|
+
message_name, partial = incomplete.split(".", 1)
|
|
117
|
+
try:
|
|
118
|
+
message = db.get_message_by_name(message_name)
|
|
119
|
+
except KeyError:
|
|
120
|
+
return []
|
|
121
|
+
return [
|
|
122
|
+
CompletionItem(f"{message_name}.{s.name}")
|
|
123
|
+
for s in message.signals
|
|
124
|
+
if s.name.startswith(partial)
|
|
125
|
+
]
|
|
126
|
+
except Exception:
|
|
127
|
+
return []
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def complete_prefix(ctx, param, incomplete) -> List[CompletionItem]:
|
|
131
|
+
"""Offer the stack-position prefixes actually present in the log."""
|
|
132
|
+
try:
|
|
133
|
+
logfile = _logfile_from_ctx(ctx)
|
|
134
|
+
if not logfile:
|
|
135
|
+
return []
|
|
136
|
+
seen = set()
|
|
137
|
+
with open(Path(logfile).expanduser(), "r") as fh:
|
|
138
|
+
for count, line in enumerate(fh):
|
|
139
|
+
if count >= _PREFIX_SCAN_LINES:
|
|
140
|
+
break
|
|
141
|
+
match = _ID_LINE.match(line)
|
|
142
|
+
if match:
|
|
143
|
+
seen.add(int(match.group(1), 16) & _PREFIX_MASK)
|
|
144
|
+
return [
|
|
145
|
+
CompletionItem(f"0x{p:08X}")
|
|
146
|
+
for p in sorted(seen)
|
|
147
|
+
if f"0x{p:08X}".lower().startswith(incomplete.lower())
|
|
148
|
+
]
|
|
149
|
+
except Exception:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fish_source() -> str:
|
|
154
|
+
"""The fish completion script, generated by Click itself."""
|
|
155
|
+
from click.shell_completion import FishComplete
|
|
156
|
+
|
|
157
|
+
from pycan_plot.main import main
|
|
158
|
+
|
|
159
|
+
return FishComplete(main, {}, "pycan-plot", "_PYCAN_PLOT_COMPLETE").source()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def install_fish(quiet: bool = False) -> None:
|
|
163
|
+
"""Write the fish completion script into the user's fish config."""
|
|
164
|
+
from pycan_plot._fish import install_fish_script
|
|
165
|
+
|
|
166
|
+
dest = install_fish_script(str(Path.home()))
|
|
167
|
+
if not quiet:
|
|
168
|
+
print(f"Installed → {dest}")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""The fish completion script text, with no third-party imports.
|
|
2
|
+
|
|
3
|
+
This module is deliberately dependency-free: the hatchling build hook imports
|
|
4
|
+
it from an isolated build environment where neither `pycan_plot`'s
|
|
5
|
+
dependencies nor the package itself are installed yet.
|
|
6
|
+
|
|
7
|
+
The script is generic boilerplate — it delegates every completion back to the
|
|
8
|
+
running command via `_PYCAN_PLOT_COMPLETE=fish_complete`, and embeds no option
|
|
9
|
+
or signal names. That is what makes hard-coding it safe. `test_completion.py`
|
|
10
|
+
asserts it still matches what the installed Click generates, so a change in
|
|
11
|
+
Click's template is caught rather than silently shipped stale.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
FISH_SCRIPT = '''function _pycan_plot_completion;
|
|
15
|
+
set -l response (env _PYCAN_PLOT_COMPLETE=fish_complete COMP_WORDS=(commandline -cp) COMP_CWORD=(commandline -t) pycan-plot);
|
|
16
|
+
|
|
17
|
+
for completion in $response;
|
|
18
|
+
set -l metadata (string split "," $completion);
|
|
19
|
+
|
|
20
|
+
if test $metadata[1] = "dir";
|
|
21
|
+
__fish_complete_directories $metadata[2];
|
|
22
|
+
else if test $metadata[1] = "file";
|
|
23
|
+
__fish_complete_path $metadata[2];
|
|
24
|
+
else if test $metadata[1] = "plain";
|
|
25
|
+
echo $metadata[2];
|
|
26
|
+
end;
|
|
27
|
+
end;
|
|
28
|
+
end;
|
|
29
|
+
|
|
30
|
+
complete --no-files --command pycan-plot --arguments "(_pycan_plot_completion)";
|
|
31
|
+
'''
|
|
32
|
+
|
|
33
|
+
#: Where the script belongs in a user's fish configuration.
|
|
34
|
+
FISH_RELATIVE_DEST = ".config/fish/completions/pycan-plot.fish"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def install_fish_script(home: str) -> str:
|
|
38
|
+
"""Write FISH_SCRIPT under `home` and return the path written."""
|
|
39
|
+
import os
|
|
40
|
+
|
|
41
|
+
dest = os.path.join(home, FISH_RELATIVE_DEST)
|
|
42
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
43
|
+
with open(dest, "w") as fh:
|
|
44
|
+
fh.write(FISH_SCRIPT)
|
|
45
|
+
return dest
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Parsing of `candump`-format text logs.
|
|
2
|
+
|
|
3
|
+
A line looks like:
|
|
4
|
+
|
|
5
|
+
(1786364740.740423) can0 0107800C#00000000
|
|
6
|
+
|
|
7
|
+
That is: a parenthesised epoch timestamp, the interface name, then the
|
|
8
|
+
arbitration ID and payload separated by `#`. IDs of 3 hex digits are
|
|
9
|
+
standard, 8 hex digits extended — we do not distinguish them, since a KCD
|
|
10
|
+
frame ID comparison covers both. Lines using `##` (CAN FD) or `#R`
|
|
11
|
+
(remote frames) carry nothing we can plot and are counted as malformed.
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from typing import Iterator, NamedTuple, Union
|
|
16
|
+
|
|
17
|
+
_LINE = re.compile(
|
|
18
|
+
r"^\((?P<ts>\d+(?:\.\d+)?)\)\s+" # (timestamp)
|
|
19
|
+
r"\S+\s+" # interface
|
|
20
|
+
r"(?P<id>[0-9A-Fa-f]+)" # arbitration ID
|
|
21
|
+
r"#(?P<data>[0-9A-Fa-f]*)\s*$" # payload (possibly empty)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Frame(NamedTuple):
|
|
26
|
+
timestamp: float
|
|
27
|
+
arb_id: int
|
|
28
|
+
data: bytes
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CandumpReader:
|
|
32
|
+
"""Iterable over the frames in a candump log.
|
|
33
|
+
|
|
34
|
+
Malformed lines are skipped rather than raised on; after iteration
|
|
35
|
+
finishes, `malformed` holds how many were skipped. Blank lines are
|
|
36
|
+
ignored entirely and do not count as malformed. Re-iterating restarts
|
|
37
|
+
the count from zero.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, path: Union[str, os.PathLike]) -> None:
|
|
41
|
+
self.path = path
|
|
42
|
+
self.malformed = 0
|
|
43
|
+
|
|
44
|
+
def __iter__(self) -> Iterator[Frame]:
|
|
45
|
+
self.malformed = 0
|
|
46
|
+
with open(self.path, "r") as fh:
|
|
47
|
+
for line in fh:
|
|
48
|
+
if not line.strip():
|
|
49
|
+
continue
|
|
50
|
+
match = _LINE.match(line)
|
|
51
|
+
if match is None:
|
|
52
|
+
self.malformed += 1
|
|
53
|
+
continue
|
|
54
|
+
payload = match.group("data")
|
|
55
|
+
if len(payload) % 2:
|
|
56
|
+
self.malformed += 1
|
|
57
|
+
continue
|
|
58
|
+
yield Frame(
|
|
59
|
+
float(match.group("ts")),
|
|
60
|
+
int(match.group("id"), 16),
|
|
61
|
+
bytes.fromhex(payload),
|
|
62
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Exceptions that carry the tool's documented exit codes."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PlotError(Exception):
|
|
5
|
+
"""Base for errors that map onto a process exit code."""
|
|
6
|
+
|
|
7
|
+
exit_code = 1
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InputError(PlotError):
|
|
11
|
+
"""A file could not be read, parsed, or written."""
|
|
12
|
+
|
|
13
|
+
exit_code = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UsageError(PlotError):
|
|
17
|
+
"""The command line asked for something that cannot be satisfied."""
|
|
18
|
+
|
|
19
|
+
exit_code = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NoDataError(PlotError):
|
|
23
|
+
"""Not one requested signal produced a single sample."""
|
|
24
|
+
|
|
25
|
+
exit_code = 3
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""The single streaming pass over a candump log.
|
|
2
|
+
|
|
3
|
+
Only frames whose arbitration ID matches a requested message (with the
|
|
4
|
+
stack-position prefix already ORed in) are decoded; everything else costs
|
|
5
|
+
one dict lookup. That is what keeps a multi-million-line log tractable.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set
|
|
9
|
+
|
|
10
|
+
from pycan_plot.selection import SignalRef
|
|
11
|
+
from pycan_plot.timespec import TimeWindow
|
|
12
|
+
|
|
13
|
+
#: Stack position lives in bits [28:24] of a v1 extended ID.
|
|
14
|
+
PREFIX_MASK = 0x1F000000
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Series:
|
|
19
|
+
"""All samples collected for one requested signal."""
|
|
20
|
+
|
|
21
|
+
ref: SignalRef
|
|
22
|
+
times: List[float] = field(default_factory=list)
|
|
23
|
+
values: List[float] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def label(self) -> str:
|
|
27
|
+
return self.ref.label
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def unit(self) -> Optional[str]:
|
|
31
|
+
return self.ref.signal.unit
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def choices(self) -> Optional[Dict[int, Any]]:
|
|
35
|
+
return self.ref.signal.choices
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ExtractResult:
|
|
40
|
+
series: Dict[str, Series]
|
|
41
|
+
malformed: int = 0
|
|
42
|
+
observed_prefixes: Dict[int, Set[int]] = field(default_factory=dict)
|
|
43
|
+
frames_scanned: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def extract(
|
|
47
|
+
reader: Iterable,
|
|
48
|
+
refs: Sequence[SignalRef],
|
|
49
|
+
prefix: int,
|
|
50
|
+
window: TimeWindow,
|
|
51
|
+
) -> ExtractResult:
|
|
52
|
+
"""Stream `reader` once and collect samples for every ref in `refs`."""
|
|
53
|
+
series = {ref.label: Series(ref) for ref in refs}
|
|
54
|
+
|
|
55
|
+
# Frames we must decode, keyed by the fully-prefixed ID.
|
|
56
|
+
wanted: Dict[int, List[SignalRef]] = {}
|
|
57
|
+
# Base IDs we watch for the wrong-prefix hint, whatever their prefix.
|
|
58
|
+
watched_bases: Set[int] = set()
|
|
59
|
+
for ref in refs:
|
|
60
|
+
wanted.setdefault(ref.base_frame_id | prefix, []).append(ref)
|
|
61
|
+
watched_bases.add(ref.base_frame_id)
|
|
62
|
+
|
|
63
|
+
result = ExtractResult(series=series)
|
|
64
|
+
result.observed_prefixes = {base: set() for base in watched_bases}
|
|
65
|
+
|
|
66
|
+
start: Optional[float] = None
|
|
67
|
+
end: Optional[float] = None
|
|
68
|
+
bounds_resolved = False
|
|
69
|
+
|
|
70
|
+
for frame in reader:
|
|
71
|
+
result.frames_scanned += 1
|
|
72
|
+
if not bounds_resolved:
|
|
73
|
+
# Bounds are relative to the first frame in the log, not the
|
|
74
|
+
# first matching one, so resolve them as soon as we see any frame.
|
|
75
|
+
start, end = window.resolve(frame.timestamp)
|
|
76
|
+
bounds_resolved = True
|
|
77
|
+
|
|
78
|
+
base = frame.arb_id & ~PREFIX_MASK
|
|
79
|
+
if base in result.observed_prefixes:
|
|
80
|
+
result.observed_prefixes[base].add(frame.arb_id & PREFIX_MASK)
|
|
81
|
+
|
|
82
|
+
refs_here = wanted.get(frame.arb_id)
|
|
83
|
+
if refs_here is None:
|
|
84
|
+
continue
|
|
85
|
+
if start is not None and frame.timestamp < start:
|
|
86
|
+
continue
|
|
87
|
+
if end is not None and frame.timestamp > end:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
decoded = refs_here[0].message.decode(frame.data, decode_choices=False)
|
|
92
|
+
except Exception:
|
|
93
|
+
# Short, over-long or otherwise undecodable payload: count and move on.
|
|
94
|
+
result.malformed += 1
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
for ref in refs_here:
|
|
98
|
+
if ref.signal.name not in decoded:
|
|
99
|
+
continue # a muxed signal that this frame does not carry
|
|
100
|
+
series[ref.label].times.append(frame.timestamp)
|
|
101
|
+
series[ref.label].values.append(float(decoded[ref.signal.name]))
|
|
102
|
+
|
|
103
|
+
result.malformed += getattr(reader, "malformed", 0)
|
|
104
|
+
return result
|