pyfastnet 2.0.0__tar.gz → 2.0.2__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.0/pyfastnet.egg-info → pyfastnet-2.0.2}/PKG-INFO +1 -1
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/decode_fastnet.py +36 -41
- pyfastnet-2.0.2/fastnet_decoder/frame_buffer.py +103 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/logger.py +4 -10
- {pyfastnet-2.0.0 → pyfastnet-2.0.2/pyfastnet.egg-info}/PKG-INFO +1 -1
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/pyfastnet.egg-info/SOURCES.txt +1 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/setup.py +1 -1
- pyfastnet-2.0.2/tests/test_channels.py +407 -0
- pyfastnet-2.0.0/fastnet_decoder/frame_buffer.py +0 -198
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/LICENSE +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/MANIFEST.in +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/README.md +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/__init__.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/mappings.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/utils.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/pyfastnet.egg-info/dependency_links.txt +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/pyfastnet.egg-info/top_level.txt +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/setup.cfg +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_apparent_frame.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_autopilot_frame.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_depth_frame.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_format07_msb_regression.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_format08_layout.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_heel_frame.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_rudder_frame.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_tide_frame.py +0 -0
- {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_true_frame.py +0 -0
|
@@ -24,8 +24,6 @@ def _display_from_layout(layout: str, formatted: str) -> str:
|
|
|
24
24
|
|
|
25
25
|
def decode_frame(frame: bytes) -> dict:
|
|
26
26
|
try:
|
|
27
|
-
logger.debug(f"Starting frame decoding. Frame length: {len(frame)}, Frame contents: {frame.hex()}")
|
|
28
|
-
|
|
29
27
|
to_address = frame[0]
|
|
30
28
|
from_address = frame[1]
|
|
31
29
|
body_size = frame[2]
|
|
@@ -34,76 +32,75 @@ def decode_frame(frame: bytes) -> dict:
|
|
|
34
32
|
body = frame[5:-1]
|
|
35
33
|
body_checksum = frame[-1]
|
|
36
34
|
|
|
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
35
|
if calculate_checksum(frame[:4]) != header_checksum:
|
|
43
|
-
logger.debug(f"
|
|
36
|
+
logger.debug(f"FRAME discard header-checksum [{frame.hex()[:16]}...]")
|
|
44
37
|
return {"error": "Header checksum mismatch"}
|
|
45
38
|
|
|
46
39
|
if calculate_checksum(body) != body_checksum:
|
|
47
|
-
logger.debug(f"
|
|
40
|
+
logger.debug(f"FRAME discard body-checksum [{frame.hex()[:16]}...]")
|
|
48
41
|
return {"error": "Body checksum mismatch"}
|
|
49
42
|
|
|
50
43
|
if len(body) < 2 or len(body) != body_size:
|
|
51
|
-
logger.debug(f"
|
|
44
|
+
logger.debug(f"FRAME discard body-size expected={body_size} actual={len(body)}")
|
|
52
45
|
return {"error": "Invalid body size"}
|
|
53
46
|
|
|
54
|
-
logger.debug("Header and body checksums are valid.")
|
|
55
|
-
|
|
56
47
|
decoded_data = {
|
|
57
|
-
"to_address":
|
|
48
|
+
"to_address": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
|
|
58
49
|
"from_address": ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})"),
|
|
59
|
-
"command":
|
|
60
|
-
"values":
|
|
50
|
+
"command": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
|
|
51
|
+
"values": {}
|
|
61
52
|
}
|
|
62
53
|
|
|
63
54
|
index = 0
|
|
64
55
|
while index < len(body):
|
|
65
56
|
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
|
-
)
|
|
57
|
+
logger.debug(f" CH incomplete header at index={index}")
|
|
70
58
|
return {"error": "Insufficient bytes for channel header"}
|
|
71
59
|
|
|
72
|
-
channel_id
|
|
73
|
-
format_byte
|
|
74
|
-
|
|
60
|
+
channel_id = body[index]
|
|
61
|
+
format_byte = body[index + 1]
|
|
62
|
+
channel_name = CHANNEL_LOOKUP.get(channel_id, f"Unknown (0x{channel_id:02X})")
|
|
63
|
+
index += 2
|
|
75
64
|
|
|
76
65
|
data_length = FORMAT_SIZE_MAP.get(format_byte & 0x0F, 0)
|
|
77
66
|
if index + data_length > len(body):
|
|
78
67
|
logger.debug(
|
|
79
|
-
f"
|
|
80
|
-
f"
|
|
68
|
+
f" CH 0x{channel_id:02X} {channel_name} "
|
|
69
|
+
f"incomplete need={data_length}B have={len(body) - index}B"
|
|
81
70
|
)
|
|
82
71
|
return {"error": f"Incomplete data for channel 0x{channel_id:02X}"}
|
|
83
72
|
|
|
84
73
|
data_bytes = body[index:index + data_length]
|
|
85
74
|
index += data_length
|
|
86
75
|
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
logger.debug(
|
|
77
|
+
f" CH 0x{channel_id:02X} {channel_name} "
|
|
78
|
+
f"fmt=0x{format_byte:02X} data=[{data_bytes.hex()}]"
|
|
79
|
+
)
|
|
89
80
|
|
|
81
|
+
decoded_value = decode_format_and_data(channel_id, format_byte, data_bytes)
|
|
90
82
|
decoded_data["values"][channel_name] = decoded_value
|
|
91
|
-
|
|
83
|
+
|
|
84
|
+
if decoded_value:
|
|
85
|
+
logger.debug(
|
|
86
|
+
f" value={decoded_value['value']} "
|
|
87
|
+
f"display='{decoded_value['display_text']}' "
|
|
88
|
+
f"layout={decoded_value['layout']}"
|
|
89
|
+
)
|
|
92
90
|
|
|
93
91
|
return decoded_data
|
|
94
92
|
|
|
95
93
|
except Exception as e:
|
|
96
|
-
logger.error(f"Unexpected error
|
|
94
|
+
logger.error(f"Unexpected error decoding frame: {e} [{frame.hex()}]")
|
|
97
95
|
return {"error": "Decoding failure"}
|
|
98
96
|
|
|
99
97
|
|
|
100
98
|
def decode_ascii_frame(frame: bytes) -> dict:
|
|
101
99
|
try:
|
|
102
|
-
to_address
|
|
103
|
-
from_address
|
|
104
|
-
command
|
|
105
|
-
|
|
106
|
-
body = frame[5:-1]
|
|
100
|
+
to_address = frame[0]
|
|
101
|
+
from_address = frame[1]
|
|
102
|
+
command = frame[3]
|
|
103
|
+
body = frame[5:-1]
|
|
107
104
|
|
|
108
105
|
channel_id = body[0]
|
|
109
106
|
data_bytes = body[2:]
|
|
@@ -112,13 +109,15 @@ def decode_ascii_frame(frame: bytes) -> dict:
|
|
|
112
109
|
try:
|
|
113
110
|
ascii_text = data_bytes.decode("ascii").strip()
|
|
114
111
|
except UnicodeDecodeError as e:
|
|
115
|
-
logger.warning(f"
|
|
112
|
+
logger.warning(f" CH 0x{channel_id:02X} {channel_name} ASCII decode failed: {e}")
|
|
116
113
|
return {"error": "ASCII decode failed"}
|
|
117
114
|
|
|
115
|
+
logger.debug(f" CH 0x{channel_id:02X} {channel_name} ascii='{ascii_text}'")
|
|
116
|
+
|
|
118
117
|
return {
|
|
119
|
-
"to_address":
|
|
118
|
+
"to_address": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
|
|
120
119
|
"from_address": ADDRESS_LOOKUP.get(from_address, f"Unknown (0x{from_address:02X})"),
|
|
121
|
-
"command":
|
|
120
|
+
"command": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
|
|
122
121
|
"values": {
|
|
123
122
|
channel_name: {
|
|
124
123
|
"channel_id": f"0x{channel_id:02X}",
|
|
@@ -136,14 +135,11 @@ def decode_ascii_frame(frame: bytes) -> dict:
|
|
|
136
135
|
|
|
137
136
|
def decode_format_and_data(channel_id, format_byte, data_bytes):
|
|
138
137
|
try:
|
|
139
|
-
logger.debug(f"Decoding channel ID: 0x{channel_id:02X}, format byte: 0x{format_byte:02X}, data: {data_bytes.hex()}")
|
|
140
|
-
|
|
141
138
|
divisor = _DIVISOR_MAP[(format_byte >> 6) & 0b11]
|
|
142
139
|
dp = _DP_MAP[divisor]
|
|
143
140
|
format_bits = format_byte & 0b1111
|
|
144
141
|
|
|
145
142
|
if len(data_bytes) == 0:
|
|
146
|
-
logger.debug("decode_format_and_data: Empty data bytes; cannot decode.")
|
|
147
143
|
return None
|
|
148
144
|
|
|
149
145
|
layout = None
|
|
@@ -193,7 +189,6 @@ def decode_format_and_data(channel_id, format_byte, data_bytes):
|
|
|
193
189
|
return None
|
|
194
190
|
value = None
|
|
195
191
|
display_text = "".join(SEGMENT_B.get(b, "?") for b in data_bytes)
|
|
196
|
-
logger.debug(f"Decoded 7-segment text: {display_text}")
|
|
197
192
|
|
|
198
193
|
elif format_bits == 0x07:
|
|
199
194
|
if len(data_bytes) != 4:
|
|
@@ -222,7 +217,7 @@ def decode_format_and_data(channel_id, format_byte, data_bytes):
|
|
|
222
217
|
display_text = f"{first:.{dp}f} / {second:.{dp}f}"
|
|
223
218
|
|
|
224
219
|
else:
|
|
225
|
-
logger.debug(f"
|
|
220
|
+
logger.debug(f" unsupported format 0x{format_bits:02X}")
|
|
226
221
|
return None
|
|
227
222
|
|
|
228
223
|
return {
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from .utils import calculate_checksum
|
|
2
|
+
from .mappings import COMMAND_LOOKUP, IGNORED_COMMANDS
|
|
3
|
+
from .decode_fastnet import decode_frame, decode_ascii_frame
|
|
4
|
+
from .logger import logger
|
|
5
|
+
from queue import Queue, Full
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FrameBuffer:
|
|
9
|
+
"""
|
|
10
|
+
Manages an incoming byte stream, extracts valid FastNet frames,
|
|
11
|
+
decodes them, and queues the results.
|
|
12
|
+
|
|
13
|
+
Typical workflow:
|
|
14
|
+
1. Feed raw bytes via add_to_buffer()
|
|
15
|
+
2. Call get_complete_frames() to process the buffer
|
|
16
|
+
3. Pull decoded frames from frame_queue
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, max_buffer_size=8192, max_queue_size=1000):
|
|
20
|
+
self.buffer = bytearray()
|
|
21
|
+
self.max_buffer_size = max_buffer_size
|
|
22
|
+
self.frame_queue = Queue(maxsize=max_queue_size)
|
|
23
|
+
|
|
24
|
+
def add_to_buffer(self, new_data):
|
|
25
|
+
if not isinstance(new_data, (bytes, bytearray)):
|
|
26
|
+
logger.error("Invalid data type passed to add_to_buffer. Expected bytes or bytearray.")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
self.buffer.extend(new_data)
|
|
30
|
+
logger.debug(f"BUF +{len(new_data)}B total={len(self.buffer)}B")
|
|
31
|
+
|
|
32
|
+
if len(self.buffer) > self.max_buffer_size:
|
|
33
|
+
logger.warning("Buffer size exceeded maximum limit. Trimming the oldest data.")
|
|
34
|
+
self.buffer = self.buffer[-self.max_buffer_size:]
|
|
35
|
+
|
|
36
|
+
def get_complete_frames(self):
|
|
37
|
+
"""Extract, validate, and queue complete frames from the buffer."""
|
|
38
|
+
while len(self.buffer) >= 6:
|
|
39
|
+
to_address = self.buffer[0]
|
|
40
|
+
from_address = self.buffer[1]
|
|
41
|
+
body_size = self.buffer[2]
|
|
42
|
+
command = self.buffer[3]
|
|
43
|
+
header_checksum = self.buffer[4]
|
|
44
|
+
|
|
45
|
+
command_name = COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})")
|
|
46
|
+
full_frame_length = 5 + body_size + 1
|
|
47
|
+
|
|
48
|
+
if len(self.buffer) < full_frame_length:
|
|
49
|
+
logger.debug(f"FRAME wait need={full_frame_length}B have={len(self.buffer)}B")
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
frame = self.buffer[:full_frame_length]
|
|
53
|
+
body = self.buffer[5:full_frame_length - 1]
|
|
54
|
+
body_checksum = self.buffer[full_frame_length - 1]
|
|
55
|
+
frame_hex = bytes(frame[:8]).hex()
|
|
56
|
+
|
|
57
|
+
if calculate_checksum(self.buffer[:4]) != header_checksum:
|
|
58
|
+
logger.debug(f"FRAME discard header-checksum [{frame_hex}...]")
|
|
59
|
+
self.buffer = self.buffer[1:]
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if calculate_checksum(body) != body_checksum:
|
|
63
|
+
logger.debug(f"FRAME discard body-checksum [{frame_hex}...]")
|
|
64
|
+
self.buffer = self.buffer[1:]
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
self.buffer = self.buffer[full_frame_length:]
|
|
68
|
+
|
|
69
|
+
if command_name in IGNORED_COMMANDS:
|
|
70
|
+
logger.debug(f"FRAME skip cmd={command_name}")
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
logger.debug(
|
|
74
|
+
f"FRAME cmd={command_name} "
|
|
75
|
+
f"0x{to_address:02X}←0x{from_address:02X} "
|
|
76
|
+
f"body={body_size}B [{frame_hex}...]"
|
|
77
|
+
)
|
|
78
|
+
self.decode_and_queue_frame(frame, command_name)
|
|
79
|
+
|
|
80
|
+
def decode_and_queue_frame(self, frame, command_name):
|
|
81
|
+
"""Decode a frame and add it to the queue if valid."""
|
|
82
|
+
decoder = decode_ascii_frame if command_name == "LatLon" else decode_frame
|
|
83
|
+
decoded_frame = decoder(frame)
|
|
84
|
+
if decoded_frame and "values" in decoded_frame:
|
|
85
|
+
channel_names = list(decoded_frame["values"].keys())
|
|
86
|
+
names_str = ", ".join(channel_names[:4])
|
|
87
|
+
if len(channel_names) > 4:
|
|
88
|
+
names_str += f", +{len(channel_names) - 4} more"
|
|
89
|
+
try:
|
|
90
|
+
self.frame_queue.put_nowait(decoded_frame)
|
|
91
|
+
logger.debug(f"QUEUE {len(channel_names)} channel(s) [{names_str}]")
|
|
92
|
+
except Full:
|
|
93
|
+
logger.warning("Frame queue full, dropping frame.")
|
|
94
|
+
else:
|
|
95
|
+
logger.debug(f"QUEUE fail decode error [{frame.hex()[:16]}...]")
|
|
96
|
+
|
|
97
|
+
def get_buffer_size(self):
|
|
98
|
+
return len(self.buffer)
|
|
99
|
+
|
|
100
|
+
def get_buffer_contents(self):
|
|
101
|
+
hex_contents = self.buffer.hex()
|
|
102
|
+
logger.debug(f"BUF contents [{hex_contents}]")
|
|
103
|
+
return hex_contents
|
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
logger = logging.getLogger("fastnet_decoder")
|
|
3
|
+
logger = logging.getLogger("pyfastnet")
|
|
5
4
|
|
|
6
|
-
# Default log level
|
|
7
5
|
DEFAULT_LOG_LEVEL = logging.INFO
|
|
8
6
|
|
|
9
|
-
# Avoid duplicate handlers
|
|
10
7
|
if not logger.hasHandlers():
|
|
11
8
|
handler = logging.StreamHandler()
|
|
12
|
-
formatter = logging.Formatter("%(asctime)s [
|
|
9
|
+
formatter = logging.Formatter("%(asctime)s [pyfastnet] %(levelname)-5s %(message)s")
|
|
13
10
|
handler.setFormatter(formatter)
|
|
14
11
|
logger.addHandler(handler)
|
|
15
12
|
|
|
16
13
|
logger.setLevel(DEFAULT_LOG_LEVEL)
|
|
17
14
|
|
|
15
|
+
|
|
18
16
|
def set_log_level(level_name: str):
|
|
19
|
-
"""
|
|
20
|
-
Sets the log level dynamically at runtime.
|
|
21
|
-
Args:
|
|
22
|
-
level_name (str): Name of the log level (e.g., "DEBUG", "INFO", "WARNING").
|
|
23
|
-
"""
|
|
17
|
+
"""Sets the log level dynamically at runtime."""
|
|
24
18
|
level = getattr(logging, level_name.upper(), DEFAULT_LOG_LEVEL)
|
|
25
19
|
logger.setLevel(level)
|
|
26
20
|
logger.info(f"Log level set to {level_name.upper()}.")
|
|
@@ -14,6 +14,7 @@ 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_channels.py
|
|
17
18
|
tests/test_depth_frame.py
|
|
18
19
|
tests/test_format07_msb_regression.py
|
|
19
20
|
tests/test_format08_layout.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.2", # 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,407 @@
|
|
|
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
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Boatspeed — format 0x01 (16-bit signed, divisor 100) + 0x0A pair
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
class TestBoatspeed(unittest.TestCase):
|
|
16
|
+
"""
|
|
17
|
+
Frame from Performance Processor containing Boatspeed (Knots) and
|
|
18
|
+
Boatspeed (Raw). Raw uses format 0x0A — two 16-bit signed integers.
|
|
19
|
+
"""
|
|
20
|
+
FRAME = "ff010a01f54192f9dd420a01ec082cea"
|
|
21
|
+
|
|
22
|
+
def setUp(self):
|
|
23
|
+
self.v = _decode(self.FRAME)
|
|
24
|
+
|
|
25
|
+
def test_boatspeed_knots_value(self):
|
|
26
|
+
self.assertAlmostEqual(self.v["Boatspeed (Knots)"]["value"], 4.77, places=2)
|
|
27
|
+
|
|
28
|
+
def test_boatspeed_knots_display(self):
|
|
29
|
+
self.assertEqual(self.v["Boatspeed (Knots)"]["display_text"], "4.77")
|
|
30
|
+
|
|
31
|
+
def test_boatspeed_raw_display_pair(self):
|
|
32
|
+
# format 0x0A renders as "first / second"
|
|
33
|
+
self.assertIn(" / ", self.v["Boatspeed (Raw)"]["display_text"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Apparent wind — format 0x01 (AWS) + 0x07 with port layout (AWA)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
class TestApparentWind(unittest.TestCase):
|
|
41
|
+
"""
|
|
42
|
+
18-channel broadcast frame containing all three apparent wind channels.
|
|
43
|
+
- AWS Knots / m/s: format 0x01, divisor 10
|
|
44
|
+
- AWA: format 0x07, layout '[data]-' (trailing dash = port side)
|
|
45
|
+
"""
|
|
46
|
+
FRAME = "ff051801e34e0a061c05fe4d51009c4f610050520a47f347f351032065a0"
|
|
47
|
+
|
|
48
|
+
def setUp(self):
|
|
49
|
+
self.v = _decode(self.FRAME)
|
|
50
|
+
|
|
51
|
+
def test_aws_knots_value(self):
|
|
52
|
+
self.assertAlmostEqual(self.v["Apparent Wind Speed (Knots)"]["value"], 15.6, places=1)
|
|
53
|
+
|
|
54
|
+
def test_aws_knots_display(self):
|
|
55
|
+
self.assertEqual(self.v["Apparent Wind Speed (Knots)"]["display_text"], "15.6")
|
|
56
|
+
|
|
57
|
+
def test_aws_ms_value(self):
|
|
58
|
+
self.assertAlmostEqual(self.v["Apparent Wind Speed (m/s)"]["value"], 8.0, places=1)
|
|
59
|
+
|
|
60
|
+
def test_awa_value(self):
|
|
61
|
+
# Port/starboard is encoded in layout, not value sign — value stays positive
|
|
62
|
+
self.assertEqual(self.v["Apparent Wind Angle"]["value"], 101.0)
|
|
63
|
+
|
|
64
|
+
def test_awa_layout_port(self):
|
|
65
|
+
self.assertEqual(self.v["Apparent Wind Angle"]["layout"], "[data]-")
|
|
66
|
+
|
|
67
|
+
def test_awa_display_port(self):
|
|
68
|
+
# Trailing dash is the port indicator rendered in display_text
|
|
69
|
+
self.assertEqual(self.v["Apparent Wind Angle"]["display_text"], "101-")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# True wind — format 0x01 (TWS) + 0x07 (TWA starboard, TWD °M, VMG)
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
class TestTrueWind(unittest.TestCase):
|
|
77
|
+
"""
|
|
78
|
+
16-channel broadcast frame with all true wind and VMG channels.
|
|
79
|
+
- TWA: format 0x07, layout '[data]=' (trailing equals = starboard)
|
|
80
|
+
- TWD: format 0x07, layout '°M'
|
|
81
|
+
- VMG: format 0x07
|
|
82
|
+
"""
|
|
83
|
+
FRAME = "ff051601e5555100a656610055590328767f8700bb00db6d08cc7061"
|
|
84
|
+
|
|
85
|
+
def setUp(self):
|
|
86
|
+
self.v = _decode(self.FRAME)
|
|
87
|
+
|
|
88
|
+
def test_tws_knots(self):
|
|
89
|
+
self.assertAlmostEqual(self.v["True Wind Speed (Knots)"]["value"], 16.6, places=1)
|
|
90
|
+
|
|
91
|
+
def test_tws_ms(self):
|
|
92
|
+
self.assertAlmostEqual(self.v["True Wind Speed (m/s)"]["value"], 8.5, places=1)
|
|
93
|
+
|
|
94
|
+
def test_twa_value(self):
|
|
95
|
+
self.assertEqual(self.v["True Wind Angle"]["value"], 118.0)
|
|
96
|
+
|
|
97
|
+
def test_twa_layout_starboard(self):
|
|
98
|
+
# Trailing equals = starboard; value remains positive
|
|
99
|
+
self.assertEqual(self.v["True Wind Angle"]["layout"], "[data]=")
|
|
100
|
+
|
|
101
|
+
def test_twa_display_starboard(self):
|
|
102
|
+
self.assertEqual(self.v["True Wind Angle"]["display_text"], "118=")
|
|
103
|
+
|
|
104
|
+
def test_twd_value(self):
|
|
105
|
+
self.assertEqual(self.v["True Wind Direction"]["value"], 112.0)
|
|
106
|
+
|
|
107
|
+
def test_twd_layout_magnetic(self):
|
|
108
|
+
self.assertEqual(self.v["True Wind Direction"]["layout"], "°M")
|
|
109
|
+
|
|
110
|
+
def test_twd_display(self):
|
|
111
|
+
self.assertEqual(self.v["True Wind Direction"]["display_text"], "112°M")
|
|
112
|
+
|
|
113
|
+
def test_vmg(self):
|
|
114
|
+
self.assertAlmostEqual(self.v["Velocity Made Good (Knots)"]["value"], 2.19, places=2)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Heading and Rudder — format 0x08 (Heading °M) + 0x03 (Rudder signed)
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
class TestHeadingAndRudder(unittest.TestCase):
|
|
122
|
+
"""
|
|
123
|
+
3-channel frame: Heading, Rudder Angle, Heading (Raw).
|
|
124
|
+
- Heading: format 0x08, segment code 0x66 → layout '°M'
|
|
125
|
+
- Rudder Angle: format 0x03, segment byte 0x8c → layout '=[data]',
|
|
126
|
+
value is negative (sign from layout, not from the raw byte)
|
|
127
|
+
- Heading (Raw): format 0x0A two-signed pair
|
|
128
|
+
"""
|
|
129
|
+
FRAME = "ff120e01e00b038c024908cd634a0afbe13d492d"
|
|
130
|
+
|
|
131
|
+
def setUp(self):
|
|
132
|
+
self.v = _decode(self.FRAME)
|
|
133
|
+
|
|
134
|
+
def test_heading_value(self):
|
|
135
|
+
self.assertEqual(self.v["Heading"]["value"], 355.0)
|
|
136
|
+
|
|
137
|
+
def test_heading_layout(self):
|
|
138
|
+
self.assertEqual(self.v["Heading"]["layout"], "°M")
|
|
139
|
+
|
|
140
|
+
def test_heading_display(self):
|
|
141
|
+
self.assertEqual(self.v["Heading"]["display_text"], "355°M")
|
|
142
|
+
|
|
143
|
+
def test_rudder_negative_value(self):
|
|
144
|
+
# '=[data]' → _sign_from_layout returns -1; raw byte = 2 → value = -2
|
|
145
|
+
self.assertEqual(self.v["Rudder Angle"]["value"], -2.0)
|
|
146
|
+
|
|
147
|
+
def test_rudder_layout(self):
|
|
148
|
+
self.assertEqual(self.v["Rudder Angle"]["layout"], "=[data]")
|
|
149
|
+
|
|
150
|
+
def test_rudder_display(self):
|
|
151
|
+
# No layout suffix for '=[data]'; sign appears in the formatted number
|
|
152
|
+
self.assertEqual(self.v["Rudder Angle"]["display_text"], "-2")
|
|
153
|
+
|
|
154
|
+
def test_heading_raw_is_pair(self):
|
|
155
|
+
# format 0x0A always renders as "signed / signed"
|
|
156
|
+
self.assertIn(" / ", self.v["Heading (Raw)"]["display_text"])
|
|
157
|
+
|
|
158
|
+
def test_heading_raw_first_signed(self):
|
|
159
|
+
# Both halves signed; first value should be negative here
|
|
160
|
+
self.assertLess(self.v["Heading (Raw)"]["value"], 0)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
# Course + Leeway + Heading on Next Tack — format 0x08 / 0x07
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
class TestCourseAndNavigation(unittest.TestCase):
|
|
168
|
+
"""
|
|
169
|
+
4-channel frame: Heading (Raw), Leeway, Course (HDG+Leeway),
|
|
170
|
+
Heading on Next Tack.
|
|
171
|
+
Course and Heading on Next Tack use format 0x08 with '°M' layout.
|
|
172
|
+
Leeway uses format 0x07 with an unrecognised segment code → layout '?'.
|
|
173
|
+
"""
|
|
174
|
+
FRAME = "ff051401e74a0afbe1fbe1824700d800006908cd639a08cce65e"
|
|
175
|
+
|
|
176
|
+
def setUp(self):
|
|
177
|
+
self.v = _decode(self.FRAME)
|
|
178
|
+
|
|
179
|
+
def test_course_value(self):
|
|
180
|
+
self.assertEqual(self.v["Course (HDG + Leeway)"]["value"], 355.0)
|
|
181
|
+
|
|
182
|
+
def test_course_layout(self):
|
|
183
|
+
self.assertEqual(self.v["Course (HDG + Leeway)"]["layout"], "°M")
|
|
184
|
+
|
|
185
|
+
def test_course_display(self):
|
|
186
|
+
self.assertEqual(self.v["Course (HDG + Leeway)"]["display_text"], "355°M")
|
|
187
|
+
|
|
188
|
+
def test_heading_on_next_tack_value(self):
|
|
189
|
+
self.assertEqual(self.v["Heading on Next Tack"]["value"], 230.0)
|
|
190
|
+
|
|
191
|
+
def test_heading_on_next_tack_layout(self):
|
|
192
|
+
self.assertEqual(self.v["Heading on Next Tack"]["layout"], "°M")
|
|
193
|
+
|
|
194
|
+
def test_heading_on_next_tack_display(self):
|
|
195
|
+
self.assertEqual(self.v["Heading on Next Tack"]["display_text"], "230°M")
|
|
196
|
+
|
|
197
|
+
def test_leeway_value(self):
|
|
198
|
+
self.assertEqual(self.v["Leeway"]["value"], 0.0)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
# Heel Angle + Fore/Aft Trim — format 0x07, sign from segment layout
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
class TestHeelAndTrim(unittest.TestCase):
|
|
206
|
+
"""
|
|
207
|
+
Heel-port capture: Heel Angle and Fore/Aft Trim both use format 0x07.
|
|
208
|
+
- Heel: segment byte 0xf3 → layout 'H[data]' (H prefix, value positive)
|
|
209
|
+
- Trim: segment byte 0xa0 → layout '-[data]' (value negative = aft trim)
|
|
210
|
+
|
|
211
|
+
Battery Volts is also in this frame: format 0x01, divisor 100.
|
|
212
|
+
"""
|
|
213
|
+
FRAME = "ff051401e78d8105263b3101fa344700f300cc9b4700a000099b"
|
|
214
|
+
|
|
215
|
+
def setUp(self):
|
|
216
|
+
self.v = _decode(self.FRAME)
|
|
217
|
+
|
|
218
|
+
def test_heel_positive_value(self):
|
|
219
|
+
# 'H[data]' does NOT flip sign — port heel is still stored positive
|
|
220
|
+
self.assertAlmostEqual(self.v["Heel Angle"]["value"], 20.4, places=1)
|
|
221
|
+
|
|
222
|
+
def test_heel_layout(self):
|
|
223
|
+
self.assertEqual(self.v["Heel Angle"]["layout"], "H[data]")
|
|
224
|
+
|
|
225
|
+
def test_heel_display_prefix(self):
|
|
226
|
+
# H prefix indicates port side on the instrument display
|
|
227
|
+
self.assertEqual(self.v["Heel Angle"]["display_text"], "H20.4")
|
|
228
|
+
|
|
229
|
+
def test_trim_negative_value(self):
|
|
230
|
+
# '-[data]' drives sign negative
|
|
231
|
+
self.assertAlmostEqual(self.v["Fore/Aft Trim"]["value"], -0.9, places=1)
|
|
232
|
+
|
|
233
|
+
def test_trim_layout(self):
|
|
234
|
+
self.assertEqual(self.v["Fore/Aft Trim"]["layout"], "-[data]")
|
|
235
|
+
|
|
236
|
+
def test_trim_display(self):
|
|
237
|
+
# No suffix; sign is embedded in the formatted number
|
|
238
|
+
self.assertEqual(self.v["Fore/Aft Trim"]["display_text"], "-0.9")
|
|
239
|
+
|
|
240
|
+
def test_battery_volts(self):
|
|
241
|
+
self.assertAlmostEqual(self.v["Battery Volts"]["value"], 13.18, places=2)
|
|
242
|
+
self.assertEqual(self.v["Battery Volts"]["display_text"], "13.18")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
# Sea Temperature — format 0x07, unrecognised segment code → layout '?'
|
|
247
|
+
# ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
class TestSeaTemperature(unittest.TestCase):
|
|
250
|
+
"""
|
|
251
|
+
Stored-log frame also carries sea temperature in Celsius and Fahrenheit.
|
|
252
|
+
Format 0x07 with a segment byte not present in SEGMENT_A → layout '?'.
|
|
253
|
+
Cross-check: 23 °C ≈ 73 °F within 1 degree rounding.
|
|
254
|
+
"""
|
|
255
|
+
FRAME = "ff011801e7cd840000acc7cf84ff0000001f17005c00171e17007400494f"
|
|
256
|
+
|
|
257
|
+
def setUp(self):
|
|
258
|
+
self.v = _decode(self.FRAME)
|
|
259
|
+
|
|
260
|
+
def test_sea_temp_celsius(self):
|
|
261
|
+
self.assertEqual(self.v["Sea Temperature (°C)"]["value"], 23.0)
|
|
262
|
+
self.assertEqual(self.v["Sea Temperature (°C)"]["display_text"], "23")
|
|
263
|
+
|
|
264
|
+
def test_sea_temp_fahrenheit(self):
|
|
265
|
+
self.assertEqual(self.v["Sea Temperature (°F)"]["value"], 73.0)
|
|
266
|
+
self.assertEqual(self.v["Sea Temperature (°F)"]["display_text"], "73")
|
|
267
|
+
|
|
268
|
+
def test_cross_unit_consistency(self):
|
|
269
|
+
c = self.v["Sea Temperature (°C)"]["value"]
|
|
270
|
+
f = self.v["Sea Temperature (°F)"]["value"]
|
|
271
|
+
self.assertAlmostEqual(c * 9 / 5 + 32, f, delta=1.0)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# Navigation Log — format 0x04 (3-byte unsigned, divisor 100)
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
class TestNavigationLog(unittest.TestCase):
|
|
279
|
+
"""
|
|
280
|
+
Stored Log and Trip Log from the same frame as sea temperature.
|
|
281
|
+
Format 0x04: 4-byte payload, value taken from bytes[1:] (big-endian unsigned).
|
|
282
|
+
Divisor 100 → two decimal places.
|
|
283
|
+
"""
|
|
284
|
+
FRAME = "ff011801e7cd840000acc7cf84ff0000001f17005c00171e17007400494f"
|
|
285
|
+
|
|
286
|
+
def setUp(self):
|
|
287
|
+
self.v = _decode(self.FRAME)
|
|
288
|
+
|
|
289
|
+
def test_stored_log_value(self):
|
|
290
|
+
self.assertAlmostEqual(self.v["Stored Log (NM)"]["value"], 442.31, places=2)
|
|
291
|
+
|
|
292
|
+
def test_stored_log_display(self):
|
|
293
|
+
self.assertEqual(self.v["Stored Log (NM)"]["display_text"], "442.31")
|
|
294
|
+
|
|
295
|
+
def test_trip_log_zero(self):
|
|
296
|
+
self.assertEqual(self.v["Trip Log (NM)"]["value"], 0.0)
|
|
297
|
+
self.assertEqual(self.v["Trip Log (NM)"]["display_text"], "0.00")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Timer — format 0x05 (H/M/S bytes → total seconds + HH:MM:SS display)
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
class TestTimer(unittest.TestCase):
|
|
305
|
+
"""
|
|
306
|
+
Timer channel uses format 0x05: data bytes [unused, H, M, S].
|
|
307
|
+
value = total seconds; display_text = timedelta string (H:MM:SS).
|
|
308
|
+
"""
|
|
309
|
+
FRAME = "ff050601f5750501072c064c"
|
|
310
|
+
|
|
311
|
+
def setUp(self):
|
|
312
|
+
self.v = _decode(self.FRAME)
|
|
313
|
+
|
|
314
|
+
def test_timer_total_seconds(self):
|
|
315
|
+
# 7 h × 3600 + 44 min × 60 + 6 s = 27846
|
|
316
|
+
self.assertEqual(self.v["Timer"]["value"], 27846.0)
|
|
317
|
+
|
|
318
|
+
def test_timer_display_hms(self):
|
|
319
|
+
self.assertEqual(self.v["Timer"]["display_text"], "7:44:06")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
# Speed and Course Over Ground — format 0x01, from NMEA FFD frame
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
class TestSpeedAndCourseOverGround(unittest.TestCase):
|
|
327
|
+
"""
|
|
328
|
+
SOG / COG True / COG Mag from an NMEA FFD broadcast.
|
|
329
|
+
- SOG: format 0x01, divisor 10
|
|
330
|
+
- COG: format 0x01, divisor 1 (integer degrees)
|
|
331
|
+
"""
|
|
332
|
+
FRAME = "ff600c0194e9310000ea11015beb6100360d"
|
|
333
|
+
|
|
334
|
+
def setUp(self):
|
|
335
|
+
self.v = _decode(self.FRAME)
|
|
336
|
+
|
|
337
|
+
def test_sog_value(self):
|
|
338
|
+
self.assertAlmostEqual(self.v["Speed Over Ground"]["value"], 5.4, places=1)
|
|
339
|
+
|
|
340
|
+
def test_sog_display(self):
|
|
341
|
+
self.assertEqual(self.v["Speed Over Ground"]["display_text"], "5.4")
|
|
342
|
+
|
|
343
|
+
def test_cog_mag_value(self):
|
|
344
|
+
self.assertEqual(self.v["Course Over Ground (Mag)"]["value"], 347.0)
|
|
345
|
+
self.assertEqual(self.v["Course Over Ground (Mag)"]["display_text"], "347")
|
|
346
|
+
|
|
347
|
+
def test_cog_true_value(self):
|
|
348
|
+
self.assertEqual(self.v["Course Over Ground (True)"]["value"], 0.0)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# Autopilot modes — format 0x01 raw int mapped to mode name string
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
class TestAutopilotModes(unittest.TestCase):
|
|
356
|
+
"""
|
|
357
|
+
Autopilot Mode channel (0xB5) uses format 0x01 with a special-case
|
|
358
|
+
lookup: the raw 16-bit integer is mapped to a mode name string.
|
|
359
|
+
|
|
360
|
+
Three frames cover Standby, Compass and Wind modes.
|
|
361
|
+
In Standby the companion Target channel is OFF (value=None).
|
|
362
|
+
In Compass/Wind modes, Autopilot Compass Target carries a °M bearing.
|
|
363
|
+
"""
|
|
364
|
+
STANDBY_FRAME = "ff121c01d2b5015004a606bee8e800af06bee8e8005306bee8e8007606bee8e80088"
|
|
365
|
+
COMPASS_FRAME = "ff120a01e4b5015101a60700660000e5"
|
|
366
|
+
WIND_FRAME = "ff120a01e4b5015104a6070066014b96"
|
|
367
|
+
|
|
368
|
+
def _v(self, hex_str):
|
|
369
|
+
return _decode(hex_str)
|
|
370
|
+
|
|
371
|
+
def test_standby_mode_display(self):
|
|
372
|
+
self.assertEqual(self._v(self.STANDBY_FRAME)["Autopilot Mode"]["display_text"], "Standby")
|
|
373
|
+
|
|
374
|
+
def test_standby_target_is_off(self):
|
|
375
|
+
v = self._v(self.STANDBY_FRAME)
|
|
376
|
+
self.assertIsNone(v["Autopilot Compass Target"]["value"])
|
|
377
|
+
self.assertEqual(v["Autopilot Compass Target"]["display_text"], "OFF ")
|
|
378
|
+
|
|
379
|
+
def test_compass_mode_display(self):
|
|
380
|
+
self.assertEqual(self._v(self.COMPASS_FRAME)["Autopilot Mode"]["display_text"], "Compass")
|
|
381
|
+
|
|
382
|
+
def test_compass_target_value(self):
|
|
383
|
+
v = self._v(self.COMPASS_FRAME)
|
|
384
|
+
self.assertEqual(v["Autopilot Compass Target"]["value"], 0.0)
|
|
385
|
+
|
|
386
|
+
def test_compass_target_layout(self):
|
|
387
|
+
v = self._v(self.COMPASS_FRAME)
|
|
388
|
+
self.assertEqual(v["Autopilot Compass Target"]["layout"], "°M")
|
|
389
|
+
|
|
390
|
+
def test_compass_target_display(self):
|
|
391
|
+
v = self._v(self.COMPASS_FRAME)
|
|
392
|
+
self.assertEqual(v["Autopilot Compass Target"]["display_text"], "0°M")
|
|
393
|
+
|
|
394
|
+
def test_wind_mode_display(self):
|
|
395
|
+
self.assertEqual(self._v(self.WIND_FRAME)["Autopilot Mode"]["display_text"], "Wind")
|
|
396
|
+
|
|
397
|
+
def test_wind_target_value(self):
|
|
398
|
+
v = self._v(self.WIND_FRAME)
|
|
399
|
+
self.assertEqual(v["Autopilot Compass Target"]["value"], 331.0)
|
|
400
|
+
|
|
401
|
+
def test_wind_target_display(self):
|
|
402
|
+
v = self._v(self.WIND_FRAME)
|
|
403
|
+
self.assertEqual(v["Autopilot Compass Target"]["display_text"], "331°M")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
if __name__ == "__main__":
|
|
407
|
+
unittest.main()
|
|
@@ -1,198 +0,0 @@
|
|
|
1
|
-
from .utils import calculate_checksum # Import checksum function from utils.py
|
|
2
|
-
from .mappings import COMMAND_LOOKUP, IGNORED_COMMANDS
|
|
3
|
-
from .decode_fastnet import decode_frame, decode_ascii_frame
|
|
4
|
-
from .logger import logger
|
|
5
|
-
from queue import Queue
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"""
|
|
9
|
-
FrameBuffer Class
|
|
10
|
-
=================
|
|
11
|
-
|
|
12
|
-
The `FrameBuffer` class is responsible for managing a stream of incoming data, validating and extracting complete
|
|
13
|
-
frames, and decoding those frames using the FastNet protocol. It is designed to handle real-time data input, making
|
|
14
|
-
it suitable for scenarios like serial communication with hardware devices.
|
|
15
|
-
|
|
16
|
-
Core Responsibilities:
|
|
17
|
-
-----------------------
|
|
18
|
-
1. **Buffer Management**:
|
|
19
|
-
- The class maintains an internal `bytearray` buffer to store incoming raw data.
|
|
20
|
-
- New data can be added to the buffer using the `add_to_buffer` method.
|
|
21
|
-
- The buffer automatically trims its size to a configurable maximum (`max_buffer_size`) to avoid unbounded memory usage.
|
|
22
|
-
|
|
23
|
-
2. **Frame Extraction**:
|
|
24
|
-
- The `get_complete_frames` method scans the buffer for complete frames based on the FastNet protocol structure:
|
|
25
|
-
- A frame consists of a 5-byte header, a variable-length body, and a checksum.
|
|
26
|
-
- Both the header and body checksums are validated before processing the frame.
|
|
27
|
-
- Frames that are incomplete or fail checksum validation are skipped, and the buffer is adjusted to search for the next valid frame.
|
|
28
|
-
|
|
29
|
-
3. **Frame Decoding**:
|
|
30
|
-
- After extracting a valid frame, the method determines the appropriate decoder (ASCII or standard) based on the command type.
|
|
31
|
-
- Decoded frames are added to an internal queue (`frame_queue`) for further processing.
|
|
32
|
-
|
|
33
|
-
4. **Command Filtering**:
|
|
34
|
-
- Certain command types, such as "Keep Alive" or "Light Intensity," can be ignored based on a configurable list of ignored commands
|
|
35
|
-
(managed in the `mappings.py` file).
|
|
36
|
-
|
|
37
|
-
Key Features:
|
|
38
|
-
-------------
|
|
39
|
-
- **Thread-Safe Queue**:
|
|
40
|
-
The `frame_queue` is implemented using Python's `queue.Queue`, enabling safe concurrent access from multiple threads
|
|
41
|
-
(if needed).
|
|
42
|
-
|
|
43
|
-
- **Error Handling**:
|
|
44
|
-
- The class is robust against malformed or corrupted data. Frames with invalid checksums are discarded without crashing the application.
|
|
45
|
-
- Warnings and errors are logged for debugging purposes.
|
|
46
|
-
|
|
47
|
-
- **Modularity**:
|
|
48
|
-
- The decoding logic is separated from the frame extraction, adhering to the single-responsibility principle.
|
|
49
|
-
- The list of ignored commands is managed in `mappings.py`, promoting modularity and ease of configuration.
|
|
50
|
-
|
|
51
|
-
Typical Workflow:
|
|
52
|
-
-----------------
|
|
53
|
-
1. Raw data (e.g., from a serial port) is fed into the buffer using the `add_to_buffer` method.
|
|
54
|
-
2. The `get_complete_frames` method scans the buffer for valid frames, processes them, and adds decoded frames to the `frame_queue`.
|
|
55
|
-
3. Another part of the application (e.g., a main loop) retrieves and processes frames from the queue for further action (e.g., broadcasting NMEA sentences).
|
|
56
|
-
|
|
57
|
-
Configuration Parameters:
|
|
58
|
-
-------------------------
|
|
59
|
-
- `max_buffer_size`: Limits the size of the internal buffer. Older data is discarded when the buffer exceeds this size.
|
|
60
|
-
- `max_queue_size`: Specifies the maximum number of frames that can be stored in the `frame_queue`.
|
|
61
|
-
|
|
62
|
-
Use Cases:
|
|
63
|
-
----------
|
|
64
|
-
The `FrameBuffer` class is ideal for:
|
|
65
|
-
- Real-time data processing systems where incoming data must be validated and decoded before use.
|
|
66
|
-
- Applications where reliability is critical, such as navigation systems or hardware communication protocols.
|
|
67
|
-
|
|
68
|
-
Example:
|
|
69
|
-
--------
|
|
70
|
-
# Initialize the FrameBuffer
|
|
71
|
-
frame_buffer = FrameBuffer(max_buffer_size=8192, max_queue_size=1000)
|
|
72
|
-
|
|
73
|
-
# Add raw data to the buffer
|
|
74
|
-
frame_buffer.add_to_buffer(new_data)
|
|
75
|
-
|
|
76
|
-
# Extract and decode complete frames
|
|
77
|
-
frame_buffer.get_complete_frames()
|
|
78
|
-
|
|
79
|
-
# Process frames from the queue
|
|
80
|
-
while not frame_buffer.frame_queue.empty():
|
|
81
|
-
frame = frame_buffer.frame_queue.get()
|
|
82
|
-
# Process the decoded frame
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
class FrameBuffer:
|
|
87
|
-
"""
|
|
88
|
-
A class that manages an incoming data stream, extracts valid frames,
|
|
89
|
-
and decodes them using the FastNet protocol.
|
|
90
|
-
"""
|
|
91
|
-
def __init__(self, max_buffer_size=8192, max_queue_size=1000):
|
|
92
|
-
self.buffer = bytearray()
|
|
93
|
-
self.max_buffer_size = max_buffer_size
|
|
94
|
-
self.frame_queue = Queue(maxsize=max_queue_size) # Shared instance for frames
|
|
95
|
-
|
|
96
|
-
def add_to_buffer(self, new_data):
|
|
97
|
-
"""
|
|
98
|
-
Adds new data to the buffer.
|
|
99
|
-
|
|
100
|
-
Args:
|
|
101
|
-
new_data (bytes): New data from the serial input.
|
|
102
|
-
"""
|
|
103
|
-
if not isinstance(new_data, (bytes, bytearray)):
|
|
104
|
-
logger.error("Invalid data type passed to add_to_buffer. Expected bytes or bytearray.")
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
self.buffer.extend(new_data)
|
|
108
|
-
logger.debug(f"Added {len(new_data)} bytes to buffer. Buffer size: {len(self.buffer)} bytes.")
|
|
109
|
-
|
|
110
|
-
# Prevent the buffer from growing indefinitely
|
|
111
|
-
if len(self.buffer) > self.max_buffer_size:
|
|
112
|
-
logger.warning("Buffer size exceeded maximum limit. Trimming the oldest data.")
|
|
113
|
-
self.buffer = self.buffer[-self.max_buffer_size:] # Keep the latest data only
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def get_complete_frames(self):
|
|
117
|
-
"""
|
|
118
|
-
Extract and validate complete frames from the buffer, then add them to the internal queue.
|
|
119
|
-
"""
|
|
120
|
-
while len(self.buffer) >= 6: # Minimum frame size (5 header + 1 body checksum)
|
|
121
|
-
to_address = self.buffer[0]
|
|
122
|
-
from_address = self.buffer[1]
|
|
123
|
-
body_size = self.buffer[2]
|
|
124
|
-
command = self.buffer[3]
|
|
125
|
-
header_checksum = self.buffer[4]
|
|
126
|
-
|
|
127
|
-
# Identify command name from lookup
|
|
128
|
-
command_name = COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})")
|
|
129
|
-
|
|
130
|
-
# Calculate full frame length
|
|
131
|
-
full_frame_length = 5 + body_size + 1 # Header (5 bytes) + body + body checksum
|
|
132
|
-
if len(self.buffer) < full_frame_length:
|
|
133
|
-
logger.debug(f"Incomplete frame: waiting for more bytes (needed {full_frame_length}, got {len(self.buffer)})")
|
|
134
|
-
break
|
|
135
|
-
|
|
136
|
-
# Extract frame data
|
|
137
|
-
frame = self.buffer[:full_frame_length]
|
|
138
|
-
body = self.buffer[5:full_frame_length - 1]
|
|
139
|
-
body_checksum = self.buffer[full_frame_length - 1]
|
|
140
|
-
|
|
141
|
-
# Verify header and body checksums
|
|
142
|
-
if calculate_checksum(self.buffer[:4]) != header_checksum:
|
|
143
|
-
logger.debug("Header checksum mismatch. Dropping first byte.")
|
|
144
|
-
self.buffer = self.buffer[1:]
|
|
145
|
-
continue
|
|
146
|
-
|
|
147
|
-
if calculate_checksum(body) != body_checksum:
|
|
148
|
-
logger.debug("Body checksum mismatch. Dropping first byte.")
|
|
149
|
-
self.buffer = self.buffer[1:]
|
|
150
|
-
continue
|
|
151
|
-
|
|
152
|
-
# Remove frame from buffer after validation
|
|
153
|
-
self.buffer = self.buffer[full_frame_length:]
|
|
154
|
-
|
|
155
|
-
# Skip ignored commands
|
|
156
|
-
if command_name in IGNORED_COMMANDS:
|
|
157
|
-
logger.debug(f"Skipping ignored command: {command_name}")
|
|
158
|
-
continue
|
|
159
|
-
|
|
160
|
-
# Decode the frame
|
|
161
|
-
self.decode_and_queue_frame(frame, command_name)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def decode_and_queue_frame(self, frame, command_name):
|
|
165
|
-
"""Decode a frame and add it to the queue if valid."""
|
|
166
|
-
decoder = decode_ascii_frame if command_name == "LatLon" else decode_frame
|
|
167
|
-
decoded_frame = decoder(frame)
|
|
168
|
-
if decoded_frame:
|
|
169
|
-
try:
|
|
170
|
-
self.frame_queue.put_nowait(decoded_frame)
|
|
171
|
-
logger.debug(f"Added frame to queue: {decoded_frame}")
|
|
172
|
-
except queue.Full:
|
|
173
|
-
logger.warning("Frame queue is full. Dropping frame.")
|
|
174
|
-
else:
|
|
175
|
-
logger.debug(f"Failed to decode frame: {frame.hex()}")
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def get_buffer_size(self):
|
|
180
|
-
"""
|
|
181
|
-
Returns the current size of the buffer.
|
|
182
|
-
|
|
183
|
-
Returns:
|
|
184
|
-
int: The number of bytes currently in the buffer.
|
|
185
|
-
"""
|
|
186
|
-
return len(self.buffer)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def get_buffer_contents(self):
|
|
190
|
-
"""
|
|
191
|
-
Returns the contents of the buffer as a hex string.
|
|
192
|
-
|
|
193
|
-
Returns:
|
|
194
|
-
str: The hexadecimal representation of the buffer contents.
|
|
195
|
-
"""
|
|
196
|
-
hex_contents = self.buffer.hex()
|
|
197
|
-
logger.debug(f"Buffer contents: {hex_contents}")
|
|
198
|
-
return hex_contents
|
|
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
|
|
File without changes
|
|
File without changes
|