pgntui 0.1.2__py3-none-any.whl
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.
- pgntui/__init__.py +3 -0
- pgntui/__main__.py +55 -0
- pgntui/app.py +67 -0
- pgntui/config.py +42 -0
- pgntui/containers/__init__.py +0 -0
- pgntui/containers/loader.py +59 -0
- pgntui/containers/screen.py +60 -0
- pgntui/debug/__init__.py +0 -0
- pgntui/debug/tab.py +46 -0
- pgntui/decode/__init__.py +0 -0
- pgntui/decode/canboat.py +111 -0
- pgntui/decode/pgns.json +67732 -0
- pgntui/decode/router.py +52 -0
- pgntui/drivers/__init__.py +0 -0
- pgntui/drivers/actisense.py +136 -0
- pgntui/drivers/base.py +33 -0
- pgntui/drivers/replay.py +55 -0
- pgntui/logging/__init__.py +0 -0
- pgntui/logging/csv.py +42 -0
- pgntui/recording/__init__.py +0 -0
- pgntui/recording/reader.py +37 -0
- pgntui/recording/writer.py +46 -0
- pgntui/replay_mode.py +42 -0
- pgntui/signals/__init__.py +0 -0
- pgntui/signals/base.py +158 -0
- pgntui/signals/widgets.py +140 -0
- pgntui/themes/__init__.py +0 -0
- pgntui/themes/builtin/__init__.py +0 -0
- pgntui/themes/builtin/amber-crt.json +13 -0
- pgntui/themes/builtin/dark.json +16 -0
- pgntui/themes/builtin/green-phosphor.json +13 -0
- pgntui/themes/builtin/light.json +13 -0
- pgntui/themes/builtin/mono-ascii.json +13 -0
- pgntui/themes/builtin/rainbow-disco.json +20 -0
- pgntui/themes/loader.py +125 -0
- pgntui-0.1.2.dist-info/METADATA +66 -0
- pgntui-0.1.2.dist-info/RECORD +40 -0
- pgntui-0.1.2.dist-info/WHEEL +4 -0
- pgntui-0.1.2.dist-info/entry_points.txt +6 -0
- pgntui-0.1.2.dist-info/licenses/LICENSE +21 -0
pgntui/__init__.py
ADDED
pgntui/__main__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""CLI entry point for pgntui."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pgntui.config import load_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
p = argparse.ArgumentParser(prog="pgntui", description="NMEA 2000 terminal UI")
|
|
14
|
+
p.add_argument("--workspace", default=None, help="override workspace directory")
|
|
15
|
+
p.add_argument("--enable-write", action="store_true", help="enable writes")
|
|
16
|
+
sub = p.add_subparsers(dest="command")
|
|
17
|
+
replay = sub.add_parser("replay", help="replay a .pgnlog file")
|
|
18
|
+
replay.add_argument("replay_file")
|
|
19
|
+
return p
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
23
|
+
return build_parser().parse_args(argv)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: list[str] | None = None) -> int:
|
|
27
|
+
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
28
|
+
workspace = (
|
|
29
|
+
Path(args.workspace).expanduser()
|
|
30
|
+
if args.workspace
|
|
31
|
+
else Path("~/.config/pgntui").expanduser()
|
|
32
|
+
)
|
|
33
|
+
cfg_path = workspace / "config.toml"
|
|
34
|
+
cfg = load_config(cfg_path)
|
|
35
|
+
if args.enable_write:
|
|
36
|
+
# Re-create with write_enabled flipped on
|
|
37
|
+
cfg = type(cfg)(
|
|
38
|
+
driver_name=cfg.driver_name,
|
|
39
|
+
driver_options=cfg.driver_options,
|
|
40
|
+
write_enabled=True,
|
|
41
|
+
theme=cfg.theme,
|
|
42
|
+
workspace=workspace,
|
|
43
|
+
csv_dir=cfg.csv_dir,
|
|
44
|
+
record_dir=cfg.record_dir,
|
|
45
|
+
)
|
|
46
|
+
if args.command == "replay":
|
|
47
|
+
# Replay mode is bootstrapped here in a later integration step.
|
|
48
|
+
print(f"replay: {args.replay_file} (workspace={workspace}, theme={cfg.theme})")
|
|
49
|
+
return 0
|
|
50
|
+
print(f"pgntui: workspace={workspace} theme={cfg.theme} write_enabled={cfg.write_enabled}")
|
|
51
|
+
return 0
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
raise SystemExit(main())
|
pgntui/app.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Textual app shell — tabs, hotkey strip, status bar."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.containers import Vertical
|
|
7
|
+
from textual.widgets import Header, Static, TabbedContent, TabPane
|
|
8
|
+
|
|
9
|
+
from pgntui.themes.loader import Theme, to_textual_css
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PgntuiApp(App[None]):
|
|
13
|
+
CSS = ""
|
|
14
|
+
|
|
15
|
+
BINDINGS = [
|
|
16
|
+
("tab", "next_container", "Next"),
|
|
17
|
+
("shift+tab", "prev_container", "Prev"),
|
|
18
|
+
("d", "show_debug", "Debug"),
|
|
19
|
+
("r", "toggle_record", "Record"),
|
|
20
|
+
("q", "quit", "Quit"),
|
|
21
|
+
("question_mark", "help", "Help"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self, theme: Theme, container_titles: list[str]) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self._theme = theme
|
|
27
|
+
self._container_titles = container_titles
|
|
28
|
+
|
|
29
|
+
def on_mount(self) -> None:
|
|
30
|
+
self.stylesheet.add_source(to_textual_css(self._theme), read_from=("theme", "theme"))
|
|
31
|
+
self.stylesheet.parse()
|
|
32
|
+
self.refresh_css()
|
|
33
|
+
|
|
34
|
+
def compose(self) -> ComposeResult:
|
|
35
|
+
yield Header()
|
|
36
|
+
with Vertical():
|
|
37
|
+
with TabbedContent(id="tabs"):
|
|
38
|
+
for title in self._container_titles:
|
|
39
|
+
with TabPane(title):
|
|
40
|
+
yield Static(title, classes="signal-title")
|
|
41
|
+
with TabPane("Debug", id="debug"):
|
|
42
|
+
yield Static("debug placeholder")
|
|
43
|
+
yield Static("[Tab] Next [D] Debug [R] Rec [Q] Quit", id="hotkey-strip")
|
|
44
|
+
yield Static("status: idle", id="status-bar")
|
|
45
|
+
|
|
46
|
+
def action_next_container(self) -> None:
|
|
47
|
+
tabs = self.query_one(TabbedContent)
|
|
48
|
+
tabs.action_next_tab() # type: ignore[attr-defined] # Textual provides at runtime
|
|
49
|
+
|
|
50
|
+
def action_prev_container(self) -> None:
|
|
51
|
+
tabs = self.query_one(TabbedContent)
|
|
52
|
+
tabs.action_previous_tab() # type: ignore[attr-defined] # Textual provides at runtime
|
|
53
|
+
|
|
54
|
+
def action_show_debug(self) -> None:
|
|
55
|
+
tabs = self.query_one(TabbedContent)
|
|
56
|
+
tabs.active = "debug"
|
|
57
|
+
|
|
58
|
+
def action_toggle_record(self) -> None:
|
|
59
|
+
status = self.query_one("#status-bar", Static)
|
|
60
|
+
status.update("status: REC")
|
|
61
|
+
|
|
62
|
+
def action_help(self) -> None:
|
|
63
|
+
status = self.query_one("#status-bar", Static)
|
|
64
|
+
status.update("help: Tab/D/R/Q")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = ["PgntuiApp"]
|
pgntui/config.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""TOML config loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Config:
|
|
13
|
+
driver_name: str = "file-replay"
|
|
14
|
+
driver_options: dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
write_enabled: bool = False
|
|
16
|
+
theme: str = "dark"
|
|
17
|
+
workspace: Path = Path("~/.config/pgntui")
|
|
18
|
+
csv_dir: str = "logs"
|
|
19
|
+
record_dir: str = "recordings"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(path: Path) -> Config:
|
|
23
|
+
path = Path(path)
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return Config()
|
|
26
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
27
|
+
driver = data.get("driver", {})
|
|
28
|
+
app = data.get("app", {})
|
|
29
|
+
logging_cfg = data.get("logging", {})
|
|
30
|
+
driver_opts = {k: v for k, v in driver.items() if k != "name"}
|
|
31
|
+
return Config(
|
|
32
|
+
driver_name=driver.get("name", "file-replay"),
|
|
33
|
+
driver_options=driver_opts,
|
|
34
|
+
write_enabled=bool(app.get("write_enabled", False)),
|
|
35
|
+
theme=app.get("theme", "dark"),
|
|
36
|
+
workspace=Path(app.get("workspace", "~/.config/pgntui")).expanduser(),
|
|
37
|
+
csv_dir=logging_cfg.get("csv_dir", "logs"),
|
|
38
|
+
record_dir=logging_cfg.get("record_dir", "recordings"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["Config", "load_config"]
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Container JSON loader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContainerLoadError(ValueError):
|
|
11
|
+
"""Raised when a container JSON document is invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class SignalPlacement:
|
|
16
|
+
ref: str
|
|
17
|
+
row: int
|
|
18
|
+
col: int
|
|
19
|
+
w: int
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class Container:
|
|
24
|
+
id: str
|
|
25
|
+
title: str
|
|
26
|
+
cols: int
|
|
27
|
+
signals: list[SignalPlacement]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_container(path: Path, known_signal_ids: set[str]) -> Container:
|
|
31
|
+
try:
|
|
32
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
except json.JSONDecodeError as e:
|
|
34
|
+
raise ContainerLoadError(f"{path}: invalid JSON: {e}") from e
|
|
35
|
+
try:
|
|
36
|
+
cid = payload["id"]
|
|
37
|
+
title = payload["title"]
|
|
38
|
+
except KeyError as e:
|
|
39
|
+
raise ContainerLoadError(f"{path}: missing key {e}") from e
|
|
40
|
+
cols = int(payload.get("cols", 12))
|
|
41
|
+
if cols <= 0:
|
|
42
|
+
raise ContainerLoadError(f"{path}: cols must be positive")
|
|
43
|
+
placements: list[SignalPlacement] = []
|
|
44
|
+
for item in payload.get("signals", []):
|
|
45
|
+
ref = item["ref"]
|
|
46
|
+
if ref not in known_signal_ids:
|
|
47
|
+
raise ContainerLoadError(f"{path}: unknown signal ref {ref!r}")
|
|
48
|
+
row = int(item["row"])
|
|
49
|
+
col = int(item["col"])
|
|
50
|
+
w = int(item["w"])
|
|
51
|
+
if row < 0 or col < 0 or w <= 0:
|
|
52
|
+
raise ContainerLoadError(f"{path}: ref {ref!r} has invalid geometry")
|
|
53
|
+
if col + w > cols:
|
|
54
|
+
raise ContainerLoadError(f"{path}: ref {ref!r} overflows grid (cols={cols})")
|
|
55
|
+
placements.append(SignalPlacement(ref=ref, row=row, col=col, w=w))
|
|
56
|
+
return Container(id=cid, title=title, cols=cols, signals=placements)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
__all__ = ["Container", "ContainerLoadError", "SignalPlacement", "load_container"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""ContainerScreen — renders a Container's grid of signal widgets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Grid
|
|
7
|
+
from textual.screen import Screen
|
|
8
|
+
from textual.widget import Widget
|
|
9
|
+
|
|
10
|
+
from pgntui.containers.loader import Container
|
|
11
|
+
from pgntui.signals.base import AnalogIn, AnalogOut, DigitalIn, DigitalOut, Signal
|
|
12
|
+
from pgntui.signals.widgets import (
|
|
13
|
+
AnalogInWidget,
|
|
14
|
+
AnalogOutWidget,
|
|
15
|
+
DigitalInWidget,
|
|
16
|
+
DigitalOutWidget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ContainerScreen(Screen[None]):
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
container: Container,
|
|
24
|
+
signals: dict[str, Signal],
|
|
25
|
+
write_enabled: bool,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__()
|
|
28
|
+
self.container_def = container
|
|
29
|
+
self.signals = signals
|
|
30
|
+
self.write_enabled = write_enabled
|
|
31
|
+
self.widgets: dict[str, Widget] = {}
|
|
32
|
+
|
|
33
|
+
def compose(self) -> ComposeResult:
|
|
34
|
+
grid = Grid(id="container-grid")
|
|
35
|
+
grid.styles.grid_size_columns = self.container_def.cols
|
|
36
|
+
for placement in self.container_def.signals:
|
|
37
|
+
sig = self.signals[placement.ref]
|
|
38
|
+
w = self._make_widget(sig)
|
|
39
|
+
w.styles.column_span = placement.w
|
|
40
|
+
self.widgets[placement.ref] = w
|
|
41
|
+
yield grid
|
|
42
|
+
|
|
43
|
+
def on_mount(self) -> None:
|
|
44
|
+
grid = self.query_one(Grid)
|
|
45
|
+
for w in self.widgets.values():
|
|
46
|
+
grid.mount(w)
|
|
47
|
+
|
|
48
|
+
def _make_widget(self, sig: Signal) -> Widget:
|
|
49
|
+
if isinstance(sig, AnalogIn):
|
|
50
|
+
return AnalogInWidget(sig)
|
|
51
|
+
if isinstance(sig, AnalogOut):
|
|
52
|
+
return AnalogOutWidget(sig, write_enabled=self.write_enabled)
|
|
53
|
+
if isinstance(sig, DigitalIn):
|
|
54
|
+
return DigitalInWidget(sig)
|
|
55
|
+
if isinstance(sig, DigitalOut):
|
|
56
|
+
return DigitalOutWidget(sig, write_enabled=self.write_enabled)
|
|
57
|
+
raise TypeError(f"Unknown signal type: {type(sig).__name__}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
__all__ = ["ContainerScreen"]
|
pgntui/debug/__init__.py
ADDED
|
File without changes
|
pgntui/debug/tab.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Debug tab — scrolling decoded-frame buffer with filters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import deque
|
|
6
|
+
|
|
7
|
+
from pgntui.decode.canboat import DecodedFrame
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DebugBuffer:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
max_rows: int = 1000,
|
|
14
|
+
pgn_filter: set[int] | None = None,
|
|
15
|
+
source_filter: set[int] | None = None,
|
|
16
|
+
show_raw_hex: bool = False,
|
|
17
|
+
) -> None:
|
|
18
|
+
self._rows: deque[DecodedFrame] = deque(maxlen=max_rows)
|
|
19
|
+
self.paused = False
|
|
20
|
+
self.pgn_filter = pgn_filter
|
|
21
|
+
self.source_filter = source_filter
|
|
22
|
+
self.show_raw_hex = show_raw_hex
|
|
23
|
+
|
|
24
|
+
def push(self, df: DecodedFrame) -> None:
|
|
25
|
+
if self.paused:
|
|
26
|
+
return
|
|
27
|
+
if self.pgn_filter is not None and df.pgn not in self.pgn_filter:
|
|
28
|
+
return
|
|
29
|
+
if self.source_filter is not None and df.source_addr not in self.source_filter:
|
|
30
|
+
return
|
|
31
|
+
self._rows.append(df)
|
|
32
|
+
|
|
33
|
+
def rows(self) -> list[DecodedFrame]:
|
|
34
|
+
return list(self._rows)
|
|
35
|
+
|
|
36
|
+
def clear(self) -> None:
|
|
37
|
+
self._rows.clear()
|
|
38
|
+
|
|
39
|
+
def toggle_pause(self) -> None:
|
|
40
|
+
self.paused = not self.paused
|
|
41
|
+
|
|
42
|
+
def toggle_raw_hex(self) -> None:
|
|
43
|
+
self.show_raw_hex = not self.show_raw_hex
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = ["DebugBuffer"]
|
|
File without changes
|
pgntui/decode/canboat.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Thin wrapper around the bundled canboat pgns.json database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import struct # noqa: F401 (retained for future tasks)
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pgntui.drivers.base import Frame
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class DecodedFrame:
|
|
16
|
+
timestamp: float
|
|
17
|
+
source_addr: int
|
|
18
|
+
pgn: int
|
|
19
|
+
name: str | None
|
|
20
|
+
fields: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Aliases for field names that conventional tools (signalk etc.) expose
|
|
24
|
+
# differently from the bare canboat `Name`. Keyed by (pgn, canboat field name).
|
|
25
|
+
_FIELD_ALIASES: dict[tuple[int, str], str] = {
|
|
26
|
+
(127488, "Speed"): "Engine Speed",
|
|
27
|
+
(127488, "Boost Pressure"): "Engine Boost Pressure",
|
|
28
|
+
(127488, "Tilt/Trim"): "Engine Tilt/Trim",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CanboatDecoder:
|
|
33
|
+
def __init__(self, db: dict[str, Any]) -> None:
|
|
34
|
+
self._db = db
|
|
35
|
+
pgn_list = db.get("PGNs") or db.get("pgns") or []
|
|
36
|
+
self._by_pgn: dict[int, list[dict[str, Any]]] = {}
|
|
37
|
+
for entry in pgn_list:
|
|
38
|
+
pgn = int(entry.get("PGN") or entry.get("pgn") or 0)
|
|
39
|
+
if pgn:
|
|
40
|
+
self._by_pgn.setdefault(pgn, []).append(entry)
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def load_bundled(cls) -> CanboatDecoder:
|
|
44
|
+
with (
|
|
45
|
+
resources.files("pgntui.decode").joinpath("pgns.json").open("r", encoding="utf-8") as fh
|
|
46
|
+
):
|
|
47
|
+
return cls(json.load(fh))
|
|
48
|
+
|
|
49
|
+
def has_pgn(self, pgn: int) -> bool:
|
|
50
|
+
return pgn in self._by_pgn
|
|
51
|
+
|
|
52
|
+
def decode(self, frame: Frame) -> DecodedFrame | None:
|
|
53
|
+
entries = self._by_pgn.get(frame.pgn)
|
|
54
|
+
if not entries:
|
|
55
|
+
return None
|
|
56
|
+
entry = entries[0]
|
|
57
|
+
fields = self._decode_fields(entry, frame.data, frame.pgn)
|
|
58
|
+
return DecodedFrame(
|
|
59
|
+
timestamp=frame.timestamp,
|
|
60
|
+
source_addr=frame.source_addr,
|
|
61
|
+
pgn=frame.pgn,
|
|
62
|
+
name=entry.get("Description") or entry.get("Id"),
|
|
63
|
+
fields=fields,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def _decode_fields(self, entry: dict[str, Any], data: bytes, pgn: int) -> dict[str, Any]:
|
|
67
|
+
out: dict[str, Any] = {}
|
|
68
|
+
bit_offset = 0
|
|
69
|
+
for f in entry.get("Fields", []) or entry.get("fields", []) or []:
|
|
70
|
+
size = int(
|
|
71
|
+
f.get("BitLength") or f.get("bitLength") or f.get("Length") or f.get("length") or 0
|
|
72
|
+
)
|
|
73
|
+
if size <= 0:
|
|
74
|
+
continue
|
|
75
|
+
name = f.get("Name") or f.get("name") or "?"
|
|
76
|
+
raw = _read_bits(data, bit_offset, size)
|
|
77
|
+
resolution_raw = f.get("Resolution")
|
|
78
|
+
if resolution_raw is None:
|
|
79
|
+
resolution_raw = f.get("resolution")
|
|
80
|
+
try:
|
|
81
|
+
resolution = float(resolution_raw) if resolution_raw is not None else 1.0
|
|
82
|
+
except (TypeError, ValueError):
|
|
83
|
+
resolution = 1.0
|
|
84
|
+
if resolution == 0:
|
|
85
|
+
resolution = 1.0
|
|
86
|
+
signed = bool(f.get("Signed") or f.get("signed"))
|
|
87
|
+
if signed and raw >= (1 << (size - 1)):
|
|
88
|
+
raw -= 1 << size
|
|
89
|
+
value: Any = raw * resolution if resolution != 1.0 else raw
|
|
90
|
+
out[name] = value
|
|
91
|
+
alias = _FIELD_ALIASES.get((pgn, name))
|
|
92
|
+
if alias:
|
|
93
|
+
out[alias] = value
|
|
94
|
+
bit_offset += size
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_bits(data: bytes, offset: int, length: int) -> int:
|
|
99
|
+
result = 0
|
|
100
|
+
for i in range(length):
|
|
101
|
+
bit_index = offset + i
|
|
102
|
+
byte_index = bit_index // 8
|
|
103
|
+
bit_in_byte = bit_index % 8
|
|
104
|
+
if byte_index >= len(data):
|
|
105
|
+
break
|
|
106
|
+
bit = (data[byte_index] >> bit_in_byte) & 1
|
|
107
|
+
result |= bit << i
|
|
108
|
+
return result
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["CanboatDecoder", "DecodedFrame"]
|