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 ADDED
@@ -0,0 +1,5 @@
1
+ from .decoder import NMEA2000Decoder
2
+ from .encoder import NMEA2000Encoder
3
+ from .message import NMEA2000Message
4
+
5
+ __all__ = ["NMEA2000Decoder", "NMEA2000Encoder", "NMEA2000Message"]
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
+