pyfastnet 2.0.16__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.
Files changed (35) hide show
  1. {pyfastnet-2.0.16/pyfastnet.egg-info → pyfastnet-2.0.18}/PKG-INFO +50 -22
  2. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/README.md +49 -21
  3. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/fastnet_decoder/__init__.py +3 -3
  4. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/fastnet_decoder/decode_fastnet.py +129 -3
  5. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/fastnet_decoder/frame_buffer.py +20 -4
  6. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/fastnet_decoder/mappings.py +10 -4
  7. {pyfastnet-2.0.16 → pyfastnet-2.0.18/pyfastnet.egg-info}/PKG-INFO +50 -22
  8. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/pyfastnet.egg-info/SOURCES.txt +1 -0
  9. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_autopilot.py +33 -0
  10. pyfastnet-2.0.18/tests/test_backlight.py +42 -0
  11. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/LICENSE +0 -0
  12. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/fastnet_decoder/logger.py +0 -0
  13. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/fastnet_decoder/utils.py +0 -0
  14. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/pyfastnet.egg-info/dependency_links.txt +0 -0
  15. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/pyfastnet.egg-info/requires.txt +0 -0
  16. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/pyfastnet.egg-info/top_level.txt +0 -0
  17. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/pyproject.toml +0 -0
  18. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/setup.cfg +0 -0
  19. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_awa.py +0 -0
  20. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_aws.py +0 -0
  21. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_battery.py +0 -0
  22. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_boatspeed.py +0 -0
  23. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_depth_frame.py +0 -0
  24. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_format07_msb_regression.py +0 -0
  25. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_format08_layout.py +0 -0
  26. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_heading.py +0 -0
  27. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_heel_trim.py +0 -0
  28. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_latlon_frame.py +0 -0
  29. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_sea_temp.py +0 -0
  30. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_sog.py +0 -0
  31. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_tidal.py +0 -0
  32. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_timer.py +0 -0
  33. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_twa.py +0 -0
  34. {pyfastnet-2.0.16 → pyfastnet-2.0.18}/tests/test_twd.py +0 -0
  35. {pyfastnet-2.0.16 → 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.16
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 instruments. Developed for personal use and published for general interest.
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
- ## Purpose
60
+ Developed for personal use and published for general interest. Runs on Raspberry
61
+ Pi, macOS, or Linux.
58
62
 
59
- Feed a raw byte stream from a FastNet bus into the library. It handles synchronisation, checksum validation, and decoding — returning structured instrument data ready for further processing.
63
+ ## Quick start
60
64
 
61
- ## Installation
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 `values`. Each entry in `values` is keyed by channel name:
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 coordinate
135
- string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value` is
136
- `None`. The originating source's marker byte is preserved in `channel_id` (e.g. `0x47`,
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 around the numeric value:
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
- ## Debug API
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 instruments. Developed for personal use and published for general interest.
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
- ## Purpose
8
+ Developed for personal use and published for general interest. Runs on Raspberry
9
+ Pi, macOS, or Linux.
6
10
 
7
- Feed a raw byte stream from a FastNet bus into the library. It handles synchronisation, checksum validation, and decoding — returning structured instrument data ready for further processing.
11
+ ## Quick start
8
12
 
9
- ## Installation
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 `values`. Each entry in `values` is keyed by channel name:
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 coordinate
83
- string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value` is
84
- `None`. The originating source's marker byte is preserved in `channel_id` (e.g. `0x47`,
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 around the numeric value:
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
- ## Debug API
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.16"
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,6 +1,7 @@
1
1
  import datetime
2
+ import logging
2
3
  from .mappings import ADDRESS_LOOKUP, COMMAND_LOOKUP, CHANNEL_LOOKUP, FORMAT_SIZE_MAP
3
- from .mappings import SEGMENT_A, SEGMENT_B, AUTOPILOT_MODES
4
+ from .mappings import SEGMENT_A, SEGMENT_B, AUTOPILOT_MODE_BY_LOW, BACKLIGHT_LEVELS
4
5
  from .logger import logger
5
6
 
6
7
 
@@ -109,6 +110,81 @@ def decode_frame(frame: bytes) -> dict:
109
110
  return {"error": "Decoding failure"}
110
111
 
111
112
 
113
+ def probe_frame(frame: bytes) -> None:
114
+ """
115
+ Speculatively unpack a non-broadcast frame using the same channel-record
116
+ assembly as Broadcast frames, for reverse-engineering only.
117
+
118
+ The body layout of non-broadcast commands (pilot messages, NMEA-sourced
119
+ data, etc.) is NOT known to match the Broadcast channel-record format, so
120
+ everything decoded here is a guess: results are logged at DEBUG and are
121
+ never queued or assigned to channels. The point is to eyeball whether the
122
+ same (channel_id, format_byte, data...) structure holds for these frames.
123
+ """
124
+ if not logger.isEnabledFor(logging.DEBUG):
125
+ return
126
+ try:
127
+ to_address = frame[0]
128
+ from_address = frame[1]
129
+ command = frame[3]
130
+ body = frame[5:-1]
131
+
132
+ to_name = ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})")
133
+ from_name = ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})")
134
+ cmd_name = COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})")
135
+
136
+ logger.debug(
137
+ f" PROBE cmd={cmd_name} {to_name}←{from_name} body=[{body.hex()}]"
138
+ )
139
+
140
+ index = 0
141
+ while index < len(body):
142
+ if index + 1 >= len(body):
143
+ logger.debug(f" PROBE trailing byte [{body[index:].hex()}]")
144
+ break
145
+
146
+ channel_id = body[index]
147
+ format_byte = body[index + 1]
148
+ channel_name = CHANNEL_LOOKUP.get(channel_id, f"Unknown (0x{channel_id:02X})")
149
+ index += 2
150
+
151
+ data_length = FORMAT_SIZE_MAP.get(format_byte & 0x0F, 0)
152
+ if data_length == 0:
153
+ logger.debug(
154
+ f" PROBE 0x{channel_id:02X} {channel_name} "
155
+ f"fmt=0x{format_byte:02X} unknown format, stop "
156
+ f"remaining=[{body[index:].hex()}]"
157
+ )
158
+ break
159
+ if index + data_length > len(body):
160
+ logger.debug(
161
+ f" PROBE 0x{channel_id:02X} {channel_name} "
162
+ f"fmt=0x{format_byte:02X} incomplete need={data_length}B "
163
+ f"have={len(body) - index}B remaining=[{body[index:].hex()}]"
164
+ )
165
+ break
166
+
167
+ data_bytes = body[index:index + data_length]
168
+ index += data_length
169
+
170
+ decoded = decode_format_and_data(channel_id, format_byte, data_bytes)
171
+ if decoded:
172
+ logger.debug(
173
+ f" PROBE 0x{channel_id:02X} {channel_name} "
174
+ f"fmt=0x{format_byte:02X} data=[{data_bytes.hex()}] "
175
+ f"value={decoded['value']} display='{decoded['display_text']}' "
176
+ f"layout={decoded['layout']}"
177
+ )
178
+ else:
179
+ logger.debug(
180
+ f" PROBE 0x{channel_id:02X} {channel_name} "
181
+ f"fmt=0x{format_byte:02X} data=[{data_bytes.hex()}] (no decode)"
182
+ )
183
+
184
+ except Exception as e:
185
+ logger.debug(f" PROBE error: {e} [{frame.hex()}]")
186
+
187
+
112
188
  def decode_ascii_frame(frame: bytes) -> dict:
