pyfastnet 1.2.4__tar.gz → 2.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 (27) hide show
  1. {pyfastnet-1.2.4/pyfastnet.egg-info → pyfastnet-2.0.0}/PKG-INFO +1 -1
  2. pyfastnet-2.0.0/fastnet_decoder/decode_fastnet.py +237 -0
  3. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/mappings.py +19 -0
  4. pyfastnet-2.0.0/fastnet_decoder/utils.py +48 -0
  5. {pyfastnet-1.2.4 → pyfastnet-2.0.0/pyfastnet.egg-info}/PKG-INFO +1 -1
  6. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/setup.py +1 -1
  7. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_depth_frame.py +6 -6
  8. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_format07_msb_regression.py +4 -4
  9. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_format08_layout.py +9 -19
  10. pyfastnet-1.2.4/fastnet_decoder/decode_fastnet.py +0 -407
  11. pyfastnet-1.2.4/fastnet_decoder/utils.py +0 -98
  12. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/LICENSE +0 -0
  13. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/MANIFEST.in +0 -0
  14. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/README.md +0 -0
  15. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/__init__.py +0 -0
  16. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/frame_buffer.py +0 -0
  17. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/logger.py +0 -0
  18. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/pyfastnet.egg-info/SOURCES.txt +0 -0
  19. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/pyfastnet.egg-info/dependency_links.txt +0 -0
  20. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/pyfastnet.egg-info/top_level.txt +0 -0
  21. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/setup.cfg +0 -0
  22. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_apparent_frame.py +0 -0
  23. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_autopilot_frame.py +0 -0
  24. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_heel_frame.py +0 -0
  25. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_rudder_frame.py +0 -0
  26. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_tide_frame.py +0 -0
  27. {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_true_frame.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfastnet
3
- Version: 1.2.4
3
+ Version: 2.0.0
4
4
  Summary: A Python library for decoding FastNet protocol data streams.
5
5
  Home-page: https://github.com/ghotihook/pyfastnet
6
6
  Author: Alex Salmon
@@ -0,0 +1,237 @@
1
+ import datetime
2
+ from .utils import calculate_checksum
3
+ from .mappings import ADDRESS_LOOKUP, COMMAND_LOOKUP, CHANNEL_LOOKUP, FORMAT_SIZE_MAP
4
+ from .mappings import SEGMENT_A, SEGMENT_B, AUTOPILOT_MODES
5
+ from .logger import logger
6
+
7
+
8
+ _DIVISOR_MAP = {0b00: 1, 0b01: 10, 0b10: 100, 0b11: 1000}
9
+ _DP_MAP = {1: 0, 10: 1, 100: 2, 1000: 3}
10
+
11
+
12
+ def _sign_from_layout(layout: str) -> int:
13
+ return -1 if layout in ("-[data]", "=[data]") else 1
14
+
15
+
16
+ def _display_from_layout(layout: str, formatted: str) -> str:
17
+ if layout == "°M": return f"{formatted}°M"
18
+ if layout == "H[data]": return f"H{formatted}"
19
+ if layout == "[data]H": return f"{formatted}H"
20
+ if layout in ("[data]=", "[data]-"):
21
+ return f"{formatted}{layout[-1]}"
22
+ return formatted
23
+
24
+
25
+ def decode_frame(frame: bytes) -> dict:
26
+ try:
27
+ logger.debug(f"Starting frame decoding. Frame length: {len(frame)}, Frame contents: {frame.hex()}")
28
+
29
+ to_address = frame[0]
30
+ from_address = frame[1]
31
+ body_size = frame[2]
32
+ command = frame[3]
33
+ header_checksum = frame[4]
34
+ body = frame[5:-1]
35
+ body_checksum = frame[-1]
36
+
37
+ logger.debug(
38
+ f"Parsed header: to_address=0x{to_address:02X}, from_address=0x{from_address:02X}, "
39
+ f"body_size={body_size}, command=0x{command:02X}, header_checksum=0x{header_checksum:02X}"
40
+ )
41
+
42
+ if calculate_checksum(frame[:4]) != header_checksum:
43
+ logger.debug(f"Header checksum mismatch. Frame dropped: {frame.hex()}")
44
+ return {"error": "Header checksum mismatch"}
45
+
46
+ if calculate_checksum(body) != body_checksum:
47
+ logger.debug(f"Body checksum mismatch. Frame dropped: {frame.hex()}")
48
+ return {"error": "Body checksum mismatch"}
49
+
50
+ if len(body) < 2 or len(body) != body_size:
51
+ logger.debug(f"Invalid body size: Expected {body_size}, Actual {len(body)}. Frame: {frame.hex()}")
52
+ return {"error": "Invalid body size"}
53
+
54
+ logger.debug("Header and body checksums are valid.")
55
+
56
+ decoded_data = {
57
+ "to_address": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
58
+ "from_address": ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})"),
59
+ "command": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
60
+ "values": {}
61
+ }
62
+
63
+ index = 0
64
+ while index < len(body):
65
+ if index + 1 >= len(body):
66
+ logger.debug(
67
+ f"Insufficient bytes to decode channel ID and format byte at index {index}. "
68
+ f"Remaining length: {len(body) - index}"
69
+ )
70
+ return {"error": "Insufficient bytes for channel header"}
71
+
72
+ channel_id = body[index]
73
+ format_byte = body[index + 1]
74
+ index += 2
75
+
76
+ data_length = FORMAT_SIZE_MAP.get(format_byte & 0x0F, 0)
77
+ if index + data_length > len(body):
78
+ logger.debug(
79
+ f"Incomplete data for channel 0x{channel_id:02X}. "
80
+ f"Expected length: {data_length}, Available: {len(body) - index}"
81
+ )
82
+ return {"error": f"Incomplete data for channel 0x{channel_id:02X}"}
83
+
84
+ data_bytes = body[index:index + data_length]
85
+ index += data_length
86
+
87
+ decoded_value = decode_format_and_data(channel_id, format_byte, data_bytes)
88
+ channel_name = CHANNEL_LOOKUP.get(channel_id, f"Unknown (0x{channel_id:02X})")
89
+
90
+ decoded_data["values"][channel_name] = decoded_value
91
+ logger.debug(f"Decoded value for channel {channel_name}: {decoded_value}")
92
+
93
+ return decoded_data
94
+
95
+ except Exception as e:
96
+ logger.error(f"Unexpected error during frame decoding: {e}. Frame contents: {frame.hex()}")
97
+ return {"error": "Decoding failure"}
98
+
99
+
100
+ def decode_ascii_frame(frame: bytes) -> dict:
101
+ try:
102
+ to_address = frame[0]
103
+ from_address = frame[1]
104
+ command = frame[3]
105
+ header_checksum = frame[4]
106
+ body = frame[5:-1]
107
+
108
+ channel_id = body[0]
109
+ data_bytes = body[2:]
110
+ channel_name = CHANNEL_LOOKUP.get(channel_id, f"Unknown (0x{channel_id:02X})")
111
+
112
+ try:
113
+ ascii_text = data_bytes.decode("ascii").strip()
114
+ except UnicodeDecodeError as e:
115
+ logger.warning(f"Failed to decode ASCII text: {e}")
116
+ return {"error": "ASCII decode failed"}
117
+
118
+ return {
119
+ "to_address": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
120
+ "from_address": ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})"),
121
+ "command": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
122
+ "values": {
123
+ channel_name: {
124
+ "channel_id": f"0x{channel_id:02X}",
125
+ "value": None,
126
+ "display_text": ascii_text,
127
+ "layout": None,
128
+ }
129
+ }
130
+ }
131
+
132
+ except Exception as e:
133
+ logger.error(f"Error decoding ASCII frame: {e}")
134
+ return {"error": str(e)}
135
+
136
+
137
+ def decode_format_and_data(channel_id, format_byte, data_bytes):
138
+ try:
139
+ logger.debug(f"Decoding channel ID: 0x{channel_id:02X}, format byte: 0x{format_byte:02X}, data: {data_bytes.hex()}")
140
+
141
+ divisor = _DIVISOR_MAP[(format_byte >> 6) & 0b11]
142
+ dp = _DP_MAP[divisor]
143
+ format_bits = format_byte & 0b1111
144
+
145
+ if len(data_bytes) == 0:
146
+ logger.debug("decode_format_and_data: Empty data bytes; cannot decode.")
147
+ return None
148
+
149
+ layout = None
150
+
151
+ if format_bits == 0x01:
152
+ if len(data_bytes) != 2:
153
+ return None
154
+ raw = int.from_bytes(data_bytes, byteorder="big", signed=True)
155
+ if channel_id == 0xB5:
156
+ value = float(raw)
157
+ display_text = AUTOPILOT_MODES.get(raw, f"Unknown ({raw})")
158
+ else:
159
+ value = raw / divisor
160
+ display_text = f"{value:.{dp}f}"
161
+
162
+ elif format_bits == 0x02:
163
+ if len(data_bytes) != 2:
164
+ return None
165
+ unsigned = ((data_bytes[0] & 0b11) << 8) | data_bytes[1]
166
+ value = unsigned / divisor
167
+ display_text = f"{value:.{dp}f}"
168
+
169
+ elif format_bits == 0x03:
170
+ if len(data_bytes) != 2:
171
+ return None
172
+ layout = SEGMENT_A.get(data_bytes[0], "?")
173
+ unsigned = data_bytes[1]
174
+ value = _sign_from_layout(layout) * unsigned / divisor
175
+ display_text = _display_from_layout(layout, f"{value:.{dp}f}")
176
+
177
+ elif format_bits == 0x04:
178
+ if len(data_bytes) != 4:
179
+ return None
180
+ unsigned = int.from_bytes(data_bytes[1:], byteorder="big", signed=False)
181
+ value = unsigned / divisor
182
+ display_text = f"{value:.{dp}f}"
183
+
184
+ elif format_bits == 0x05:
185
+ if len(data_bytes) != 4:
186
+ return None
187
+ h, m, s = data_bytes[1], data_bytes[2], data_bytes[3]
188
+ value = float(h * 3600 + m * 60 + s)
189
+ display_text = str(datetime.timedelta(hours=h, minutes=m, seconds=s))
190
+
191
+ elif format_bits == 0x06:
192
+ if len(data_bytes) != 4:
193
+ return None
194
+ value = None
195
+ display_text = "".join(SEGMENT_B.get(b, "?") for b in data_bytes)
196
+ logger.debug(f"Decoded 7-segment text: {display_text}")
197
+
198
+ elif format_bits == 0x07:
199
+ if len(data_bytes) != 4:
200
+ return None
201
+ layout = SEGMENT_A.get(data_bytes[1], "?")
202
+ msb = data_bytes[2] & 0b01111111
203
+ unsigned = (msb << 8) | data_bytes[3]
204
+ value = _sign_from_layout(layout) * unsigned / divisor
205
+ display_text = _display_from_layout(layout, f"{value:.{dp}f}")
206
+
207
+ elif format_bits == 0x08:
208
+ if len(data_bytes) != 2:
209
+ return None
210
+ segment_code = (data_bytes[0] >> 1) & 0b01111111
211
+ layout = SEGMENT_A.get(segment_code, "?")
212
+ unsigned = ((data_bytes[0] & 0b1) << 8) | data_bytes[1]
213
+ value = unsigned / divisor
214
+ display_text = _display_from_layout(layout, f"{value:.{dp}f}")
215
+
216
+ elif format_bits == 0x0A:
217
+ if len(data_bytes) != 4:
218
+ return None
219
+ first = int.from_bytes(data_bytes[:2], byteorder="big", signed=True) / divisor
220
+ second = int.from_bytes(data_bytes[2:], byteorder="big", signed=True) / divisor
221
+ value = first
222
+ display_text = f"{first:.{dp}f} / {second:.{dp}f}"
223
+
224
+ else:
225
+ logger.debug(f"Unsupported format: 0x{format_bits:02X}.")
226
+ return None
227
+
228
+ return {
229
+ "channel_id": f"0x{channel_id:02X}",
230
+ "value": value,
231
+ "display_text": display_text,
232
+ "layout": layout,
233
+ }
234
+
235
+ except Exception as e:
236
+ logger.error(f"Error decoding channel 0x{channel_id:02X}: {e}")
237
+ return None
@@ -275,6 +275,25 @@ CHANNEL_LOOKUP = {
275
275
  }
