pyfastnet 2.0.18__tar.gz → 3.1.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 (40) hide show
  1. {pyfastnet-2.0.18/pyfastnet.egg-info → pyfastnet-3.1.0}/PKG-INFO +79 -58
  2. pyfastnet-3.1.0/README.md +190 -0
  3. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/fastnet_decoder/__init__.py +4 -2
  4. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/fastnet_decoder/decode_fastnet.py +4 -1
  5. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/fastnet_decoder/frame_buffer.py +33 -12
  6. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/fastnet_decoder/logger.py +6 -1
  7. pyfastnet-3.1.0/fastnet_decoder/signalk_map.py +352 -0
  8. {pyfastnet-2.0.18 → pyfastnet-3.1.0/pyfastnet.egg-info}/PKG-INFO +79 -58
  9. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/pyfastnet.egg-info/SOURCES.txt +4 -0
  10. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_backlight.py +6 -2
  11. pyfastnet-3.1.0/tests/test_channel_map.py +28 -0
  12. pyfastnet-3.1.0/tests/test_golden.py +33 -0
  13. pyfastnet-3.1.0/tests/test_signalk_projection.py +191 -0
  14. pyfastnet-2.0.18/README.md +0 -169
  15. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/LICENSE +0 -0
  16. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/fastnet_decoder/mappings.py +0 -0
  17. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/fastnet_decoder/utils.py +0 -0
  18. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/pyfastnet.egg-info/dependency_links.txt +0 -0
  19. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/pyfastnet.egg-info/requires.txt +0 -0
  20. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/pyfastnet.egg-info/top_level.txt +0 -0
  21. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/pyproject.toml +0 -0
  22. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/setup.cfg +0 -0
  23. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_autopilot.py +0 -0
  24. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_awa.py +0 -0
  25. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_aws.py +0 -0
  26. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_battery.py +0 -0
  27. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_boatspeed.py +0 -0
  28. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_depth_frame.py +0 -0
  29. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_format07_msb_regression.py +0 -0
  30. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_format08_layout.py +0 -0
  31. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_heading.py +0 -0
  32. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_heel_trim.py +0 -0
  33. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_latlon_frame.py +0 -0
  34. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_sea_temp.py +0 -0
  35. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_sog.py +0 -0
  36. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_tidal.py +0 -0
  37. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_timer.py +0 -0
  38. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_twa.py +0 -0
  39. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_twd.py +0 -0
  40. {pyfastnet-2.0.18 → pyfastnet-3.1.0}/tests/test_tws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfastnet
3
- Version: 2.0.18
3
+ Version: 3.1.0
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
@@ -54,8 +54,13 @@ Dynamic: license-file
54
54
 
55
55
  A Python library for decoding the **FastNet** protocol used by B&G Hydra / H2000
56
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.
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)`.
59
64
 
60
65
  Developed for personal use and published for general interest. Runs on Raspberry
61
66
  Pi, macOS, or Linux.
@@ -91,8 +96,8 @@ try:
91
96
  fb.get_complete_frames()
92
97
  while not fb.frame_queue.empty():
93
98
  frame = fb.frame_queue.get()
94
- for channel, decoded in frame["values"].items():
95
- print(channel, decoded)
99
+ for path, value in frame["values"].items():
100
+ print(path, value) # e.g. navigation.speedThroughWater 3.6
96
101
  finally:
97
102
  ser.close()
98
103
  ```
@@ -108,7 +113,7 @@ data to end up:
108
113
 
109
114
  | Project | What it does | Use it when |
110
115
  |---|---|---|
111
- | **pyfastnet** *(this library)* | **Decoder.** Turns raw Fastnet bytes into named instrument channels. | You're writing your own Python and want the decoded data. |
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. |
112
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. |
113
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. |
114
119
 
@@ -126,7 +131,8 @@ port, a live data store, rate limiting, and output for you.
126
131
  ## Output format
127
132
 
128
133
  Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
129
- `values`. Each entry in `values` is keyed by channel name:
134
+ `values`. `values` maps **Signal K paths to SI values** one canonical entry per
135
+ physical quantity:
130
136
 
131
137
  ```python
132
138
  {
@@ -134,68 +140,83 @@ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and
134
140
  "from_address": "Normal CPU (Wind Board in H2000)",
135
141
  "command": "Broadcast",
136
142
  "values": {
137
- "Apparent Wind Speed (Knots)": {
138
- "channel_id": "0x4D",
139
- "value": 7.0,
140
- "display_text": "7.0",
141
- "layout": None,
142
- },
143
- "Apparent Wind Angle": {
144
- "channel_id": "0x51",
145
- "value": -6.0,
146
- "display_text": "-6.0",
147
- "layout": "-[data]",
148
- },
149
- "True Wind Direction": {
150
- "channel_id": "0x6D",
151
- "value": 213.0,
152
- "display_text": "213.0°M",
153
- "layout": "°M",
154
- },
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
155
147
  }
156
148
  }
157
149
  ```
