fastnet2n2k 0.1.3__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.
- fastnet2n2k-0.1.3/.gitignore +6 -0
- fastnet2n2k-0.1.3/LICENSE +21 -0
- fastnet2n2k-0.1.3/PKG-INFO +225 -0
- fastnet2n2k-0.1.3/README.md +201 -0
- fastnet2n2k-0.1.3/fastnet2n2k/__init__.py +8 -0
- fastnet2n2k-0.1.3/fastnet2n2k/__main__.py +155 -0
- fastnet2n2k-0.1.3/fastnet2n2k/display.py +28 -0
- fastnet2n2k-0.1.3/fastnet2n2k/input_source.py +65 -0
- fastnet2n2k-0.1.3/fastnet2n2k/live_store.py +32 -0
- fastnet2n2k-0.1.3/fastnet2n2k/mapping.py +381 -0
- fastnet2n2k-0.1.3/fastnet2n2k.service +33 -0
- fastnet2n2k-0.1.3/pyproject.toml +45 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ghotihook
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastnet2n2k
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Bridge a B&G Fastnet instrument stream onto an NMEA2000 CAN bus
|
|
5
|
+
Project-URL: Homepage, https://github.com/ghotihook/fastnet2n2k
|
|
6
|
+
Project-URL: Repository, https://github.com/ghotihook/fastnet2n2k
|
|
7
|
+
Project-URL: Issues, https://github.com/ghotihook/fastnet2n2k/issues
|
|
8
|
+
Author: ghotihook
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: b&g,canbus,fastnet,marine,n2k,nmea2000,socketcan
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Communications
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: nmea2000>=2026.5
|
|
20
|
+
Requires-Dist: pyfastnet>=2.0.17
|
|
21
|
+
Requires-Dist: pyserial>=3.5
|
|
22
|
+
Requires-Dist: python-can>=4.5
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# fastnet2n2k — B&G Fastnet → NMEA2000 bridge
|
|
26
|
+
|
|
27
|
+
Reads a **B&G Fastnet** instrument stream (live serial or a captured hex file),
|
|
28
|
+
decodes it with [`pyfastnet`](https://github.com/ghotihook/pyfastnet), maps the
|
|
29
|
+
channels to **NMEA2000** PGNs and transmits them onto a CAN bus. Built for the
|
|
30
|
+
[M5Stack CoreMP135](https://docs.m5stack.com/en/core/M5CoreMP135) but runs on any
|
|
31
|
+
Linux box with a SocketCAN interface.
|
|
32
|
+
|
|
33
|
+
The CoreMP135 runs Linux on an STM32MP135 and exposes its two FDCAN interfaces
|
|
34
|
+
(SIT1051T transceivers) as the SocketCAN netdevs `can0` (FDCAN1, PE3/PE10) and
|
|
35
|
+
`can1` (FDCAN2, PG0/PE0). NMEA2000 runs at **250 kbit/s** with 29-bit extended IDs.
|
|
36
|
+
Message encoding, CAN-ID construction, fast-packet framing and ISO address claiming
|
|
37
|
+
are handled by the [`nmea2000`](https://github.com/tomer-w/nmea2000) library (canboat-
|
|
38
|
+
based) on top of `python-can`'s socketcan backend.
|
|
39
|
+
|
|
40
|
+
## What it sends
|
|
41
|
+
|
|
42
|
+
Each Fastnet channel is mapped to the matching PGN and emitted **only when the
|
|
43
|
+
channel updates** (with a 0.05 s minimum interval and a 5 s maximum re-broadcast),
|
|
44
|
+
so when the instruments go quiet the output stops and consumers time the data out.
|
|
45
|
+
|
|
46
|
+
| Data | PGN | Priority | Notes |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| Heading | 127250 | 2 | T/M reference taken from the Fastnet display layout (`°M`/`°T`) |
|
|
49
|
+
| Apparent / True wind, TWD | 130306 | 2 | knots→m/s, deg→rad; reference per layout |
|
|
50
|
+
| Boat speed | 128259 | 2 | knots→m/s |
|
|
51
|
+
| Depth | 128267 | 3 | metres |
|
|
52
|
+
| COG/SOG | 129026 | 2 | prefers True COG, falls back to Magnetic |
|
|
53
|
+
| Attitude (heel/trim) | 127257 | 3 | signed value passed through unchanged |
|
|
54
|
+
| Rudder, Leeway, Rate of turn | 127245 / 128000 / 127251 | 2 / 4 / 2 | |
|
|
55
|
+
| Distance log, XTE | 128275 / 129283 | 6 / 3 | NM→m |
|
|
56
|
+
| Position | 129025 | 2 | |
|
|
57
|
+
| Sea / air temperature | 130312 | 5 | °C/°F→Kelvin |
|
|
58
|
+
| Barometric pressure | 130314 | 5 | mbar→Pa |
|
|
59
|
+
| Tidal set & drift | 129291 | 3 | set deg→rad, drift kn→m/s; reference per layout |
|
|
60
|
+
|
|
61
|
+
The **Priority** column lists each PGN's NMEA2000 standard CAN priority (0 = highest,
|
|
62
|
+
7 = lowest). These are the values used **by default** — when `--n2k-priority` is *not*
|
|
63
|
+
given, every frame keeps the per-PGN priority shown above. Passing `--n2k-priority N`
|
|
64
|
+
overrides the whole column, forcing all frames to a single priority `N`.
|
|
65
|
+
|
|
66
|
+
**Units** are converted to NMEA2000 SI. **Sign** comes straight from pyfastnet's
|
|
67
|
+
decoded value. **True/Magnetic** is read from the pyfastnet `layout` field (the only
|
|
68
|
+
place it exists); a bearing whose layout can't be resolved is skipped, never guessed.
|
|
69
|
+
The B&G proprietary raw PGNs (65280–65282) are deferred (manufacturer-specific layout).
|
|
70
|
+
|
|
71
|
+
> **WiFi gateways:** if you feed a WiFi NMEA2000 gateway downstream, configure it for
|
|
72
|
+
> **unicast** UDP, not broadcast — WiFi broadcast is unacknowledged and silently drops
|
|
73
|
+
> frames even at low rates.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
Recommended — install the CLI in its own isolated environment with
|
|
78
|
+
[pipx](https://pipx.pypa.io/):
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pipx install fastnet2n2k
|
|
82
|
+
fastnet2n2k --serial /dev/ttyUSB0 --channel can0
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
This puts a `fastnet2n2k` command on your PATH. `python -m fastnet2n2k ...`
|
|
86
|
+
also works once installed.
|
|
87
|
+
|
|
88
|
+
### From source (development)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/ghotihook/fastnet2n2k.git
|
|
92
|
+
cd fastnet2n2k
|
|
93
|
+
python3 -m venv .venv
|
|
94
|
+
source .venv/bin/activate
|
|
95
|
+
pip install -e .
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Bring up the CAN bus
|
|
99
|
+
|
|
100
|
+
Once per boot (250 kbit/s for NMEA2000). `restart-ms 100` makes the controller
|
|
101
|
+
auto-recover from a bus-off instead of staying down:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
sudo ip link set can0 up type can bitrate 250000 restart-ms 100 # FDCAN1
|
|
105
|
+
# sudo ip link set can1 up type can bitrate 250000 restart-ms 100 # FDCAN2
|
|
106
|
+
ip -details link show can0 # want: state ERROR-ACTIVE, bitrate 250000
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
If `can0` shows `BUS-OFF`, fix that before expecting output to land — a CAN frame
|
|
110
|
+
needs at least one other node on the wire to acknowledge it (check termination
|
|
111
|
+
≈60 Ω, common ground, and CAN-H/CAN-L not swapped).
|
|
112
|
+
|
|
113
|
+
## Run
|
|
114
|
+
|
|
115
|
+
Activate the venv first (`source .venv/bin/activate`), then:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# replay a captured Fastnet hex file (safest first test)
|
|
119
|
+
python -m fastnet2n2k --file capture.txt --channel can0
|
|
120
|
+
|
|
121
|
+
# live from the Fastnet bus
|
|
122
|
+
python -m fastnet2n2k --serial /dev/ttyUSB0 --channel can0
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Find your serial adapter with `ls /dev/ttyUSB* /dev/ttyACM* /dev/ttyS*`. Stop with
|
|
126
|
+
Ctrl-C.
|
|
127
|
+
|
|
128
|
+
Options:
|
|
129
|
+
|
|
130
|
+
| Option | Default | Meaning |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| `--serial DEV` / `--file PATH` | — | input source (one is required) |
|
|
133
|
+
| `--channel` | `can0` | SocketCAN interface |
|
|
134
|
+
| `--n2k-priority` | per-PGN standard | override CAN priority (0–7, 0=highest) for **all** transmitted frames; **if omitted, each PGN keeps its standard priority** (see the PGN table above) |
|
|
135
|
+
| `--unique` | from hostname | device NAME unique number |
|
|
136
|
+
| `--live-data` | off | print the live channel table to the console once per second |
|
|
137
|
+
| `--log-level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
138
|
+
|
|
139
|
+
**CAN failure handling:** the device reconnects automatically. If `can0` isn't up at
|
|
140
|
+
start it waits (logging retries) rather than exiting; if the bus drops or goes bus-off
|
|
141
|
+
mid-run, sends fail quietly (logged at most every 5 s) and resume once it recovers — the
|
|
142
|
+
bridge keeps running. Use `--log-level DEBUG` to see connection/retry detail.
|
|
143
|
+
|
|
144
|
+
## Verify
|
|
145
|
+
|
|
146
|
+
Watch the raw frames on the board with `can-utils`
|
|
147
|
+
(`sudo apt install can-utils`):
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
candump -ta can0
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
You should see 29-bit frames appear as instruments update — heading, wind, depth,
|
|
154
|
+
speed, etc. — and stop when they go quiet. On a connected chartplotter / analyzer
|
|
155
|
+
the device appears in the device list after its ISO address claim (PGN 60928).
|
|
156
|
+
|
|
157
|
+
### Test the whole chain with a loopback (no instruments needed)
|
|
158
|
+
|
|
159
|
+
With `can0` and `can1` wired together (CAN-H↔CAN-H, CAN-L↔CAN-L, one 120 Ω
|
|
160
|
+
terminator), `can1` provides the ACK so `can0` can transmit:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
sudo ip link set can1 up type can bitrate 250000 restart-ms 100
|
|
164
|
+
candump -ta can1 # terminal 1
|
|
165
|
+
python -m fastnet2n2k --file capture.txt --channel can0 # terminal 2
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## Run at boot (systemd)
|
|
169
|
+
|
|
170
|
+
A unit file is provided in [`fastnet2n2k.service`](fastnet2n2k.service). Edit the
|
|
171
|
+
paths, user and `--serial` device to match your install, then:
|
|
172
|
+
|
|
173
|
+
Set `ExecStart` to match how you installed it:
|
|
174
|
+
|
|
175
|
+
- pipx: `ExecStart=/home/<user>/.local/bin/fastnet2n2k --serial /dev/ttySTM3 --channel can0`
|
|
176
|
+
- source venv: `ExecStart=/home/<user>/fastnet2n2k/.venv/bin/python -m fastnet2n2k --serial /dev/ttySTM3 --channel can0`
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
sudo cp fastnet2n2k.service /etc/systemd/system/
|
|
181
|
+
sudo systemctl daemon-reload
|
|
182
|
+
sudo systemctl enable --now fastnet2n2k.service
|
|
183
|
+
journalctl -u fastnet2n2k.service -f # watch logs
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The unit brings `can0` up itself (with `restart-ms 100`) before starting, and
|
|
187
|
+
restarts the bridge on failure.
|
|
188
|
+
|
|
189
|
+
## Tests
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
source .venv/bin/activate
|
|
193
|
+
pip install pytest
|
|
194
|
+
python -m pytest tests/ -q
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The suite drives the mapping with pyfastnet's bundled capture files and round-trips
|
|
198
|
+
the resulting NMEA2000 messages to assert PGNs, unit conversions, T/M references,
|
|
199
|
+
sign passthrough, the send throttle, and the full file→decode→send pipeline.
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## Sender POC (`nmea2000_poc.py`)
|
|
204
|
+
|
|
205
|
+
A standalone minimal proof-of-concept that transmits a single NMEA2000 PGN (127250
|
|
206
|
+
Vessel Heading) onto the bus — useful for smoke-testing a CAN link independently of
|
|
207
|
+
the Fastnet pipeline.
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
python nmea2000_poc.py --channel can0 --heading 90 --once # single frame
|
|
211
|
+
python nmea2000_poc.py --channel can0 --heading 90 # ~10 Hz loop
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Options: `--channel` (default `can0`), `--heading` degrees, `--ref true|magnetic`,
|
|
215
|
+
`--rate` Hz, `--once`.
|
|
216
|
+
|
|
217
|
+
### Desk testing without a bus (virtual CAN)
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
sudo modprobe vcan
|
|
221
|
+
sudo ip link add dev vcan0 type vcan
|
|
222
|
+
sudo ip link set up vcan0
|
|
223
|
+
candump vcan0 # terminal 1
|
|
224
|
+
python nmea2000_poc.py --channel vcan0 --heading 90 --once # terminal 2
|
|
225
|
+
```
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# fastnet2n2k — B&G Fastnet → NMEA2000 bridge
|
|
2
|
+
|
|
3
|
+
Reads a **B&G Fastnet** instrument stream (live serial or a captured hex file),
|
|
4
|
+
decodes it with [`pyfastnet`](https://github.com/ghotihook/pyfastnet), maps the
|
|
5
|
+
channels to **NMEA2000** PGNs and transmits them onto a CAN bus. Built for the
|
|
6
|
+
[M5Stack CoreMP135](https://docs.m5stack.com/en/core/M5CoreMP135) but runs on any
|
|
7
|
+
Linux box with a SocketCAN interface.
|
|
8
|
+
|
|
9
|
+
The CoreMP135 runs Linux on an STM32MP135 and exposes its two FDCAN interfaces
|
|
10
|
+
(SIT1051T transceivers) as the SocketCAN netdevs `can0` (FDCAN1, PE3/PE10) and
|
|
11
|
+
`can1` (FDCAN2, PG0/PE0). NMEA2000 runs at **250 kbit/s** with 29-bit extended IDs.
|
|
12
|
+
Message encoding, CAN-ID construction, fast-packet framing and ISO address claiming
|
|
13
|
+
are handled by the [`nmea2000`](https://github.com/tomer-w/nmea2000) library (canboat-
|
|
14
|
+
based) on top of `python-can`'s socketcan backend.
|
|
15
|
+
|
|
16
|
+
## What it sends
|
|
17
|
+
|
|
18
|
+
Each Fastnet channel is mapped to the matching PGN and emitted **only when the
|
|
19
|
+
channel updates** (with a 0.05 s minimum interval and a 5 s maximum re-broadcast),
|
|
20
|
+
so when the instruments go quiet the output stops and consumers time the data out.
|
|
21
|
+
|
|
22
|
+
| Data | PGN | Priority | Notes |
|
|
23
|
+
|---|---|---|---|
|
|
24
|
+
| Heading | 127250 | 2 | T/M reference taken from the Fastnet display layout (`°M`/`°T`) |
|
|
25
|
+
| Apparent / True wind, TWD | 130306 | 2 | knots→m/s, deg→rad; reference per layout |
|
|
26
|
+
| Boat speed | 128259 | 2 | knots→m/s |
|
|
27
|
+
| Depth | 128267 | 3 | metres |
|
|
28
|
+
| COG/SOG | 129026 | 2 | prefers True COG, falls back to Magnetic |
|
|
29
|
+
| Attitude (heel/trim) | 127257 | 3 | signed value passed through unchanged |
|
|
30
|
+
| Rudder, Leeway, Rate of turn | 127245 / 128000 / 127251 | 2 / 4 / 2 | |
|
|
31
|
+
| Distance log, XTE | 128275 / 129283 | 6 / 3 | NM→m |
|
|
32
|
+
| Position | 129025 | 2 | |
|
|
33
|
+
| Sea / air temperature | 130312 | 5 | °C/°F→Kelvin |
|
|
34
|
+
| Barometric pressure | 130314 | 5 | mbar→Pa |
|
|
35
|
+
| Tidal set & drift | 129291 | 3 | set deg→rad, drift kn→m/s; reference per layout |
|
|
36
|
+
|
|
37
|
+
The **Priority** column lists each PGN's NMEA2000 standard CAN priority (0 = highest,
|
|
38
|
+
7 = lowest). These are the values used **by default** — when `--n2k-priority` is *not*
|
|
39
|
+
given, every frame keeps the per-PGN priority shown above. Passing `--n2k-priority N`
|
|
40
|
+
overrides the whole column, forcing all frames to a single priority `N`.
|
|
41
|
+
|
|
42
|
+
**Units** are converted to NMEA2000 SI. **Sign** comes straight from pyfastnet's
|
|
43
|
+
decoded value. **True/Magnetic** is read from the pyfastnet `layout` field (the only
|
|
44
|
+
place it exists); a bearing whose layout can't be resolved is skipped, never guessed.
|
|
45
|
+
The B&G proprietary raw PGNs (65280–65282) are deferred (manufacturer-specific layout).
|
|
46
|
+
|
|
47
|
+
> **WiFi gateways:** if you feed a WiFi NMEA2000 gateway downstream, configure it for
|
|
48
|
+
> **unicast** UDP, not broadcast — WiFi broadcast is unacknowledged and silently drops
|
|
49
|
+
> frames even at low rates.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
Recommended — install the CLI in its own isolated environment with
|
|
54
|
+
[pipx](https://pipx.pypa.io/):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pipx install fastnet2n2k
|
|
58
|
+
fastnet2n2k --serial /dev/ttyUSB0 --channel can0
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
This puts a `fastnet2n2k` command on your PATH. `python -m fastnet2n2k ...`
|
|
62
|
+
also works once installed.
|
|
63
|
+
|
|
64
|
+
### From source (development)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/ghotihook/fastnet2n2k.git
|
|
68
|
+
cd fastnet2n2k
|
|
69
|
+
python3 -m venv .venv
|
|
70
|
+
source .venv/bin/activate
|
|
71
|
+
pip install -e .
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Bring up the CAN bus
|
|
75
|
+
|
|
76
|
+
Once per boot (250 kbit/s for NMEA2000). `restart-ms 100` makes the controller
|
|
77
|
+
auto-recover from a bus-off instead of staying down:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
sudo ip link set can0 up type can bitrate 250000 restart-ms 100 # FDCAN1
|
|
81
|
+
# sudo ip link set can1 up type can bitrate 250000 restart-ms 100 # FDCAN2
|
|
82
|
+
ip -details link show can0 # want: state ERROR-ACTIVE, bitrate 250000
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
If `can0` shows `BUS-OFF`, fix that before expecting output to land — a CAN frame
|
|
86
|
+
needs at least one other node on the wire to acknowledge it (check termination
|
|
87
|
+
≈60 Ω, common ground, and CAN-H/CAN-L not swapped).
|
|
88
|
+
|
|
89
|
+
## Run
|
|
90
|
+
|
|
91
|
+
Activate the venv first (`source .venv/bin/activate`), then:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# replay a captured Fastnet hex file (safest first test)
|
|
95
|
+
python -m fastnet2n2k --file capture.txt --channel can0
|
|
96
|
+
|
|
97
|
+
# live from the Fastnet bus
|
|
98
|
+
python -m fastnet2n2k --serial /dev/ttyUSB0 --channel can0
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Find your serial adapter with `ls /dev/ttyUSB* /dev/ttyACM* /dev/ttyS*`. Stop with
|
|
102
|
+
Ctrl-C.
|
|
103
|
+
|
|
104
|
+
Options:
|
|
105
|
+
|
|
106
|
+
| Option | Default | Meaning |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `--serial DEV` / `--file PATH` | — | input source (one is required) |
|
|
109
|
+
| `--channel` | `can0` | SocketCAN interface |
|
|
110
|
+
| `--n2k-priority` | per-PGN standard | override CAN priority (0–7, 0=highest) for **all** transmitted frames; **if omitted, each PGN keeps its standard priority** (see the PGN table above) |
|
|
111
|
+
| `--unique` | from hostname | device NAME unique number |
|
|
112
|
+
| `--live-data` | off | print the live channel table to the console once per second |
|
|
113
|
+
| `--log-level` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
|
|
114
|
+
|
|
115
|
+
**CAN failure handling:** the device reconnects automatically. If `can0` isn't up at
|
|
116
|
+
start it waits (logging retries) rather than exiting; if the bus drops or goes bus-off
|
|
117
|
+
mid-run, sends fail quietly (logged at most every 5 s) and resume once it recovers — the
|
|
118
|
+
bridge keeps running. Use `--log-level DEBUG` to see connection/retry detail.
|
|
119
|
+
|
|
120
|
+
## Verify
|
|
121
|
+
|
|
122
|
+
Watch the raw frames on the board with `can-utils`
|
|
123
|
+
(`sudo apt install can-utils`):
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
candump -ta can0
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
You should see 29-bit frames appear as instruments update — heading, wind, depth,
|
|
130
|
+
speed, etc. — and stop when they go quiet. On a connected chartplotter / analyzer
|
|
131
|
+
the device appears in the device list after its ISO address claim (PGN 60928).
|
|
132
|
+
|
|
133
|
+
### Test the whole chain with a loopback (no instruments needed)
|
|
134
|
+
|
|
135
|
+
With `can0` and `can1` wired together (CAN-H↔CAN-H, CAN-L↔CAN-L, one 120 Ω
|
|
136
|
+
terminator), `can1` provides the ACK so `can0` can transmit:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
sudo ip link set can1 up type can bitrate 250000 restart-ms 100
|
|
140
|
+
candump -ta can1 # terminal 1
|
|
141
|
+
python -m fastnet2n2k --file capture.txt --channel can0 # terminal 2
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Run at boot (systemd)
|
|
145
|
+
|
|
146
|
+
A unit file is provided in [`fastnet2n2k.service`](fastnet2n2k.service). Edit the
|
|
147
|
+
paths, user and `--serial` device to match your install, then:
|
|
148
|
+
|
|
149
|
+
Set `ExecStart` to match how you installed it:
|
|
150
|
+
|
|
151
|
+
- pipx: `ExecStart=/home/<user>/.local/bin/fastnet2n2k --serial /dev/ttySTM3 --channel can0`
|
|
152
|
+
- source venv: `ExecStart=/home/<user>/fastnet2n2k/.venv/bin/python -m fastnet2n2k --serial /dev/ttySTM3 --channel can0`
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
sudo cp fastnet2n2k.service /etc/systemd/system/
|
|
157
|
+
sudo systemctl daemon-reload
|
|
158
|
+
sudo systemctl enable --now fastnet2n2k.service
|
|
159
|
+
journalctl -u fastnet2n2k.service -f # watch logs
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The unit brings `can0` up itself (with `restart-ms 100`) before starting, and
|
|
163
|
+
restarts the bridge on failure.
|
|
164
|
+
|
|
165
|
+
## Tests
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
source .venv/bin/activate
|
|
169
|
+
pip install pytest
|
|
170
|
+
python -m pytest tests/ -q
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The suite drives the mapping with pyfastnet's bundled capture files and round-trips
|
|
174
|
+
the resulting NMEA2000 messages to assert PGNs, unit conversions, T/M references,
|
|
175
|
+
sign passthrough, the send throttle, and the full file→decode→send pipeline.
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Sender POC (`nmea2000_poc.py`)
|
|
180
|
+
|
|
181
|
+
A standalone minimal proof-of-concept that transmits a single NMEA2000 PGN (127250
|
|
182
|
+
Vessel Heading) onto the bus — useful for smoke-testing a CAN link independently of
|
|
183
|
+
the Fastnet pipeline.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
python nmea2000_poc.py --channel can0 --heading 90 --once # single frame
|
|
187
|
+
python nmea2000_poc.py --channel can0 --heading 90 # ~10 Hz loop
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Options: `--channel` (default `can0`), `--heading` degrees, `--ref true|magnetic`,
|
|
191
|
+
`--rate` Hz, `--once`.
|
|
192
|
+
|
|
193
|
+
### Desk testing without a bus (virtual CAN)
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
sudo modprobe vcan
|
|
197
|
+
sudo ip link add dev vcan0 type vcan
|
|
198
|
+
sudo ip link set up vcan0
|
|
199
|
+
candump vcan0 # terminal 1
|
|
200
|
+
python nmea2000_poc.py --channel vcan0 --heading 90 --once # terminal 2
|
|
201
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""fastnet2n2k — bridge a B&G Fastnet instrument stream onto an NMEA2000 CAN bus.
|
|
2
|
+
|
|
3
|
+
Reads Fastnet (serial or a captured hex file), decodes it with ``pyfastnet``, maps
|
|
4
|
+
the decoded channels to NMEA2000 PGNs and transmits them on a SocketCAN interface
|
|
5
|
+
via the ``nmea2000`` (tomer-w) library. Run with ``python -m fastnet2n2k ...``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.3"
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""fastnet2n2k entry point: Fastnet (serial/file) → decode → NMEA2000 on the CAN bus.
|
|
2
|
+
|
|
3
|
+
python -m fastnet2n2k --serial /dev/ttyUSB0 --channel can0
|
|
4
|
+
python -m fastnet2n2k --file capture.txt --channel can0
|
|
5
|
+
|
|
6
|
+
Bring the CAN interface up first:
|
|
7
|
+
sudo ip link set can0 up type can bitrate 250000
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import socket
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
import can
|
|
18
|
+
from fastnet_decoder import FrameBuffer
|
|
19
|
+
from nmea2000.device import N2KDevice
|
|
20
|
+
|
|
21
|
+
from . import __version__, mapping
|
|
22
|
+
from .display import print_live_data
|
|
23
|
+
from .input_source import initialize_input_source, read_input_source
|
|
24
|
+
from .live_store import live_data, update_live_data
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("fastnet2n2k")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fnv_unique() -> int:
|
|
30
|
+
"""A stable 21-bit unique number derived from the hostname, so two boards don't
|
|
31
|
+
default to the same NMEA2000 NAME."""
|
|
32
|
+
h = 2166136261
|
|
33
|
+
for b in socket.gethostname().encode():
|
|
34
|
+
h = ((h ^ b) * 16777619) & 0xFFFFFFFF
|
|
35
|
+
return h & 0x1FFFFF
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_args() -> argparse.Namespace:
|
|
39
|
+
p = argparse.ArgumentParser(description=__doc__,
|
|
40
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
41
|
+
src = p.add_mutually_exclusive_group(required=True)
|
|
42
|
+
src.add_argument("--serial", metavar="DEV", help="Fastnet serial port, e.g. /dev/ttyUSB0")
|
|
43
|
+
src.add_argument("--file", metavar="PATH", help="Captured Fastnet hex (.txt) to replay")
|
|
44
|
+
p.add_argument("--channel", default="can0", help="SocketCAN interface (default: can0)")
|
|
45
|
+
p.add_argument("--n2k-priority", type=lambda x: int(x, 0), default=None,
|
|
46
|
+
help="Override the CAN priority (0–7, 0=highest) for ALL transmitted "
|
|
47
|
+
"frames. Default: each PGN uses its NMEA2000 standard priority.")
|
|
48
|
+
p.add_argument("--unique", type=int, default=fnv_unique(),
|
|
49
|
+
help="Device NAME unique number (default: derived from hostname)")
|
|
50
|
+
p.add_argument("--live-data", action="store_true",
|
|
51
|
+
help="Print the live channel table to the console once per second")
|
|
52
|
+
p.add_argument("--log-level", default="INFO",
|
|
53
|
+
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
|
|
54
|
+
help="Logging verbosity (default: INFO)")
|
|
55
|
+
args = p.parse_args()
|
|
56
|
+
if args.n2k_priority is not None and not 0 <= args.n2k_priority <= 7:
|
|
57
|
+
p.error("--n2k-priority must be 0–7")
|
|
58
|
+
return args
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def make_device(args: argparse.Namespace) -> N2KDevice:
|
|
62
|
+
return N2KDevice.for_python_can(
|
|
63
|
+
"socketcan", args.channel,
|
|
64
|
+
unique_number=args.unique,
|
|
65
|
+
manufacturer_code=2046, # open-source / unregistered
|
|
66
|
+
device_function=190, # 190 = Navigation
|
|
67
|
+
device_class=60, # 60 = Navigation
|
|
68
|
+
industry_group=4, # 4 = Marine
|
|
69
|
+
model_id="fastnet2n2k",
|
|
70
|
+
model_version=__version__,
|
|
71
|
+
software_version_code=__version__,
|
|
72
|
+
transmit_pgns=mapping.TX_PGNS,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _dispatch_frame(frame: dict) -> None:
|
|
77
|
+
for channel_name, decoded in frame.get("values", {}).items():
|
|
78
|
+
old = live_data.get(channel_name)
|
|
79
|
+
old_copy = dict(old) if old else None
|
|
80
|
+
update_live_data(channel_name, decoded.get("channel_id"), decoded.get("value"),
|
|
81
|
+
decoded.get("display_text"), decoded.get("layout"))
|
|
82
|
+
await mapping.process_channel(channel_name, old_copy)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def run(args: argparse.Namespace) -> int:
|
|
86
|
+
try:
|
|
87
|
+
device = make_device(args)
|
|
88
|
+
except (OSError, can.CanError) as exc:
|
|
89
|
+
logger.error("Could not create CAN interface '%s': %s", args.channel, exc)
|
|
90
|
+
logger.error("Bring it up first: sudo ip link set %s up type can bitrate 250000",
|
|
91
|
+
args.channel)
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
# connect() retries with backoff until the interface is available, so this waits
|
|
95
|
+
# (rather than failing) if can0 isn't up yet — the right behaviour for a boat bus
|
|
96
|
+
# that may power-cycle. Watch the logs; raise --log-level if it seems stuck.
|
|
97
|
+
logger.info("Connecting to CAN interface %s (waiting if it isn't up yet)…",
|
|
98
|
+
args.channel)
|
|
99
|
+
await device.start()
|
|
100
|
+
|
|
101
|
+
mapping.set_device(device)
|
|
102
|
+
if args.n2k_priority is not None:
|
|
103
|
+
mapping.set_priority_override(args.n2k_priority)
|
|
104
|
+
logger.info("Overriding priority for all frames: %d", args.n2k_priority)
|
|
105
|
+
logger.info("Transmitting on %s (src=%d); reading Fastnet from %s",
|
|
106
|
+
args.channel, device.address, args.serial or args.file)
|
|
107
|
+
try:
|
|
108
|
+
await asyncio.wait_for(device.wait_ready(), timeout=10)
|
|
109
|
+
logger.info("Address claimed: %d", device.address)
|
|
110
|
+
except asyncio.TimeoutError:
|
|
111
|
+
logger.warning("Address claim not confirmed within 10s — continuing")
|
|
112
|
+
|
|
113
|
+
source, is_file = initialize_input_source(serial_port=args.serial, file_path=args.file)
|
|
114
|
+
fb = FrameBuffer()
|
|
115
|
+
last_print = time.monotonic()
|
|
116
|
+
try:
|
|
117
|
+
while True:
|
|
118
|
+
data = await asyncio.to_thread(read_input_source, source, is_file)
|
|
119
|
+
if data is not None:
|
|
120
|
+
fb.add_to_buffer(data)
|
|
121
|
+
fb.get_complete_frames()
|
|
122
|
+
while not fb.frame_queue.empty():
|
|
123
|
+
await _dispatch_frame(fb.frame_queue.get())
|
|
124
|
+
|
|
125
|
+
if args.live_data and time.monotonic() - last_print >= 1:
|
|
126
|
+
print_live_data(fb)
|
|
127
|
+
last_print = time.monotonic()
|
|
128
|
+
|
|
129
|
+
if is_file and data is None:
|
|
130
|
+
logger.info("File replay complete")
|
|
131
|
+
break
|
|
132
|
+
except KeyboardInterrupt:
|
|
133
|
+
logger.info("Stopping")
|
|
134
|
+
finally:
|
|
135
|
+
await device.close()
|
|
136
|
+
if not is_file:
|
|
137
|
+
source.close()
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> int:
|
|
142
|
+
args = parse_args()
|
|
143
|
+
level = getattr(logging, args.log_level)
|
|
144
|
+
logging.basicConfig(
|
|
145
|
+
level=level, format="%(asctime)s [%(name)s] %(levelname)-5s %(message)s")
|
|
146
|
+
# The nmea2000 client is chatty at DEBUG; keep it in step with our level.
|
|
147
|
+
logging.getLogger("nmea2000").setLevel(level)
|
|
148
|
+
try:
|
|
149
|
+
return asyncio.run(run(args))
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Optional live channel table for the console (--live-data).
|
|
2
|
+
|
|
3
|
+
Ported from fastnet2ip/core/display.py. Clears the screen and prints every channel
|
|
4
|
+
currently in the live store with its value, layout and age, so you can eyeball what
|
|
5
|
+
the decoder is producing while the bridge runs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from .live_store import live_data
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def print_live_data(fb):
|
|
14
|
+
print("\033c", end="") # clear screen
|
|
15
|
+
now = datetime.now(timezone.utc)
|
|
16
|
+
hdr = f"{'Channel':<35} {'ID':<10} {'Value':<20} {'Layout':<12} {'Age(s)':<10}"
|
|
17
|
+
print(hdr)
|
|
18
|
+
print("-" * len(hdr))
|
|
19
|
+
for name, data in sorted(live_data.items()):
|
|
20
|
+
ts = data.get("timestamp")
|
|
21
|
+
val = data.get("value")
|
|
22
|
+
display = str(val) if val is not None else data.get("display_text", "")
|
|
23
|
+
age = f"{(now - ts).total_seconds():.1f}" if ts else ""
|
|
24
|
+
print(
|
|
25
|
+
f"{str(name):<35} {str(data.get('channel_id', '')):<10} "
|
|
26
|
+
f"{display:<20} {str(data.get('layout', '')):<12} {age:<10}"
|
|
27
|
+
)
|
|
28
|
+
print(f"Buffer: {fb.get_buffer_size()}\n")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Fastnet input sources: a live serial port or a captured hex file.
|
|
2
|
+
|
|
3
|
+
Fastnet line settings are 28800 baud, 8 data bits, 2 stop bits, odd parity.
|
|
4
|
+
Ported from fastnet2ip/core/input.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import select
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import serial
|
|
12
|
+
|
|
13
|
+
BAUDRATE = 28800
|
|
14
|
+
BYTE_SIZE = serial.EIGHTBITS
|
|
15
|
+
STOP_BITS = serial.STOPBITS_TWO
|
|
16
|
+
PARITY = serial.PARITY_ODD
|
|
17
|
+
READ_SIZE = 256
|
|
18
|
+
FILE_READ_DELAY = 0.05
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("fastnet2n2k.input")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def initialize_input_source(serial_port=None, file_path=None):
|
|
24
|
+
"""Return ``(source, is_file)``. ``source`` is a ``serial.Serial`` for a live
|
|
25
|
+
port, or an iterator of byte chunks for a file."""
|
|
26
|
+
if serial_port:
|
|
27
|
+
logger.info("Serial port: %s", serial_port)
|
|
28
|
+
try:
|
|
29
|
+
return serial.Serial(
|
|
30
|
+
port=serial_port, baudrate=BAUDRATE, bytesize=BYTE_SIZE,
|
|
31
|
+
stopbits=STOP_BITS, parity=PARITY, timeout=0,
|
|
32
|
+
), False
|
|
33
|
+
except (serial.SerialException, OSError) as e:
|
|
34
|
+
logger.error("Cannot open %s: %s", serial_port, e)
|
|
35
|
+
raise SystemExit(1)
|
|
36
|
+
elif file_path:
|
|
37
|
+
logger.info("File: %s", file_path)
|
|
38
|
+
try:
|
|
39
|
+
with open(file_path) as f:
|
|
40
|
+
hex_data = f.read().strip().replace(" ", "").replace("\n", "")
|
|
41
|
+
if not hex_data:
|
|
42
|
+
raise ValueError("File is empty")
|
|
43
|
+
binary = bytes.fromhex(hex_data)
|
|
44
|
+
except (OSError, ValueError) as e:
|
|
45
|
+
logger.error("File error: %s", e)
|
|
46
|
+
raise SystemExit(1)
|
|
47
|
+
chunks = [binary[i:i + READ_SIZE] for i in range(0, len(binary), READ_SIZE)]
|
|
48
|
+
return iter(chunks), True
|
|
49
|
+
else:
|
|
50
|
+
logger.error("Specify a serial port or a file")
|
|
51
|
+
raise SystemExit(1)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_input_source(input_source, is_file):
|
|
55
|
+
"""Return the next chunk of bytes, or ``None`` (file exhausted / no data yet)."""
|
|
56
|
+
if is_file:
|
|
57
|
+
try:
|
|
58
|
+
time.sleep(FILE_READ_DELAY)
|
|
59
|
+
return next(input_source)
|
|
60
|
+
except StopIteration:
|
|
61
|
+
return None
|
|
62
|
+
rlist, _, _ = select.select([input_source], [], [], 1)
|
|
63
|
+
if input_source in rlist:
|
|
64
|
+
return input_source.read(READ_SIZE)
|
|
65
|
+
return None
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Latest-value store for decoded Fastnet channels.
|
|
2
|
+
|
|
3
|
+
Single-threaded: written and read from the main decode loop only. Each entry keeps
|
|
4
|
+
the decoded ``value``, the ``display_text`` and ``layout`` from pyfastnet (the layout
|
|
5
|
+
carries the T/M reference), and a ``timestamp`` so freshness can be reasoned about.
|
|
6
|
+
|
|
7
|
+
Ported from fastnet2ip/core/data_store.py.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
live_data: dict = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def update_live_data(channel_name, channel_id, value, display_text, layout):
|
|
16
|
+
live_data[channel_name] = {
|
|
17
|
+
"channel_id": channel_id,
|
|
18
|
+
"value": value,
|
|
19
|
+
"display_text": display_text,
|
|
20
|
+
"layout": layout,
|
|
21
|
+
"timestamp": datetime.now(timezone.utc),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_live_data(name):
|
|
26
|
+
entry = live_data.get(name)
|
|
27
|
+
return entry.get("value") if entry else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_live_display(name):
|
|
31
|
+
entry = live_data.get(name)
|
|
32
|
+
return entry.get("display_text") if entry else None
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Map decoded Fastnet channels to NMEA2000 messages and send them on the CAN bus.
|
|
2
|
+
|
|
3
|
+
Uses the ``nmea2000`` library (tomer-w), which is canboat-based: messages are built
|
|
4
|
+
by taking a blank PGN template from :mod:`nmea2000.pgns`, setting the fields we care
|
|
5
|
+
about (by id, in SI units), and handing the resulting ``NMEA2000Message`` to an
|
|
6
|
+
``N2KDevice`` that transmits it over SocketCAN and manages ISO address claiming.
|
|
7
|
+
|
|
8
|
+
Conventions:
|
|
9
|
+
- **Units** → NMEA2000 SI: knots→m/s, degrees→radians, °C/°F→Kelvin, NM→m, mbar→Pa.
|
|
10
|
+
- **Sign** is taken directly from pyfastnet's decoded ``value``.
|
|
11
|
+
- **T/M reference** is taken from the pyfastnet ``layout`` field (the only place it
|
|
12
|
+
exists) and mapped to the canboat lookup string. If a bearing channel's layout
|
|
13
|
+
can't be resolved the frame is skipped and logged — never guessed.
|
|
14
|
+
- **Staleness**: a PGN is sent only when its channel updates (event-driven), with a
|
|
15
|
+
minimum interval and a maximum re-broadcast age; when the source stops, output
|
|
16
|
+
stops and consumers time the PGN out themselves.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import time
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from math import radians
|
|
23
|
+
|
|
24
|
+
import nmea2000.pgns as pgns
|
|
25
|
+
|
|
26
|
+
from .live_store import get_live_data, get_live_display, live_data
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("fastnet2n2k.mapping")
|
|
29
|
+
|
|
30
|
+
MIN_SEND_INTERVAL = 0.05
|
|
31
|
+
REBROADCAST_AGE = 5.0
|
|
32
|
+
KN_MS = 0.514444
|
|
33
|
+
|
|
34
|
+
_channel_last_sent: dict = {}
|
|
35
|
+
_device = None
|
|
36
|
+
_priority_override = None
|
|
37
|
+
_last_send_error_log = 0.0
|
|
38
|
+
_SEND_ERROR_LOG_INTERVAL = 5.0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_device(device) -> None:
|
|
42
|
+
"""Register the N2KDevice that frames are transmitted through."""
|
|
43
|
+
global _device
|
|
44
|
+
_device = device
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def set_priority_override(priority) -> None:
|
|
48
|
+
"""Force every transmitted frame to ``priority`` (0–7), ignoring the per-PGN
|
|
49
|
+
standard priorities. ``None`` restores the standard per-PGN behaviour."""
|
|
50
|
+
global _priority_override
|
|
51
|
+
_priority_override = priority
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _c_to_k(c):
|
|
55
|
+
return c + 273.15
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _f_to_k(f):
|
|
59
|
+
return (f - 32) * 5 / 9 + 273.15
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build(pgn, priority, **fields):
|
|
63
|
+
"""Build an NMEA2000Message for ``pgn`` with the given field id → SI value pairs.
|
|
64
|
+
|
|
65
|
+
``None`` values are written through as "data not available". Source is left 0 so
|
|
66
|
+
the N2KDevice substitutes its claimed address.
|
|
67
|
+
"""
|
|
68
|
+
msg = getattr(pgns, f"decode_pgn_{pgn}")(0, 0)
|
|
69
|
+
msg.source = 0
|
|
70
|
+
msg.priority = _priority_override if _priority_override is not None else priority
|
|
71
|
+
msg.timestamp = datetime.now(timezone.utc)
|
|
72
|
+
for f in msg.fields:
|
|
73
|
+
if f.id in fields:
|
|
74
|
+
f.raw_value = None
|
|
75
|
+
f.value = fields[f.id]
|
|
76
|
+
return msg
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ── Layout → canboat reference string (the only source of T/M) ────────────────
|
|
80
|
+
_LAYOUT_BEARING_REF = {"°M": "Magnetic", "°T": "True"}
|
|
81
|
+
_LAYOUT_WIND_REF = {
|
|
82
|
+
"°M": "Magnetic (ground referenced to Magnetic North)",
|
|
83
|
+
"°T": "True (ground referenced to North)",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _bearing_ref(name):
|
|
88
|
+
entry = live_data.get(name)
|
|
89
|
+
if entry is None:
|
|
90
|
+
return None
|
|
91
|
+
ref = _LAYOUT_BEARING_REF.get(entry["layout"])
|
|
92
|
+
if ref is None:
|
|
93
|
+
logger.error("%s: unrecognised layout %r — skipping frame", name, entry["layout"])
|
|
94
|
+
return ref
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── Triggers: each returns one NMEA2000Message, or None ───────────────────────
|
|
98
|
+
|
|
99
|
+
def _wind(angle_ch, speed_ch, reference):
|
|
100
|
+
angle = get_live_data(angle_ch)
|
|
101
|
+
speed = get_live_data(speed_ch)
|
|
102
|
+
if angle is None and speed is None:
|
|
103
|
+
return None
|
|
104
|
+
if angle is not None and angle < 0: # N2K wind angle is 0..2π
|
|
105
|
+
angle += 360
|
|
106
|
+
return _build(130306, 2,
|
|
107
|
+
windSpeed=speed * KN_MS if speed is not None else None,
|
|
108
|
+
windAngle=radians(angle) if angle is not None else None,
|
|
109
|
+
reference=reference)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def process_apparent_wind():
|
|
113
|
+
return _wind("Apparent Wind Angle", "Apparent Wind Speed (Knots)", "Apparent")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def process_true_wind():
|
|
117
|
+
return _wind("True Wind Angle", "True Wind Speed (Knots)", "True (boat referenced)")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def process_twd():
|
|
121
|
+
entry = live_data.get("True Wind Direction")
|
|
122
|
+
if entry is None:
|
|
123
|
+
return None
|
|
124
|
+
ref = _LAYOUT_WIND_REF.get(entry["layout"])
|
|
125
|
+
if ref is None:
|
|
126
|
+
logger.error("True Wind Direction: unrecognised layout %r — skipping frame",
|
|
127
|
+
entry["layout"])
|
|
128
|
+
return None
|
|
129
|
+
return _wind("True Wind Direction", "True Wind Speed (Knots)", ref)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def process_heading():
|
|
133
|
+
hdg = get_live_data("Heading")
|
|
134
|
+
if hdg is None:
|
|
135
|
+
return None
|
|
136
|
+
ref = _bearing_ref("Heading")
|
|
137
|
+
if ref is None:
|
|
138
|
+
return None
|
|
139
|
+
return _build(127250, 2, heading=radians(hdg), reference=ref,
|
|
140
|
+
deviation=None, variation=None)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def process_boatspeed():
|
|
144
|
+
bs = get_live_data("Boatspeed (Knots)")
|
|
145
|
+
if bs is None:
|
|
146
|
+
return None
|
|
147
|
+
return _build(128259, 2, speedWaterReferenced=bs * KN_MS,
|
|
148
|
+
speedGroundReferenced=None, speedDirection=None)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def process_depth():
|
|
152
|
+
dm = get_live_data("Depth (Meters)")
|
|
153
|
+
if dm is None:
|
|
154
|
+
return None
|
|
155
|
+
return _build(128267, 3, depth=dm, offset=None, range=None)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def process_rudder():
|
|
159
|
+
ra = get_live_data("Rudder Angle")
|
|
160
|
+
if ra is None:
|
|
161
|
+
return None
|
|
162
|
+
return _build(127245, 2, instance=0, position=radians(ra), angleOrder=None)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def process_leeway():
|
|
166
|
+
lw = get_live_data("Leeway")
|
|
167
|
+
if lw is None:
|
|
168
|
+
return None
|
|
169
|
+
return _build(128000, 4, leewayAngle=radians(lw))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def process_cog_sog():
|
|
173
|
+
cog_true = get_live_data("Course Over Ground (True)")
|
|
174
|
+
cog_mag = get_live_data("Course Over Ground (Mag)")
|
|
175
|
+
sog = get_live_data("Speed Over Ground")
|
|
176
|
+
if sog is None:
|
|
177
|
+
return None
|
|
178
|
+
sog_ms = sog * KN_MS
|
|
179
|
+
if cog_true is not None:
|
|
180
|
+
return _build(129026, 2, cogReference="True",
|
|
181
|
+
cog=radians(cog_true % 360), sog=sog_ms)
|
|
182
|
+
if cog_mag is not None:
|
|
183
|
+
return _build(129026, 2, cogReference="Magnetic",
|
|
184
|
+
cog=radians(cog_mag % 360), sog=sog_ms)
|
|
185
|
+
return _build(129026, 2, cog=None, sog=sog_ms)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def process_battery():
|
|
189
|
+
v = get_live_data("Battery Volts")
|
|
190
|
+
if v is None:
|
|
191
|
+
return None
|
|
192
|
+
return _build(127508, 6, instance=0, voltage=v, current=None, temperature=None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def process_attitude():
|
|
196
|
+
roll = get_live_data("Heel Angle")
|
|
197
|
+
pitch = get_live_data("Fore/Aft Trim")
|
|
198
|
+
if roll is None and pitch is None:
|
|
199
|
+
return None
|
|
200
|
+
return _build(127257, 3, yaw=None,
|
|
201
|
+
pitch=radians(pitch) if pitch is not None else None,
|
|
202
|
+
roll=radians(roll) if roll is not None else None)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def process_pressure():
|
|
206
|
+
bp = get_live_data("Barometric Pressure") # mbar / hPa
|
|
207
|
+
if bp is None:
|
|
208
|
+
return None
|
|
209
|
+
return _build(130314, 5, instance=0, source="Atmospheric", pressure=bp * 100)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _temperature(channel_c, channel_f, source):
|
|
213
|
+
c = get_live_data(channel_c)
|
|
214
|
+
if c is not None:
|
|
215
|
+
k = _c_to_k(c)
|
|
216
|
+
else:
|
|
217
|
+
f = get_live_data(channel_f)
|
|
218
|
+
if f is None:
|
|
219
|
+
return None
|
|
220
|
+
k = _f_to_k(f)
|
|
221
|
+
return _build(130312, 5, instance=0, source=source,
|
|
222
|
+
actualTemperature=k, setTemperature=None)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def process_sea_temp():
|
|
226
|
+
return _temperature("Sea Temperature (°C)", "Sea Temperature (°F)", "Sea Temperature")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def process_air_temp():
|
|
230
|
+
return _temperature("Air Temperature (°C)", "Air Temperature (°F)", "Outside Temperature")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def process_distance_log():
|
|
234
|
+
stored = get_live_data("Stored Log (NM)")
|
|
235
|
+
trip = get_live_data("Trip Log (NM)")
|
|
236
|
+
if stored is None and trip is None:
|
|
237
|
+
return None
|
|
238
|
+
now = datetime.now(timezone.utc)
|
|
239
|
+
return _build(128275, 6, date=now.date(), time=now.time(),
|
|
240
|
+
log=int(stored * 1852) if stored is not None else None,
|
|
241
|
+
tripLog=int(trip * 1852) if trip is not None else None)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def process_xte():
|
|
245
|
+
xte = get_live_data("Cross Track Error")
|
|
246
|
+
if xte is None:
|
|
247
|
+
return None
|
|
248
|
+
return _build(129283, 3, xteMode="Autonomous", navigationTerminated="No",
|
|
249
|
+
xte=xte * 1852)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def process_rate_of_turn():
|
|
253
|
+
yr = get_live_data("Yaw rate")
|
|
254
|
+
if yr is None:
|
|
255
|
+
return None
|
|
256
|
+
return _build(127251, 2, rate=radians(yr))
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def process_set_drift():
|
|
260
|
+
set_deg = get_live_data("Tidal Set") # degrees
|
|
261
|
+
drift = get_live_data("Tidal Drift") # knots
|
|
262
|
+
if set_deg is None and drift is None:
|
|
263
|
+
return None
|
|
264
|
+
ref = _bearing_ref("Tidal Set")
|
|
265
|
+
if ref is None:
|
|
266
|
+
return None
|
|
267
|
+
return _build(129291, 3, setReference=ref,
|
|
268
|
+
set=radians(set_deg % 360) if set_deg is not None else None,
|
|
269
|
+
drift=max(0.0, drift) * KN_MS if drift is not None else None)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def process_position():
|
|
273
|
+
latlon = get_live_display("LatLon") # e.g. "3352.450S15113.920E"
|
|
274
|
+
if not latlon:
|
|
275
|
+
return None
|
|
276
|
+
lat_idx = latlon.find('N') if 'N' in latlon else latlon.find('S')
|
|
277
|
+
lon_idx = latlon.find('E') if 'E' in latlon else latlon.find('W')
|
|
278
|
+
if lat_idx == -1 or lon_idx == -1:
|
|
279
|
+
return None
|
|
280
|
+
try:
|
|
281
|
+
lat_part, lat_dir = latlon[:lat_idx], latlon[lat_idx]
|
|
282
|
+
lon_part, lon_dir = latlon[lat_idx + 1:lon_idx], latlon[lon_idx]
|
|
283
|
+
lat = int(lat_part[:2]) + float(lat_part[2:]) / 60
|
|
284
|
+
lon = int(lon_part[:3]) + float(lon_part[3:]) / 60
|
|
285
|
+
except (ValueError, IndexError):
|
|
286
|
+
logger.debug("position: could not parse %r", latlon)
|
|
287
|
+
return None
|
|
288
|
+
if lat_dir == 'S':
|
|
289
|
+
lat = -lat
|
|
290
|
+
if lon_dir == 'W':
|
|
291
|
+
lon = -lon
|
|
292
|
+
return _build(129025, 2, latitude=lat, longitude=lon)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ── Channel → trigger map ─────────────────────────────────────────────────────
|
|
296
|
+
_CHANNEL_MAP = {
|
|
297
|
+
"Heading": process_heading,
|
|
298
|
+
"Rudder Angle": process_rudder,
|
|
299
|
+
"Boatspeed (Knots)": process_boatspeed,
|
|
300
|
+
"Depth (Meters)": process_depth,
|
|
301
|
+
"Depth (Feet)": "duplicate of Depth (Meters)",
|
|
302
|
+
"Depth (Fathoms)": "duplicate of Depth (Meters)",
|
|
303
|
+
"Apparent Wind Angle": process_apparent_wind,
|
|
304
|
+
"Apparent Wind Speed (Knots)": "covered by 'Apparent Wind Angle' (same frame)",
|
|
305
|
+
"True Wind Angle": process_true_wind,
|
|
306
|
+
"True Wind Direction": process_twd,
|
|
307
|
+
"True Wind Speed (Knots)": "covered by True Wind Angle/Direction (same frame)",
|
|
308
|
+
"True Wind Speed (m/s)": "covered by True Wind Angle/Direction (same frame)",
|
|
309
|
+
"Leeway": process_leeway,
|
|
310
|
+
"Speed Over Ground": process_cog_sog,
|
|
311
|
+
"Course Over Ground (True)": "covered by 'Speed Over Ground' (same frame)",
|
|
312
|
+
"Course Over Ground (Mag)": "covered by 'Speed Over Ground' (same frame)",
|
|
313
|
+
"Battery Volts": process_battery,
|
|
314
|
+
"Heel Angle": process_attitude,
|
|
315
|
+
"Fore/Aft Trim": "covered by 'Heel Angle' (same frame)",
|
|
316
|
+
"Stored Log (NM)": process_distance_log,
|
|
317
|
+
"Trip Log (NM)": "covered by 'Stored Log (NM)' (same frame)",
|
|
318
|
+
"Sea Temperature (°C)": process_sea_temp,
|
|
319
|
+
"Sea Temperature (°F)": process_sea_temp,
|
|
320
|
+
"Air Temperature (°C)": process_air_temp,
|
|
321
|
+
"Air Temperature (°F)": process_air_temp,
|
|
322
|
+
"LatLon": process_position,
|
|
323
|
+
"Barometric Pressure": process_pressure,
|
|
324
|
+
"Yaw rate": process_rate_of_turn,
|
|
325
|
+
"Cross Track Error": process_xte,
|
|
326
|
+
"Tidal Set": process_set_drift,
|
|
327
|
+
"Tidal Drift": "covered by 'Tidal Set' (same frame)",
|
|
328
|
+
# Deferred: B&G proprietary raw PGNs (65280-65282) — manufacturer-specific.
|
|
329
|
+
"Boatspeed (Raw)": "TODO: proprietary PGN 65282 (deferred)",
|
|
330
|
+
"Heading (Raw)": "TODO: proprietary PGN 65281 (deferred)",
|
|
331
|
+
"Apparent Wind Speed (Raw)": "TODO: proprietary PGN 65280 (deferred)",
|
|
332
|
+
"Apparent Wind Angle (Raw)": "TODO: proprietary PGN 65280 (deferred)",
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
# PGNs this node transmits — advertised to the bus by the N2KDevice.
|
|
336
|
+
TX_PGNS = [127245, 127250, 127251, 127257, 127508, 128000, 128259, 128267,
|
|
337
|
+
128275, 129025, 129026, 129283, 129291, 130306, 130312, 130314]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def trigger_n2k_frame(channel_name):
|
|
341
|
+
entry = _CHANNEL_MAP.get(channel_name)
|
|
342
|
+
if callable(entry):
|
|
343
|
+
return entry()
|
|
344
|
+
if isinstance(entry, str):
|
|
345
|
+
logger.debug("No trigger for %r — %s", channel_name, entry)
|
|
346
|
+
else:
|
|
347
|
+
logger.debug("No trigger for %r", channel_name)
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
async def process_channel(channel_name, old_entry):
|
|
352
|
+
"""Apply the throttle policy, then build and transmit the channel's frame."""
|
|
353
|
+
now = time.monotonic()
|
|
354
|
+
current = live_data.get(channel_name)
|
|
355
|
+
new_key = (current["value"], current["display_text"]) if current else (None, None)
|
|
356
|
+
old_key = (old_entry["value"], old_entry["display_text"]) if old_entry else (None, None)
|
|
357
|
+
|
|
358
|
+
last_sent = _channel_last_sent.get(channel_name)
|
|
359
|
+
if last_sent is not None:
|
|
360
|
+
if (now - last_sent) < MIN_SEND_INTERVAL:
|
|
361
|
+
return
|
|
362
|
+
if new_key == old_key and (now - last_sent) < REBROADCAST_AGE:
|
|
363
|
+
return
|
|
364
|
+
|
|
365
|
+
msg = trigger_n2k_frame(channel_name)
|
|
366
|
+
if msg is None:
|
|
367
|
+
return
|
|
368
|
+
if _device is None or not _device.ready:
|
|
369
|
+
return # not connected / address not claimed — retry on the next update
|
|
370
|
+
_channel_last_sent[channel_name] = now
|
|
371
|
+
try:
|
|
372
|
+
await _device.send(msg)
|
|
373
|
+
except Exception as exc: # noqa: BLE001 — bus-off / interface drop / reconnect
|
|
374
|
+
# The N2KDevice client reconnects underneath; don't let a transient CAN
|
|
375
|
+
# failure tear down the bridge. Log at most once per interval to avoid
|
|
376
|
+
# flooding while the bus is down.
|
|
377
|
+
global _last_send_error_log
|
|
378
|
+
if now - _last_send_error_log >= _SEND_ERROR_LOG_INTERVAL:
|
|
379
|
+
logger.warning("CAN send failed (%s) — continuing; the device will reconnect",
|
|
380
|
+
exc)
|
|
381
|
+
_last_send_error_log = now
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# systemd unit for the fastnet2n2k bridge.
|
|
2
|
+
#
|
|
3
|
+
# Edit User=, the paths, and the --serial device to match your install, then:
|
|
4
|
+
# sudo cp fastnet2n2k.service /etc/systemd/system/
|
|
5
|
+
# sudo systemctl daemon-reload
|
|
6
|
+
# sudo systemctl enable --now fastnet2n2k.service
|
|
7
|
+
# journalctl -u fastnet2n2k.service -f
|
|
8
|
+
#
|
|
9
|
+
# The service runs as an unprivileged user; the ExecStartPre line uses systemd's
|
|
10
|
+
# "+" prefix so only the CAN bring-up runs as root. Make sure that user is in the
|
|
11
|
+
# "dialout" group so it can read the Fastnet serial device:
|
|
12
|
+
# sudo usermod -aG dialout <user>
|
|
13
|
+
|
|
14
|
+
[Unit]
|
|
15
|
+
Description=Fastnet to NMEA2000 bridge
|
|
16
|
+
After=network.target
|
|
17
|
+
|
|
18
|
+
[Service]
|
|
19
|
+
Type=simple
|
|
20
|
+
User=alex060
|
|
21
|
+
WorkingDirectory=/home/alex060/fastnet2n2k
|
|
22
|
+
|
|
23
|
+
# Bring can0 up at the NMEA2000 bitrate before starting (run as root via "+").
|
|
24
|
+
# restart-ms 100 lets the controller auto-recover from a bus-off.
|
|
25
|
+
ExecStartPre=+/bin/sh -c 'ip link set can0 down 2>/dev/null; ip link set can0 up type can bitrate 250000 restart-ms 100'
|
|
26
|
+
|
|
27
|
+
ExecStart=/home/alex060/fastnet2n2k/.venv/bin/python -m fastnet2n2k --serial /dev/ttySTM3 --channel can0
|
|
28
|
+
|
|
29
|
+
Restart=on-failure
|
|
30
|
+
RestartSec=5
|
|
31
|
+
|
|
32
|
+
[Install]
|
|
33
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fastnet2n2k"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Bridge a B&G Fastnet instrument stream onto an NMEA2000 CAN bus"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "ghotihook" }]
|
|
14
|
+
keywords = ["nmea2000", "fastnet", "n2k", "canbus", "marine", "b&g", "socketcan"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: POSIX :: Linux",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Communications",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"nmea2000>=2026.5",
|
|
25
|
+
"python-can>=4.5",
|
|
26
|
+
"pyfastnet>=2.0.17",
|
|
27
|
+
"pyserial>=3.5",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
fastnet2n2k = "fastnet2n2k.__main__:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/ghotihook/fastnet2n2k"
|
|
35
|
+
Repository = "https://github.com/ghotihook/fastnet2n2k"
|
|
36
|
+
Issues = "https://github.com/ghotihook/fastnet2n2k/issues"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.version]
|
|
39
|
+
path = "fastnet2n2k/__init__.py"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["fastnet2n2k"]
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.sdist]
|
|
45
|
+
include = ["fastnet2n2k", "README.md", "LICENSE", "fastnet2n2k.service"]
|