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.
- {pyfastnet-1.2.2/pyfastnet.egg-info → pyfastnet-1.2.4}/PKG-INFO +1 -1
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/decode_fastnet.py +3 -2
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/utils.py +1 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4/pyfastnet.egg-info}/PKG-INFO +1 -1
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/pyfastnet.egg-info/SOURCES.txt +3 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/setup.py +1 -1
- pyfastnet-1.2.4/tests/test_depth_frame.py +61 -0
- pyfastnet-1.2.4/tests/test_format07_msb_regression.py +77 -0
- pyfastnet-1.2.4/tests/test_format08_layout.py +61 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/LICENSE +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/MANIFEST.in +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/README.md +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/__init__.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/frame_buffer.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/logger.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/fastnet_decoder/mappings.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/pyfastnet.egg-info/dependency_links.txt +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/pyfastnet.egg-info/top_level.txt +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/setup.cfg +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_apparent_frame.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_autopilot_frame.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_heel_frame.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_rudder_frame.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_tide_frame.py +0 -0
- {pyfastnet-1.2.2 → pyfastnet-1.2.4}/tests/test_true_frame.py +0 -0
|
@@ -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 =
|
|
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
|
-
|
|
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:
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|