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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
@@ -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"]