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.
- {pyfastnet-1.2.4/pyfastnet.egg-info → pyfastnet-2.0.0}/PKG-INFO +1 -1
- pyfastnet-2.0.0/fastnet_decoder/decode_fastnet.py +237 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/mappings.py +19 -0
- pyfastnet-2.0.0/fastnet_decoder/utils.py +48 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0/pyfastnet.egg-info}/PKG-INFO +1 -1
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/setup.py +1 -1
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_depth_frame.py +6 -6
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_format07_msb_regression.py +4 -4
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_format08_layout.py +9 -19
- pyfastnet-1.2.4/fastnet_decoder/decode_fastnet.py +0 -407
- pyfastnet-1.2.4/fastnet_decoder/utils.py +0 -98
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/LICENSE +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/MANIFEST.in +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/README.md +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/__init__.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/frame_buffer.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/fastnet_decoder/logger.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/pyfastnet.egg-info/SOURCES.txt +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/pyfastnet.egg-info/dependency_links.txt +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/pyfastnet.egg-info/top_level.txt +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/setup.cfg +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_apparent_frame.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_autopilot_frame.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_heel_frame.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_rudder_frame.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_tide_frame.py +0 -0
- {pyfastnet-1.2.4 → pyfastnet-2.0.0}/tests/test_true_frame.py +0 -0
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
|
2
2
|
|
|
3
3
|
setup(
|
|
4
4
|
name="pyfastnet",
|
|
5
|
-
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)"]["
|
|
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)"]["
|
|
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)"]["
|
|
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)"]["
|
|
49
|
-
feet = self.values["Depth (Feet)"]["
|
|
50
|
-
fathoms = self.values["Depth (Fathoms)"]["
|
|
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"]["
|
|
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"]["
|
|
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"]["
|
|
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"]["
|
|
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:
|
|
10
|
-
Segment code 0x66
|
|
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 →
|
|
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
|
|
34
|
-
self.assertEqual(self.heading["
|
|
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["
|
|
36
|
+
self.assertEqual(self.heading["layout"], "°M")
|
|
44
37
|
|
|
45
|
-
def
|
|
46
|
-
|
|
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["
|
|
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
|
|
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
|