158
150
 
159
- ### Position (LatLon) frames
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
160
162
 
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:
163
+ Every emitted path has a canonical SI unit, available programmatically:
165
164
 
166
165
  ```python
167
- "LatLon": {
168
- "channel_id": "0x4E",
169
- "value": None,
170
- "display_text": "3352.450S15113.920E",
171
- "layout": None,
172
- }
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 master reference — every B&G channel number → name → Signal K path + unit — is
174
+ `channel_map()`, derived from the projection tables so it can't drift:
175
+
176
+ ```python
177
+ from fastnet_decoder import channel_map
178
+ channel_map()[0x41]
179
+ # {'name': 'Boatspeed (Knots)', 'path': 'navigation.speedThroughWater',
180
+ # 'unit': 'm/s', 'kind': 'standard'}
173
181
  ```
174
182
 
175
- ### Layout field
183
+ It is rendered as a table in [`docs/channel_map.md`](docs/channel_map.md). The design
184
+ rationale, `bandg.*` namespace, Magnetic/True routing, and open TBCs are in
185
+ [`docs/v3_signalk_mapping.md`](docs/v3_signalk_mapping.md).
176
186
 
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.
187
+ ### Position
181
188
 
182
- | `layout` | Meaning | Sign |
183
- |---|---|---|
184
- | `None` | No indicator symbol | positive |
185
- | `"[data]="` | `=` after value (starboard) | positive |
186
- | `"=[data]"` | `=` before value (port) | negative |
187
- | `"[data]-"` | `-` after value (starboard) | positive |
188
- | `"-[data]"` | `-` before value (port) | negative |
189
- | `"H[data]"` | `H` prefix — heading | positive |
190
- | `"°M"` | Magnetic bearing suffix | positive |
191
- | `"u[data]"` | `u` prefix upwind (VMG) | positive |
192
- | `"d[data]"` | `d` prefix — downwind (VMG) | positive |
193
- | `"L[data]"` | `L` before — leeway port | negative |
194
- | `"[data]L"` | `L` after AP compass target | positive |
195
- | `"[data]°C"` | Celsius suffix | positive |
196
- | `"[data]°F"` | Fahrenheit suffix | positive |
197
- | `"[data]z"` / `"z[data]"` | Dog-leg symbol — AP off course | positive |
198
- | `"TBC"` | Symbol seen but not yet identified | positive |
189
+ Position frames are emitted as `navigation.position`, a decimal-degree object
190
+ (negative for S / W):
191
+
192
+ ```python
193
+ "navigation.position": {"latitude": -33.8742, "longitude": 151.2320}
194
+ ```
195
+
196
+ ### True vs Magnetic
197
+
198
+ The reference is carried by the **path**, not a separate field:
199
+ `navigation.headingMagnetic` vs `navigation.headingTrue`,
200
+ `environment.wind.directionMagnetic` vs `directionTrue`,
201
+ `environment.current.setMagnetic` vs `setTrue`. The decoder selects the right path
202
+ from the display's indicator symbol at decode time. (The raw stream carries no
203
+ magnetic variation or deviation, so it cannot convert between the two.)
204
+
205
+ ### Complete (rich) decode
206
+
207
+ `FrameBuffer(project=False)` queues the full internal decode instead of the Signal K
208
+ projection. Each value is then a dict — `value`, `display_text`, `layout`,
209
+ `channel_id` — keyed by human-readable channel name (the v2 format). Useful for
210
+ debugging and reverse-engineering; `display_text` and `layout` are not exposed in
211
+ the default Signal K output.
212
+
213
+ You can also project a single decoded frame yourself:
214
+
215
+ ```python
216
+ from fastnet_decoder import decode_frame, project
217
+ rich = decode_frame(raw_frame_bytes) # complete decode
218
+ si = project(rich) # {signalk_path: SI_value}
219
+ ```
199
220
 
200
221
  ### Debug API
201
222
 
@@ -0,0 +1,190 @@
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 master reference — every B&G channel number → name → Signal K path + unit — is
122
+ `channel_map()`, derived from the projection tables so it can't drift:
123
+
124
+ ```python
125
+ from fastnet_decoder import channel_map
126
+ channel_map()[0x41]
127
+ # {'name': 'Boatspeed (Knots)', 'path': 'navigation.speedThroughWater',
128
+ # 'unit': 'm/s', 'kind': 'standard'}
129
+ ```
130
+
131
+ It is rendered as a table in [`docs/channel_map.md`](docs/channel_map.md). The design
132
+ rationale, `bandg.*` namespace, Magnetic/True routing, and open TBCs are in
133
+ [`docs/v3_signalk_mapping.md`](docs/v3_signalk_mapping.md).
134
+
135
+ ### Position
136
+
137
+ Position frames are emitted as `navigation.position`, a decimal-degree object
138
+ (negative for S / W):
139
+
140
+ ```python
141
+ "navigation.position": {"latitude": -33.8742, "longitude": 151.2320}
142
+ ```
143
+
144
+ ### True vs Magnetic
145
+
146
+ The reference is carried by the **path**, not a separate field:
147
+ `navigation.headingMagnetic` vs `navigation.headingTrue`,
148
+ `environment.wind.directionMagnetic` vs `directionTrue`,
149
+ `environment.current.setMagnetic` vs `setTrue`. The decoder selects the right path
150
+ from the display's indicator symbol at decode time. (The raw stream carries no
151
+ magnetic variation or deviation, so it cannot convert between the two.)
152
+
153
+ ### Complete (rich) decode
154
+
155
+ `FrameBuffer(project=False)` queues the full internal decode instead of the Signal K
156
+ projection. Each value is then a dict — `value`, `display_text`, `layout`,
157
+ `channel_id` — keyed by human-readable channel name (the v2 format). Useful for
158
+ debugging and reverse-engineering; `display_text` and `layout` are not exposed in
159
+ the default Signal K output.
160
+
161
+ You can also project a single decoded frame yourself:
162
+
163
+ ```python
164
+ from fastnet_decoder import decode_frame, project
165
+ rich = decode_frame(raw_frame_bytes) # complete decode
166
+ si = project(rich) # {signalk_path: SI_value}
167
+ ```
168
+
169
+ ### Debug API
170
+
171
+ ```python
172
+ from fastnet_decoder import set_log_level
173
+ import logging
174
+ set_log_level(logging.DEBUG)
175
+ ```
176
+
177
+ ```python
178
+ fb.get_buffer_size() # bytes currently in buffer
179
+ fb.get_buffer_contents() # hex string of buffer contents
180
+ ```
181
+
182
+ ## Acknowledgments
183
+
184
+ - [trlafleur](https://github.com/trlafleur) — background research
185
+ - [Oppedijk](https://www.oppedijk.com/bandg/fastnet.html) — protocol documentation
186
+ - [timmathews](https://github.com/timmathews/bg-fastnet-driver) — C++ reference implementation
187
+
188
+ ## License
189
+
190
+ MIT — see [LICENSE](LICENSE).
@@ -1,7 +1,9 @@
1
- __version__ = "2.0.18"
1
+ __version__ = "3.1.0"
2
2
 
3
3
  from .frame_buffer import FrameBuffer
4
4
  from .decode_fastnet import decode_frame, decode_ascii_frame, decode_light_frame
5
+ from .signalk_map import project, unit_for, channel_map # projection + units + master map
5
6
  from .logger import logger, set_log_level # Import set_log_level for user control
6
7
 
7
- __all__ = ["FrameBuffer", "decode_frame", "decode_ascii_frame", "decode_light_frame", "logger", "set_log_level"]
8
+ __all__ = ["FrameBuffer", "decode_frame", "decode_ascii_frame", "decode_light_frame",
9
+ "project", "unit_for", "channel_map", "logger", "set_log_level"]
@@ -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:]
@@ -2,6 +2,7 @@ import logging
2
2
  from .utils import calculate_checksum
3
3
  from .mappings import COMMAND_LOOKUP, IGNORED_COMMANDS
4
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)):
@@ -103,20 +107,37 @@ class FrameBuffer:
103
107
  probe_frame(frame)
104
108
  return
105
109
 
106
- if decoded_frame and "values" in decoded_frame:
107
- try:
108
- 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
109
119
  if logger.isEnabledFor(logging.DEBUG):
110
- channel_names = list(decoded_frame["values"].keys())
111
- names_str = ", ".join(channel_names[:4])
112
- if len(channel_names) > 4:
113
- names_str += f", +{len(channel_names) - 4} more"
114
- logger.debug(f" QUEUE {len(channel_names)} channel(s) [{names_str}]")
115
- except Full:
116
- 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
+ }
117
128
  else:
129
+ queued = decoded_frame
130
+
131
+ try:
132
+ self.frame_queue.put_nowait(queued)
118
133
  if logger.isEnabledFor(logging.DEBUG):
119
- 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.")
120
141
 
121
142
  def get_buffer_size(self):
122
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()}.")