pyfastnet 2.0.17__tar.gz → 3.0.0__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 (42) hide show
  1. pyfastnet-3.0.0/PKG-INFO +233 -0
  2. pyfastnet-3.0.0/README.md +181 -0
  3. pyfastnet-3.0.0/fastnet_decoder/__init__.py +9 -0
  4. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/fastnet_decoder/decode_fastnet.py +57 -4
  5. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/fastnet_decoder/frame_buffer.py +41 -17
  6. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/fastnet_decoder/logger.py +6 -1
  7. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/fastnet_decoder/mappings.py +10 -4
  8. pyfastnet-3.0.0/fastnet_decoder/signalk_map.py +293 -0
  9. pyfastnet-3.0.0/pyfastnet.egg-info/PKG-INFO +233 -0
  10. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/pyfastnet.egg-info/SOURCES.txt +4 -0
  11. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_autopilot.py +33 -0
  12. pyfastnet-3.0.0/tests/test_backlight.py +46 -0
  13. pyfastnet-3.0.0/tests/test_golden.py +33 -0
  14. pyfastnet-3.0.0/tests/test_signalk_projection.py +171 -0
  15. pyfastnet-2.0.17/PKG-INFO +0 -193
  16. pyfastnet-2.0.17/README.md +0 -141
  17. pyfastnet-2.0.17/fastnet_decoder/__init__.py +0 -7
  18. pyfastnet-2.0.17/pyfastnet.egg-info/PKG-INFO +0 -193
  19. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/LICENSE +0 -0
  20. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/fastnet_decoder/utils.py +0 -0
  21. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/pyfastnet.egg-info/dependency_links.txt +0 -0
  22. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/pyfastnet.egg-info/requires.txt +0 -0
  23. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/pyfastnet.egg-info/top_level.txt +0 -0
  24. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/pyproject.toml +0 -0
  25. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/setup.cfg +0 -0
  26. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_awa.py +0 -0
  27. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_aws.py +0 -0
  28. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_battery.py +0 -0
  29. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_boatspeed.py +0 -0
  30. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_depth_frame.py +0 -0
  31. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_format07_msb_regression.py +0 -0
  32. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_format08_layout.py +0 -0
  33. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_heading.py +0 -0
  34. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_heel_trim.py +0 -0
  35. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_latlon_frame.py +0 -0
  36. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_sea_temp.py +0 -0
  37. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_sog.py +0 -0
  38. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_tidal.py +0 -0
  39. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_timer.py +0 -0
  40. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_twa.py +0 -0
  41. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_twd.py +0 -0
  42. {pyfastnet-2.0.17 → pyfastnet-3.0.0}/tests/test_tws.py +0 -0
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyfastnet
3
+ Version: 3.0.0
4
+ Summary: A Python library for decoding FastNet protocol data streams.
5
+ Author-email: Alex Salmon <alex@ivila.net>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2025 ghotihook
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/ghotihook/pyfastnet
29
+ Project-URL: Repository, https://github.com/ghotihook/pyfastnet
30
+ Project-URL: Issues, https://github.com/ghotihook/pyfastnet/issues
31
+ Keywords: fastnet,bandg,hydra,h2000,marine,sailing,decoder
32
+ Classifier: Development Status :: 5 - Production/Stable
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.7
38
+ Classifier: Programming Language :: Python :: 3.8
39
+ Classifier: Programming Language :: Python :: 3.9
40
+ Classifier: Programming Language :: Python :: 3.10
41
+ Classifier: Programming Language :: Python :: 3.11
42
+ Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
44
+ Classifier: Topic :: Communications
45
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
46
+ Requires-Python: >=3.7
47
+ Description-Content-Type: text/markdown
48
+ License-File: LICENSE
49
+ Provides-Extra: test
50
+ Requires-Dist: pytest>=7.0; extra == "test"
51
+ Dynamic: license-file
52
+
53
+ # pyfastnet
54
+
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 instrument data as
58
+ **Signal K paths in SI units**, ready for further processing.
59
+
60
+ > **v3.0 changes the output format.** Decoded frames are now `{signalk_path:
61
+ > SI_value}` (e.g. `navigation.speedThroughWater = 3.6`) instead of the v2
62
+ > name-keyed `{value, display_text, layout}` dicts. See **Output format** below.
63
+ > The full v2-style decode is still available via `FrameBuffer(project=False)`.
64
+
65
+ Developed for personal use and published for general interest. Runs on Raspberry
66
+ Pi, macOS, or Linux.
67
+
68
+ ## Quick start
69
+
70
+ ```bash
71
+ pip install pyfastnet
72
+ ```
73
+
74
+ ```python
75
+ #!/usr/bin/env python3
76
+ import serial
77
+ from fastnet_decoder import FrameBuffer
78
+
79
+ fb = FrameBuffer()
80
+
81
+ ser = serial.Serial(
82
+ port="/dev/ttyUSB0",
83
+ baudrate=28800,
84
+ bytesize=serial.EIGHTBITS,
85
+ stopbits=serial.STOPBITS_TWO,
86
+ parity=serial.PARITY_ODD,
87
+ timeout=0.1,
88
+ )
89
+
90
+ try:
91
+ while True:
92
+ data = ser.read(256)
93
+ if not data:
94
+ continue
95
+ fb.add_to_buffer(data)
96
+ fb.get_complete_frames()
97
+ while not fb.frame_queue.empty():
98
+ frame = fb.frame_queue.get()
99
+ for path, value in frame["values"].items():
100
+ print(path, value) # e.g. navigation.speedThroughWater 3.6
101
+ finally:
102
+ ser.close()
103
+ ```
104
+
105
+ Serial settings for a B&G Fastnet bus are **28,800 baud, 8 data bits, odd parity,
106
+ 2 stop bits**. No instruments to hand? The companion apps below ship recorded
107
+ captures you can replay.
108
+
109
+ ## The Fastnet toolkit
110
+
111
+ Three projects stack together — pick the layer that matches where you want the
112
+ data to end up:
113
+
114
+ | Project | What it does | Use it when |
115
+ |---|---|---|
116
+ | **pyfastnet** *(this library)* | **Decoder.** Turns raw Fastnet bytes into Signal K paths in SI units. | You're writing your own Python and want the decoded data. |
117
+ | [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. |
118
+ | [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. |
119
+
120
+ ```
121
+ ┌─ fastnet2ip → UDP (NMEA 0183 / NMEA 2000 over IP) → Signal K, OpenCPN, plotters
122
+ B&G Fastnet bus ─(serial)─→ pyfastnet ─┤
123
+ └─ fastnet2n2k → SocketCAN (NMEA 2000 PGNs) → CAN backbone, chartplotter
124
+ ```
125
+
126
+ pyfastnet is the **engine** at the bottom of the stack. If you only want the
127
+ decoded data on your network or NMEA 2000 bus — including running it as an
128
+ always-on systemd service — use one of the companion apps; they handle the serial
129
+ port, a live data store, rate limiting, and output for you.
130
+
131
+ ## Output format
132
+
133
+ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
134
+ `values`. `values` maps **Signal K paths to SI values** — one canonical entry per
135
+ physical quantity:
136
+
137
+ ```python
138
+ {
139
+ "to_address": "Entire System",
140
+ "from_address": "Normal CPU (Wind Board in H2000)",
141
+ "command": "Broadcast",
142
+ "values": {
143
+ "environment.wind.speedApparent": 4.6, # m/s
144
+ "environment.wind.angleApparent": 0.419, # radians
145
+ "environment.wind.directionMagnetic": 1.239, # radians
146
+ "navigation.speedThroughWater": 3.19, # m/s
147
+ }
148
+ }
149
+ ```
150
+
151
+ Units follow the Signal K spec: angles in **radians**, speed in **m/s**, distance
152
+ in **metres**, temperature in **Kelvin**, pressure in **Pascals**. A value is a
153
+ `float` (SI), a `str` enum (e.g. `steering.autopilot.state` → `"standby"`), a
154
+ position object, or `None` when unavailable.
155
+
156
+ Redundant unit-variant channels the bus sends (feet/fathoms depth, knots wind, °F)
157
+ are collapsed to one canonical path. B&G-proprietary channels with no standard
158
+ Signal K path — including the pre-calibration `raw` sensor values — are emitted
159
+ under a `bandg.*` namespace (e.g. `bandg.wind.rawAngleApparent`).
160
+
161
+ ### Units and the full path map
162
+
163
+ Every emitted path has a canonical SI unit, available programmatically:
164
+
165
+ ```python
166
+ from fastnet_decoder import unit_for
167
+ unit_for("navigation.speedThroughWater") # "m/s"
168
+ unit_for("navigation.headingMagnetic") # "rad"
169
+ unit_for("environment.water.temperature") # "K"
170
+ unit_for("navigation.position") # "deg"
171
+ ```
172
+
173
+ The complete mapping — every B&G channel id/name → Signal K path + unit, the
174
+ `bandg.*` vendor namespace, layout-driven Magnetic/True routing, and the
175
+ dropped/collapsed channels — is documented in
176
+ [`docs/v3_signalk_mapping.md`](docs/v3_signalk_mapping.md).
177
+
178
+ ### Position
179
+
180
+ Position frames are emitted as `navigation.position`, a decimal-degree object
181
+ (negative for S / W):
182
+
183
+ ```python
184
+ "navigation.position": {"latitude": -33.8742, "longitude": 151.2320}
185
+ ```
186
+
187
+ ### True vs Magnetic
188
+
189
+ The reference is carried by the **path**, not a separate field:
190
+ `navigation.headingMagnetic` vs `navigation.headingTrue`,
191
+ `environment.wind.directionMagnetic` vs `directionTrue`,
192
+ `environment.current.setMagnetic` vs `setTrue`. The decoder selects the right path
193
+ from the display's indicator symbol at decode time. (The raw stream carries no
194
+ magnetic variation or deviation, so it cannot convert between the two.)
195
+
196
+ ### Complete (rich) decode
197
+
198
+ `FrameBuffer(project=False)` queues the full internal decode instead of the Signal K
199
+ projection. Each value is then a dict — `value`, `display_text`, `layout`,
200
+ `channel_id` — keyed by human-readable channel name (the v2 format). Useful for
201
+ debugging and reverse-engineering; `display_text` and `layout` are not exposed in
202
+ the default Signal K output.
203
+
204
+ You can also project a single decoded frame yourself:
205
+
206
+ ```python
207
+ from fastnet_decoder import decode_frame, project
208
+ rich = decode_frame(raw_frame_bytes) # complete decode
209
+ si = project(rich) # {signalk_path: SI_value}
210
+ ```
211
+
212
+ ### Debug API
213
+
214
+ ```python
215
+ from fastnet_decoder import set_log_level
216
+ import logging
217
+ set_log_level(logging.DEBUG)
218
+ ```
219
+
220
+ ```python
221
+ fb.get_buffer_size() # bytes currently in buffer
222
+ fb.get_buffer_contents() # hex string of buffer contents
223
+ ```
224
+
225
+ ## Acknowledgments
226
+
227
+ - [trlafleur](https://github.com/trlafleur) — background research
228
+ - [Oppedijk](https://www.oppedijk.com/bandg/fastnet.html) — protocol documentation
229
+ - [timmathews](https://github.com/timmathews/bg-fastnet-driver) — C++ reference implementation
230
+
231
+ ## License
232
+
233
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,181 @@
1
+ # pyfastnet
2
+
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 instrument data as
6
+ **Signal K paths in SI units**, ready for further processing.
7
+
8
+ > **v3.0 changes the output format.** Decoded frames are now `{signalk_path:
9
+ > SI_value}` (e.g. `navigation.speedThroughWater = 3.6`) instead of the v2
10
+ > name-keyed `{value, display_text, layout}` dicts. See **Output format** below.
11
+ > The full v2-style decode is still available via `FrameBuffer(project=False)`.
12
+
13
+ Developed for personal use and published for general interest. Runs on Raspberry
14
+ Pi, macOS, or Linux.
15
+
16
+ ## Quick start
17
+
18
+ ```bash
19
+ pip install pyfastnet
20
+ ```
21
+
22
+ ```python
23
+ #!/usr/bin/env python3
24
+ import serial
25
+ from fastnet_decoder import FrameBuffer
26
+
27
+ fb = FrameBuffer()
28
+
29
+ ser = serial.Serial(
30
+ port="/dev/ttyUSB0",
31
+ baudrate=28800,
32
+ bytesize=serial.EIGHTBITS,
33
+ stopbits=serial.STOPBITS_TWO,
34
+ parity=serial.PARITY_ODD,
35
+ timeout=0.1,
36
+ )
37
+
38
+ try:
39
+ while True:
40
+ data = ser.read(256)
41
+ if not data:
42
+ continue
43
+ fb.add_to_buffer(data)
44
+ fb.get_complete_frames()
45
+ while not fb.frame_queue.empty():
46
+ frame = fb.frame_queue.get()
47
+ for path, value in frame["values"].items():
48
+ print(path, value) # e.g. navigation.speedThroughWater 3.6
49
+ finally:
50
+ ser.close()
51
+ ```
52
+
53
+ Serial settings for a B&G Fastnet bus are **28,800 baud, 8 data bits, odd parity,
54
+ 2 stop bits**. No instruments to hand? The companion apps below ship recorded
55
+ captures you can replay.
56
+
57
+ ## The Fastnet toolkit
58
+
59
+ Three projects stack together — pick the layer that matches where you want the
60
+ data to end up:
61
+
62
+ | Project | What it does | Use it when |
63
+ |---|---|---|
64
+ | **pyfastnet** *(this library)* | **Decoder.** Turns raw Fastnet bytes into Signal K paths in SI units. | You're writing your own Python and want the decoded data. |
65
+ | [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. |
66
+ | [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. |
67
+
68
+ ```
69
+ ┌─ fastnet2ip → UDP (NMEA 0183 / NMEA 2000 over IP) → Signal K, OpenCPN, plotters
70
+ B&G Fastnet bus ─(serial)─→ pyfastnet ─┤
71
+ └─ fastnet2n2k → SocketCAN (NMEA 2000 PGNs) → CAN backbone, chartplotter
72
+ ```
73
+
74
+ pyfastnet is the **engine** at the bottom of the stack. If you only want the
75
+ decoded data on your network or NMEA 2000 bus — including running it as an
76
+ always-on systemd service — use one of the companion apps; they handle the serial
77
+ port, a live data store, rate limiting, and output for you.
78
+
79
+ ## Output format
80
+
81
+ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
82
+ `values`. `values` maps **Signal K paths to SI values** — one canonical entry per
83
+ physical quantity:
84
+
85
+ ```python
86
+ {
87
+ "to_address": "Entire System",
88
+ "from_address": "Normal CPU (Wind Board in H2000)",
89
+ "command": "Broadcast",
90
+ "values": {
91
+ "environment.wind.speedApparent": 4.6, # m/s
92
+ "environment.wind.angleApparent": 0.419, # radians
93
+ "environment.wind.directionMagnetic": 1.239, # radians
94
+ "navigation.speedThroughWater": 3.19, # m/s
95
+ }
96
+ }
97
+ ```
98
+
99
+ Units follow the Signal K spec: angles in **radians**, speed in **m/s**, distance
100
+ in **metres**, temperature in **Kelvin**, pressure in **Pascals**. A value is a
101
+ `float` (SI), a `str` enum (e.g. `steering.autopilot.state` → `"standby"`), a
102
+ position object, or `None` when unavailable.
103
+
104
+ Redundant unit-variant channels the bus sends (feet/fathoms depth, knots wind, °F)
105
+ are collapsed to one canonical path. B&G-proprietary channels with no standard
106
+ Signal K path — including the pre-calibration `raw` sensor values — are emitted
107
+ under a `bandg.*` namespace (e.g. `bandg.wind.rawAngleApparent`).
108
+
109
+ ### Units and the full path map
110
+
111
+ Every emitted path has a canonical SI unit, available programmatically:
112
+
113
+ ```python
114
+ from fastnet_decoder import unit_for
115
+ unit_for("navigation.speedThroughWater") # "m/s"
116
+ unit_for("navigation.headingMagnetic") # "rad"
117
+ unit_for("environment.water.temperature") # "K"
118
+ unit_for("navigation.position") # "deg"
119
+ ```
120
+
121
+ The complete mapping — every B&G channel id/name → Signal K path + unit, the
122
+ `bandg.*` vendor namespace, layout-driven Magnetic/True routing, and the
123
+ dropped/collapsed channels — is documented in
124
+ [`docs/v3_signalk_mapping.md`](docs/v3_signalk_mapping.md).
125
+
126
+ ### Position
127
+
128
+ Position frames are emitted as `navigation.position`, a decimal-degree object
129
+ (negative for S / W):
130
+
131
+ ```python
132
+ "navigation.position": {"latitude": -33.8742, "longitude": 151.2320}
133
+ ```
134
+
135
+ ### True vs Magnetic
136
+
137
+ The reference is carried by the **path**, not a separate field:
138
+ `navigation.headingMagnetic` vs `navigation.headingTrue`,
139
+ `environment.wind.directionMagnetic` vs `directionTrue`,
140
+ `environment.current.setMagnetic` vs `setTrue`. The decoder selects the right path
141
+ from the display's indicator symbol at decode time. (The raw stream carries no
142
+ magnetic variation or deviation, so it cannot convert between the two.)
143
+
144
+ ### Complete (rich) decode
145
+
146
+ `FrameBuffer(project=False)` queues the full internal decode instead of the Signal K
147
+ projection. Each value is then a dict — `value`, `display_text`, `layout`,
148
+ `channel_id` — keyed by human-readable channel name (the v2 format). Useful for
149
+ debugging and reverse-engineering; `display_text` and `layout` are not exposed in
150
+ the default Signal K output.
151
+
152
+ You can also project a single decoded frame yourself:
153
+
154
+ ```python
155
+ from fastnet_decoder import decode_frame, project
156
+ rich = decode_frame(raw_frame_bytes) # complete decode
157
+ si = project(rich) # {signalk_path: SI_value}
158
+ ```
159
+
160
+ ### Debug API
161
+
162
+ ```python
163
+ from fastnet_decoder import set_log_level
164
+ import logging
165
+ set_log_level(logging.DEBUG)
166
+ ```
167
+
168
+ ```python
169
+ fb.get_buffer_size() # bytes currently in buffer
170
+ fb.get_buffer_contents() # hex string of buffer contents
171
+ ```
172
+
173
+ ## Acknowledgments
174
+
175
+ - [trlafleur](https://github.com/trlafleur) — background research
176
+ - [Oppedijk](https://www.oppedijk.com/bandg/fastnet.html) — protocol documentation
177
+ - [timmathews](https://github.com/timmathews/bg-fastnet-driver) — C++ reference implementation
178
+
179
+ ## License
180
+
181
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ __version__ = "3.0.0"
2
+
3
+ from .frame_buffer import FrameBuffer
4
+ from .decode_fastnet import decode_frame, decode_ascii_frame, decode_light_frame
5
+ from .signalk_map import project, unit_for # v3: projection + per-path SI units
6
+ from .logger import logger, set_log_level # Import set_log_level for user control
7
+
8
+ __all__ = ["FrameBuffer", "decode_frame", "decode_ascii_frame", "decode_light_frame",
9
+ "project", "unit_for", "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, AUTOPILOT_MODES
4
+ from .mappings import SEGMENT_A, SEGMENT_B, AUTOPILOT_MODE_BY_LOW, BACKLIGHT_LEVELS
5
5
  from .logger import logger
6
6
 
7
7
 
@@ -13,7 +13,8 @@ _DECIMAL_PLACES_MAP = {1: 0, 10: 1, 100: 2, 1000: 3}
13
13
  # "[data]-" → value is positive, trailing minus means port/starboard convention
14
14
  # "-[data]" → value is negative (subtract sign)
15
15
  # "=[data]" → value is negative (equals sign variant)
16
- # "H[data]" → "H" prefix, e.g. H045 for a heading
16
+ # "H[data]" → heel to port; H carries the sign, value stored negative (e.g. H20.4)
17
+ # "[data]H" → heel to starboard; value positive (e.g. 33.8H)
17
18
  # "°M" → magnetic bearing, suffix added after value
18
19
 
19
20
  def _sign_from_layout(layout: str) -> int:
@@ -192,6 +193,8 @@ def decode_ascii_frame(frame: bytes) -> dict:
192
193
  command = frame[3]
193
194
  body = frame[5:-1]
194
195
 
196
+ # TBC: no len(body) guard here (unlike decode_frame/decode_light_frame) —
197
+ # a short/empty body raises IndexError and falls to the except below.
195
198
  channel_id = body[0]
196
199
  # body[1] is a format byte — not used for ASCII frames
197
200
  data_bytes = body[2:]
@@ -234,6 +237,50 @@ def decode_ascii_frame(frame: bytes) -> dict:
234
237
  return {"error": str(e)}
235
238
 
236
239
 
240
+ def decode_light_frame(frame: bytes) -> dict:
241
+ """
242
+ Decode a Light Intensity (0xC9) command into the system backlight level.
243
+
244
+ Broadcast from a Pilot FFD to the whole system; the body is a single byte
245
+ giving the backlight level (Off/Low/Medium/High). Surfaced as a synthetic
246
+ "Backlight" channel so it queues like other channel data.
247
+ """
248
+ try:
249
+ to_address = frame[0]
250
+ from_address = frame[1]
251
+ command = frame[3]
252
+ body = frame[5:-1]
253
+
254
+ if len(body) < 1:
255
+ return {"error": "Invalid body size"}
256
+
257
+ level = body[0]
258
+ cmd_name = COMMAND_LOOKUP.get(command)
259
+ to_name = ADDRESS_LOOKUP.get(to_address)
260
+ from_name = ADDRESS_LOOKUP.get(from_address)
261
+
262
+ display_text = BACKLIGHT_LEVELS.get(level, f"Unknown ({level})")
263
+ logger.debug(f" CH Backlight level={level} display='{display_text}'")
264
+
265
+ return {
266
+ "to_address": to_name if to_name is not None else f"Unknown (0x{to_address:02X})",
267
+ "from_address": from_name if from_name is not None else f"Unknown (0x{from_address:02X})",
268
+ "command": cmd_name if cmd_name is not None else f"Unknown (0x{command:02X})",
269
+ "values": {
270
+ "Backlight": {
271
+ "channel_id": None,
272
+ "value": float(level),
273
+ "display_text": display_text,
274
+ "layout": None,
275
+ }
276
+ }
277
+ }
278
+
279
+ except Exception as e:
280
+ logger.error(f"Error decoding Light Intensity frame: {e}")
281
+ return {"error": str(e)}
282
+
283
+
237
284
  def decode_format_and_data(channel_id, format_byte, data_bytes):
238
285
  try:
239
286
  divisor = _DIVISOR_MAP[(format_byte >> 6) & 0b11]
@@ -250,8 +297,14 @@ def decode_format_and_data(channel_id, format_byte, data_bytes):
250
297
  return None
251
298
  raw = int.from_bytes(data_bytes, byteorder="big", signed=True)
252
299
  if channel_id == 0xB5:
253
- value = float(raw)
254
- display_text = AUTOPILOT_MODES.get(raw, f"Unknown ({raw})")
300
+ value = float(raw)
301
+ high, low = (raw >> 8) & 0xFF, raw & 0xFF
302
+ if high == 0x50:
303
+ display_text = "Standby"
304
+ elif high in (0x51, 0x59):
305
+ display_text = AUTOPILOT_MODE_BY_LOW.get(low, f"Unknown ({raw})")
306
+ else:
307
+ display_text = f"Unknown ({raw})"
255
308
  else:
256
309
  value = raw / divisor
257
310
  display_text = f"{value:.{decimal_places}f}"
@@ -1,7 +1,8 @@
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
+ from . import signalk_map
5
6
  from .logger import logger
6
7
  from queue import Queue, Full
7
8
 
@@ -17,10 +18,13 @@ class FrameBuffer:
17
18
  3. Pull decoded frames from frame_queue
18
19
  """
19
20
 
20
- def __init__(self, max_buffer_size=8192, max_queue_size=1000):
21
+ def __init__(self, max_buffer_size=8192, max_queue_size=1000, project=True):
21
22
  self.buffer = bytearray()
22
23
  self.max_buffer_size = max_buffer_size
23
24
  self.frame_queue = Queue(maxsize=max_queue_size)
25
+ # v3: queue the {signalk_path: SI_value} projection by default. project=False
26
+ # queues the complete (rich) decode instead — for the golden guard / debugging.
27
+ self.project = project
24
28
 
25
29
  def add_to_buffer(self, new_data):
26
30
  if not isinstance(new_data, (bytes, bytearray)):
@@ -87,33 +91,53 @@ class FrameBuffer:
87
91
  """
88
92
  Decode a frame and add it to the queue if valid.
89
93
 
90
- Only Broadcast frames carry the channel-record body layout, and LatLon
91
- frames carry ASCII; those are decoded and queued. Every other command
92
- (pilot messages, NMEA-sourced data, etc.) has an unknown body structure,
93
- so it is never queued it is only probed speculatively for debugging.
94
+ Broadcast frames carry the channel-record body layout, LatLon frames
95
+ carry ASCII, and Light Intensity frames carry a backlight level byte;
96
+ those are decoded and queued. Every other command (pilot messages,
97
+ NMEA-sourced data, etc.) has an unknown body structure, so it is never
98
+ queued — it is only probed speculatively for debugging.
94
99
  """
95
100
  if command_name == "Broadcast":
96
101
  decoded_frame = decode_frame(frame)
97
102
  elif command_name == "LatLon":
98
103
  decoded_frame = decode_ascii_frame(frame)
104
+ elif command_name == "Light Intensity":
105
+ decoded_frame = decode_light_frame(frame)
99
106
  else:
100
107
  probe_frame(frame)
101
108
  return
102
109
 
103
- if decoded_frame and "values" in decoded_frame:
104
- try:
105
- self.frame_queue.put_nowait(decoded_frame)
110
+ if not (decoded_frame and "values" in decoded_frame):
111
+ if logger.isEnabledFor(logging.DEBUG):
112
+ logger.debug(f" QUEUE fail decode error [{frame.hex()}]")
113
+ return
114
+
115
+ if self.project:
116
+ values = signalk_map.project(decoded_frame)
117
+ if not values:
118
+ # every channel dropped / unmapped (e.g. Backlight, protocol) — nothing to emit
106
119
  if logger.isEnabledFor(logging.DEBUG):
107
- channel_names = list(decoded_frame["values"].keys())
108
- names_str = ", ".join(channel_names[:4])
109
- if len(channel_names) > 4:
110
- names_str += f", +{len(channel_names) - 4} more"
111
- logger.debug(f" QUEUE {len(channel_names)} channel(s) [{names_str}]")
112
- except Full:
113
- logger.warning("Frame queue full, dropping frame.")
120
+ logger.debug(f" QUEUE skip no mapped paths cmd={command_name}")
121
+ return
122
+ queued = {
123
+ "to_address": decoded_frame.get("to_address"),
124
+ "from_address": decoded_frame.get("from_address"),
125
+ "command": decoded_frame.get("command"),
126
+ "values": values,
127
+ }
114
128
  else:
129
+ queued = decoded_frame
130
+
131
+ try:
132
+ self.frame_queue.put_nowait(queued)
115
133
  if logger.isEnabledFor(logging.DEBUG):
116
- logger.debug(f" QUEUE fail decode error [{frame.hex()}]")
134
+ keys = list(queued["values"].keys())
135
+ names_str = ", ".join(keys[:4])
136
+ if len(keys) > 4:
137
+ names_str += f", +{len(keys) - 4} more"
138
+ logger.debug(f" QUEUE {len(keys)} value(s) [{names_str}]")
139
+ except Full:
140
+ logger.warning("Frame queue full, dropping frame.")
117
141
 
118
142
  def get_buffer_size(self):
119
143
  return len(self.buffer)
@@ -15,6 +15,11 @@ logger.setLevel(DEFAULT_LOG_LEVEL)
15
15
 
16
16
  def set_log_level(level_name: str):
17
17
  """Sets the log level dynamically at runtime."""
18
- level = getattr(logging, level_name.upper(), DEFAULT_LOG_LEVEL)
18
+ level = logging.getLevelName(level_name.upper())
19
+ if not isinstance(level, int):
20
+ logger.warning(
21
+ f"Unknown log level '{level_name}'; keeping current level."
22
+ )
23
+ return
19
24
  logger.setLevel(level)
20
25
  logger.info(f"Log level set to {level_name.upper()}.")
@@ -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 = {