nmea2000 0.1.0__py3-none-any.whl
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.
- nmea2000/__init__.py +5 -0
- nmea2000/cli.py +81 -0
- nmea2000/decoder.py +206 -0
- nmea2000/encoder.py +45 -0
- nmea2000/message.py +72 -0
- nmea2000/pgns.py +40979 -0
- nmea2000/utils.py +220 -0
- nmea2000-0.1.0.dist-info/LICENSE +201 -0
- nmea2000-0.1.0.dist-info/METADATA +16 -0
- nmea2000-0.1.0.dist-info/RECORD +16 -0
- nmea2000-0.1.0.dist-info/WHEEL +5 -0
- nmea2000-0.1.0.dist-info/entry_points.txt +2 -0
- nmea2000-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/test_decoder.py +51 -0
- tests/test_utils.py +148 -0
nmea2000/__init__.py
ADDED
nmea2000/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
import logging
|
|
4
|
+
import binascii
|
|
5
|
+
|
|
6
|
+
from .decoder import NMEA2000Decoder
|
|
7
|
+
from .pgns import *
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse(filename: str, decoder: NMEA2000Decoder):
|
|
13
|
+
data_length = 8
|
|
14
|
+
try:
|
|
15
|
+
with open(filename, 'r') as file:
|
|
16
|
+
while True:
|
|
17
|
+
line = file.readline()
|
|
18
|
+
if not line:
|
|
19
|
+
break
|
|
20
|
+
if line.startswith('#') or len(line) <= 1:
|
|
21
|
+
continue
|
|
22
|
+
line = line.strip()
|
|
23
|
+
parts = line.split(',')
|
|
24
|
+
date = parts[0]
|
|
25
|
+
priority = int(parts[1])
|
|
26
|
+
pgn = int(parts[2])
|
|
27
|
+
src = int(parts[3])
|
|
28
|
+
dest = int(parts[4])
|
|
29
|
+
no_idea = parts[5]
|
|
30
|
+
can_data = parts[6:6 + data_length][::-1]
|
|
31
|
+
can_data_bytes = [int(byte, 16) for byte in can_data]
|
|
32
|
+
can_data_hex = binascii.hexlify(bytes(can_data_bytes)).decode('ascii')
|
|
33
|
+
logger.info(f'Processing: {can_data_hex}')
|
|
34
|
+
decoder.decode(date, priority, pgn, src, dest, can_data_hex)
|
|
35
|
+
except KeyboardInterrupt:
|
|
36
|
+
sys.stdout.flush()
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def main():
|
|
40
|
+
logging.basicConfig(filename='parser.log', level=logging.NOTSET)
|
|
41
|
+
parser = argparse.ArgumentParser(description="NMEA 2000 CLI Tool")
|
|
42
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
43
|
+
|
|
44
|
+
# Decode command
|
|
45
|
+
decode_parser = subparsers.add_parser("decode", help="Decode an NMEA 2000 frame")
|
|
46
|
+
|
|
47
|
+
# Either provide a frame string or a file
|
|
48
|
+
decode_parser.add_argument(
|
|
49
|
+
"--frame",
|
|
50
|
+
type=str,
|
|
51
|
+
help="Hex-encoded NMEA 2000 frame (optional if file is provided)"
|
|
52
|
+
)
|
|
53
|
+
decode_parser.add_argument(
|
|
54
|
+
"--file",
|
|
55
|
+
type=str,
|
|
56
|
+
help="Path to a file containing a Hex-encoded NMEA 2000 frame"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Parse arguments
|
|
60
|
+
args = parser.parse_args()
|
|
61
|
+
|
|
62
|
+
if args.command == "decode":
|
|
63
|
+
decoder = NMEA2000Decoder()
|
|
64
|
+
|
|
65
|
+
# Decode from a frame string if provided
|
|
66
|
+
if args.frame:
|
|
67
|
+
frame_str = args.frame
|
|
68
|
+
print(f"Decoding frame: {frame_str}")
|
|
69
|
+
decoded = decoder.decode_actisense_string(frame_str)
|
|
70
|
+
print(decoded)
|
|
71
|
+
|
|
72
|
+
# Decode from a file if provided
|
|
73
|
+
elif args.file:
|
|
74
|
+
parse(args.file, decoder)
|
|
75
|
+
else:
|
|
76
|
+
print("Error: You must provide either a frame or a file to decode.")
|
|
77
|
+
exit(1)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
nmea2000/decoder.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import logging
|
|
3
|
+
import binascii
|
|
4
|
+
from .message import *
|
|
5
|
+
from .pgns import *
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class fast_pgn_metadata():
|
|
10
|
+
def __init__(self) -> None:
|
|
11
|
+
self.frames = {}
|
|
12
|
+
self.payload_length = 0
|
|
13
|
+
self.bytes_stored = 0
|
|
14
|
+
self.sequence_counter = 0
|
|
15
|
+
|
|
16
|
+
class NMEA2000Decoder():
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.data = {}
|
|
19
|
+
|
|
20
|
+
def _decode_fast_message(self, pgn, priority, src, dest, can_data_hex) -> NMEA2000Message:
|
|
21
|
+
"""Parse a fast packet message and store the data until all frames are received."""
|
|
22
|
+
fast_packet_key = f"{pgn}_{src}_{dest}"
|
|
23
|
+
|
|
24
|
+
# Check if this PGN already has a storage structure; if not, create one
|
|
25
|
+
if self.data.get(fast_packet_key) is None:
|
|
26
|
+
self.data[fast_packet_key] = fast_pgn_metadata()
|
|
27
|
+
|
|
28
|
+
fast_pgn = self.data[fast_packet_key]
|
|
29
|
+
|
|
30
|
+
# Convert the last two characters to an integer to get the sequence and frame counters
|
|
31
|
+
last_byte = int(can_data_hex[-2:], 16) # Convert the last two hex digits to an integer
|
|
32
|
+
|
|
33
|
+
# Extract the sequence counter (high 3 bits) and frame counter (low 5 bits) from the last byte
|
|
34
|
+
sequence_counter = (last_byte >> 5) & 0b111 # Extract high 3 bits
|
|
35
|
+
frame_counter = last_byte & 0b11111 # Extract low 5 bits
|
|
36
|
+
|
|
37
|
+
total_bytes = None
|
|
38
|
+
|
|
39
|
+
if frame_counter != 0 and fast_pgn.payload_length == 0:
|
|
40
|
+
logger.debug(f"Ignoring frame {frame_counter} for PGN {pgn} as first frame has not been received.")
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
# Calculate data payload
|
|
44
|
+
if frame_counter == 0:
|
|
45
|
+
|
|
46
|
+
# Extract the total number of frames from the second-to-last byte
|
|
47
|
+
total_bytes_hex = can_data_hex[-4:-2] # Get the second-to-last byte in hex
|
|
48
|
+
total_bytes = int(total_bytes_hex, 16) # Convert hex to int
|
|
49
|
+
|
|
50
|
+
# Start a new pgn hass structure
|
|
51
|
+
|
|
52
|
+
fast_pgn.payload_length = total_bytes
|
|
53
|
+
fast_pgn.sequence_counter = sequence_counter
|
|
54
|
+
fast_pgn.bytes_stored = 0 # Reset bytes stored for a new message
|
|
55
|
+
fast_pgn.frames.clear() # Clear previous frames
|
|
56
|
+
|
|
57
|
+
# For the first frame, exclude the last 4 hex characters (2 bytes) from the payload
|
|
58
|
+
data_payload_hex = can_data_hex[:-4]
|
|
59
|
+
|
|
60
|
+
else:
|
|
61
|
+
if sequence_counter != fast_pgn.sequence_counter:
|
|
62
|
+
logger.debug(f"Ignoring frame {sequence_counter} for PGN {pgn} as it does not match current sequence.")
|
|
63
|
+
return None
|
|
64
|
+
elif frame_counter in fast_pgn.frames:
|
|
65
|
+
logger.debug(f"Frame {frame_counter} for PGN {pgn} is already stored.")
|
|
66
|
+
return None
|
|
67
|
+
else:
|
|
68
|
+
# For subsequent frames, exclude the last 2 hex characters (1 byte) from the payload
|
|
69
|
+
data_payload_hex = can_data_hex[:-2]
|
|
70
|
+
|
|
71
|
+
byte_length = len(data_payload_hex) // 2
|
|
72
|
+
|
|
73
|
+
# Store the frame data
|
|
74
|
+
fast_pgn.frames[frame_counter] = data_payload_hex
|
|
75
|
+
fast_pgn.bytes_stored += byte_length # Update the count of bytes stored
|
|
76
|
+
|
|
77
|
+
# Log the extracted values
|
|
78
|
+
logger.debug(f"Sequence Counter: {sequence_counter}, Frame Counter: {frame_counter}")
|
|
79
|
+
|
|
80
|
+
if total_bytes is not None:
|
|
81
|
+
logger.debug(f"Total Payload Bytes: {total_bytes}")
|
|
82
|
+
|
|
83
|
+
logger.debug(f"Orig Payload (hex): {can_data_hex}, Data Payload (hex): {data_payload_hex}")
|
|
84
|
+
|
|
85
|
+
logger.debug("HASS PGN Data: %s", fast_pgn)
|
|
86
|
+
|
|
87
|
+
# Check if all expected bytes have been stored
|
|
88
|
+
if fast_pgn.bytes_stored >= fast_pgn.payload_length:
|
|
89
|
+
|
|
90
|
+
logger.debug("All Fast packet frames collected for PGN: %d", pgn)
|
|
91
|
+
|
|
92
|
+
# All data for this PGN has been received, proceed to publish
|
|
93
|
+
combined_payload_hex = self._combine_pgn_frames(fast_pgn)
|
|
94
|
+
combined_payload_int = int(combined_payload_hex, 16)
|
|
95
|
+
|
|
96
|
+
nmea = None
|
|
97
|
+
if combined_payload_int is not None:
|
|
98
|
+
logger.debug(f"Combined Payload (hex): {combined_payload_hex})")
|
|
99
|
+
logger.debug(f"Combined Payload (hex): (hex: {combined_payload_int:x})")
|
|
100
|
+
|
|
101
|
+
nmea = self._call_decode_function(pgn, priority, src, dest, combined_payload_int)
|
|
102
|
+
|
|
103
|
+
# Reset the structure for this PGN
|
|
104
|
+
del self.data[fast_packet_key]
|
|
105
|
+
return nmea
|
|
106
|
+
else:
|
|
107
|
+
logger.debug(f"Waiting for {fast_pgn.payload_length - fast_pgn.bytes_stored} more bytes.")
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
def _combine_pgn_frames(self, fast_pgn):
|
|
111
|
+
"""Combine stored frame data for a PGN into a single hex string, preserving the original byte lengths."""
|
|
112
|
+
|
|
113
|
+
combined_payload_hex = "" # Start with an empty string
|
|
114
|
+
|
|
115
|
+
for frame_counter in sorted(fast_pgn.frames):
|
|
116
|
+
frame_data_hex = fast_pgn.frames[frame_counter]
|
|
117
|
+
combined_payload_hex = frame_data_hex + combined_payload_hex
|
|
118
|
+
|
|
119
|
+
return combined_payload_hex
|
|
120
|
+
|
|
121
|
+
def decode_actisense_string(self, actisense_string: str) -> NMEA2000Message:
|
|
122
|
+
"""Process an Actisense packet string and extract the PGN, source ID, and CAN data."""
|
|
123
|
+
# Split the Actisense string by spaces
|
|
124
|
+
parts = actisense_string.split()
|
|
125
|
+
|
|
126
|
+
if len(parts) < 2:
|
|
127
|
+
raise ValueError("Invalid Actisense string format")
|
|
128
|
+
|
|
129
|
+
# Extract the priority, destination, and source from the first part
|
|
130
|
+
n = int(parts[0], 16)
|
|
131
|
+
priority = n & 0xF
|
|
132
|
+
dest = (n >> 4) & 0xFF
|
|
133
|
+
src = (n >> 12) & 0xFF
|
|
134
|
+
|
|
135
|
+
# Extract the PGN from the second part
|
|
136
|
+
pgn = int(parts[1], 16)
|
|
137
|
+
|
|
138
|
+
# Extract the CAN data from the remaining parts
|
|
139
|
+
# Convert to bytes
|
|
140
|
+
bytes_data = bytes.fromhex(parts[2])
|
|
141
|
+
|
|
142
|
+
# Reverse the byte order
|
|
143
|
+
reversed_bytes = bytes_data[::-1]
|
|
144
|
+
|
|
145
|
+
# Log the extracted information
|
|
146
|
+
logger.debug(f"Priority: {priority}, Destination: {dest}, Source: {src}, PGN: {pgn}, CAN Data: {reversed_bytes}")
|
|
147
|
+
|
|
148
|
+
return self._call_decode_function(pgn, priority, src, dest, reversed_bytes)
|
|
149
|
+
|
|
150
|
+
def decode(self, packet: bytes) -> NMEA2000Message:
|
|
151
|
+
"""Process a single packet and extract the PGN, source ID, and CAN data."""
|
|
152
|
+
|
|
153
|
+
# Extract the priority (first 3 bits) and data length (last 4 bits) from the type byte
|
|
154
|
+
type_byte = packet[0]
|
|
155
|
+
priority = (type_byte >> 5) & 0x07 # first 3 bits represent the priority
|
|
156
|
+
data_length = type_byte & 0x0F # last 4 bits represent the data length
|
|
157
|
+
|
|
158
|
+
# Extract and reverse the frame ID
|
|
159
|
+
frame_id = packet[1:5][::-1]
|
|
160
|
+
|
|
161
|
+
# Convert frame_id bytes to an integer
|
|
162
|
+
frame_id_int = int.from_bytes(frame_id, byteorder='big')
|
|
163
|
+
|
|
164
|
+
# Extracting Source ID from the frame ID
|
|
165
|
+
source_id = frame_id_int & 0xFF
|
|
166
|
+
|
|
167
|
+
# Extracting PGN ID from the frame ID
|
|
168
|
+
pgn_id = (frame_id_int >> 8) & 0x3FFFF # Shift right by 8 bits and mask to 18 bits
|
|
169
|
+
|
|
170
|
+
# Extract and reverse the CAN data
|
|
171
|
+
can_data = packet[5:5 + data_length][::-1]
|
|
172
|
+
|
|
173
|
+
# Log the extracted information including the combined string
|
|
174
|
+
logger.debug("PGN ID: %s, Frame ID: %s, CAN Data: %s, Source ID: %s",
|
|
175
|
+
pgn_id,
|
|
176
|
+
binascii.hexlify(frame_id).decode('ascii'),
|
|
177
|
+
can_data,
|
|
178
|
+
source_id)
|
|
179
|
+
|
|
180
|
+
is_fast_func_name = f'is_fast_pgn_{pgn_id}'
|
|
181
|
+
is_fast_func = globals().get(is_fast_func_name)
|
|
182
|
+
|
|
183
|
+
if is_fast_func:
|
|
184
|
+
is_fast = is_fast_func()
|
|
185
|
+
logger.info(f"Is fast PGN: {is_fast}")
|
|
186
|
+
else:
|
|
187
|
+
logger.error(f"No function found for PGN: {pgn_id}\n")
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
if (is_fast):
|
|
191
|
+
return self._decode_fast_message(pgn_id, priority, source_id, 0, can_data) # TODO: destination is hardcoded to 0
|
|
192
|
+
else:
|
|
193
|
+
return self._call_decode_function(pgn_id, priority, source_id, 0, can_data) # TODO: destination is hardcoded to 0
|
|
194
|
+
|
|
195
|
+
def _call_decode_function(self, pgn:int, priority: int, src: int, dest: int, data:bytes) -> NMEA2000Message:
|
|
196
|
+
decode_func_name = f'decode_pgn_{pgn}'
|
|
197
|
+
decode_func = globals().get(decode_func_name)
|
|
198
|
+
|
|
199
|
+
if not decode_func:
|
|
200
|
+
raise ValueError(f"No function found for PGN: {pgn}")
|
|
201
|
+
|
|
202
|
+
nmea2000Message = decode_func(int.from_bytes(data, "big"))
|
|
203
|
+
nmea2000Message.add_data(src, dest, priority)
|
|
204
|
+
sys.stdout.write(nmea2000Message.to_string_test_style()+"\n")
|
|
205
|
+
return nmea2000Message
|
|
206
|
+
|
nmea2000/encoder.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import logging
|
|
3
|
+
import binascii
|
|
4
|
+
from .message import *
|
|
5
|
+
from .pgns import *
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class NMEA2000Encoder:
|
|
10
|
+
def _call_encode_function(self, nmea2000Message: NMEA2000Message) -> bytearray:
|
|
11
|
+
encode_func_name = f'encode_pgn_{nmea2000Message.PGN}'
|
|
12
|
+
encode_func = globals().get(encode_func_name)
|
|
13
|
+
|
|
14
|
+
if encode_func:
|
|
15
|
+
bytes = encode_func(nmea2000Message)
|
|
16
|
+
return bytes
|
|
17
|
+
else:
|
|
18
|
+
logger.error(f"No function found for PGN: {nmea2000Message.PGN}\n")
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
def encode_actisense(self, nmea2000Message: NMEA2000Message) -> str:
|
|
22
|
+
"""Convert an Nmea2000Message object into an Actisense packet string."""
|
|
23
|
+
# Extract necessary fields
|
|
24
|
+
priority = nmea2000Message.priority & 0xF
|
|
25
|
+
dest = nmea2000Message.destination & 0xFF
|
|
26
|
+
src = nmea2000Message.source & 0xFF
|
|
27
|
+
pgn = nmea2000Message.PGN & 0xFFFFFF
|
|
28
|
+
|
|
29
|
+
# Construct the first part (priority, dest, src)
|
|
30
|
+
n = (src << 12) | (dest << 4) | priority
|
|
31
|
+
first_part = f"{n:05X}"
|
|
32
|
+
|
|
33
|
+
# Convert PGN to hex
|
|
34
|
+
pgn_part = f"{pgn:05X}"
|
|
35
|
+
|
|
36
|
+
nmea_int = self._call_encode_function(nmea2000Message)
|
|
37
|
+
byte_length = (nmea_int.bit_length() + 7) // 8
|
|
38
|
+
can_data_part = nmea_int.to_bytes(byte_length, byteorder="big")[::-1].hex().upper()
|
|
39
|
+
|
|
40
|
+
# Construct the final Actisense string
|
|
41
|
+
actisense_string = f"{first_part} {pgn_part} {can_data_part}"
|
|
42
|
+
|
|
43
|
+
logger.debug(f"Encoded Actisense string: {actisense_string}")
|
|
44
|
+
|
|
45
|
+
return actisense_string
|
nmea2000/message.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import binascii
|
|
2
|
+
|
|
3
|
+
# NMEA2000Message class represent a single NMEA 2000 PGN
|
|
4
|
+
class NMEA2000Message():
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
PGN,
|
|
8
|
+
id,
|
|
9
|
+
description,
|
|
10
|
+
):
|
|
11
|
+
"""Initialize the NMEA2000Message."""
|
|
12
|
+
self.PGN = PGN
|
|
13
|
+
self.id = id
|
|
14
|
+
self.description = description
|
|
15
|
+
self.fields = []
|
|
16
|
+
|
|
17
|
+
def add_data(self, src, dest, priority):
|
|
18
|
+
self.source = src
|
|
19
|
+
self.destination = dest
|
|
20
|
+
self.priority = priority
|
|
21
|
+
|
|
22
|
+
def __repr__(self):
|
|
23
|
+
return f"NMEA2000Message(PGN={self.PGN}, id={self.id}, description={self.description}, fields={self.fields})"
|
|
24
|
+
|
|
25
|
+
def to_string_test_style(self):
|
|
26
|
+
fields_str = ', '.join([field.to_string_test_style() for field in self.fields])
|
|
27
|
+
return f"{self.PGN} {self.description}: {fields_str}"
|
|
28
|
+
|
|
29
|
+
# NMEA2000Field class represent a single NMEA 2000 field
|
|
30
|
+
class NMEA2000Field():
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
id,
|
|
34
|
+
name,
|
|
35
|
+
description,
|
|
36
|
+
unit_of_measurement,
|
|
37
|
+
value,
|
|
38
|
+
raw_value
|
|
39
|
+
):
|
|
40
|
+
"""Initialize the field."""
|
|
41
|
+
self.id = id
|
|
42
|
+
self.name = name
|
|
43
|
+
self.description = description
|
|
44
|
+
self.unit_of_measurement = unit_of_measurement
|
|
45
|
+
self.value = value
|
|
46
|
+
self.raw_value = raw_value
|
|
47
|
+
|
|
48
|
+
def __repr__(self):
|
|
49
|
+
return f"NMEA2000Field(id={self.id}, name={self.name}, description={self.description}, unit_of_measurement={self.unit_of_measurement}, value={self.value}, raw_value={self.raw_value})"
|
|
50
|
+
|
|
51
|
+
def to_string_test_style(self):
|
|
52
|
+
# raw_value_hex = hex(int(self.raw_value)) if isinstance(self.raw_value, int) else self.raw_value
|
|
53
|
+
if isinstance(self.raw_value, int):
|
|
54
|
+
# Determine the number of bytes needed
|
|
55
|
+
byte_length = (self.raw_value.bit_length() + 8) // 8 or 1
|
|
56
|
+
|
|
57
|
+
# Convert integer to bytes (big-endian)
|
|
58
|
+
value_bytes = int_to_bytes(self.raw_value, byte_length)
|
|
59
|
+
|
|
60
|
+
# Use binascii.hexlify to convert bytes to hex
|
|
61
|
+
hex_value = binascii.hexlify(value_bytes, " ").upper()
|
|
62
|
+
|
|
63
|
+
# Convert hex bytes to a string (if needed)
|
|
64
|
+
raw_value_hex = hex_value.decode('utf-8')
|
|
65
|
+
else:
|
|
66
|
+
raw_value_hex = self.raw_value
|
|
67
|
+
return f'{self.name} = {self.value} (bytes = "{raw_value_hex}")'
|
|
68
|
+
|
|
69
|
+
def int_to_bytes(value, length):
|
|
70
|
+
result = value.to_bytes(length, byteorder='little', signed=True)
|
|
71
|
+
return result
|
|
72
|
+
|