pyfastnet 2.0.13__tar.gz → 2.0.15__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-2.0.13/pyfastnet.egg-info → pyfastnet-2.0.15}/PKG-INFO +19 -2
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/README.md +16 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/fastnet_decoder/decode_fastnet.py +10 -6
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/fastnet_decoder/mappings.py +0 -1
- {pyfastnet-2.0.13 → pyfastnet-2.0.15/pyfastnet.egg-info}/PKG-INFO +19 -2
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/pyfastnet.egg-info/SOURCES.txt +15 -7
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/setup.py +1 -1
- pyfastnet-2.0.15/tests/test_autopilot.py +74 -0
- pyfastnet-2.0.15/tests/test_awa.py +60 -0
- pyfastnet-2.0.15/tests/test_aws.py +36 -0
- pyfastnet-2.0.15/tests/test_battery.py +29 -0
- pyfastnet-2.0.15/tests/test_boatspeed.py +34 -0
- pyfastnet-2.0.15/tests/test_heading.py +80 -0
- pyfastnet-2.0.15/tests/test_heel_trim.py +100 -0
- pyfastnet-2.0.15/tests/test_latlon_frame.py +56 -0
- pyfastnet-2.0.15/tests/test_sea_temp.py +64 -0
- pyfastnet-2.0.15/tests/test_sog.py +39 -0
- pyfastnet-2.0.15/tests/test_tidal.py +57 -0
- pyfastnet-2.0.15/tests/test_timer.py +30 -0
- pyfastnet-2.0.15/tests/test_twa.py +61 -0
- pyfastnet-2.0.15/tests/test_twd.py +60 -0
- pyfastnet-2.0.15/tests/test_tws.py +57 -0
- pyfastnet-2.0.13/tests/test_apparent_frame.py +0 -35
- pyfastnet-2.0.13/tests/test_autopilot_frame.py +0 -33
- pyfastnet-2.0.13/tests/test_channels.py +0 -406
- pyfastnet-2.0.13/tests/test_heel_frame.py +0 -35
- pyfastnet-2.0.13/tests/test_rudder_frame.py +0 -34
- pyfastnet-2.0.13/tests/test_tide_frame.py +0 -33
- pyfastnet-2.0.13/tests/test_true_frame.py +0 -33
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/LICENSE +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/MANIFEST.in +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/fastnet_decoder/__init__.py +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/fastnet_decoder/frame_buffer.py +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/fastnet_decoder/logger.py +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/fastnet_decoder/utils.py +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/pyfastnet.egg-info/dependency_links.txt +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/pyfastnet.egg-info/top_level.txt +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/setup.cfg +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/tests/test_depth_frame.py +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/tests/test_format07_msb_regression.py +0 -0
- {pyfastnet-2.0.13 → pyfastnet-2.0.15}/tests/test_format08_layout.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyfastnet
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.15
|
|
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
|
|
@@ -17,6 +17,7 @@ Dynamic: classifier
|
|
|
17
17
|
Dynamic: description
|
|
18
18
|
Dynamic: description-content-type
|
|
19
19
|
Dynamic: home-page
|
|
20
|
+
Dynamic: license-file
|
|
20
21
|
Dynamic: requires-python
|
|
21
22
|
Dynamic: summary
|
|
22
23
|
|
|
@@ -99,6 +100,22 @@ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and `
|
|
|
99
100
|
}
|
|
100
101
|
```
|
|
101
102
|
|
|
103
|
+
### Position (LatLon) frames
|
|
104
|
+
|
|
105
|
+
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw coordinate
|
|
106
|
+
string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value` is
|
|
107
|
+
`None`. The originating source's marker byte is preserved in `channel_id` (e.g. `0x47`,
|
|
108
|
+
`0x4E`) but does not affect the key:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
"LatLon": {
|
|
112
|
+
"channel_id": "0x4E",
|
|
113
|
+
"value": None,
|
|
114
|
+
"display_text": "3352.450S15113.920E",
|
|
115
|
+
"layout": None,
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
102
119
|
### Layout field
|
|
103
120
|
|
|
104
121
|
The `layout` field describes the indicator symbol shown on the physical display around the numeric value:
|
|
@@ -77,6 +77,22 @@ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and `
|
|
|
77
77
|
}
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
### Position (LatLon) frames
|
|
81
|
+
|
|
82
|
+
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw coordinate
|
|
83
|
+
string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value` is
|
|
84
|
+
`None`. The originating source's marker byte is preserved in `channel_id` (e.g. `0x47`,
|
|
85
|
+
`0x4E`) but does not affect the key:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
"LatLon": {
|
|
89
|
+
"channel_id": "0x4E",
|
|
90
|
+
"value": None,
|
|
91
|
+
"display_text": "3352.450S15113.920E",
|
|
92
|
+
"layout": None,
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
80
96
|
### Layout field
|
|
81
97
|
|
|
82
98
|
The `layout` field describes the indicator symbol shown on the physical display around the numeric value:
|
|
@@ -16,13 +16,13 @@ _DECIMAL_PLACES_MAP = {1: 0, 10: 1, 100: 2, 1000: 3}
|
|
|
16
16
|
# "°M" → magnetic bearing, suffix added after value
|
|
17
17
|
|
|
18
18
|
def _sign_from_layout(layout: str) -> int:
|
|
19
|
-
return -1 if layout in ("-[data]", "=[data]", "L[data]") else 1
|
|
19
|
+
return -1 if layout in ("-[data]", "=[data]", "L[data]", "H[data]") else 1
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def _display_from_layout(layout: str, formatted: str) -> str:
|
|
23
23
|
if layout is None: return formatted
|
|
24
24
|
if layout == "°M": return f"{formatted}°M"
|
|
25
|
-
if layout == "H[data]": return f"H{formatted}"
|
|
25
|
+
if layout == "H[data]": return f"H{formatted.lstrip('-')}"
|
|
26
26
|
if layout == "[data]H": return f"{formatted}H"
|
|
27
27
|
if layout == "[data]=": return f"{formatted}="
|
|
28
28
|
if layout == "[data]-": return f"{formatted}-"
|
|
@@ -119,9 +119,14 @@ def decode_ascii_frame(frame: bytes) -> dict:
|
|
|
119
119
|
channel_id = body[0]
|
|
120
120
|
# body[1] is a format byte — not used for ASCII frames
|
|
121
121
|
data_bytes = body[2:]
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
# This decoder is only ever called for LatLon command frames, so the
|
|
123
|
+
# entry is named from the command — not from body[0]. body[0] is a
|
|
124
|
+
# source/marker byte that varies by GPS unit (0x47, 0x4E, ...) and is
|
|
125
|
+
# NOT a generic channel id; looking it up in CHANNEL_LOOKUP produced
|
|
126
|
+
# nonsense names like "Apparent Wind Speed (Raw)". The raw byte is kept
|
|
127
|
+
# in channel_id below for diagnostics.
|
|
128
|
+
cmd_name = COMMAND_LOOKUP.get(command)
|
|
129
|
+
channel_name = cmd_name if cmd_name is not None else f"Unknown (0x{command:02X})"
|
|
125
130
|
|
|
126
131
|
try:
|
|
127
132
|
ascii_text = data_bytes.decode("ascii").strip()
|
|
@@ -133,7 +138,6 @@ def decode_ascii_frame(frame: bytes) -> dict:
|
|
|
133
138
|
|
|
134
139
|
to_name = ADDRESS_LOOKUP.get(to_address)
|
|
135
140
|
from_name = ADDRESS_LOOKUP.get(from_address)
|
|
136
|
-
cmd_name = COMMAND_LOOKUP.get(command)
|
|
137
141
|
|
|
138
142
|
return {
|
|
139
143
|
"to_address": to_name if to_name is not None else f"Unknown (0x{to_address:02X})",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyfastnet
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.15
|
|
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
|
|
@@ -17,6 +17,7 @@ Dynamic: classifier
|
|
|
17
17
|
Dynamic: description
|
|
18
18
|
Dynamic: description-content-type
|
|
19
19
|
Dynamic: home-page
|
|
20
|
+
Dynamic: license-file
|
|
20
21
|
Dynamic: requires-python
|
|
21
22
|
Dynamic: summary
|
|
22
23
|
|
|
@@ -99,6 +100,22 @@ Each decoded frame is a dict with `to_address`, `from_address`, `command`, and `
|
|
|
99
100
|
}
|
|
100
101
|
```
|
|
101
102
|
|
|
103
|
+
### Position (LatLon) frames
|
|
104
|
+
|
|
105
|
+
Position frames (command `LatLon`) appear under the `"LatLon"` key. The raw coordinate
|
|
106
|
+
string (`DDMM.mmm` with hemisphere letters) is carried in `display_text`; `value` is
|
|
107
|
+
`None`. The originating source's marker byte is preserved in `channel_id` (e.g. `0x47`,
|
|
108
|
+
`0x4E`) but does not affect the key:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
"LatLon": {
|
|
112
|
+
"channel_id": "0x4E",
|
|
113
|
+
"value": None,
|
|
114
|
+
"display_text": "3352.450S15113.920E",
|
|
115
|
+
"layout": None,
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
102
119
|
### Layout field
|
|
103
120
|
|
|
104
121
|
The `layout` field describes the indicator symbol shown on the physical display around the numeric value:
|
|
@@ -12,13 +12,21 @@ pyfastnet.egg-info/PKG-INFO
|
|
|
12
12
|
pyfastnet.egg-info/SOURCES.txt
|
|
13
13
|
pyfastnet.egg-info/dependency_links.txt
|
|
14
14
|
pyfastnet.egg-info/top_level.txt
|
|
15
|
-
tests/
|
|
16
|
-
tests/
|
|
17
|
-
tests/
|
|
15
|
+
tests/test_autopilot.py
|
|
16
|
+
tests/test_awa.py
|
|
17
|
+
tests/test_aws.py
|
|
18
|
+
tests/test_battery.py
|
|
19
|
+
tests/test_boatspeed.py
|
|
18
20
|
tests/test_depth_frame.py
|
|
19
21
|
tests/test_format07_msb_regression.py
|
|
20
22
|
tests/test_format08_layout.py
|
|
21
|
-
tests/
|
|
22
|
-
tests/
|
|
23
|
-
tests/
|
|
24
|
-
tests/
|
|
23
|
+
tests/test_heading.py
|
|
24
|
+
tests/test_heel_trim.py
|
|
25
|
+
tests/test_latlon_frame.py
|
|
26
|
+
tests/test_sea_temp.py
|
|
27
|
+
tests/test_sog.py
|
|
28
|
+
tests/test_tidal.py
|
|
29
|
+
tests/test_timer.py
|
|
30
|
+
tests/test_twa.py
|
|
31
|
+
tests/test_twd.py
|
|
32
|
+
tests/test_tws.py
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="pyfastnet",
|
|
5
|
-
version="2.0.
|
|
5
|
+
version="2.0.15",
|
|
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,74 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
result = decode_frame(bytes.fromhex(hex_str))
|
|
7
|
+
assert "error" not in result, f"Decode error: {result}"
|
|
8
|
+
return result["values"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestAutopilotStandby(unittest.TestCase):
|
|
12
|
+
"""
|
|
13
|
+
AP Mode = Standby: Autopilot Mode channel (0xB5), format 0x01,
|
|
14
|
+
raw int 20484 maps to display string "Standby".
|
|
15
|
+
In Standby the Compass Target channel carries an OFF segment display (value=None).
|
|
16
|
+
"""
|
|
17
|
+
FRAME = "ff121c01d2b5015004a606bee8e800af06bee8e8005306bee8e8007606bee8e80088"
|
|
18
|
+
|
|
19
|
+
def setUp(self):
|
|
20
|
+
self.v = _decode(self.FRAME)
|
|
21
|
+
|
|
22
|
+
def test_mode_display(self):
|
|
23
|
+
self.assertEqual(self.v["Autopilot Mode"]["display_text"], "Standby")
|
|
24
|
+
|
|
25
|
+
def test_target_is_off(self):
|
|
26
|
+
self.assertIsNone(self.v["Autopilot Compass Target"]["value"])
|
|
27
|
+
self.assertEqual(self.v["Autopilot Compass Target"]["display_text"], "OFF ")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestAutopilotCompass(unittest.TestCase):
|
|
31
|
+
"""
|
|
32
|
+
AP Mode = Compass: target is a magnetic bearing (°M layout).
|
|
33
|
+
Target = 0°M in this frame (just engaged).
|
|
34
|
+
"""
|
|
35
|
+
FRAME = "ff120a01e4b5015101a60700660000e5"
|
|
36
|
+
|
|
37
|
+
def setUp(self):
|
|
38
|
+
self.v = _decode(self.FRAME)
|
|
39
|
+
|
|
40
|
+
def test_mode_display(self):
|
|
41
|
+
self.assertEqual(self.v["Autopilot Mode"]["display_text"], "Compass")
|
|
42
|
+
|
|
43
|
+
def test_target_value(self):
|
|
44
|
+
self.assertEqual(self.v["Autopilot Compass Target"]["value"], 0.0)
|
|
45
|
+
|
|
46
|
+
def test_target_layout(self):
|
|
47
|
+
self.assertEqual(self.v["Autopilot Compass Target"]["layout"], "°M")
|
|
48
|
+
|
|
49
|
+
def test_target_display(self):
|
|
50
|
+
self.assertEqual(self.v["Autopilot Compass Target"]["display_text"], "0°M")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestAutopilotWind(unittest.TestCase):
|
|
54
|
+
"""
|
|
55
|
+
AP Mode = Wind: target is a magnetic bearing representing a locked wind angle.
|
|
56
|
+
Target = 331°M in this frame.
|
|
57
|
+
"""
|
|
58
|
+
FRAME = "ff120a01e4b5015104a6070066014b96"
|
|
59
|
+
|
|
60
|
+
def setUp(self):
|
|
61
|
+
self.v = _decode(self.FRAME)
|
|
62
|
+
|
|
63
|
+
def test_mode_display(self):
|
|
64
|
+
self.assertEqual(self.v["Autopilot Mode"]["display_text"], "Wind")
|
|
65
|
+
|
|
66
|
+
def test_target_value(self):
|
|
67
|
+
self.assertEqual(self.v["Autopilot Compass Target"]["value"], 331.0)
|
|
68
|
+
|
|
69
|
+
def test_target_display(self):
|
|
70
|
+
self.assertEqual(self.v["Autopilot Compass Target"]["display_text"], "331°M")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
unittest.main()
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
result = decode_frame(bytes.fromhex(hex_str))
|
|
7
|
+
assert "error" not in result, f"Decode error: {result}"
|
|
8
|
+
return result["values"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Sign convention (format 0x03, segment byte drives layout):
|
|
12
|
+
# AWA port: seg 0xa0 → layout '-[data]' → value negative
|
|
13
|
+
# AWA stb: seg 0x20 → layout '[data]-' → value positive
|
|
14
|
+
#
|
|
15
|
+
# Dash before digits = port (negative); dash after digits = starboard (positive).
|
|
16
|
+
# The trailing dash on the B&G display is a direction marker, not a minus sign.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestAWAPort(unittest.TestCase):
|
|
20
|
+
"""
|
|
21
|
+
AWA to port: segment byte 0xa0 → layout '-[data]'.
|
|
22
|
+
Leading dash drives sign negative; value is stored negative.
|
|
23
|
+
"""
|
|
24
|
+
FRAME = "ff051801e34e0a030a02bd4d61004a4f610026520ac4bbc4bb5113a0525e"
|
|
25
|
+
|
|
26
|
+
def setUp(self):
|
|
27
|
+
self.v = _decode(self.FRAME)["Apparent Wind Angle"]
|
|
28
|
+
|
|
29
|
+
def test_value(self):
|
|
30
|
+
self.assertAlmostEqual(self.v["value"], -82.0, places=0)
|
|
31
|
+
|
|
32
|
+
def test_layout(self):
|
|
33
|
+
self.assertEqual(self.v["layout"], "-[data]")
|
|
34
|
+
|
|
35
|
+
def test_display(self):
|
|
36
|
+
self.assertEqual(self.v["display_text"], "-82")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestAWAStarboard(unittest.TestCase):
|
|
40
|
+
"""
|
|
41
|
+
AWA to starboard: segment byte 0x20 → layout '[data]-'.
|
|
42
|
+
Trailing dash encodes starboard direction; value is positive.
|
|
43
|
+
"""
|
|
44
|
+
FRAME = "ff051801e34e0a02cf02804d61005a4f61002e520a128f128f5113201835"
|
|
45
|
+
|
|
46
|
+
def setUp(self):
|
|
47
|
+
self.v = _decode(self.FRAME)["Apparent Wind Angle"]
|
|
48
|
+
|
|
49
|
+
def test_value(self):
|
|
50
|
+
self.assertAlmostEqual(self.v["value"], 24.0, places=0)
|
|
51
|
+
|
|
52
|
+
def test_layout(self):
|
|
53
|
+
self.assertEqual(self.v["layout"], "[data]-")
|
|
54
|
+
|
|
55
|
+
def test_display(self):
|
|
56
|
+
self.assertEqual(self.v["display_text"], "24-")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
unittest.main()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
result = decode_frame(bytes.fromhex(hex_str))
|
|
7
|
+
assert "error" not in result, f"Decode error: {result}"
|
|
8
|
+
return result["values"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestApparentWindSpeed(unittest.TestCase):
|
|
12
|
+
"""
|
|
13
|
+
18-channel broadcast containing apparent wind speed in both units.
|
|
14
|
+
Format 0x01, 16-bit signed, divisor 10.
|
|
15
|
+
AWA sign variants are in test_awa.py.
|
|
16
|
+
"""
|
|
17
|
+
FRAME = "ff051801e34e0a061c05fe4d51009c4f610050520a47f347f351032065a0"
|
|
18
|
+
|
|
19
|
+
def setUp(self):
|
|
20
|
+
self.v = _decode(self.FRAME)
|
|
21
|
+
|
|
22
|
+
def test_knots_value(self):
|
|
23
|
+
self.assertAlmostEqual(self.v["Apparent Wind Speed (Knots)"]["value"], 15.6, places=1)
|
|
24
|
+
|
|
25
|
+
def test_knots_display(self):
|
|
26
|
+
self.assertEqual(self.v["Apparent Wind Speed (Knots)"]["display_text"], "15.6")
|
|
27
|
+
|
|
28
|
+
def test_ms_value(self):
|
|
29
|
+
self.assertAlmostEqual(self.v["Apparent Wind Speed (m/s)"]["value"], 8.0, places=1)
|
|
30
|
+
|
|
31
|
+
def test_ms_display(self):
|
|
32
|
+
self.assertEqual(self.v["Apparent Wind Speed (m/s)"]["display_text"], "8.0")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
unittest.main()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
result = decode_frame(bytes.fromhex(hex_str))
|
|
7
|
+
assert "error" not in result, f"Decode error: {result}"
|
|
8
|
+
return result["values"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestBatteryVolts(unittest.TestCase):
|
|
12
|
+
"""
|
|
13
|
+
Battery Volts: format 0x01, 16-bit signed, divisor 100.
|
|
14
|
+
Channel 0x8D; value 13.18 V.
|
|
15
|
+
"""
|
|
16
|
+
FRAME = "ff051401e78d8105263b3101fa344700f300cc9b4700a000099b"
|
|
17
|
+
|
|
18
|
+
def setUp(self):
|
|
19
|
+
self.v = _decode(self.FRAME)["Battery Volts"]
|
|
20
|
+
|
|
21
|
+
def test_value(self):
|
|
22
|
+
self.assertAlmostEqual(self.v["value"], 13.18, places=2)
|
|
23
|
+
|
|
24
|
+
def test_display(self):
|
|
25
|
+
self.assertEqual(self.v["display_text"], "13.18")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
unittest.main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
result = decode_frame(bytes.fromhex(hex_str))
|
|
7
|
+
assert "error" not in result, f"Decode error: {result}"
|
|
8
|
+
return result["values"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestBoatspeed(unittest.TestCase):
|
|
12
|
+
"""
|
|
13
|
+
Performance Processor broadcast containing Boatspeed (Knots) and
|
|
14
|
+
Boatspeed (Raw).
|
|
15
|
+
- Knots: format 0x01, 16-bit signed, divisor 100
|
|
16
|
+
- Raw: format 0x0A, two 16-bit signed integers rendered as "first / second"
|
|
17
|
+
"""
|
|
18
|
+
FRAME = "ff010a01f54192f9dd420a01ec082cea"
|
|
19
|
+
|
|
20
|
+
def setUp(self):
|
|
21
|
+
self.v = _decode(self.FRAME)
|
|
22
|
+
|
|
23
|
+
def test_knots_value(self):
|
|
24
|
+
self.assertAlmostEqual(self.v["Boatspeed (Knots)"]["value"], 4.77, places=2)
|
|
25
|
+
|
|
26
|
+
def test_knots_display(self):
|
|
27
|
+
self.assertEqual(self.v["Boatspeed (Knots)"]["display_text"], "4.77")
|
|
28
|
+
|
|
29
|
+
def test_raw_is_pair(self):
|
|
30
|
+
self.assertIn(" / ", self.v["Boatspeed (Raw)"]["display_text"])
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
unittest.main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
result = decode_frame(bytes.fromhex(hex_str))
|
|
7
|
+
assert "error" not in result, f"Decode error: {result}"
|
|
8
|
+
return result["values"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestHeading(unittest.TestCase):
|
|
12
|
+
"""
|
|
13
|
+
Heading: format 0x08, segment code 0x66 → layout '°M'.
|
|
14
|
+
9-bit value; MSB=0 for values ≤ 255.
|
|
15
|
+
"""
|
|
16
|
+
FRAME = "ff120e01e00b038c024908cd634a0afbe13d492d"
|
|
17
|
+
|
|
18
|
+
def setUp(self):
|
|
19
|
+
self.v = _decode(self.FRAME)["Heading"]
|
|
20
|
+
|
|
21
|
+
def test_value(self):
|
|
22
|
+
self.assertEqual(self.v["value"], 355.0)
|
|
23
|
+
|
|
24
|
+
def test_layout(self):
|
|
25
|
+
self.assertEqual(self.v["layout"], "°M")
|
|
26
|
+
|
|
27
|
+
def test_display(self):
|
|
28
|
+
self.assertEqual(self.v["display_text"], "355°M")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestRudder(unittest.TestCase):
|
|
32
|
+
"""
|
|
33
|
+
Rudder Angle: format 0x03, segment byte 0x8c → layout '=[data]'.
|
|
34
|
+
'=' is B&G's alternative minus symbol; value is stored negative.
|
|
35
|
+
Same frame as TestHeading.
|
|
36
|
+
"""
|
|
37
|
+
FRAME = "ff120e01e00b038c024908cd634a0afbe13d492d"
|
|
38
|
+
|
|
39
|
+
def setUp(self):
|
|
40
|
+
self.v = _decode(self.FRAME)["Rudder Angle"]
|
|
41
|
+
|
|
42
|
+
def test_value(self):
|
|
43
|
+
self.assertEqual(self.v["value"], -2.0)
|
|
44
|
+
|
|
45
|
+
def test_layout(self):
|
|
46
|
+
self.assertEqual(self.v["layout"], "=[data]")
|
|
47
|
+
|
|
48
|
+
def test_display(self):
|
|
49
|
+
self.assertEqual(self.v["display_text"], "-2")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestCourse(unittest.TestCase):
|
|
53
|
+
"""
|
|
54
|
+
Course (HDG + Leeway): format 0x08, layout '°M'.
|
|
55
|
+
Heading on Next Tack: format 0x08, layout '°M'.
|
|
56
|
+
Both from a 4-channel navigation broadcast.
|
|
57
|
+
"""
|
|
58
|
+
FRAME = "ff051401e74a0afbe1fbe1824700d800006908cd639a08cce65e"
|
|
59
|
+
|
|
60
|
+
def setUp(self):
|
|
61
|
+
self.v = _decode(self.FRAME)
|
|
62
|
+
|
|
63
|
+
def test_course_value(self):
|
|
64
|
+
self.assertEqual(self.v["Course (HDG + Leeway)"]["value"], 355.0)
|
|
65
|
+
|
|
66
|
+
def test_course_display(self):
|
|
67
|
+
self.assertEqual(self.v["Course (HDG + Leeway)"]["display_text"], "355°M")
|
|
68
|
+
|
|
69
|
+
def test_next_tack_value(self):
|
|
70
|
+
self.assertEqual(self.v["Heading on Next Tack"]["value"], 230.0)
|
|
71
|
+
|
|
72
|
+
def test_next_tack_display(self):
|
|
73
|
+
self.assertEqual(self.v["Heading on Next Tack"]["display_text"], "230°M")
|
|
74
|
+
|
|
75
|
+
def test_leeway_value(self):
|
|
76
|
+
self.assertEqual(self.v["Leeway"]["value"], 0.0)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
unittest.main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _decode(hex_str):
|
|
6
|
+
result = decode_frame(bytes.fromhex(hex_str))
|
|
7
|
+
assert "error" not in result, f"Decode error: {result}"
|
|
8
|
+
return result["values"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Sign convention (format 0x07, segment byte drives layout):
|
|
12
|
+
# Heel port: seg 0xf3 → layout 'H[data]' → value negative, display "H20.4"
|
|
13
|
+
# Heel stb: seg 0x73 → layout '[data]H' → value positive, display "33.8H"
|
|
14
|
+
# Trim pos: seg 0x20 → layout '[data]-' → value positive, display "19.7-"
|
|
15
|
+
# Trim neg: seg 0xa0 → layout '-[data]' → value negative, display "-24.4"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TestHeelPort(unittest.TestCase):
|
|
19
|
+
"""
|
|
20
|
+
Heel to port: segment byte 0xf3 → layout 'H[data]'.
|
|
21
|
+
H before the digits encodes port direction; value is stored negative.
|
|
22
|
+
Display strips the minus — the H symbol itself carries the sign.
|
|
23
|
+
"""
|
|
24
|
+
FRAME = "ff051401e78d8105263b3101fa344700f300cc9b4700a000099b"
|
|
25
|
+
|
|
26
|
+
def setUp(self):
|
|
27
|
+
self.v = _decode(self.FRAME)["Heel Angle"]
|
|
28
|
+
|
|
29
|
+
def test_value(self):
|
|
30
|
+
self.assertAlmostEqual(self.v["value"], -20.4, places=1)
|
|
31
|
+
|
|
32
|
+
def test_layout(self):
|
|
33
|
+
self.assertEqual(self.v["layout"], "H[data]")
|
|
34
|
+
|
|
35
|
+
def test_display(self):
|
|
36
|
+
self.assertEqual(self.v["display_text"], "H20.4")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TestHeelStarboard(unittest.TestCase):
|
|
40
|
+
"""
|
|
41
|
+
Heel to starboard: segment byte 0x73 → layout '[data]H'.
|
|
42
|
+
H after the digits encodes starboard direction; value is positive.
|
|
43
|
+
"""
|
|
44
|
+
FRAME = "ff051401e78d8105213b3101fb3447007301529b4700a0000899"
|
|
45
|
+
|
|
46
|
+
def setUp(self):
|
|
47
|
+
self.v = _decode(self.FRAME)["Heel Angle"]
|
|
48
|
+
|
|
49
|
+
def test_value(self):
|
|
50
|
+
self.assertAlmostEqual(self.v["value"], 33.8, places=1)
|
|
51
|
+
|
|
52
|
+
def test_layout(self):
|
|
53
|
+
self.assertEqual(self.v["layout"], "[data]H")
|
|
54
|
+
|
|
55
|
+
def test_display(self):
|
|
56
|
+
self.assertEqual(self.v["display_text"], "33.8H")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TestTrimPositive(unittest.TestCase):
|
|
60
|
+
"""
|
|
61
|
+
Positive (bow-down) trim: segment byte 0x20 → layout '[data]-'.
|
|
62
|
+
Trailing dash encodes positive direction; value is positive.
|
|
63
|
+
"""
|
|
64
|
+
FRAME = "ff051401e78d8105463b3101e13447007300249b47002000c580"
|
|
65
|
+
|
|
66
|
+
def setUp(self):
|
|
67
|
+
self.v = _decode(self.FRAME)["Fore/Aft Trim"]
|
|
68
|
+
|
|
69
|
+
def test_value(self):
|
|
70
|
+
self.assertAlmostEqual(self.v["value"], 19.7, places=1)
|
|
71
|
+
|
|
72
|
+
def test_layout(self):
|
|
73
|
+
self.assertEqual(self.v["layout"], "[data]-")
|
|
74
|
+
|
|
75
|
+
def test_display(self):
|
|
76
|
+
self.assertEqual(self.v["display_text"], "19.7-")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestTrimNegative(unittest.TestCase):
|
|
80
|
+
"""
|
|
81
|
+
Negative (bow-up) trim: segment byte 0xa0 → layout '-[data]'.
|
|
82
|
+
Leading dash encodes negative direction; sign appears in the formatted number.
|
|
83
|
+
"""
|
|
84
|
+
FRAME = "ff051401e78d8105413b3101df34470073001d9b4700a000f4df"
|
|
85
|
+
|
|
86
|
+
def setUp(self):
|
|
87
|
+
self.v = _decode(self.FRAME)["Fore/Aft Trim"]
|
|
88
|
+
|
|
89
|
+
def test_value(self):
|
|
90
|
+
self.assertAlmostEqual(self.v["value"], -24.4, places=1)
|
|
91
|
+
|
|
92
|
+
def test_layout(self):
|
|
93
|
+
self.assertEqual(self.v["layout"], "-[data]")
|
|
94
|
+
|
|
95
|
+
def test_display(self):
|
|
96
|
+
self.assertEqual(self.v["display_text"], "-24.4")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
unittest.main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
from fastnet_decoder.decode_fastnet import decode_ascii_frame
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TestLatLonFrame(unittest.TestCase):
|
|
6
|
+
"""
|
|
7
|
+
Tests for LatLon (position) command frames (command 0x03).
|
|
8
|
+
|
|
9
|
+
These frames are dispatched to decode_ascii_frame, which carries the raw
|
|
10
|
+
coordinate string in display_text. The entry is named from the command
|
|
11
|
+
("LatLon") — NOT from body[0]. body[0] is a per-source marker byte that
|
|
12
|
+
varies by GPS unit (0x4E, 0x47, ...) and is not a generic channel id, so
|
|
13
|
+
it must not be looked up in CHANNEL_LOOKUP (doing so previously mislabelled
|
|
14
|
+
positions as "Apparent Wind Speed (Raw)").
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
# Real frame, from_address 0x60 (External Compass NMEA FFD 60).
|
|
18
|
+
# body[0] = 0x4E. Position: 33°52.450'S, 151°13.920'E (Sydney).
|
|
19
|
+
FRAME_4E = "ff601503894e50333335322e3435305331353131332e3932304572"
|
|
20
|
+
|
|
21
|
+
# Real frame, from_address 0x62 (External Compass NMEA FFD 62).
|
|
22
|
+
# body[0] = 0x47. Position: 16°46.61'S, 179°20.23'E.
|
|
23
|
+
FRAME_47 = "ff621503874750313634362e3631205331373932302e3233204595"
|
|
24
|
+
|
|
25
|
+
def test_4e_source_keyed_latlon(self):
|
|
26
|
+
values = decode_ascii_frame(bytes.fromhex(self.FRAME_4E))["values"]
|
|
27
|
+
self.assertIn("LatLon", values)
|
|
28
|
+
self.assertNotIn("Apparent Wind Speed (Raw)", values)
|
|
29
|
+
entry = values["LatLon"]
|
|
30
|
+
self.assertEqual(entry["channel_id"], "0x4E")
|
|
31
|
+
self.assertEqual(entry["display_text"], "3352.450S15113.920E")
|
|
32
|
+
self.assertIsNone(entry["value"])
|
|
33
|
+
|
|
34
|
+
def test_47_source_keyed_latlon(self):
|
|
35
|
+
values = decode_ascii_frame(bytes.fromhex(self.FRAME_47))["values"]
|
|
36
|
+
self.assertIn("LatLon", values)
|
|
37
|
+
entry = values["LatLon"]
|
|
38
|
+
self.assertEqual(entry["channel_id"], "0x47")
|
|
39
|
+
self.assertEqual(entry["display_text"], "1646.61 S17920.23 E")
|
|
40
|
+
self.assertIsNone(entry["value"])
|
|
41
|
+
|
|
42
|
+
def test_both_sources_share_one_key(self):
|
|
43
|
+
# The fix normalises differing source bytes to a single consistent key.
|
|
44
|
+
keys_4e = set(decode_ascii_frame(bytes.fromhex(self.FRAME_4E))["values"])
|
|
45
|
+
keys_47 = set(decode_ascii_frame(bytes.fromhex(self.FRAME_47))["values"])
|
|
46
|
+
self.assertEqual(keys_4e, keys_47, {"LatLon"})
|
|
47
|
+
|
|
48
|
+
def test_command_metadata(self):
|
|
49
|
+
decoded = decode_ascii_frame(bytes.fromhex(self.FRAME_4E))
|
|
50
|
+
self.assertNotIn("error", decoded)
|
|
51
|
+
self.assertEqual(decoded["command"], "LatLon")
|
|
52
|
+
self.assertEqual(decoded["from_address"], "External Compass (NMEA FFD 60)")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
unittest.main()
|