113
189
  try:
114
190
  to_address = frame[0]
@@ -158,6 +234,50 @@ def decode_ascii_frame(frame: bytes) -> dict:
158
234
  return {"error": str(e)}
159
235
 
160
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
+
161
281
  def decode_format_and_data(channel_id, format_byte, data_bytes):
162
282
  try:
163
283
  divisor = _DIVISOR_MAP[(format_byte >> 6) & 0b11]
@@ -174,8 +294,14 @@ def decode_format_and_data(channel_id, format_byte, data_bytes):
174
294
  return None
175
295
  raw = int.from_bytes(data_bytes, byteorder="big", signed=True)
176
296
  if channel_id == 0xB5:
177
- value = float(raw)
178
- display_text = AUTOPILOT_MODES.get(raw, f"Unknown ({raw})")
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})"
179
305
  else:
180
306
  value = raw / divisor
181
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
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
 
@@ -84,9 +84,25 @@ class FrameBuffer:
84
84
  self.decode_and_queue_frame(frame, command_name)
85
85
 
86
86
  def decode_and_queue_frame(self, frame, command_name):
87
- """Decode a frame and add it to the queue if valid."""
88
- decoder = decode_ascii_frame if command_name == "LatLon" else decode_frame
89
- decoded_frame = decoder(frame)
87
+ """
88
+ Decode a frame and add it to the queue if valid.
89
+
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.
95
+ """
96
+ if command_name == "Broadcast":
97
+ decoded_frame = decode_frame(frame)
98
+ elif command_name == "LatLon":
99
+ decoded_frame = decode_ascii_frame(frame)
100
+ elif command_name == "Light Intensity":
101
+ decoded_frame = decode_light_frame(frame)
102
+ else:
103
+ probe_frame(frame)
104
+ return
105
+
90
106
  if decoded_frame and "values" in decoded_frame:
91
107
  try:
92
108
  self.frame_queue.put_nowait(decoded_frame)
@@ -146,7 +146,10 @@ ADDRESS_LOOKUP = {
146
146
  }
147
147
 
148
148
 
149
- IGNORED_COMMANDS = {"Keep Alive", "Light Intensity"}
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
- AUTOPILOT_MODES = {
327
- 20484: "Standby", 20737: "Compass", 20738: "Power",
328
- 20740: "Wind", 20755: "NMEA WP",
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.16
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 instruments. Developed for personal use and published for general interest.
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
- ## Purpose
60
+ Developed for personal use and published for general interest. Runs on Raspberry
61
+ Pi, macOS, or Linux.
58
62
 
59
- Feed a raw byte stream from a FastNet bus into the library. It handles synchronisation, checksum validation, and decoding — returning structured instrument data ready for further processing.
63
+ ## Quick start
60
64
 
61
- ## Installation
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 `values`. Each entry in `values` is keyed by channel name:
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 coordinate
135
- string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value` is
136
- `None`. The originating source's marker byte is preserved in `channel_id` (e.g. `0x47`,
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 around the numeric value:
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
- ## Debug API
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).
@@ -15,6 +15,7 @@ pyfastnet.egg-info/top_level.txt
15
15
  tests/test_autopilot.py
16
16
  tests/test_awa.py
17
17
  tests/test_aws.py
18
+ tests/test_backlight.py
18
19
  tests/test_battery.py
19
20
  tests/test_boatspeed.py
20
21
  tests/test_depth_frame.py
@@ -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