pyfastnet 1.2.2__tar.gz → 1.2.4__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 (25) hide show
  1. {pyfastnet-1.2.2/pyfastnet.egg-info → pyfastnet-1.2.4}/PKG-INFO +1 -1
  2. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/decode_fastnet.py +3 -2
  3. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/utils.py +1 -0
  4. {pyfastnet-1.2.2 → pyfastnet-1.2.4/pyfastnet.egg-info}/PKG-INFO +1 -1
  5. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/pyfastnet.egg-info/SOURCES.txt +3 -0
  6. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/setup.py +1 -1
  7. pyfastnet-1.2.4/tests/test_depth_frame.py +61 -0
  8. pyfastnet-1.2.4/tests/test_format07_msb_regression.py +77 -0
  9. pyfastnet-1.2.4/tests/test_format08_layout.py +61 -0
  10. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/LICENSE +0 -0
  11. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/MANIFEST.in +0 -0
  12. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/README.md +0 -0
  13. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/__init__.py +0 -0
  14. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/frame_buffer.py +0 -0
  15. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/logger.py +0 -0
  16. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/mappings.py +0 -0
  17. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/pyfastnet.egg-info/dependency_links.txt +0 -0
  18. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/pyfastnet.egg-info/top_level.txt +0 -0
  19. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/setup.cfg +0 -0
  20. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_apparent_frame.py +0 -0
  21. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_autopilot_frame.py +0 -0
  22. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_heel_frame.py +0 -0
  23. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_rudder_frame.py +0 -0
  24. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_tide_frame.py +0 -0
  25. {pyfastnet-1.2.2 → pyfastnet-1.2.4}/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.2
3
+ Version: 1.2.4
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
@@ -336,7 +336,7 @@ def decode_format_and_data(channel_id, format_byte, data_bytes):
336
336
  return None
337
337
  # unused - SegCodeA - 7bit MSB, 8 bit LSB
338
338
  segment_code = data_bytes[1]
339
- msb = (data_bytes[2] >> 1) & 0b01111111 # 7 bits from third byte
339
+ msb = data_bytes[2] & 0b01111111 # 7 bits from third byte
340
340
  lsb = data_bytes[3] # Full 8 bits from fourth byte
341
341
  unsigned_value = (msb << 8) | lsb # Combine MSB and LSB into 15-bit value
342
342
 
@@ -369,7 +369,8 @@ def decode_format_and_data(channel_id, format_byte, data_bytes):
369
369
  segment_code = (data_bytes[0] >> 1) & 0b01111111 # 7-bit segment
370
370
  unsigned_value = ((data_bytes[0] & 0b1) << 8) | data_bytes[1] # 9-bit unsigned value
371
371
  interpreted_value = unsigned_value / divisor
372
- raw_value = {"segment_code": segment_code, "unsigned_value": unsigned_value}
372
+ layout = convert_segment_a_to_char(segment_code)
373
+ raw_value = {"segment_code": segment_code, "layout": layout, "unsigned_value": unsigned_value}
373
374
 
374
375
  elif format_bits == 0x0A: # 16-bit signed + 16-bit signed
375
376
  if len(data_bytes) != 4:
@@ -62,6 +62,7 @@ def convert_segment_a_to_char(segment_byte):
62
62
  #}
63
63
 