276
276
 
277
277
 
278
+ SEGMENT_A = {
279
+ 0x66: "°M",
280
+ 0x28: "[data]=", 0xa8: "=[data]",
281
+ 0x20: "[data]-", 0xa0: "-[data]",
282
+ 0x8c: "=[data]", 0x0c: "[data]=",
283
+ 0xf3: "H[data]", 0x73: "[data]H",
284
+ 0x00: " ",
285
+ }
286
+
287
+ SEGMENT_B = {
288
+ 0xBE: "O", 0xE8: "F", 0x62: "n",
289
+ 0x72: "o", 0x40: "-", 0x00: " ",
290
+ }
291
+
292
+ AUTOPILOT_MODES = {
293
+ 20484: "Standby", 20737: "Compass", 20738: "Power",
294
+ 20740: "Wind", 20755: "NMEA WP",
295
+ }
296
+
278
297
  FORMAT_SIZE_MAP = {
279
298
  0x00: 4, # 32 bits (4 bytes)
280
299
  0x01: 2, # 16 bits (2 bytes)
@@ -0,0 +1,48 @@
1
+
2
+
3
+ def calculate_checksum(data):
4
+ """
5
+ Calculates the checksum for the given data bytes this one is for Fastnet Checksum
6
+ Args:
7
+ data (bytes): The data bytes to calculate checksum for.
8
+ Returns:
9
+ int: The calculated checksum.
10
+ """
11
+ return (0x100 - sum(data) % 0x100) & 0xFF
12
+
13
+
14
+ def calculate_nmea_checksum(sentence):
15
+ """
16
+ Calculates the NMEA checksum for a given sentence (excluding '$' and '*').
17
+ Args:
18
+ sentence (str): NMEA sentence string without '$' and checksum '*'.
19
+ Returns:
20
+ str: Hexadecimal checksum as a string.
21
+ """
22
+ checksum = 0
23
+ for char in sentence:
24
+ checksum ^= ord(char)
25
+ return f"{checksum:02X}"
26
+
27
+
28
+
29
+ def parse_format_byte(format_byte):
30
+ """
31
+ Parses the format byte into divisor, digits, and format type.
32
+ Args:
33
+ format_byte (int): The format byte.
34
+ Returns:
35
+ dict: Parsed divisor, digits, and format type.
36
+ """
37
+ divisor_bits = (format_byte >> 6) & 0b11 # First two bits
38
+ digits_bits = (format_byte >> 4) & 0b11 # Next two bits
39
+ format_type = format_byte & 0x0F # Last 4 bits (format type)
40
+
41
+ divisor_map = {0b00: 1, 0b01: 10, 0b10: 100, 0b11: 1000}
42
+ digits_map = {0b00: 1, 0b01: 2, 0b10: 3, 0b11: 4}
43
+
44
+ return {
45
+ "divisor": divisor_map.get(divisor_bits, 1),
46
+ "digits": digits_map.get(digits_bits, 1),
47
+ "format_type": format_type,
48
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfastnet
3
- Version: 1.2.4
3
+ Version: 2.0.0
4
4
  Summary: A Python library for decoding FastNet protocol data streams.
5
5
  Home-page: https://github.com/ghotihook/pyfastnet
6
6
  Author: Alex Salmon
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="pyfastnet",
5
- version="1.2.4", # Ensure this matches your intended version
5
+ version="2.0.0", # Ensure this matches your intended version
6
6
  author="Alex Salmon",
7
7
  author_email="alex@ivila.net",
8
8
  description="A Python library for decoding FastNet protocol data streams.",
@@ -34,20 +34,20 @@ class TestDepthFrame(unittest.TestCase):
34
34
  self.assertIn("Depth (Fathoms)", self.values)
35
35
 
36
36
  def test_depth_meters(self):
37
- self.assertEqual(self.values["Depth (Meters)"]["interpreted"], 12.0)
37
+ self.assertEqual(self.values["Depth (Meters)"]["value"], 12.0)
38
38
 
39
39
  def test_depth_feet(self):
40
40
  # Raw value is 395 (> 255), so data_bytes[2] = 0x01 — this is the case
41
41
  # that was broken before the msb >> 1 fix. Correct value is 39.5 ft.
42
- self.assertEqual(self.values["Depth (Feet)"]["interpreted"], 39.5)
42
+ self.assertEqual(self.values["Depth (Feet)"]["value"], 39.5)
43
43
 
44
44
  def test_depth_fathoms(self):
45
- self.assertEqual(self.values["Depth (Fathoms)"]["interpreted"], 6.6)
45
+ self.assertEqual(self.values["Depth (Fathoms)"]["value"], 6.6)
46
46
 
47
47
  def test_depth_units_are_consistent(self):
48
- meters = self.values["Depth (Meters)"]["interpreted"]
49
- feet = self.values["Depth (Feet)"]["interpreted"]
50
- fathoms = self.values["Depth (Fathoms)"]["interpreted"]
48
+ meters = self.values["Depth (Meters)"]["value"]
49
+ feet = self.values["Depth (Feet)"]["value"]
50
+ fathoms = self.values["Depth (Fathoms)"]["value"]
51
51
 
52
52
  # Allow 0.2 tolerance: each channel is independently rounded to 0.1 of its
53
53
  # own unit by the instrument, so cross-unit comparisons can differ by ~0.15.
@@ -40,14 +40,14 @@ class TestFormat07MSBRegression(unittest.TestCase):
40
40
 
41
41
  def test_tidal_set_value(self):
42
42
  values = self._decode(self.TIDAL_SET_FRAME)
43
- self.assertEqual(values["Tidal Set"]["interpreted"], 297.0,
43
+ self.assertEqual(values["Tidal Set"]["value"], 297.0,
44
44
  "Tidal Set should be 297° (was 41° with the >> 1 bug)")
45
45
 
46
46
  def test_tidal_drift_unaffected(self):
47
47
  # Tidal Drift raw value is 48 (< 256), so data_bytes[2] == 0 — unaffected by bug
48
48
  values = self._decode(self.TIDAL_SET_FRAME)
49
49
  self.assertIn("Tidal Drift", values)
50
- self.assertAlmostEqual(values["Tidal Drift"]["interpreted"], 0.48, places=2)
50
+ self.assertAlmostEqual(values["Tidal Drift"]["value"], 0.48, places=2)
51
51
 
52
52
  # ------------------------------------------------------------------
53
53
  # Autopilot Compass Target (0xA6) — raw 354, format_byte 0x07, divisor 1
@@ -63,14 +63,14 @@ class TestFormat07MSBRegression(unittest.TestCase):
63
63
 
64
64
  def test_autopilot_target_value(self):
65
65
  values = self._decode(self.AUTOPILOT_TARGET_FRAME)
66
- self.assertEqual(values["Autopilot Compass Target"]["interpreted"], 354.0,
66
+ self.assertEqual(values["Autopilot Compass Target"]["value"], 354.0,
67
67
  "Autopilot Compass Target should be 354° (was 98° with the >> 1 bug)")
68
68
 
69
69
  def test_autopilot_mode_unaffected(self):
70
70
  # Autopilot Mode uses format 0x01 (16-bit signed), not format 0x07 — unaffected
71
71
  values = self._decode(self.AUTOPILOT_TARGET_FRAME)
72
72
  self.assertIn("Autopilot Mode", values)
73
- self.assertEqual(values["Autopilot Mode"]["interpreted"], "Compass")
73
+ self.assertEqual(values["Autopilot Mode"]["display_text"], "Compass")
74
74
 
75
75
 
76
76
  if __name__ == "__main__":
@@ -6,15 +6,14 @@ class TestFormat08Layout(unittest.TestCase):
6
6
  """
7
7
  Tests for format 0x08 layout field (magnetic bearing indicator).
8
8
 
9
- Format 0x08: SEGCODE_B-derived 7-bit segment code + 9-bit unsigned value.
10
- Segment code 0x66 (segments f+g+a+b = degree symbol) activates the "°M"
11
- annunciator on the Hydra 2000 FFD, indicating a magnetic-referenced bearing.
9
+ Format 0x08: 7-bit segment code + 9-bit unsigned value.
10
+ Segment code 0x66 activates the "°M" annunciator, indicating magnetic reference.
12
11
 
13
12
  Frame "ff120e01e00b038c274908cc294a0a1cdd6067e5" contains:
14
13
  - Rudder Angle (0x0b), format 0x03
15
14
  - Heading (0x49), format 0x08, data=[0xCC, 0x29]
16
15
  data[0]=0xCC → segment_code = (0xCC >> 1) & 0x7F = 0x66 → layout "°M"
17
- unsigned_value = 0x29 = 41 → interpreted 41.0°
16
+ unsigned_value = 0x29 = 41 → value 41.0
18
17
  - Heading Raw (0x4a), format 0x0a
19
18
  """
20
19
 
@@ -30,31 +29,22 @@ class TestFormat08Layout(unittest.TestCase):
30
29
  def test_heading_present(self):
31
30
  self.assertIn("Heading", self.decoded["values"])
32
31
 
33
- def test_heading_interpreted_value(self):
34
- self.assertEqual(self.heading["interpreted"], 41.0)
35
-
36
- def test_heading_format_is_08(self):
37
- self.assertEqual(self.heading["format_bits"], 8)
38
-
39
- def test_heading_raw_has_layout(self):
40
- self.assertIn("layout", self.heading["raw"])
32
+ def test_heading_value(self):
33
+ self.assertEqual(self.heading["value"], 41.0)
41
34
 
42
35
  def test_heading_layout_is_magnetic(self):
43
- self.assertEqual(self.heading["raw"]["layout"], "°M")
36
+ self.assertEqual(self.heading["layout"], "°M")
44
37
 
45
- def test_heading_segment_code(self):
46
- # 0x66 = segments f+g+a+b = degree symbol, co-activates Hydra 2000 "M" annunciator
47
- self.assertEqual(self.heading["raw"]["segment_code"], 0x66)
38
+ def test_heading_display_text(self):
39
+ self.assertEqual(self.heading["display_text"], "41°M")
48
40
 
49
41
  def test_cog_true_has_blank_layout(self):
50
42
  # COG True is GPS-derived — no magnetic indicator, segment code 0x00 = blank
51
- # Frame: ff051601e5 ... e9 08 00 XX ... (COG True channel 0xe9)
52
- # Use a frame that contains COG True with blank segment code
53
43
  cog_frame = bytes.fromhex("FF051601E555610030566100185903A86B7F8700BB00016D08CD0DCB")
54
44
  decoded = decode_frame(cog_frame)
55
45
  cog = decoded["values"].get("Course Over Ground (True)")
56
46
  if cog:
57
- self.assertEqual(cog["raw"]["layout"], " ", "GPS COG should have blank layout (no magnetic indicator)")
47
+ self.assertEqual(cog["layout"], " ", "GPS COG should have blank layout (no magnetic indicator)")
58
48
 
59
49
 
60
50
  if __name__ == "__main__":
@@ -1,407 +0,0 @@
1
- import datetime
2
- from .utils import calculate_checksum, convert_segment_b_to_char, convert_segment_a_to_char
3
- from .mappings import ADDRESS_LOOKUP, COMMAND_LOOKUP, CHANNEL_LOOKUP, FORMAT_SIZE_MAP
4
- from .logger import logger
5
-
6
-
7
-
8
- def decode_frame(frame: bytes) -> dict:
9
- try:
10
- logger.debug(f"Starting frame decoding. Frame length: {len(frame)}, Frame contents: {frame.hex()}")
11
-
12
- # Parse the header
13
- to_address = frame[0]
14
- from_address = frame[1]
15
- body_size = frame[2]
16
- command = frame[3]
17
- header_checksum= frame[4]
18
- body = frame[5:-1]
19
- body_checksum = frame[-1]
20
-
21
- logger.debug(
22
- f"Parsed header: to_address=0x{to_address:02X}, from_address=0x{from_address:02X}, "
23
- f"body_size={body_size}, command=0x{command:02X}, header_checksum=0x{header_checksum:02X}"
24
- )
25
-
26
- # Validate checksums
27
- if calculate_checksum(frame[:4]) != header_checksum:
28
- logger.debug(f"Header checksum mismatch. Frame dropped: {frame.hex()}")
29
- return {"error": "Header checksum mismatch"}
30
-
31
- if calculate_checksum(body) != body_checksum:
32
- logger.debug(f"Body checksum mismatch. Frame dropped: {frame.hex()}")
33
- return {"error": "Body checksum mismatch"}
34
-
35
- # Validate body size explicitly
36
- if len(body) < 2 or len(body) != body_size:
37
- logger.debug(
38
- f"Invalid body size: Expected {body_size}, Actual {len(body)}. Frame: {frame.hex()}"
39
- )
40
- return {"error": "Invalid body size"}
41
-
42
- logger.debug("Header and body checksums are valid.")
43
-
44
- decoded_data = {
45
- "to_address": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
46
- "from_address": ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})"),
47
- "command": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
48
- "values": {}
49
- }
50
-
51
- # Decode body
52
- index = 0
53
- while index < len(body):
54
- # Make sure we have at least channel ID + format byte
55
- if index + 1 >= len(body):
56
- logger.debug(
57
- f"Insufficient bytes to decode channel ID and format byte at index {index}. "
58
- f"Remaining length: {len(body) - index}"
59
- )
60
- return {"error": "Insufficient bytes for channel header"}
61
-
62
- channel_id = body[index]
63
- format_byte = body[index + 1]
64
- index += 2
65
-
66
- # Determine how many data bytes this format expects
67
- data_length = FORMAT_SIZE_MAP.get(format_byte & 0x0F, 0)
68
- if index + data_length > len(body):
69
- logger.debug(
70
- f"Incomplete data for channel 0x{channel_id:02X}. "
71
- f"Expected length: {data_length}, Available: {len(body) - index}"
72
- )
73
- return {"error": f"Incomplete data for channel 0x{channel_id:02X}"}
74
-
75
- data_bytes = body[index:index + data_length]
76
- index += data_length
77
-
78
- # Decode the actual value
79
- decoded_value = decode_format_and_data(channel_id, format_byte, data_bytes)
80
- channel_name = CHANNEL_LOOKUP.get(channel_id, f"Unknown (0x{channel_id:02X})")
81
-
82
- decoded_data["values"][channel_name] = decoded_value
83
- logger.debug(f"Decoded value for channel {channel_name}: {decoded_value}")
84
-
85
- return decoded_data
86
-
87
- except Exception as e:
88
- logger.error(f"Unexpected error during frame decoding: {e}. Frame contents: {frame.hex()}")
89
- return {"error": "Decoding failure"}
90
-
91
-
92
-
93
- # def decode_frame(frame: bytes) -> dict:
94
- # try:
95
- # logger.debug(f"Starting frame decoding. Frame length: {len(frame)}, Frame contents: {frame.hex()}")
96
-
97
- # # Parse the header
98
- # to_address = frame[0]
99
- # from_address = frame[1]
100
- # body_size = frame[2]
101
- # command = frame[3]
102
- # header_checksum = frame[4]
103
- # body = frame[5:-1]
104
- # body_checksum = frame[-1]
105
-
106
- # logger.debug(f"Parsed header: to_address=0x{to_address:02X}, from_address=0x{from_address:02X}, "
107
- # f"body_size={body_size}, command=0x{command:02X}, header_checksum=0x{header_checksum:02X}")
108
-
109
- # # Validate checksums
110
- # if calculate_checksum(frame[:4]) != header_checksum:
111
- # logger.info(f"Header checksum mismatch. Frame dropped: {frame.hex()}")
112
- # return {"error": "Header checksum mismatch"}
113
-
114
- # if calculate_checksum(body) != body_checksum:
115
- # logger.info(f"Body checksum mismatch. Frame dropped: {frame.hex()}")
116
- # return {"error": "Body checksum mismatch"}
117
-
118
- # # Validate body size explicitly
119
- # if len(body) < 2 or len(body) != body_size:
120
- # logger.info(f"Invalid body size: Expected {body_size}, Actual {len(body)}. Frame: {frame.hex()}")
121
- # return {"error": "Invalid body size"}
122
-
123
- # logger.debug("Header and body checksums are valid.")
124
-
125
- # # Decode frame...
126
- # decoded_data = {
127
- # "to_address": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
128
- # "from_address": ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})"),
129
- # "command": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
130
- # "values": {}
131
- # }
132
-
133
- # # Decode body
134
- # index = 0
135
- # while index < len(body):
136
- # if index + 1 >= len(body):
137
- # raise ValueError(f"Insufficient bytes to decode channel ID and format byte at index {index}. Remaining length: {len(body) - index}")
138
-
139
- # channel_id = body[index]
140
- # format_byte = body[index + 1]
141
- # index += 2
142
-
143
- # data_length = FORMAT_SIZE_MAP.get(format_byte & 0x0F, 0)
144
- # if index + data_length > len(body):
145
- # raise ValueError(f"Incomplete data for channel 0x{channel_id:02X}. Expected length: {data_length}, Available: {len(body) - index}")
146
-
147
- # data_bytes = body[index:index + data_length]
148
- # index += data_length
149
-
150
- # decoded_value = decode_format_and_data(channel_id, format_byte, data_bytes)
151
- # channel_name = CHANNEL_LOOKUP.get(channel_id, f"Unknown (0x{channel_id:02X})")
152
-
153
- # decoded_data["values"][channel_name] = decoded_value
154
- # logger.debug(f"Decoded value for channel {channel_name}: {decoded_value}")
155
-
156
- # return decoded_data
157
-
158
- # except Exception as e:
159
- # logger.error(f"Error during frame decoding: {e}. Frame contents: {frame.hex()}")
160
- # return None
161
-
162
-
163
-
164
-
165
-
166
-
167
-
168
-
169
-
170
-
171
- def decode_ascii_frame(frame: bytes) -> dict:
172
- """
173
- Decodes an ASCII FastNet frame and returns interpreted values.
174
-
175
- Args:
176
- frame (bytes): The full ASCII frame (header + body).
177
-
178
- Returns:
179
- dict: Decoded data including addresses, command, and channel values.
180
- """
181
- try:
182
- to_address = frame[0]
183
- from_address = frame[1]
184
- body_size = frame[2]
185
- command = frame[3]
186
- header_checksum = frame[4]
187
- body = frame[5:-1] # Body starts after header checksum
188
- body_checksum = frame[-1]
189
-
190
- channel_id = body[0]
191
- format_byte = body[1]
192
- data_bytes = body[2:]
193
-
194
- channel_name = CHANNEL_LOOKUP.get(channel_id, f"Unknown (0x{channel_id:02X})")
195
-
196
- try:
197
- ascii_text = data_bytes.decode("ascii").strip()
198
- interpreted_value = ascii_text
199
- raw_value = ascii_text
200
- except UnicodeDecodeError as decode_error:
201
- logger.warning(f"Failed to decode ASCII text: {decode_error}")
202
- return {"error": "ASCII decode failed"}
203
-
204
- decoded_data = {
205
- "to_address": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
206
- "from_address": ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})"),
207
- "command": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
208
- "values": {
209
- channel_name: {
210
- "channel_id": f"0x{channel_id:02X}",
211
- "format_byte": f"0x{format_byte:02X}",
212
- "data_bytes": data_bytes.hex(),
213
- "raw": raw_value,
214
- "interpreted": interpreted_value
215
- }
216
- }
217
- }
218
-
219
- return decoded_data
220
-
221
- except Exception as e:
222
- logger.error(f"Error decoding ASCII frame: {e}")
223
- return {"error": str(e)}
224
-
225
- def decode_format_and_data(channel_id, format_byte, data_bytes):
226
- """
227
- Decodes the format byte and interprets the data accordingly.
228
-
229
- Args:
230
- channel_id (int): Channel ID (from `CHANNEL_LOOKUP`).
231
- format_byte (int): The format byte indicating divisor, digits, and data interpretation.
232
- data_bytes (bytes): The raw data to decode.
233
-
234
- Returns:
235
- dict: Decoded results including format details and the final interpreted value.
236
- """
237
- try:
238
- logger.debug(f"Decoding channel ID: 0x{channel_id:02X}, format byte: 0x{format_byte:02X}, data: {data_bytes.hex()}")
239
-
240
- # Extract format information from the format byte
241
- divisor_bits = (format_byte >> 6) & 0b11 # First two bits
242
- digits_bits = (format_byte >> 4) & 0b11 # Next two bits
243
- format_bits = format_byte & 0b1111 # Last four bits
244
-
245
- # Map divisor and digits bits to actual values
246
- divisor_map = {0b00: 1, 0b01: 10, 0b10: 100, 0b11: 1000}
247
- digits_map = {0b00: 1, 0b01: 2, 0b10: 3, 0b11: 4}
248
-
249
- divisor = divisor_map.get(divisor_bits, 1)
250
- digits = digits_map.get(digits_bits, 1)
251
-
252
- if len(data_bytes) == 0:
253
- logger.debug("decode_format_and_data: Empty data bytes; cannot decode.")
254
- return None
255
-
256
- # Decode based on format bits
257
- if format_bits == 0x01: # 16-bit signed integer
258
- if len(data_bytes) != 2:
259
- logger.debug("Data length mismatch for 16-bit signed integer (expected 2 bytes).")
260
- return None
261
- raw_value = int.from_bytes(data_bytes, byteorder="big", signed=True)
262
-
263
- # Special handling for Autopilot Mode (channel 0xB5)
264
- if channel_id == 0xB5:
265
- autopilot_modes = {
266
- 20484: "Standby",
267
- 20737: "Compass",
268
- 20738: "Power",
269
- 20740: "Wind",
270
- 20755: "NMEA WP",
271
- }
272
- interpreted_value = autopilot_modes.get(raw_value, f"Unknown ({raw_value})")
273
- else:
274
- interpreted_value = raw_value / divisor
275
-
276
-
277
- elif format_bits == 0x02: # 6-bit segment + 10-bit unsigned value
278
- if len(data_bytes) != 2:
279
- logger.debug("Data length mismatch for 6-bit segment + 10-bit unsigned (expected 2 bytes).")
280
- return None
281
- segment_code = (data_bytes[0] >> 2) & 0b111111 # 6-bit segment code
282
- unsigned_value = ((data_bytes[0] & 0b11) << 8) | data_bytes[1] # 10-bit unsigned value
283
- interpreted_value = unsigned_value / divisor
284
- raw_value = {"segment_code": segment_code, "unsigned_value": unsigned_value}
285
-
286
- elif format_bits == 0x03:
287
- if len(data_bytes) != 2:
288
- logger.debug("Data length mismatch for 7-bit segment + 9-bit unsigned (expected 2 bytes).")
289
- return None
290
- # 7-bit segment + 9-bit unsigned - think this is wrong, believe to be 8+8 as per below
291
- #segment_code = (data_bytes[0] >> 1) & 0b01111111 # 7-bit segment
292
- #unsigned_value = ((data_bytes[0] & 0b1) << 8) | data_bytes[1] # 9-bit unsigned value
293
- segment_code = data_bytes[0]
294
- unsigned_value = data_bytes[1]
295
-
296
- layout = convert_segment_a_to_char(segment_code)
297
- if layout == "-[data]" or layout == "=[data]":
298
- signed_value = -unsigned_value
299
- else:
300
- signed_value = unsigned_value
301
- interpreted_value = signed_value / divisor
302
- raw_value = {"segment_code": hex(segment_code), "segment_code_bin": bin(segment_code), "unsigned_value": unsigned_value, "layout": layout}
303
-
304
- elif format_bits == 0x04: # 8-bit segment + 24-bit unsigned value
305
- if len(data_bytes) != 4:
306
- logger.debug("Data length mismatch for 8-bit + 24-bit unsigned (expected 4 bytes).")
307
- return None
308
- segment_code = data_bytes[0] # 8-bit segment code
309
- unsigned_value = int.from_bytes(data_bytes[1:], byteorder="big", signed=False) # 24-bit unsigned value
310
- interpreted_value = unsigned_value / divisor
311
- raw_value = {"segment_code": segment_code, "unsigned_value": unsigned_value}
312
-
313
- elif format_bits == 0x05: # Timer format (XX YY ZZ WW)
314
- if len(data_bytes) != 4:
315
- logger.debug("Data length mismatch for timer format (expected 4 bytes).")
316
- return None
317
- useless = data_bytes[0] # Useless byte (can be ignored)
318
- hours = data_bytes[1] # Hours (may exceed 24)
319
- minutes = data_bytes[2] # Minutes
320
- seconds = data_bytes[3] # Seconds
321
- interpreted_value = datetime.timedelta(hours=hours, minutes=minutes, seconds=seconds)
322
- raw_value = {"useless": useless, "hours": hours, "minutes": minutes, "seconds": seconds}
323
-
324
- elif format_bits == 0x06: # 7-segment display text
325
- if len(data_bytes) != 4:
326
- logger.debug("Data length mismatch for 7-segment display text (expected 4 bytes).")
327
- return None
328
- segment_text = "".join(convert_segment_b_to_char(byte) for byte in data_bytes)
329
- logger.debug(f"Decoded 7-segment text: {segment_text}")
330
- raw_value = [f"{byte:02X}" for byte in data_bytes] # Raw bytes as hex strings
331
- interpreted_value = segment_text
332
-
333
- elif format_bits == 0x07: # 15-bit unsigned value with 4-byte input
334
- if len(data_bytes) != 4:
335
- logger.debug("Data length mismatch for 15-bit unsigned (expected 4 bytes).")
336
- return None
337
- # unused - SegCodeA - 7bit MSB, 8 bit LSB
338
- segment_code = data_bytes[1]
339
- msb = data_bytes[2] & 0b01111111 # 7 bits from third byte
340
- lsb = data_bytes[3] # Full 8 bits from fourth byte
341
- unsigned_value = (msb << 8) | lsb # Combine MSB and LSB into 15-bit value
342
-
343
- layout = convert_segment_a_to_char(segment_code)
344
- if layout == "H[data]" or layout == "-[data]":
345
- signed_value = -unsigned_value
346
- else:
347
- signed_value = unsigned_value
348
-
349
- interpreted_value = signed_value / divisor
350
-
351
- raw_value = {"segment_code": hex(segment_code), "segment_code_bin": bin(segment_code), "unsigned_value": unsigned_value, "layout": layout}
352
-
353
-
354
- #elif format_bits == 0x07: # 15-bit unsigned value with 4-byte input
355
- # if len(data_bytes) != 4:
356
- # logger.warning("Data length mismatch for 15-bit unsigned (expected 4 bytes).")
357
- # return None
358
- # msb = (data_bytes[2] >> 1) & 0b01111111 # 7 bits from third byte
359
- # lsb = data_bytes[3] # Full 8 bits from fourth byte
360
- # unsigned_value = (msb << 8) | lsb # Combine MSB and LSB into 15-bit value
361
- # interpreted_value = unsigned_value / divisor
362
- # raw_value = unsigned_value
363
-
364
-
365
- elif format_bits == 0x08: # 7-bit segment + 9-bit unsigned (0x08 format)
366
- if len(data_bytes) != 2:
367
- logger.debug("decode_format_and_data: Data length mismatch for 0x08 (7-bit segment + 9-bit unsigned).")
368
- return None
369
- segment_code = (data_bytes[0] >> 1) & 0b01111111 # 7-bit segment
370
- unsigned_value = ((data_bytes[0] & 0b1) << 8) | data_bytes[1] # 9-bit unsigned value
371
- interpreted_value = unsigned_value / divisor
372
- layout = convert_segment_a_to_char(segment_code)
373
- raw_value = {"segment_code": segment_code, "layout": layout, "unsigned_value": unsigned_value}
374
-
375
- elif format_bits == 0x0A: # 16-bit signed + 16-bit signed
376
- if len(data_bytes) != 4:
377
- logger.debug("Data length mismatch for 16-bit + 16-bit signed (expected 4 bytes).")
378
- return None
379
- first_value = int.from_bytes(data_bytes[:2], byteorder="big", signed=True) # First 16-bit signed integer
380
- second_value = int.from_bytes(data_bytes[2:], byteorder="big", signed=True) # Second 16-bit signed integer
381
- interpreted_first_value = first_value / divisor
382
- interpreted_second_value = second_value / divisor
383
- interpreted_value = interpreted_first_value
384
- raw_value = {"first": interpreted_first_value, "second": interpreted_second_value}
385
-
386
-
387
- else:
388
- logger.debug(f"Unsupported format: 0x{format_bits:02X}.")
389
- return None
390
-
391
- # Return the result
392
- result = {
393
- "channel_id": f"0x{channel_id:02X}",
394
- "format_byte": f"0x{format_byte:02X}",
395
- "data_bytes": data_bytes.hex(),
396
- "divisor": divisor,
397
- "digits": digits,
398
- "format_bits": format_bits,
399
- "raw": raw_value,
400
- "interpreted": interpreted_value
401
- }
402
- #ogger.debug(f"Decoded value for channel 0x{channel_id:02X}: {interpreted_value}")
403
- return result
404
-
405
- except Exception as e:
406
- logger.error(f"Error decoding channel 0x{channel_id:02X}: {e}")
407
- return None
@@ -1,98 +0,0 @@
1
-
2
-
3
- def calculate_checksum(data):
4
- """
5
- Calculates the checksum for the given data bytes this one is for Fastnet Checksum
6
- Args:
7
- data (bytes): The data bytes to calculate checksum for.
8
- Returns:
9
- int: The calculated checksum.
10
- """
11
- return (0x100 - sum(data) % 0x100) & 0xFF
12
-
13
-
14
- def calculate_nmea_checksum(sentence):
15
- """
16
- Calculates the NMEA checksum for a given sentence (excluding '$' and '*').
17
- Args:
18
- sentence (str): NMEA sentence string without '$' and checksum '*'.
19
- Returns:
20
- str: Hexadecimal checksum as a string.
21
- """
22
- checksum = 0
23
- for char in sentence:
24
- checksum ^= ord(char)
25
- return f"{checksum:02X}"
26
-
27
-
28
- def convert_segment_b_to_char(segment_byte):
29
- """
30
- Converts a 7-segment display byte into a human-readable character, will add more as we figure them out
31
- Args:
32
- segment_byte (int): The byte representing the 7-segment display.
33
- Returns:
34
- str: The corresponding character or '?' if unknown.
35
- """
36
- segment_mapping = {
37
- 0xBE: "O",
38
- 0xE8: "F",
39
- 0x62: "n",
40
- 0x72: "o",
41
- 0x40: "-",
42
- 0x00: " ", # Blank
43
- }
44
- return segment_mapping.get(segment_byte, "?")
45
-
46
- def convert_segment_a_to_char(segment_byte):
47
- """
48
- Converts a 7-segment display byte into a human-readable character, will add more as we figure them out
49
- Args:
50
- segment_byte (int): The byte representing the 7-segment display.
51
- Returns:
52
- str: The corresponding character or '?' if unknown.
53
- """
54
- #segment_mapping = {
55
- # 0x14: "[data]=",
56
- # 0x54: "=[data]",
57
- # 0x10: "[data]-",
58
- # 0x50: "-[data]",
59
- # 0x46: "=[data]", #Rudder angle top and bottom segments, but lets just call it an =
60
- # 0x06: "[data]=", #Rudder angle top and bottom segments, but lets just call it an =
61
- # 0x00: " ", # Blank
62
- #}
63
-
64
- segment_mapping = {
65
- 0x66: "°M", # magnetic bearing indicator (seen on Heading, Course, TWD, Tidal Set)
66
- 0x28: "[data]=",
67
- 0xa8: "=[data]",
68
- 0x20: "[data]-",
69
- 0xa0: "-[data]",
70
- 0x8c: "=[data]", #Rudder angle top and bottom segments, but lets just call it an =
71
- 0x0c: "[data]=", #Rudder angle top and bottom segments, but lets just call it an =
72
- 0xf3: "H[data]", #Heel to port
73
- 0x73: "[data]H", #Heel to stb
74
- 0x00: " ", # Blank
75
- }
76
- return segment_mapping.get(segment_byte, "?")
77
-
78
-
79
- def parse_format_byte(format_byte):
80
- """
81
- Parses the format byte into divisor, digits, and format type.
82
- Args:
83
- format_byte (int): The format byte.
84
- Returns:
85
- dict: Parsed divisor, digits, and format type.
86
- """
87
- divisor_bits = (format_byte >> 6) & 0b11 # First two bits
88
- digits_bits = (format_byte >> 4) & 0b11 # Next two bits
89
- format_type = format_byte & 0x0F # Last 4 bits (format type)
90
-
91
- divisor_map = {0b00: 1, 0b01: 10, 0b10: 100, 0b11: 1000}
92
- digits_map = {0b00: 1, 0b01: 2, 0b10: 3, 0b11: 4}
93
-
94
- return {
95
- "divisor": divisor_map.get(divisor_bits, 1),
96
- "digits": digits_map.get(digits_bits, 1),
97
- "format_type": format_type,
98
- }
File without changes
File without changes
File without changes
File without changes