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.
Files changed (27) hide show
  1. {pyfastnet-2.0.0/pyfastnet.egg-info → pyfastnet-2.0.2}/PKG-INFO +1 -1
  2. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/decode_fastnet.py +36 -41
  3. pyfastnet-2.0.2/fastnet_decoder/frame_buffer.py +103 -0
  4. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/logger.py +4 -10
  5. {pyfastnet-2.0.0 → pyfastnet-2.0.2/pyfastnet.egg-info}/PKG-INFO +1 -1
  6. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/pyfastnet.egg-info/SOURCES.txt +1 -0
  7. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/setup.py +1 -1
  8. pyfastnet-2.0.2/tests/test_channels.py +407 -0
  9. pyfastnet-2.0.0/fastnet_decoder/frame_buffer.py +0 -198
  10. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/LICENSE +0 -0
  11. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/MANIFEST.in +0 -0
  12. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/README.md +0 -0
  13. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/__init__.py +0 -0
  14. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/mappings.py +0 -0
  15. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/fastnet_decoder/utils.py +0 -0
  16. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/pyfastnet.egg-info/dependency_links.txt +0 -0
  17. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/pyfastnet.egg-info/top_level.txt +0 -0
  18. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/setup.cfg +0 -0
  19. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_apparent_frame.py +0 -0
  20. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_autopilot_frame.py +0 -0
  21. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_depth_frame.py +0 -0
  22. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_format07_msb_regression.py +0 -0
  23. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_format08_layout.py +0 -0
  24. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_heel_frame.py +0 -0
  25. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_rudder_frame.py +0 -0
  26. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_tide_frame.py +0 -0
  27. {pyfastnet-2.0.0 → pyfastnet-2.0.2}/tests/test_true_frame.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfastnet
3
- Version: 2.0.0
3
+ Version: 2.0.2
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
@@ -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"Header checksum mismatch. Frame dropped: {frame.hex()}")
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"Body checksum mismatch. Frame dropped: {frame.hex()}")
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"Invalid body size: Expected {body_size}, Actual {len(body)}. Frame: {frame.hex()}")
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": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
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": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
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 = body[index]
73
- format_byte = body[index + 1]
74
- index += 2
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"Incomplete data for channel 0x{channel_id:02X}. "
80
- f"Expected length: {data_length}, Available: {len(body) - index}"
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
- 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})")
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
- logger.debug(f"Decoded value for channel {channel_name}: {decoded_value}")
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 during frame decoding: {e}. Frame contents: {frame.hex()}")
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 = frame[0]
103
- from_address = frame[1]
104
- command = frame[3]
105
- header_checksum = frame[4]
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"Failed to decode ASCII text: {e}")
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": ADDRESS_LOOKUP.get(to_address, f"Unknown (0x{to_address:02X})"),
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": COMMAND_LOOKUP.get(command, f"Unknown (0x{command:02X})"),
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"Unsupported format: 0x{format_bits:02X}.")
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
- # Create a logger
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 [%(levelname)s] [%(module)s] %(message)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()}.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfastnet
3
- Version: 2.0.0
3
+ Version: 2.0.2
4
4
  Summary: A Python library for decoding FastNet protocol data streams.
5
5
  Home-page: https://github.com/ghotihook/pyfastnet
6
6
  Author: Alex Salmon
@@ -14,6 +14,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.0", # Ensure this matches your intended version
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