64
64
  segment_mapping = {
65
+ 0x66: "°M", # magnetic bearing indicator (seen on Heading, Course, TWD, Tidal Set)
65
66
  0x28: "[data]=",
66
67
  0xa8: "=[data]",
67
68
  0x20: "[data]-",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfastnet
3
- Version: 1.2.2
3
+ Version: 1.2.4
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
@@ -14,6 +14,9 @@ pyfastnet.egg-info/dependency_links.txt
14
14
  pyfastnet.egg-info/top_level.txt
15
15
  tests/test_apparent_frame.py
16
16
  tests/test_autopilot_frame.py
17
+ tests/test_depth_frame.py
18
+ tests/test_format07_msb_regression.py
19
+ tests/test_format08_layout.py
17
20
  tests/test_heel_frame.py
18
21
  tests/test_rudder_frame.py
19
22
  tests/test_tide_frame.py
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="pyfastnet",
5
- version="1.2.2", # Ensure this matches your intended version
5
+ version="1.2.4", # 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.",
@@ -0,0 +1,61 @@
1
+ import unittest
2
+ from fastnet_decoder.decode_fastnet import decode_frame
3
+
4
+
5
+ class TestDepthFrame(unittest.TestCase):
6
+ """
7
+ Tests for depth channel decoding (0xC1 Meters, 0xC2 Feet, 0xC3 Fathoms).
8
+
9
+ The format 0x07 decoder uses a 15-bit value split across two bytes:
10
+ msb = data_bytes[2] & 0x7F (7 bits)
11
+ lsb = data_bytes[3] (8 bits)
12
+ value = (msb << 8) | lsb
13
+
14
+ When depth > 25.5 m (raw value > 255), data_bytes[2] is non-zero.
15
+ This is the case that previously decoded incorrectly due to a spurious >> 1.
16
+ """
17
+
18
+ # Real broadcast frame captured from fastnet_record.txt.
19
+ # from_address = 0x01 (Normal CPU / Depth Board).
20
+ # Contains: Depth Meters=12.0, Depth Feet=39.5, Depth Fathoms=6.6
21
+ # plus Dead Reckoning Distance and Dead Reckoning Course.
22
+ DEPTH_FRAME_HEX = "ff011c01e3c14700800078c2470080018bc357008000428184ff000000d308cd64ff"
23
+
24
+ def setUp(self):
25
+ self.decoded = decode_frame(bytes.fromhex(self.DEPTH_FRAME_HEX))
26
+ self.values = self.decoded["values"]
27
+
28
+ def test_no_decode_error(self):
29
+ self.assertNotIn("error", self.decoded)
30
+
31
+ def test_all_depth_channels_present(self):
32
+ self.assertIn("Depth (Meters)", self.values)
33
+ self.assertIn("Depth (Feet)", self.values)
34
+ self.assertIn("Depth (Fathoms)", self.values)
35
+
36
+ def test_depth_meters(self):
37
+ self.assertEqual(self.values["Depth (Meters)"]["interpreted"], 12.0)
38
+
39
+ def test_depth_feet(self):
40
+ # Raw value is 395 (> 255), so data_bytes[2] = 0x01 — this is the case
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)
43
+
44
+ def test_depth_fathoms(self):
45
+ self.assertEqual(self.values["Depth (Fathoms)"]["interpreted"], 6.6)
46
+
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"]
51
+
52
+ # Allow 0.2 tolerance: each channel is independently rounded to 0.1 of its
53
+ # own unit by the instrument, so cross-unit comparisons can differ by ~0.15.
54
+ self.assertAlmostEqual(meters * 3.28084, feet, delta=0.2,
55
+ msg="Feet should equal meters × 3.28084")
56
+ self.assertAlmostEqual(meters / 1.8288, fathoms, delta=0.2,
57
+ msg="Fathoms should equal meters / 1.8288")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()
@@ -0,0 +1,77 @@
1
+ import unittest
2
+ from fastnet_decoder.decode_fastnet import decode_frame
3
+
4
+
5
+ class TestFormat07MSBRegression(unittest.TestCase):
6
+ """
7
+ Regression tests for the format 0x07 MSB bit-shift bug.
8
+
9
+ The bug: msb was computed as (data_bytes[2] >> 1) & 0x7F instead of
10
+ data_bytes[2] & 0x7F. The spurious >> 1 discarded bit 0 of data_bytes[2],
11
+ collapsing the MSB contribution to zero for any raw value > 255
12
+ (i.e. whenever data_bytes[2] is non-zero).
13
+
14
+ All frames below are real captures where the raw value exceeds 255, so
15
+ data_bytes[2] == 0x01. The table shows the wrong vs correct reading:
16
+
17
+ Channel Raw Buggy Correct
18
+ Depth (Feet) 0xC2 465 20.9 ft → 46.5 ft
19
+ Tidal Set 0x84 297 41.0 ° → 297.0 °
20
+ Autopilot Target 0xA6 354 98.0 ° → 354.0 °
21
+ VMG (Knots) 0x7F 415 1.59 kn → 4.15 kn
22
+ """
23
+
24
+ def _decode(self, hex_str):
25
+ decoded = decode_frame(bytes.fromhex(hex_str))
26
+ self.assertNotIn("error", decoded, f"Frame decode error: {decoded}")
27
+ return decoded["values"]
28
+
29
+ # ------------------------------------------------------------------
30
+ # Tidal Set (0x84) — raw 297, format_byte 0x07, divisor 1
31
+ # Buggy result: 41° (297 - 256 = 41, MSB dropped)
32
+ # Correct: 297°
33
+ # Frame also contains Tidal Drift (0x83) as a sanity cross-check.
34
+ # ------------------------------------------------------------------
35
+ TIDAL_SET_FRAME = "ff600a01968407006601298383bb30f4"
36
+
37
+ def test_tidal_set_present(self):
38
+ values = self._decode(self.TIDAL_SET_FRAME)
39
+ self.assertIn("Tidal Set", values)
40
+
41
+ def test_tidal_set_value(self):
42
+ values = self._decode(self.TIDAL_SET_FRAME)
43
+ self.assertEqual(values["Tidal Set"]["interpreted"], 297.0,
44
+ "Tidal Set should be 297° (was 41° with the >> 1 bug)")
45
+
46
+ def test_tidal_drift_unaffected(self):
47
+ # Tidal Drift raw value is 48 (< 256), so data_bytes[2] == 0 — unaffected by bug
48
+ values = self._decode(self.TIDAL_SET_FRAME)
49
+ self.assertIn("Tidal Drift", values)
50
+ self.assertAlmostEqual(values["Tidal Drift"]["interpreted"], 0.48, places=2)
51
+
52
+ # ------------------------------------------------------------------
53
+ # Autopilot Compass Target (0xA6) — raw 354, format_byte 0x07, divisor 1
54
+ # Buggy result: 98° (354 - 256 = 98, MSB dropped)
55
+ # Correct: 354°
56
+ # Frame also contains Autopilot Mode as a sanity cross-check.
57
+ # ------------------------------------------------------------------
58
+ AUTOPILOT_TARGET_FRAME = "ff120a01e4b5015101a6070066016282"
59
+
60
+ def test_autopilot_target_present(self):
61
+ values = self._decode(self.AUTOPILOT_TARGET_FRAME)
62
+ self.assertIn("Autopilot Compass Target", values)
63
+
64
+ def test_autopilot_target_value(self):
65
+ values = self._decode(self.AUTOPILOT_TARGET_FRAME)
66
+ self.assertEqual(values["Autopilot Compass Target"]["interpreted"], 354.0,
67
+ "Autopilot Compass Target should be 354° (was 98° with the >> 1 bug)")
68
+
69
+ def test_autopilot_mode_unaffected(self):
70
+ # Autopilot Mode uses format 0x01 (16-bit signed), not format 0x07 — unaffected
71
+ values = self._decode(self.AUTOPILOT_TARGET_FRAME)
72
+ self.assertIn("Autopilot Mode", values)
73
+ self.assertEqual(values["Autopilot Mode"]["interpreted"], "Compass")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ unittest.main()
@@ -0,0 +1,61 @@
1
+ import unittest
2
+ from fastnet_decoder.decode_fastnet import decode_frame
3
+
4
+
5
+ class TestFormat08Layout(unittest.TestCase):
6
+ """
7
+ Tests for format 0x08 layout field (magnetic bearing indicator).
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.
12
+
13
+ Frame "ff120e01e00b038c274908cc294a0a1cdd6067e5" contains:
14
+ - Rudder Angle (0x0b), format 0x03
15
+ - Heading (0x49), format 0x08, data=[0xCC, 0x29]
16
+ data[0]=0xCC → segment_code = (0xCC >> 1) & 0x7F = 0x66 → layout "°M"
17
+ unsigned_value = 0x29 = 41 → interpreted 41.0°
18
+ - Heading Raw (0x4a), format 0x0a
19
+ """
20
+
21
+ FRAME_HEX = "ff120e01e00b038c274908cc294a0a1cdd6067e5"
22
+
23
+ def setUp(self):
24
+ self.decoded = decode_frame(bytes.fromhex(self.FRAME_HEX))
25
+ self.heading = self.decoded["values"]["Heading"]
26
+
27
+ def test_no_decode_error(self):
28
+ self.assertNotIn("error", self.decoded)
29
+
30
+ def test_heading_present(self):
31
+ self.assertIn("Heading", self.decoded["values"])
32
+
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"])
41
+
42
+ def test_heading_layout_is_magnetic(self):
43
+ self.assertEqual(self.heading["raw"]["layout"], "°M")
44
+
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)
48
+
49
+ def test_cog_true_has_blank_layout(self):
50
+ # 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
+ cog_frame = bytes.fromhex("FF051601E555610030566100185903A86B7F8700BB00016D08CD0DCB")
54
+ decoded = decode_frame(cog_frame)
55
+ cog = decoded["values"].get("Course Over Ground (True)")
56
+ if cog:
57
+ self.assertEqual(cog["raw"]["layout"], " ", "GPS COG should have blank layout (no magnetic indicator)")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ unittest.main()
File without changes
File without changes
File without changes
File without changes