pyfastnet 2.0.17__tar.gz → 2.0.18__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.
- {pyfastnet-2.0.17/pyfastnet.egg-info → pyfastnet-2.0.18}/PKG-INFO +50 -22
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/README.md +49 -21
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/fastnet_decoder/__init__.py +3 -3
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/fastnet_decoder/decode_fastnet.py +53 -3
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/fastnet_decoder/frame_buffer.py +8 -5
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/fastnet_decoder/mappings.py +10 -4
- {pyfastnet-2.0.17 → pyfastnet-2.0.18/pyfastnet.egg-info}/PKG-INFO +50 -22
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/pyfastnet.egg-info/SOURCES.txt +1 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_autopilot.py +33 -0
- pyfastnet-2.0.18/tests/test_backlight.py +42 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/LICENSE +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/fastnet_decoder/logger.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/fastnet_decoder/utils.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/pyfastnet.egg-info/dependency_links.txt +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/pyfastnet.egg-info/requires.txt +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/pyfastnet.egg-info/top_level.txt +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/pyproject.toml +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/setup.cfg +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_awa.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_aws.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_battery.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_boatspeed.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_depth_frame.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_format07_msb_regression.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_format08_layout.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_heading.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_heel_trim.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_latlon_frame.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_sea_temp.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_sog.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_tidal.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_timer.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_twa.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_twd.py +0 -0
- {pyfastnet-2.0.17 → pyfastnet-2.0.18}/tests/test_tws.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyfastnet
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.18
|
|
4
4
|
Summary: A Python library for decoding FastNet protocol data streams.
|
|
5
5
|
Author-email: Alex Salmon <alex@ivila.net>
|
|
6
6
|
License: MIT License
|
|
@@ -52,20 +52,20 @@ Dynamic: license-file
|
|
|
52
52
|
|
|
53
53
|
# pyfastnet
|
|
54
54
|
|
|
55
|
-
Python library for decoding the FastNet protocol used by B&G Hydra/H2000
|
|
55
|
+
A Python library for decoding the **FastNet** protocol used by B&G Hydra / H2000
|
|
56
|
+
instruments. Feed it raw bytes from the Fastnet bus and it handles
|
|
57
|
+
synchronisation, checksum validation, and decoding — returning structured
|
|
58
|
+
instrument data ready for further processing.
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
Developed for personal use and published for general interest. Runs on Raspberry
|
|
61
|
+
Pi, macOS, or Linux.
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
## Quick start
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```
|
|
65
|
+
```bash
|
|
64
66
|
pip install pyfastnet
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
## Example usage
|
|
68
|
-
|
|
69
69
|
```python
|
|
70
70
|
#!/usr/bin/env python3
|
|
71
71
|
import serial
|
|
@@ -97,9 +97,36 @@ finally:
|
|
|
97
97
|
ser.close()
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
Serial settings for a B&G Fastnet bus are **28,800 baud, 8 data bits, odd parity,
|
|
101
|
+
2 stop bits**. No instruments to hand? The companion apps below ship recorded
|
|
102
|
+
captures you can replay.
|
|
103
|
+
|
|
104
|
+
## The Fastnet toolkit
|
|
105
|
+
|
|
106
|
+
Three projects stack together — pick the layer that matches where you want the
|
|
107
|
+
data to end up:
|
|
108
|
+
|
|
109
|
+
| Project | What it does | Use it when |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| **pyfastnet** *(this library)* | **Decoder.** Turns raw Fastnet bytes into named instrument channels. | You're writing your own Python and want the decoded data. |
|
|
112
|
+
| [fastnet2ip](https://github.com/ghotihook/fastnet2ip) | **Serial → network.** Broadcasts decoded data over UDP as NMEA 0183 or NMEA 2000 (over IP). | Feeding Signal K, OpenCPN, or a plotter over WiFi / Ethernet. |
|
|
113
|
+
| [fastnet2n2k](https://github.com/ghotihook/fastnet2n2k) | **Serial → physical NMEA 2000 bus.** Transmits PGNs onto a CAN backbone via SocketCAN. | Wiring into a real NMEA 2000 network / chartplotter. |
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
┌─ fastnet2ip → UDP (NMEA 0183 / NMEA 2000 over IP) → Signal K, OpenCPN, plotters
|
|
117
|
+
B&G Fastnet bus ─(serial)─→ pyfastnet ─┤
|
|
118
|
+
└─ fastnet2n2k → SocketCAN (NMEA 2000 PGNs) → CAN backbone, chartplotter
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
pyfastnet is the **engine** at the bottom of the stack. If you only want the
|
|
122
|
+
decoded data on your network or NMEA 2000 bus — including running it as an
|
|
123
|
+
always-on systemd service — use one of the companion apps; they handle the serial
|
|
124
|
+
port, a live data store, rate limiting, and output for you.
|
|
125
|
+
|
|
100
126
|
## Output format
|
|
101
127
|
|
|
102
|
-
Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
|
|
128
|
+
Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
|
|
129
|
+
`values`. Each entry in `values` is keyed by channel name:
|
|
103
130
|
|
|
104
131
|
```python
|
|
105
132
|
{
|
|
@@ -131,10 +158,10 @@ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and `
|
|
|
131
158
|
|
|
132
159
|
### Position (LatLon) frames
|
|
133
160
|
|
|
134
|
-
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw
|
|
135
|
-
string (`DDMM.mmm` with hemisphere letters) is carried in
|
|
136
|
-
`None`. The originating source's marker byte is
|
|
137
|
-
`0x4E`) but does not affect the key:
|
|
161
|
+
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw
|
|
162
|
+
coordinate string (`DDMM.mmm` with hemisphere letters) is carried in
|
|
163
|
+
`display_text`; `value` is `None`. The originating source's marker byte is
|
|
164
|
+
preserved in `channel_id` (e.g. `0x47`, `0x4E`) but does not affect the key:
|
|
138
165
|
|
|
139
166
|
```python
|
|
140
167
|
"LatLon": {
|
|
@@ -147,7 +174,10 @@ string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value
|
|
|
147
174
|
|
|
148
175
|
### Layout field
|
|
149
176
|
|
|
150
|
-
The `layout` field describes the indicator symbol shown on the physical display
|
|
177
|
+
The `layout` field describes the indicator symbol shown on the physical display
|
|
178
|
+
around the numeric value. It is the **only** place True/Magnetic, port/starboard
|
|
179
|
+
sign, and similar context is carried — the raw stream contains no magnetic
|
|
180
|
+
variation or deviation.
|
|
151
181
|
|
|
152
182
|
| `layout` | Meaning | Sign |
|
|
153
183
|
|---|---|---|
|
|
@@ -167,7 +197,7 @@ The `layout` field describes the indicator symbol shown on the physical display
|
|
|
167
197
|
| `"[data]z"` / `"z[data]"` | Dog-leg symbol — AP off course | positive |
|
|
168
198
|
| `"TBC"` | Symbol seen but not yet identified | positive |
|
|
169
199
|
|
|
170
|
-
|
|
200
|
+
### Debug API
|
|
171
201
|
|
|
172
202
|
```python
|
|
173
203
|
from fastnet_decoder import set_log_level
|
|
@@ -180,14 +210,12 @@ fb.get_buffer_size() # bytes currently in buffer
|
|
|
180
210
|
fb.get_buffer_contents() # hex string of buffer contents
|
|
181
211
|
```
|
|
182
212
|
|
|
183
|
-
## Companion apps
|
|
184
|
-
|
|
185
|
-
- [fastnet2ip](https://github.com/ghotihook/fastnet2ip) — reads FastNet from serial and broadcasts it over UDP as either NMEA 0183 or NMEA 2000 (selected with `--output`). Install with `pipx install fastnet2ip`.
|
|
186
|
-
|
|
187
|
-
Runs on Raspberry Pi, macOS, or Linux.
|
|
188
|
-
|
|
189
213
|
## Acknowledgments
|
|
190
214
|
|
|
191
215
|
- [trlafleur](https://github.com/trlafleur) — background research
|
|
192
216
|
- [Oppedijk](https://www.oppedijk.com/bandg/fastnet.html) — protocol documentation
|
|
193
217
|
- [timmathews](https://github.com/timmathews/bg-fastnet-driver) — C++ reference implementation
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
# pyfastnet
|
|
2
2
|
|
|
3
|
-
Python library for decoding the FastNet protocol used by B&G Hydra/H2000
|
|
3
|
+
A Python library for decoding the **FastNet** protocol used by B&G Hydra / H2000
|
|
4
|
+
instruments. Feed it raw bytes from the Fastnet bus and it handles
|
|
5
|
+
synchronisation, checksum validation, and decoding — returning structured
|
|
6
|
+
instrument data ready for further processing.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
Developed for personal use and published for general interest. Runs on Raspberry
|
|
9
|
+
Pi, macOS, or Linux.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
## Quick start
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
```
|
|
13
|
+
```bash
|
|
12
14
|
pip install pyfastnet
|
|
13
15
|
```
|
|
14
16
|
|
|
15
|
-
## Example usage
|
|
16
|
-
|
|
17
17
|
```python
|
|
18
18
|
#!/usr/bin/env python3
|
|
19
19
|
import serial
|
|
@@ -45,9 +45,36 @@ finally:
|
|
|
45
45
|
ser.close()
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
Serial settings for a B&G Fastnet bus are **28,800 baud, 8 data bits, odd parity,
|
|
49
|
+
2 stop bits**. No instruments to hand? The companion apps below ship recorded
|
|
50
|
+
captures you can replay.
|
|
51
|
+
|
|
52
|
+
## The Fastnet toolkit
|
|
53
|
+
|
|
54
|
+
Three projects stack together — pick the layer that matches where you want the
|
|
55
|
+
data to end up:
|
|
56
|
+
|
|
57
|
+
| Project | What it does | Use it when |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| **pyfastnet** *(this library)* | **Decoder.** Turns raw Fastnet bytes into named instrument channels. | You're writing your own Python and want the decoded data. |
|
|
60
|
+
| [fastnet2ip](https://github.com/ghotihook/fastnet2ip) | **Serial → network.** Broadcasts decoded data over UDP as NMEA 0183 or NMEA 2000 (over IP). | Feeding Signal K, OpenCPN, or a plotter over WiFi / Ethernet. |
|
|
61
|
+
| [fastnet2n2k](https://github.com/ghotihook/fastnet2n2k) | **Serial → physical NMEA 2000 bus.** Transmits PGNs onto a CAN backbone via SocketCAN. | Wiring into a real NMEA 2000 network / chartplotter. |
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
┌─ fastnet2ip → UDP (NMEA 0183 / NMEA 2000 over IP) → Signal K, OpenCPN, plotters
|
|
65
|
+
B&G Fastnet bus ─(serial)─→ pyfastnet ─┤
|
|
66
|
+
└─ fastnet2n2k → SocketCAN (NMEA 2000 PGNs) → CAN backbone, chartplotter
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
pyfastnet is the **engine** at the bottom of the stack. If you only want the
|
|
70
|
+
decoded data on your network or NMEA 2000 bus — including running it as an
|
|
71
|
+
always-on systemd service — use one of the companion apps; they handle the serial
|
|
72
|
+
port, a live data store, rate limiting, and output for you.
|
|
73
|
+
|
|
48
74
|
## Output format
|
|
49
75
|
|
|
50
|
-
Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
|
|
76
|
+
Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
|
|
77
|
+
`values`. Each entry in `values` is keyed by channel name:
|
|
51
78
|
|
|
52
79
|
```python
|
|
53
80
|
{
|
|
@@ -79,10 +106,10 @@ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and `
|
|
|
79
106
|
|
|
80
107
|
### Position (LatLon) frames
|
|
81
108
|
|
|
82
|
-
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw
|
|
83
|
-
string (`DDMM.mmm` with hemisphere letters) is carried in
|
|
84
|
-
`None`. The originating source's marker byte is
|
|
85
|
-
`0x4E`) but does not affect the key:
|
|
109
|
+
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw
|
|
110
|
+
coordinate string (`DDMM.mmm` with hemisphere letters) is carried in
|
|
111
|
+
`display_text`; `value` is `None`. The originating source's marker byte is
|
|
112
|
+
preserved in `channel_id` (e.g. `0x47`, `0x4E`) but does not affect the key:
|
|
86
113
|
|
|
87
114
|
```python
|
|
88
115
|
"LatLon": {
|
|
@@ -95,7 +122,10 @@ string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value
|
|
|
95
122
|
|
|
96
123
|
### Layout field
|
|
97
124
|
|
|
98
|
-
The `layout` field describes the indicator symbol shown on the physical display
|
|
125
|
+
The `layout` field describes the indicator symbol shown on the physical display
|
|
126
|
+
around the numeric value. It is the **only** place True/Magnetic, port/starboard
|
|
127
|
+
sign, and similar context is carried — the raw stream contains no magnetic
|
|
128
|
+
variation or deviation.
|
|
99
129
|
|
|
100
130
|
| `layout` | Meaning | Sign |
|
|
101
131
|
|---|---|---|
|
|
@@ -115,7 +145,7 @@ The `layout` field describes the indicator symbol shown on the physical display
|
|
|
115
145
|
| `"[data]z"` / `"z[data]"` | Dog-leg symbol — AP off course | positive |
|
|
116
146
|
| `"TBC"` | Symbol seen but not yet identified | positive |
|
|
117
147
|
|
|
118
|
-
|
|
148
|
+
### Debug API
|
|
119
149
|
|
|
120
150
|
```python
|
|
121
151
|
from fastnet_decoder import set_log_level
|
|
@@ -128,14 +158,12 @@ fb.get_buffer_size() # bytes currently in buffer
|
|
|
128
158
|
fb.get_buffer_contents() # hex string of buffer contents
|
|
129
159
|
```
|
|
130
160
|
|
|
131
|
-
## Companion apps
|
|
132
|
-
|
|
133
|
-
- [fastnet2ip](https://github.com/ghotihook/fastnet2ip) — reads FastNet from serial and broadcasts it over UDP as either NMEA 0183 or NMEA 2000 (selected with `--output`). Install with `pipx install fastnet2ip`.
|
|
134
|
-
|
|
135
|
-
Runs on Raspberry Pi, macOS, or Linux.
|
|
136
|
-
|
|
137
161
|
## Acknowledgments
|
|
138
162
|
|
|
139
163
|
- [trlafleur](https://github.com/trlafleur) — background research
|
|
140
164
|
- [Oppedijk](https://www.oppedijk.com/bandg/fastnet.html) — protocol documentation
|
|
141
165
|
- [timmathews](https://github.com/timmathews/bg-fastnet-driver) — C++ reference implementation
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
__version__ = "2.0.
|
|
1
|
+
__version__ = "2.0.18"
|
|
2
2
|
|
|
3
3
|
from .frame_buffer import FrameBuffer
|
|
4
|
-
from .decode_fastnet import decode_frame, decode_ascii_frame
|
|
4
|
+
from .decode_fastnet import decode_frame, decode_ascii_frame, decode_light_frame
|
|
5
5
|
from .logger import logger, set_log_level # Import set_log_level for user control
|
|
6
6
|
|
|
7
|
-
__all__ = ["FrameBuffer", "decode_frame", "decode_ascii_frame", "logger", "set_log_level"]
|
|
7
|
+
__all__ = ["FrameBuffer", "decode_frame", "decode_ascii_frame", "decode_light_frame", "logger", "set_log_level"]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import datetime
|
|
2
2
|
import logging
|
|
3
3
|
from .mappings import ADDRESS_LOOKUP, COMMAND_LOOKUP, CHANNEL_LOOKUP, FORMAT_SIZE_MAP
|
|
4
|
-
from .mappings import SEGMENT_A, SEGMENT_B,
|
|
4
|
+
from .mappings import SEGMENT_A, SEGMENT_B, AUTOPILOT_MODE_BY_LOW, BACKLIGHT_LEVELS
|
|
5
5
|
from .logger import logger
|
|
6
6
|
|
|
7
7
|
|
|
@@ -234,6 +234,50 @@ def decode_ascii_frame(frame: bytes) -> dict:
|
|
|
234
234
|
return {"error": str(e)}
|
|
235
235
|
|
|
236
236
|
|
|
237
|
+
def decode_light_frame(frame: bytes) -> dict:
|
|
238
|
+
"""
|
|
239
|
+
Decode a Light Intensity (0xC9) command into the system backlight level.
|
|
240
|
+
|
|
241
|
+
Broadcast from a Pilot FFD to the whole system; the body is a single byte
|
|
242
|
+
giving the backlight level (Off/Low/Medium/High). Surfaced as a synthetic
|
|
243
|
+
"Backlight" channel so it queues like other channel data.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
to_address = frame[0]
|
|
247
|
+
from_address = frame[1]
|
|
248
|
+
command = frame[3]
|
|
249
|
+
body = frame[5:-1]
|
|
250
|
+
|
|
251
|
+
if len(body) < 1:
|
|
252
|
+
return {"error": "Invalid body size"}
|
|
253
|
+
|
|
254
|
+
level = body[0]
|
|
255
|
+
cmd_name = COMMAND_LOOKUP.get(command)
|
|
256
|
+
to_name = ADDRESS_LOOKUP.get(to_address)
|
|
257
|
+
from_name = ADDRESS_LOOKUP.get(from_address)
|
|
258
|
+
|
|
259
|
+
display_text = BACKLIGHT_LEVELS.get(level, f"Unknown ({level})")
|
|
260
|
+
logger.debug(f" CH Backlight level={level} display='{display_text}'")
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
"to_address": to_name if to_name is not None else f"Unknown (0x{to_address:02X})",
|
|
264
|
+
"from_address": from_name if from_name is not None else f"Unknown (0x{from_address:02X})",
|
|
265
|
+
"command": cmd_name if cmd_name is not None else f"Unknown (0x{command:02X})",
|
|
266
|
+
"values": {
|
|
267
|
+
"Backlight": {
|
|
268
|
+
"channel_id": None,
|
|
269
|
+
"value": float(level),
|
|
270
|
+
"display_text": display_text,
|
|
271
|
+
"layout": None,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.error(f"Error decoding Light Intensity frame: {e}")
|
|
278
|
+
return {"error": str(e)}
|
|
279
|
+
|
|
280
|
+
|
|
237
281
|
def decode_format_and_data(channel_id, format_byte, data_bytes):
|
|
238
282
|
try:
|
|
239
283
|
divisor = _DIVISOR_MAP[(format_byte >> 6) & 0b11]
|
|
@@ -250,8 +294,14 @@ def decode_format_and_data(channel_id, format_byte, data_bytes):
|
|
|
250
294
|
return None
|
|
251
295
|
raw = int.from_bytes(data_bytes, byteorder="big", signed=True)
|
|
252
296
|
if channel_id == 0xB5:
|
|
253
|
-
value
|
|
254
|
-
|
|
297
|
+
value = float(raw)
|
|
298
|
+
high, low = (raw >> 8) & 0xFF, raw & 0xFF
|
|
299
|
+
if high == 0x50:
|
|
300
|
+
display_text = "Standby"
|
|
301
|
+
elif high in (0x51, 0x59):
|
|
302
|
+
display_text = AUTOPILOT_MODE_BY_LOW.get(low, f"Unknown ({raw})")
|
|
303
|
+
else:
|
|
304
|
+
display_text = f"Unknown ({raw})"
|
|
255
305
|
else:
|
|
256
306
|
value = raw / divisor
|
|
257
307
|
display_text = f"{value:.{decimal_places}f}"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from .utils import calculate_checksum
|
|
3
3
|
from .mappings import COMMAND_LOOKUP, IGNORED_COMMANDS
|
|
4
|
-
from .decode_fastnet import decode_frame, decode_ascii_frame, probe_frame
|
|
4
|
+
from .decode_fastnet import decode_frame, decode_ascii_frame, decode_light_frame, probe_frame
|
|
5
5
|
from .logger import logger
|
|
6
6
|
from queue import Queue, Full
|
|
7
7
|
|
|
@@ -87,15 +87,18 @@ class FrameBuffer:
|
|
|
87
87
|
"""
|
|
88
88
|
Decode a frame and add it to the queue if valid.
|
|
89
89
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
90
|
+
Broadcast frames carry the channel-record body layout, LatLon frames
|
|
91
|
+
carry ASCII, and Light Intensity frames carry a backlight level byte;
|
|
92
|
+
those are decoded and queued. Every other command (pilot messages,
|
|
93
|
+
NMEA-sourced data, etc.) has an unknown body structure, so it is never
|
|
94
|
+
queued — it is only probed speculatively for debugging.
|
|
94
95
|
"""
|
|
95
96
|
if command_name == "Broadcast":
|
|
96
97
|
decoded_frame = decode_frame(frame)
|
|
97
98
|
elif command_name == "LatLon":
|
|
98
99
|
decoded_frame = decode_ascii_frame(frame)
|
|
100
|
+
elif command_name == "Light Intensity":
|
|
101
|
+
decoded_frame = decode_light_frame(frame)
|
|
99
102
|
else:
|
|
100
103
|
probe_frame(frame)
|
|
101
104
|
return
|
|
@@ -146,7 +146,10 @@ ADDRESS_LOOKUP = {
|
|
|
146
146
|
}
|
|
147
147
|
|
|
148
148
|
|
|
149
|
-
IGNORED_COMMANDS = {"Keep Alive"
|
|
149
|
+
IGNORED_COMMANDS = {"Keep Alive"}
|
|
150
|
+
|
|
151
|
+
# Backlight level carried in the 1-byte body of a Light Intensity (0xC9) command.
|
|
152
|
+
BACKLIGHT_LEVELS = {0x00: "Off", 0x01: "Low", 0x02: "Medium", 0x04: "High"}
|
|
150
153
|
|
|
151
154
|
COMMAND_LOOKUP = {
|
|
152
155
|
0x01: "Broadcast",
|
|
@@ -323,9 +326,12 @@ SEGMENT_B = {
|
|
|
323
326
|
0xB8: "C", # Celsius label — a,d,e,f segments
|
|
324
327
|
}
|
|
325
328
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
+
# Autopilot Mode (channel 0xB5) is a 16-bit composite, not an opaque code:
|
|
330
|
+
# high byte = engagement state (0x50 standby, 0x51 engaged, 0x59 compass steering)
|
|
331
|
+
# low byte = selected mode (see AUTOPILOT_MODE_BY_LOW)
|
|
332
|
+
# When in standby (high 0x50) the mode is "Standby" regardless of the low byte.
|
|
333
|
+
AUTOPILOT_MODE_BY_LOW = {
|
|
334
|
+
0x01: "Compass", 0x02: "Power", 0x04: "Wind", 0x13: "NMEA WP",
|
|
329
335
|
}
|
|
330
336
|
|
|
331
337
|
FORMAT_SIZE_MAP = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyfastnet
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.18
|
|
4
4
|
Summary: A Python library for decoding FastNet protocol data streams.
|
|
5
5
|
Author-email: Alex Salmon <alex@ivila.net>
|
|
6
6
|
License: MIT License
|
|
@@ -52,20 +52,20 @@ Dynamic: license-file
|
|
|
52
52
|
|
|
53
53
|
# pyfastnet
|
|
54
54
|
|
|
55
|
-
Python library for decoding the FastNet protocol used by B&G Hydra/H2000
|
|
55
|
+
A Python library for decoding the **FastNet** protocol used by B&G Hydra / H2000
|
|
56
|
+
instruments. Feed it raw bytes from the Fastnet bus and it handles
|
|
57
|
+
synchronisation, checksum validation, and decoding — returning structured
|
|
58
|
+
instrument data ready for further processing.
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
Developed for personal use and published for general interest. Runs on Raspberry
|
|
61
|
+
Pi, macOS, or Linux.
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
## Quick start
|
|
60
64
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
```
|
|
65
|
+
```bash
|
|
64
66
|
pip install pyfastnet
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
## Example usage
|
|
68
|
-
|
|
69
69
|
```python
|
|
70
70
|
#!/usr/bin/env python3
|
|
71
71
|
import serial
|
|
@@ -97,9 +97,36 @@ finally:
|
|
|
97
97
|
ser.close()
|
|
98
98
|
```
|
|
99
99
|
|
|
100
|
+
Serial settings for a B&G Fastnet bus are **28,800 baud, 8 data bits, odd parity,
|
|
101
|
+
2 stop bits**. No instruments to hand? The companion apps below ship recorded
|
|
102
|
+
captures you can replay.
|
|
103
|
+
|
|
104
|
+
## The Fastnet toolkit
|
|
105
|
+
|
|
106
|
+
Three projects stack together — pick the layer that matches where you want the
|
|
107
|
+
data to end up:
|
|
108
|
+
|
|
109
|
+
| Project | What it does | Use it when |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| **pyfastnet** *(this library)* | **Decoder.** Turns raw Fastnet bytes into named instrument channels. | You're writing your own Python and want the decoded data. |
|
|
112
|
+
| [fastnet2ip](https://github.com/ghotihook/fastnet2ip) | **Serial → network.** Broadcasts decoded data over UDP as NMEA 0183 or NMEA 2000 (over IP). | Feeding Signal K, OpenCPN, or a plotter over WiFi / Ethernet. |
|
|
113
|
+
| [fastnet2n2k](https://github.com/ghotihook/fastnet2n2k) | **Serial → physical NMEA 2000 bus.** Transmits PGNs onto a CAN backbone via SocketCAN. | Wiring into a real NMEA 2000 network / chartplotter. |
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
┌─ fastnet2ip → UDP (NMEA 0183 / NMEA 2000 over IP) → Signal K, OpenCPN, plotters
|
|
117
|
+
B&G Fastnet bus ─(serial)─→ pyfastnet ─┤
|
|
118
|
+
└─ fastnet2n2k → SocketCAN (NMEA 2000 PGNs) → CAN backbone, chartplotter
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
pyfastnet is the **engine** at the bottom of the stack. If you only want the
|
|
122
|
+
decoded data on your network or NMEA 2000 bus — including running it as an
|
|
123
|
+
always-on systemd service — use one of the companion apps; they handle the serial
|
|
124
|
+
port, a live data store, rate limiting, and output for you.
|
|
125
|
+
|
|
100
126
|
## Output format
|
|
101
127
|
|
|
102
|
-
Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
|
|
128
|
+
Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
|
|
129
|
+
`values`. Each entry in `values` is keyed by channel name:
|
|
103
130
|
|
|
104
131
|
```python
|
|
105
132
|
{
|
|
@@ -131,10 +158,10 @@ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and `
|
|
|
131
158
|
|
|
132
159
|
### Position (LatLon) frames
|
|
133
160
|
|
|
134
|
-
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw
|
|
135
|
-
string (`DDMM.mmm` with hemisphere letters) is carried in
|
|
136
|
-
`None`. The originating source's marker byte is
|
|
137
|
-
`0x4E`) but does not affect the key:
|
|
161
|
+
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw
|
|
162
|
+
coordinate string (`DDMM.mmm` with hemisphere letters) is carried in
|
|
163
|
+
`display_text`; `value` is `None`. The originating source's marker byte is
|
|
164
|
+
preserved in `channel_id` (e.g. `0x47`, `0x4E`) but does not affect the key:
|
|
138
165
|
|
|
139
166
|
```python
|
|
140
167
|
"LatLon": {
|
|
@@ -147,7 +174,10 @@ string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value
|
|
|
147
174
|
|
|
148
175
|
### Layout field
|
|
149
176
|
|
|
150
|
-
The `layout` field describes the indicator symbol shown on the physical display
|
|
177
|
+
The `layout` field describes the indicator symbol shown on the physical display
|
|
178
|
+
around the numeric value. It is the **only** place True/Magnetic, port/starboard
|
|
179
|
+
sign, and similar context is carried — the raw stream contains no magnetic
|
|
180
|
+
variation or deviation.
|
|
151
181
|
|
|
152
182
|
| `layout` | Meaning | Sign |
|
|
153
183
|
|---|---|---|
|
|
@@ -167,7 +197,7 @@ The `layout` field describes the indicator symbol shown on the physical display
|
|
|
167
197
|
| `"[data]z"` / `"z[data]"` | Dog-leg symbol — AP off course | positive |
|
|
168
198
|
| `"TBC"` | Symbol seen but not yet identified | positive |
|
|
169
199
|
|
|
170
|
-
|
|
200
|
+
### Debug API
|
|
171
201
|
|
|
172
202
|
```python
|
|
173
203
|
from fastnet_decoder import set_log_level
|
|
@@ -180,14 +210,12 @@ fb.get_buffer_size() # bytes currently in buffer
|
|
|
180
210
|
fb.get_buffer_contents() # hex string of buffer contents
|
|
181
211
|
```
|
|
182
212
|
|
|
183
|
-
## Companion apps
|
|
184
|
-
|
|
185
|
-
- [fastnet2ip](https://github.com/ghotihook/fastnet2ip) — reads FastNet from serial and broadcasts it over UDP as either NMEA 0183 or NMEA 2000 (selected with `--output`). Install with `pipx install fastnet2ip`.
|
|
186
|
-
|
|
187
|
-
Runs on Raspberry Pi, macOS, or Linux.
|
|
188
|
-
|
|
189
213
|
## Acknowledgments
|
|
190
214
|
|
|
191
215
|
- [trlafleur](https://github.com/trlafleur) — background research
|
|
192
216
|
- [Oppedijk](https://www.oppedijk.com/bandg/fastnet.html) — protocol documentation
|
|
193
217
|
- [timmathews](https://github.com/timmathews/bg-fastnet-driver) — C++ reference implementation
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -70,5 +70,38 @@ class TestAutopilotWind(unittest.TestCase):
|
|
|
70
70
|
self.assertEqual(self.v["Autopilot Compass Target"]["display_text"], "331°M")
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
class TestAutopilotModeComposite(unittest.TestCase):
|
|
74
|
+
"""
|
|
75
|
+
Autopilot Mode (0xB5) is a 16-bit composite: high byte = engagement state
|
|
76
|
+
(0x50 standby / 0x51 engaged / 0x59 compass steering), low byte = selected
|
|
77
|
+
mode. These real frames cover values that previously decoded as Unknown.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def _mode(self, frame):
|
|
81
|
+
return _decode(frame)["Autopilot Mode"]["display_text"]
|
|
82
|
+
|
|
83
|
+
def test_compass_standby(self):
|
|
84
|
+
# 0x5001 — standby, Compass selected
|
|
85
|
+
self.assertEqual(
|
|
86
|
+
self._mode("ff121c01d2b5015001a606bee8e800af06bee8e8005306bee8e8007606bee8e8008b"),
|
|
87
|
+
"Standby",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def test_power_standby(self):
|
|
91
|
+
# 0x5002 — standby, Power selected
|
|
92
|
+
self.assertEqual(
|
|
93
|
+
self._mode("ff121c01d2b5015002a606bee8e800af06bee8e8005306bee8e8007606bee8e8008a"),
|
|
94
|
+
"Standby",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_power_engaged(self):
|
|
98
|
+
# 0x5102 — engaged, Power
|
|
99
|
+
self.assertEqual(self._mode("ff120a01e4b5015102a6070066012eb5"), "Power")
|
|
100
|
+
|
|
101
|
+
def test_compass_steering(self):
|
|
102
|
+
# 0x5901 — compass engaged and actively steering to a heading
|
|
103
|
+
self.assertEqual(self._mode("ff120a01e4b5015901a60700660124b8"), "Compass")
|
|
104
|
+
|
|
105
|
+
|
|
73
106
|
if __name__ == "__main__":
|
|
74
107
|
unittest.main()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.frame_buffer import FrameBuffer
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
"""Feed a raw frame through the buffer and return the queued decoded frame."""
|
|
7
|
+
fb = FrameBuffer()
|
|
8
|
+
fb.add_to_buffer(bytes.fromhex(hex_str))
|
|
9
|
+
fb.get_complete_frames()
|
|
10
|
+
assert not fb.frame_queue.empty(), f"No frame queued for {hex_str}"
|
|
11
|
+
return fb.frame_queue.get_nowait()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestBacklight(unittest.TestCase):
|
|
15
|
+
"""
|
|
16
|
+
Light Intensity command (0xC9), broadcast 0x50 -> 0xFF, 1-byte body giving
|
|
17
|
+
the system backlight level. Real frames captured while toggling the FFD
|
|
18
|
+
backlight off/low/med/high.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
CASES = {
|
|
22
|
+
"ff5001c9e70000": ("Off", 0.0),
|
|
23
|
+
"ff5001c9e701ff": ("Low", 1.0),
|
|
24
|
+
"ff5001c9e702fe": ("Medium", 2.0),
|
|
25
|
+
"ff5001c9e704fc": ("High", 4.0),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
def test_levels(self):
|
|
29
|
+
for frame, (text, value) in self.CASES.items():
|
|
30
|
+
with self.subTest(frame=frame):
|
|
31
|
+
decoded = _decode(frame)
|
|
32
|
+
bl = decoded["values"]["Backlight"]
|
|
33
|
+
self.assertEqual(bl["display_text"], text)
|
|
34
|
+
self.assertEqual(bl["value"], value)
|
|
35
|
+
|
|
36
|
+
def test_command_name(self):
|
|
37
|
+
decoded = _decode("ff5001c9e70000")
|
|
38
|
+
self.assertEqual(decoded["command"], "Light Intensity")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|