dcpmessage 1.2.2__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.
dcpmessage/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .dcp_message import DcpMessage
@@ -0,0 +1,139 @@
1
+ import hashlib
2
+ from datetime import datetime
3
+
4
+ from dcpmessage.utils import ByteUtil
5
+
6
+
7
+ class HashAlgo:
8
+ """
9
+ A class representing a hashing algorithm.
10
+
11
+ This class serves as a base class for different hashing algorithms,
12
+ such as SHA-1 and SHA-256, providing a common interface to initialize
13
+ and create new hash objects.
14
+
15
+ Attributes:
16
+ algorithm (str): The name of the hashing algorithm.
17
+ """
18
+
19
+ def __init__(self, algorithm):
20
+ """
21
+ Initialize the HashAlgo with a specific hashing algorithm.
22
+
23
+ :param algorithm: The name of the hashing algorithm (e.g., "sha1", "sha256").
24
+ """
25
+ assert algorithm in {"sha1", "sha256"}, (
26
+ f"{algorithm} is not a supported hash algorithm"
27
+ )
28
+ self.algorithm = algorithm
29
+
30
+ def new(self):
31
+ """
32
+ Create a new hash object using the specified algorithm.
33
+
34
+ :return: A new hash object from the hashlib library.
35
+ """
36
+ return hashlib.new(self.algorithm)
37
+
38
+
39
+ class Sha1(HashAlgo):
40
+ """
41
+ A class representing the SHA-1 hashing algorithm.
42
+
43
+ Inherits from the HashAlgo class and is pre-configured with the "sha1" algorithm.
44
+ """
45
+
46
+ def __init__(self):
47
+ """
48
+ Initialize the Sha1 class with the "sha1" algorithm.
49
+ """
50
+ super().__init__("sha1")
51
+
52
+
53
+ class Sha256(HashAlgo):
54
+ """
55
+ A class representing the SHA-256 hashing algorithm.
56
+
57
+ Inherits from the HashAlgo class and is pre-configured with the "sha256" algorithm.
58
+ """
59
+
60
+ def __init__(self):
61
+ """
62
+ Initialize the Sha256 class with the "sha256" algorithm.
63
+ """
64
+ super().__init__("sha256")
65
+
66
+
67
+ class Credentials:
68
+ def __init__(self, username: str = None, password: str = None):
69
+ """
70
+ Initialize the Credentials with a username and password.
71
+
72
+ :param username: The username of the user.
73
+ :param password: The password of the user.
74
+ """
75
+ self.username = username
76
+ self.preliminary_hash = self.get_preliminary_hash(password)
77
+
78
+ def get_preliminary_hash(self, password: str) -> bytes:
79
+ """
80
+ Generate the preliminary hash for the password.
81
+
82
+ This method creates a hash by combining the username and password multiple times.
83
+
84
+ :param password: The password to hash.
85
+ :return: The resulting hash as bytes.
86
+ """
87
+ username_b = self.username.encode("utf-8")
88
+ password_b = password.encode("utf-8")
89
+ md = Sha1().new()
90
+ md.update(username_b)
91
+ md.update(password_b)
92
+ md.update(username_b)
93
+ md.update(password_b)
94
+ return md.digest()
95
+
96
+ def get_authenticator_hash(self, time: datetime, hash_algo: HashAlgo) -> str:
97
+ """
98
+ Generate an authenticator hash using a specified hash algorithm.
99
+
100
+ This hash is used for authenticating the user based on the current time and the user's credentials.
101
+
102
+ :param time: The current time as a datetime object.
103
+ :param hash_algo: The hashing algorithm to use (e.g., Sha1, Sha256).
104
+ :return: The authenticator hash as a hexadecimal string.
105
+ """
106
+ time_t = int(time.timestamp())
107
+ time_b = time_t.to_bytes(length=4, byteorder="big")
108
+ username_b = self.username.encode("utf-8")
109
+
110
+ """Create an authenticator."""
111
+ md = hash_algo.new()
112
+ md.update(username_b)
113
+ md.update(self.preliminary_hash)
114
+ md.update(time_b)
115
+ md.update(username_b)
116
+ md.update(self.preliminary_hash)
117
+ md.update(time_b)
118
+ authenticator_bytes = md.digest()
119
+ return ByteUtil.to_hex_string(authenticator_bytes)
120
+
121
+ def get_authenticated_hello(self, time: datetime, hash_algo: HashAlgo):
122
+ """
123
+ Create an authenticated hello message for the user.
124
+
125
+ This method combines the username, current time, authenticator hash, and protocol version
126
+ into a single string used for authentication with the server.
127
+
128
+ :param time: The current time as a datetime object.
129
+ :param hash_algo: The hashing algorithm to use for the authenticator hash (e.g., Sha1, Sha256).
130
+ :return: The authenticated hello message as a string.
131
+ """
132
+ authenticator_hash = self.get_authenticator_hash(time, hash_algo)
133
+ time_str = time.strftime("%y%j%H%M%S")
134
+ protocol_version = 14
135
+
136
+ authenticated_hello = (
137
+ f"{self.username} {time_str} {authenticator_hash} {protocol_version}"
138
+ )
139
+ return authenticated_hello
@@ -0,0 +1,118 @@
1
+ from pathlib import Path
2
+ from typing import Union
3
+
4
+ from .ldds_client import LddsClient
5
+ from .ldds_message import LddsMessage
6
+ from .logs import get_logger
7
+ from .search_criteria import SearchCriteria
8
+
9
+ logger = get_logger()
10
+
11
+
12
+ class DcpMessage:
13
+ """
14
+ Class for handling DCP messages, including fetching and processing them
15
+ from a remote server using the LDDS protocol.
16
+
17
+
18
+ :param DATA_LENGTH: Standard length of the data field in a DCP message.
19
+ :param: HEADER_LENGTH: Standard length of the header in a DCP message.
20
+ """
21
+
22
+ DATA_LENGTH = 32
23
+ HEADER_LENGTH = 37
24
+
25
+ @staticmethod
26
+ def get(
27
+ username: str,
28
+ password: str,
29
+ search_criteria: Union[dict, str, Path],
30
+ host: str,
31
+ port: int = 16003,
32
+ timeout: int = 30,
33
+ ):
34
+ """
35
+ Fetches DCP messages from a server based on provided search criteria.
36
+
37
+ This method handles the complete process of connecting to the server,
38
+ authenticating, sending search criteria, retrieving DCP messages, and
39
+ finally disconnecting.
40
+
41
+ :param username: Username for server authentication.
42
+ :param password: Password for server authentication.
43
+ :param search_criteria: File path to search criteria or search criteria as a string.
44
+ :param host: Hostname or IP address of the server.
45
+ :param port: Port number for server connection (default: 16003).
46
+ :param timeout: Connection timeout in seconds (default: 30 seconds).
47
+ Will be passed to `socket.settimeout <https://docs.python.org/3/library/socket.html#socket.socket.settimeout>`_
48
+ :return: List of DCP messages retrieved from the server.
49
+ """
50
+
51
+ client = LddsClient(host=host, port=port, timeout=timeout)
52
+
53
+ try:
54
+ client.connect()
55
+ except Exception as e:
56
+ logger.error("Failed to connect to server.")
57
+ raise e
58
+
59
+ try:
60
+ client.authenticate_user(username, password)
61
+ except Exception as e:
62
+ logger.error("Failed to authenticate user.")
63
+ client.disconnect()
64
+ raise e
65
+
66
+ match search_criteria:
67
+ case str() | Path():
68
+ criteria = SearchCriteria.from_file(search_criteria)
69
+ case dict():
70
+ criteria = SearchCriteria.from_dict(search_criteria)
71
+ case _:
72
+ raise TypeError("search_criteria must be a filepath or a dict.")
73
+
74
+ try:
75
+ client.send_search_criteria(criteria)
76
+ except Exception as e:
77
+ logger.error("Failed to send search criteria.")
78
+ client.disconnect()
79
+ raise e
80
+
81
+ # Retrieve the DCP block and process it into individual messages
82
+ dcp_blocks = client.request_dcp_blocks()
83
+ dcp_messages = DcpMessage.explode(dcp_blocks)
84
+
85
+ client.send_goodbye()
86
+ client.disconnect()
87
+ return dcp_messages
88
+
89
+ @staticmethod
90
+ def explode(
91
+ message_blocks: list[LddsMessage],
92
+ ) -> list[str]:
93
+ """
94
+ Splits a message block bytes containing multiple DCP messages into individual messages.
95
+
96
+ :param message_blocks: message block (concatenated response from the server).
97
+ :return: A list of individual DCP messages.
98
+ """
99
+
100
+ data_length = DcpMessage.DATA_LENGTH
101
+ header_length = DcpMessage.HEADER_LENGTH
102
+ dcp_messages = []
103
+
104
+ for ldds_message in message_blocks:
105
+ message = ldds_message.message_data.decode()
106
+ start_index = 0
107
+ while start_index < ldds_message.message_length:
108
+ # Extract the length of the current message
109
+ message_length = int(
110
+ message[(start_index + data_length) : (start_index + header_length)]
111
+ )
112
+ # Extract the entire message using the determined length
113
+ end_index = start_index + header_length + message_length
114
+ dcp_message = message[start_index:end_index]
115
+ dcp_messages.append(dcp_message)
116
+ start_index += DcpMessage.HEADER_LENGTH + message_length
117
+
118
+ return dcp_messages
@@ -0,0 +1,152 @@
1
+ from enum import UNIQUE, Enum, verify
2
+
3
+ from .utils import ByteUtil
4
+
5
+
6
+ @verify(UNIQUE)
7
+ class ServerErrorCode(Enum):
8
+ DSUCCESS = 0, "Success."
9
+ DNOFLAG = 1, "Could not find start of message flag."
10
+ DDUMMY = 2, "Message found (and loaded) but it's a dummy."
11
+ DLONGLIST = 3, "Network list was too long to upload."
12
+ DARCERROR = 4, "Error reading archive file."
13
+ DNOCONFIG = 5, "Cannot attach to configuration shared memory"
14
+ DNOSRCHSHM = 6, "Cannot attach to search shared memory"
15
+ DNODIRLOCK = 7, "Could not get ID of directory lock semaphore"
16
+ DNODIRFILE = 8, "Could not open message directory file"
17
+ DNOMSGFILE = 9, "Could not open message storage file"
18
+ DDIRSEMERR = 10, "Error on directory lock semaphore"
19
+ DMSGTIMEOUT = 11, "Timeout waiting for new messages"
20
+ DNONETLIST = 12, "Could not open network list file"
21
+ DNOSRCHCRIT = 13, "Could not open search criteria file"
22
+ DBADSINCE = 14, "Bad since time in search criteria file"
23
+ DBADUNTIL = 15, "Bad until time in search criteria file"
24
+ DBADNLIST = 16, "Bad network list in search criteria file"
25
+ DBADADDR = 17, "Bad DCP address in search criteria file"
26
+ DBADEMAIL = 18, "Bad electronic mail value in search criteria file"
27
+ DBADRTRAN = 19, "Bad retransmitted value in search criteria file"
28
+ DNLISTXCD = 20, "Number of network lists exceeded"
29
+ DADDRXCD = 21, "Number of DCP addresses exceeded"
30
+ DNOLRGSLAST = 22, "Could not open last read access file"
31
+ DWRONGMSG = 23, "Message doesn't correspond with directory entry"
32
+ DNOMOREPROC = 24, "Can't attach: No more processes allowed"
33
+ DBADDAPSSTAT = 25, "Bad DAPS status specified in search criteria."
34
+ DBADTIMEOUT = 26, "Bad TIMEOUT value in search crit file."
35
+ DCANTIOCTL = 27, "Cannot ioctl() the open serial port."
36
+ DUNTILDRS = 28, "Specified 'until' time reached"
37
+ DBADCHANNEL = 29, "Bad GOES channel number specified in search crit"
38
+ DCANTOPENSER = 30, "Can't open specified serial port."
39
+ DBADDCPNAME = 31, "Unrecognized DCP name in search criteria"
40
+ DNONAMELIST = 32, "Cannot attach to name list shared memory."
41
+ DIDXFILEIO = 33, "Index file I/O error"
42
+ # DNOSRCHSEM = 34, "Bad search-criteria data"
43
+ DBADSEARCHCRIT = 34, "Bad search-criteria data"
44
+ DUNTIL = 35, "Specified 'until' time reached"
45
+ DJAVAIF = 36, "Error in Java - Native Interface"
46
+ DNOTATTACHED = 37, "Not attached to LRGS native interface"
47
+ DBADKEYWORD = 38, "Bad keyword"
48
+ DPARSEERROR = 39, "Error parsing input file"
49
+ DNONAMELISTSEM = 40, "Cannot attach to name list semaphore."
50
+ DBADINPUTFILE = 41, "Cannot open or read specified input file"
51
+ DARCFILEIO = 42, "Archive file I/O error"
52
+ DNOARCFILE = 43, "Archive file not opened"
53
+ DICPIOCTL = 44, "Error on ICP188 ioctl call"
54
+ DICPIOERR = 45, "Error on ICP188 I/O call"
55
+ DINVALIDUSER = 46, "Invalid DDS User"
56
+ DDDSAUTHFAILED = 47, "DDS Authentication failed"
57
+ DDDSINTERNAL = 48, "DDS Internal Error (connection will close)"
58
+ DDDSFATAL = 49, "DDS Fatal Server Error (retry later)"
59
+ DNOSUCHSOURCE = 50, "No such data source"
60
+ DALREADYATTACHED = 51, "User already attached (mult disallowed)"
61
+ DNOSUCHFILE = 52, "No such file"
62
+ DTOOMANYDCPS = 53, "Too many DCPs for real-time stream"
63
+ DBADPASSWORD = 54, "Password does not meet local requirements."
64
+ DSTRONGREQUIRED = 55, "Server requires strong encryption algorithm"
65
+
66
+ def __new__(cls, *args, **kwargs):
67
+ obj = object.__new__(cls)
68
+ obj._value_ = args[0]
69
+ return obj
70
+
71
+ # ignore the first param since it's already set by __new__
72
+ def __init__(self, _: str, description: str = None):
73
+ self.__description = description
74
+
75
+ def __str__(self):
76
+ return self.value
77
+
78
+ @property
79
+ def description(self):
80
+ return self.__description
81
+
82
+
83
+ class ServerError:
84
+ def __init__(self, message: str, server_code_no: int = 0, system_code_no: int = 0):
85
+ """
86
+
87
+ :param message:
88
+ :param server_code_no:
89
+ :param system_code_no:
90
+ """
91
+
92
+ self.message = message
93
+ self.server_code_no = server_code_no
94
+ self.system_code_no = system_code_no
95
+ self.is_end_of_message = self.is_end_of_message()
96
+
97
+ def is_end_of_message(self):
98
+ if self.server_code_no in (
99
+ ServerErrorCode.DUNTIL.value,
100
+ ServerErrorCode.DUNTILDRS.value,
101
+ ):
102
+ return True
103
+ return False
104
+
105
+ @property
106
+ def description(self):
107
+ return ServerErrorCode(self.server_code_no).description
108
+
109
+ @staticmethod
110
+ def parse(message: bytes):
111
+ """
112
+ Parse ServerError from error_string
113
+
114
+ :param message: bytes response returned from server
115
+ :return: object of ServerError class
116
+ """
117
+ if not message.startswith(b"?"):
118
+ # Not a server error
119
+ return ServerError("")
120
+ error_string = ByteUtil.extract_string(message)
121
+ split_error_string = error_string[1:].split(",", maxsplit=2)
122
+ sever_code_no, system_code_no, error_string = [
123
+ x.strip() for x in split_error_string
124
+ ]
125
+ return ServerError(error_string, int(sever_code_no), int(system_code_no))
126
+
127
+ def raise_exception(self):
128
+ raise ProtocolError(self.__str__())
129
+
130
+ def __str__(self):
131
+ if self.system_code_no == 0 and self.server_code_no == 0:
132
+ return "No Server Error"
133
+
134
+ server_error_code = ServerErrorCode(self.server_code_no)
135
+ r = f"System Code #{self.system_code_no}; "
136
+ r += f"Server Code #{server_error_code.value} - {self.message} ({server_error_code.description})"
137
+ return r
138
+
139
+ def __eq__(self, other):
140
+ return (
141
+ self.server_code_no == other.server_code_no
142
+ and self.system_code_no == other.system_code_no
143
+ and self.message == other.message
144
+ )
145
+
146
+
147
+ class ProtocolError(Exception):
148
+ pass
149
+
150
+
151
+ class LddsMessageError(Exception):
152
+ pass
@@ -0,0 +1,240 @@
1
+ import socket
2
+ from datetime import datetime, timezone
3
+ from typing import Union
4
+
5
+ from .credentials import Credentials, Sha1, Sha256
6
+ from .ldds_message import LddsMessage, LddsMessageConstants, LddsMessageIds
7
+ from .logs import get_logger
8
+ from .search_criteria import SearchCriteria
9
+
10
+ logger = get_logger()
11
+
12
+
13
+ class BasicClient:
14
+ """
15
+ A class for managing basic socket connections to a remote server.
16
+
17
+ :param host: The hostname or IP address of the remote server.
18
+ :param port: The port number to connect to on the remote server.
19
+ :param timeout: The timeout duration for the socket connection in seconds.
20
+ """
21
+
22
+ def __init__(self, host: str, port: int, timeout: Union[float, int]):
23
+ """
24
+ Initialize the BasicClient with the provided host, port, and timeout.
25
+
26
+ :param host: The hostname or IP address of the remote server.
27
+ :param port: The port number to connect to on the remote server.
28
+ :param timeout: The timeout duration for the socket connection in seconds.
29
+ """
30
+ self.host = host
31
+ self.port = port
32
+ self.timeout = timeout
33
+ self.socket = None
34
+
35
+ def connect(self):
36
+ """
37
+ Establish a socket connection to the server using the provided host and port.
38
+ Sets the socket to blocking mode and applies the specified timeout.
39
+
40
+ :raises IOError: If the connection attempt times out or fails for any reason.
41
+ :return: None
42
+ """
43
+ try:
44
+ logger.info(f"Connecting to {self.host}:{self.port}")
45
+ self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
46
+ self.socket.settimeout(self.timeout)
47
+ self.socket.connect((self.host, self.port))
48
+ logger.info(f"Successfully connected to {self.host}:{self.port}")
49
+ except socket.timeout as ex:
50
+ raise IOError(f"Connection to {self.host}:{self.port} timed out") from ex
51
+ except socket.error as ex:
52
+ raise IOError(f"Cannot connect to {self.host}:{self.port}") from ex
53
+
54
+ def disconnect(self):
55
+ """
56
+ Close the established socket connection.
57
+
58
+ :return: None
59
+ """
60
+ try:
61
+ if self.socket:
62
+ self.socket.close()
63
+ logger.debug("Closed socket")
64
+ except IOError as ex:
65
+ logger.debug(f"Error during disconnect: {ex}")
66
+ finally:
67
+ self.socket = None
68
+
69
+ def send_data(
70
+ self,
71
+ data: bytes,
72
+ ):
73
+ """
74
+ Send data over the established socket connection.
75
+
76
+ :param data: The byte data to send over the socket.
77
+ :raises IOError: If the socket is not connected.
78
+ :return: None
79
+ """
80
+ if self.socket is None:
81
+ raise IOError("BasicClient socket closed.")
82
+ self.socket.sendall(data)
83
+
84
+
85
+ class LddsClient(BasicClient):
86
+ """
87
+ A client for communicating with an LDDS (Low Data Rate Demodulation System) server.
88
+ Inherits from BasicClient and adds LDDS-specific functionality.
89
+ """
90
+
91
+ def __init__(self, host: str, port: int, timeout: Union[float, int]):
92
+ """
93
+ Initialize the LddsClient with the provided host, port, and timeout.
94
+
95
+ :param host: The hostname or IP address of the LDDS server.
96
+ :param port: The port number to connect to on the LDDS server.
97
+ :param timeout: The timeout duration for the socket connection in seconds.
98
+ """
99
+ super().__init__(host=host, port=port, timeout=timeout)
100
+
101
+ def receive_data(
102
+ self,
103
+ buffer_size: int = 1024,
104
+ ) -> bytes:
105
+ """
106
+ Receive data from the socket.
107
+
108
+ :param buffer_size: The size of the buffer to use when receiving data.
109
+ :return: The received byte data.
110
+ :raises IOError: If the socket is not connected.
111
+ """
112
+ if self.socket is None:
113
+ raise IOError("BasicClient socket closed.")
114
+
115
+ data = self.socket.recv(buffer_size)
116
+ if len(data) == 0:
117
+ raise IOError("BasicClient socket closed.")
118
+
119
+ ldds_message_length = LddsMessage.get_total_length(data)
120
+ while len(data) < ldds_message_length:
121
+ data += self.socket.recv(buffer_size)
122
+
123
+ return data
124
+
125
+ def authenticate_user(
126
+ self,
127
+ user_name: str = "user",
128
+ password: str = "pass",
129
+ ):
130
+ """
131
+ Authenticate a user with the LDDS server using the provided username and password.
132
+
133
+ :param user_name: The username to authenticate with.
134
+ :param password: The password to authenticate with.
135
+ :raises Exception: If authentication fails.
136
+ :return: None
137
+ """
138
+ msg_id = LddsMessageIds.auth_hello
139
+ credentials = Credentials(username=user_name, password=password)
140
+
141
+ is_authenticated = False
142
+ for hash_algo in [Sha1, Sha256]:
143
+ auth_str = credentials.get_authenticated_hello(
144
+ datetime.now(timezone.utc), hash_algo()
145
+ )
146
+ logger.debug(auth_str)
147
+ ldds_message = self.request_dcp_message(msg_id, auth_str)
148
+ server_error = ldds_message.server_error
149
+ if server_error is not None:
150
+ logger.debug(str(server_error))
151
+ else:
152
+ is_authenticated = True
153
+
154
+ if is_authenticated:
155
+ logger.info("Successfully authenticated user")
156
+ else:
157
+ raise Exception(
158
+ f"Could not authenticate for user:{user_name}\n{server_error}"
159
+ )
160
+
161
+ def request_dcp_message(
162
+ self,
163
+ message_id,
164
+ message_data: Union[str, bytes, bytearray] = "",
165
+ ) -> LddsMessage:
166
+ """
167
+ Request a DCP (Data Collection Platform) message from the LDDS server.
168
+
169
+ :param message_id: The ID of the message to request.
170
+ :param message_data: The data to include in the message request.
171
+ :return: The response from the server as bytes.
172
+ """
173
+ if isinstance(message_data, str):
174
+ message_data = message_data.encode()
175
+ message = LddsMessage.create(message_id=message_id, message_data=message_data)
176
+ message_bytes = message.to_bytes()
177
+ self.send_data(message_bytes)
178
+ server_response = self.receive_data()
179
+ return LddsMessage.parse(server_response)
180
+
181
+ def send_search_criteria(
182
+ self,
183
+ search_criteria: SearchCriteria,
184
+ ):
185
+ """
186
+ Send search criteria to the LDDS server.
187
+
188
+ :param search_criteria: The search criteria to send.
189
+ :return: None
190
+ """
191
+ data_to_send = bytearray(50) + bytes(search_criteria)
192
+ logger.debug(f"Sending criteria message (filesize = {len(data_to_send)} bytes)")
193
+ ldds_message = self.request_dcp_message(
194
+ LddsMessageIds.search_criteria, data_to_send
195
+ )
196
+
197
+ server_error = ldds_message.server_error
198
+ if server_error is not None:
199
+ server_error.raise_exception()
200
+ else:
201
+ logger.info("Search criteria sent successfully.")
202
+
203
+ def request_dcp_blocks(
204
+ self,
205
+ ) -> list[LddsMessage]:
206
+ """
207
+ Request a block of DCP messages from the LDDS server.
208
+
209
+ :return: The received DCP block as bytearray.
210
+ """
211
+ msg_id = LddsMessageIds.dcp_block
212
+ max_data_length = LddsMessageConstants.MAX_DATA_LENGTH
213
+ dcp_messages = []
214
+ try:
215
+ while True:
216
+ response = self.request_dcp_message(msg_id)
217
+ server_error = response.server_error
218
+ if server_error is not None:
219
+ if server_error.is_end_of_message:
220
+ logger.info(server_error.description)
221
+ break
222
+ else:
223
+ server_error.raise_exception()
224
+ dcp_messages.append(response)
225
+
226
+ return dcp_messages
227
+ except Exception as err:
228
+ logger.debug(f"Error receiving data: {err}")
229
+ raise err
230
+
231
+ def send_goodbye(self):
232
+ """
233
+ Send a goodbye message to the LDDS server to terminate the session.
234
+
235
+ :return: None
236
+ """
237
+ message_id = LddsMessageIds.goodbye
238
+ ldds_message = self.request_dcp_message(message_id, "")
239
+ server_error = ldds_message.server_error
240
+ logger.debug(ldds_message.to_bytes())
@@ -0,0 +1,232 @@
1
+ from dataclasses import dataclass
2
+
3
+ from .exceptions import LddsMessageError, ProtocolError, ServerError, ServerErrorCode
4
+
5
+
6
+ @dataclass
7
+ class LddsMessageConstants:
8
+ """Constants related to LDDS messages."""
9
+
10
+ VALID_HEADER_LENGTH: int = 10
11
+ SYNC_LENGTH: int = 4
12
+ VALID_SYNC_CODE: bytes = b"FAF0"
13
+ MAX_DATA_LENGTH: int = 99000
14
+ VALID_IDS: frozenset[str] = frozenset(
15
+ (
16
+ "a",
17
+ "b",
18
+ "c",
19
+ "d",
20
+ "e",
21
+ "f",
22
+ "g",
23
+ "h",
24
+ "i",
25
+ "j",
26
+ "k",
27
+ "l",
28
+ "m",
29
+ "n",
30
+ "o",
31
+ "p",
32
+ "q",
33
+ "r",
34
+ "s",
35
+ "t",
36
+ "u",
37
+ )
38
+ )
39
+
40
+
41
+ @dataclass
42
+ class LddsMessageIds:
43
+ """
44
+ Ids associated with LDDS messages.
45
+ """
46
+
47
+ hello: str = "a"
48
+ goodbye: str = "b"
49
+ status: str = "c"
50
+ start: str = "d"
51
+ stop: str = "e"
52
+ dcp: str = "f"
53
+ search_criteria: str = "g"
54
+ get_outages: str = "h"
55
+ idle: str = "i"
56
+ put_netlist: str = "j"
57
+ get_netlist: str = "k"
58
+ assert_outages: str = "l"
59
+ auth_hello: str = "m"
60
+ dcp_block: str = "n"
61
+ events: str = "o"
62
+ ret_config: str = "p"
63
+ inst_config: str = "q"
64
+ dcp_block_ext: str = "r"
65
+ unused_6: str = "s"
66
+ unused_7: str = "t"
67
+ user: str = "u"
68
+
69
+
70
+ class LddsMessage:
71
+ def __init__(
72
+ self,
73
+ message_id: str = None,
74
+ message_length: int = None,
75
+ message_data: bytes = None,
76
+ header: bytes = None,
77
+ ):
78
+ """
79
+ Initialize a LddsMessage instance.
80
+
81
+ :param message_id: The ID of the message.
82
+ :param message_length: The length of the message data.
83
+ :param message_data: The message data in bytes.
84
+ :param header: The header of the message in bytes.
85
+ """
86
+ self.message_id = message_id
87
+ self.message_length = message_length
88
+ self.message_data = message_data
89
+ self.header = header
90
+ self.server_error: ServerError = None
91
+ self.error: LddsMessageError = None
92
+
93
+ def check_server_errors(self):
94
+ if self.message_data.startswith(b"?"):
95
+ self.server_error = ServerError.parse(self.message_data)
96
+
97
+ def check_other_errors(self):
98
+ if self.message_length != len(self.message_data):
99
+ self.error = LddsMessageError("Inconsistent LDDS message length")
100
+ # TODO: add other errors
101
+
102
+ @staticmethod
103
+ def parse(
104
+ message: bytes,
105
+ ):
106
+ """
107
+ Parse bytes into an LddsMessage instance.
108
+
109
+ :param message: The message in bytes to parse.
110
+ :return: A tuple containing an LddsMessage instance and a ServerError if any.
111
+ :raises ProtocolError: If there is an issue with the message header.
112
+ """
113
+ header_length = LddsMessageConstants.VALID_HEADER_LENGTH
114
+ assert len(message) >= header_length, (
115
+ f"Invalid LDDS message - length={len(message)}"
116
+ )
117
+ header = message[:header_length]
118
+
119
+ sync_length = LddsMessageConstants.SYNC_LENGTH
120
+ sync = header[:sync_length]
121
+ assert sync == LddsMessageConstants.VALID_SYNC_CODE, (
122
+ f"Invalid LDDS message header - bad sync '{sync}'"
123
+ )
124
+
125
+ message_id = header.decode()[4]
126
+ assert message_id in LddsMessageConstants.VALID_IDS, (
127
+ f"Invalid LDDS message header - ID = '{message_id}'"
128
+ )
129
+
130
+ message_length = LddsMessage.get_message_length(header)
131
+ message_data = message[header_length:]
132
+
133
+ ldds_message = LddsMessage(
134
+ message_id=message_id,
135
+ message_length=message_length,
136
+ message_data=message_data,
137
+ header=header,
138
+ )
139
+ ldds_message.check_other_errors()
140
+ ldds_message.check_server_errors()
141
+
142
+ return ldds_message
143
+
144
+ @staticmethod
145
+ def get_message_length(
146
+ message: bytes,
147
+ ) -> int:
148
+ message_length_str = message[5:10].decode().replace(" ", "0")
149
+ try:
150
+ message_length = int(message_length_str)
151
+ except ValueError:
152
+ raise ProtocolError(
153
+ f"Invalid LDDS message header - bad length field = '{message_length_str}'"
154
+ )
155
+ return message_length
156
+
157
+ @staticmethod
158
+ def get_total_length(
159
+ message: bytes,
160
+ ) -> int:
161
+ return (
162
+ LddsMessage.get_message_length(message)
163
+ + LddsMessageConstants.VALID_HEADER_LENGTH
164
+ )
165
+
166
+ @staticmethod
167
+ def create(message_id: str, message_data: bytes = b""):
168
+ """
169
+ Create a LDDS message from scratch.
170
+
171
+ :param message_id: The ID of the message.
172
+ :param message_data: The data of the message in bytes.
173
+ :return: A new LddsMessage instance.
174
+ """
175
+ message_length = len(message_data)
176
+ ldds_message = LddsMessage(
177
+ message_id=message_id,
178
+ message_length=message_length,
179
+ message_data=message_data,
180
+ )
181
+ ldds_message.__make_header()
182
+ return ldds_message
183
+
184
+ def __make_header(self):
185
+ """
186
+ Generate the header for the LDDS message.
187
+ """
188
+ header = bytearray(LddsMessageConstants.VALID_HEADER_LENGTH)
189
+ header[:4] = LddsMessageConstants.VALID_SYNC_CODE
190
+ header[4] = ord(self.message_id)
191
+
192
+ message_length_str = f"{self.message_length:05d}"
193
+ header[5:10] = (
194
+ message_length_str.encode()
195
+ ) # Set the message length in the header
196
+ self.header = header
197
+
198
+ def to_bytes(self):
199
+ """
200
+ Convert the LDDS message to bytes.
201
+
202
+ :return: The message in bytes.
203
+ """
204
+ return self.header + self.message_data
205
+
206
+ def __eq__(self, other):
207
+ """
208
+ Check equality with another LddsMessage instance.
209
+
210
+ :param other: Another LddsMessage instance to compare with.
211
+ :return: True if equal, False otherwise.
212
+ """
213
+ return (
214
+ self.message_length == other.message_length
215
+ and self.message_id == other.message_id
216
+ and self.message_data == other.message_data
217
+ )
218
+
219
+ def is_end_of_message(self):
220
+ server_error = ServerError.parse(self.message_data)
221
+ if server_error.server_code_no in (
222
+ ServerErrorCode.DUNTIL.value,
223
+ ServerErrorCode.DUNTILDRS.value,
224
+ ):
225
+ return True
226
+ return False
227
+
228
+ def is_success(self):
229
+ server_error = ServerError.parse(self.message_data)
230
+ if server_error.server_code_no == 0 and server_error.system_code_no == 0:
231
+ return True
232
+ return False
dcpmessage/logs.py ADDED
@@ -0,0 +1,9 @@
1
+ import logging
2
+
3
+
4
+ def get_logger():
5
+ logger_ = logging.getLogger(__name__)
6
+ return logger_
7
+
8
+
9
+ logger = get_logger()
@@ -0,0 +1,207 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass
4
+ from enum import UNIQUE, Enum, verify
5
+
6
+ from .logs import get_logger
7
+
8
+ logger = get_logger()
9
+
10
+
11
+ @verify(UNIQUE)
12
+ class DcpMessageSource(Enum):
13
+ """
14
+ Enumeration for DCP message sources with unique values.
15
+
16
+
17
+ :param GOES: Represents a standard GOES message source.
18
+ :param GOES_SELFTIMED: Represents a self-timed GOES message source.
19
+ :param GOES_RANDOM: Represents a random GOES message source.
20
+ """
21
+
22
+ GOES = 0x00000000
23
+ GOES_SELFTIMED = 0x00010000
24
+ GOES_RANDOM = 0x00020000
25
+
26
+
27
+ @dataclass
28
+ class SearchCriteriaConstants:
29
+ """
30
+ Constants used in SearchCriteria.
31
+
32
+
33
+ :param max_sources: The maximum number of sources that can be added to the search criteria.
34
+ """
35
+
36
+ max_sources: int = 12
37
+
38
+
39
+ class DcpAddress:
40
+ """
41
+ Class representing a DCP address.
42
+
43
+
44
+ :param address: (str) The DCP address, which must be 8 characters long.
45
+ """
46
+
47
+ def __init__(self, address: str):
48
+ """
49
+ Initialize the DcpAddress with a given address.
50
+
51
+ :param address: The address string, which must be exactly 8 characters long.
52
+ :raises AssertionError: If the address is not 8 characters long.
53
+ """
54
+ assert len(address) == 8
55
+ self.address = address
56
+
57
+ def __eq__(self, other):
58
+ """
59
+ Compare two DcpAddress objects for equality.
60
+
61
+ :param other: The other DcpAddress object to compare with.
62
+ :return: True if the addresses are the same, False otherwise.
63
+ """
64
+ return self.address == other.address
65
+
66
+
67
+ class SearchCriteria:
68
+ def __init__(
69
+ self,
70
+ lrgs_since: str,
71
+ lrgs_until: str,
72
+ dcp_address: list[DcpAddress],
73
+ sources: list[int],
74
+ ):
75
+ """
76
+ Initialize the SearchCriteria with provided parameters.
77
+
78
+ :param lrgs_since: The start time for the search criteria.
79
+ :param lrgs_until: The end time for the search criteria.
80
+ :param dcp_address: A list of DCP addresses to search for.
81
+ :param sources: A list of sources to include in the search.
82
+ """
83
+ self.lrgs_since = lrgs_since
84
+ self.lrgs_until = lrgs_until
85
+ self.dcp_address = dcp_address
86
+ self.sources = [0 for _ in range(SearchCriteriaConstants.max_sources)]
87
+ self.num_sources = 0
88
+ for source in sources:
89
+ self.__add_source(source)
90
+
91
+ @classmethod
92
+ def from_file(
93
+ cls,
94
+ file: str,
95
+ ) -> "SearchCriteria":
96
+ """
97
+ Create a SearchCriteria object from a JSON file.
98
+
99
+ :param file: Path to the JSON file containing search criteria.
100
+ :return: A SearchCriteria object.
101
+ :raises Exception: If there is an issue parsing the JSON file.
102
+ """
103
+ with open(file, "r") as json_file:
104
+ json_data = json.load(json_file)
105
+ return cls.from_dict(json_data)
106
+
107
+ @classmethod
108
+ def from_dict(
109
+ cls,
110
+ data: dict,
111
+ ) -> "SearchCriteria":
112
+ """
113
+ Create a SearchCriteria object from a dict.
114
+
115
+ :param data: dict containing search criteria data.
116
+ :return: A SearchCriteria object.
117
+ :raises Exception: If there is an issue parsing the JSON file.
118
+ """
119
+
120
+ lrgs_since, lrgs_until, dcp_addresses, sources = "last", "now", [], []
121
+ try:
122
+ for key_word_, data in data.items():
123
+ match key_word_:
124
+ case "DRS_SINCE":
125
+ lrgs_since = data
126
+ case "DRS_UNTIL":
127
+ lrgs_until = data
128
+ case "DCP_ADDRESS":
129
+ data = list(set(data))
130
+ dcp_addresses = [DcpAddress(x) for x in data]
131
+ case "SOURCE":
132
+ data = list(set(data))
133
+ sources = [DcpMessageSource[x].value for x in data]
134
+ case _:
135
+ logger.debug(
136
+ f"Unrecognized key word {key_word_} in Search Criteria. Will be ignored."
137
+ )
138
+ search_criteria = cls(lrgs_since, lrgs_until, dcp_addresses, sources)
139
+ logger.debug(str(search_criteria))
140
+ return search_criteria
141
+ except Exception as ex:
142
+ raise Exception(f"Unexpected exception parsing search-criteria: {ex}")
143
+
144
+ def __add_source(
145
+ self,
146
+ source: int,
147
+ ):
148
+ """
149
+ Add a source to the search criteria if not already present.
150
+
151
+ :param source: The source identifier to add.
152
+ """
153
+ if self.num_sources >= SearchCriteriaConstants.max_sources:
154
+ return
155
+ if source not in self.sources:
156
+ self.sources[self.num_sources] = source
157
+ self.num_sources += 1
158
+
159
+ def __str__(self) -> str:
160
+ """
161
+ Convert the SearchCriteria to a string format for easy readability.
162
+
163
+ :return: A string representation of the search criteria.
164
+ """
165
+ line_separator = os.linesep
166
+
167
+ ret = ["#\n# LRGS Search Criteria\n#\n"]
168
+
169
+ if self.lrgs_since:
170
+ ret.append(f"DRS_SINCE: {self.lrgs_since}{line_separator}")
171
+ if self.lrgs_until:
172
+ ret.append(f"DRS_UNTIL: {self.lrgs_until}{line_separator}")
173
+ for dcp_address_ in self.dcp_address:
174
+ ret.append(f"DCP_ADDRESS: {dcp_address_.address}{line_separator}")
175
+
176
+ for i in range(self.num_sources):
177
+ source_name = DcpMessageSource(self.sources[i]).name
178
+ ret.append(f"SOURCE: {source_name}{line_separator}")
179
+
180
+ return "".join(ret)
181
+
182
+ def __bytes__(self) -> bytes:
183
+ """
184
+ Convert the SearchCriteria object to a bytes format for network transmission.
185
+
186
+ :return: A bytes representation of the search criteria.
187
+ """
188
+ return self.__str__().encode("utf-8")
189
+
190
+ def __eq__(self, other) -> bool:
191
+ """
192
+ Compare two SearchCriteria objects for equality.
193
+
194
+ :param other: The other SearchCriteria object to compare with.
195
+ :return: True if the criteria are the same, False otherwise.
196
+ """
197
+ if self.lrgs_since != other.lrgs_since or self.lrgs_until != other.lrgs_until:
198
+ return False
199
+
200
+ if len(self.dcp_address) != len(other.dcp_address):
201
+ return False
202
+
203
+ for explicit_dcp_address in self.dcp_address:
204
+ if explicit_dcp_address not in other.dcp_address:
205
+ return False
206
+
207
+ return True
dcpmessage/utils.py ADDED
@@ -0,0 +1,29 @@
1
+ from binascii import hexlify
2
+ from typing import Union
3
+
4
+
5
+ class ByteUtil:
6
+ @staticmethod
7
+ def extract_string(b: Union[bytes, bytearray], offset: int = 0) -> str:
8
+ """
9
+ Pull a null-terminated string out of a byte array starting at given offset.
10
+
11
+ :param b: The byte array to extract string from.
12
+ :param offset: The offset to start reading from. Default is 0.
13
+ :return: The extracted string.
14
+ """
15
+ end = b.find(b"\x00", offset)
16
+ if end == -1:
17
+ return b[offset:].decode()
18
+ else:
19
+ return b[offset:end].decode()
20
+
21
+ @staticmethod
22
+ def to_hex_string(b: Union[bytes, bytearray]):
23
+ """
24
+ Convert byte array to hex string.
25
+
26
+ :param b: The byte array to convert to hex string.
27
+ :return: Hex string of byte array.
28
+ """
29
+ return hexlify(b).decode("utf-8").upper() # Use uppercase hexadecimal
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: dcpmessage
3
+ Version: 1.2.2
4
+ Summary: package for retrieving GOES DCS message data from LRGS servers.
5
+ Author: dcpmessage authors
6
+ Requires-Python: >=3.13
7
+ Description-Content-Type: text/markdown
8
+
9
+ # Introduction
10
+
11
+ The `dcpmessage` package is a Python library designed for retrieving GOES DCS message data from LRGS servers. Initially
12
+ developed for deployment as an AWS Lambda function, its primary purpose is to execute periodic data retrieval for
13
+ specified Data Collection Platforms (DCPs). The decoding, processing, or archiving the received DCP messages should
14
+ be handled by other processes as this tool is intended only for retrieving the messages.
15
+
16
+ ## Installation
17
+
18
+ Download the latest `.tar.gz` from [releases page](https://github.com/dcspy/dcspy/releases) and install it using `pip`
19
+
20
+ ```shell
21
+ pip install dcpmessage-#.#.#.tar.gz
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from dcpmessage import DcpMessage
28
+
29
+ msg = DcpMessage.get(username="<USERNAME>",
30
+ password="<PASSWORD>",
31
+ search_criteria="<PATH TO SEARCH CRITERIA>",
32
+ host="<HOST>",
33
+ )
34
+ print("\n".join(msg))
35
+ ```
36
+
37
+ ## Search Criteria
38
+
39
+ Path to Search Criteria file should be passed when getting dcp messages. Search Criteria file should be `json`. An
40
+ example is provided below.
41
+
42
+ ```json
43
+ {
44
+ "DRS_SINCE": "now - 1 hour",
45
+ "DRS_UNTIL": "now",
46
+ "SOURCE": [
47
+ "GOES_SELFTIMED",
48
+ "GOES_RANDOM"
49
+ ],
50
+ "DCP_ADDRESS": [
51
+ "address1",
52
+ "address2"
53
+ ]
54
+ }
55
+ ```
56
+
57
+ - NOTE THAT, only following keywords are supported by `dcpmessage` at this point:
58
+ - `DRS_SINCE`: string
59
+ - `DRS_UNTIL`: string
60
+ - `SOURCE` (can be `GOES_SELFTIMED` or `GOES_RANDOM`, or both) : list of strings
61
+ - `DCP_ADDRESS` (can add multiple dcp addresses): list of strings
62
+ - All other keywords will be ignored.
63
+ - For more information about search criteria, check [opendcs
64
+ docs](https://opendcs-env.readthedocs.io/en/stable/legacy-lrgs-userguide.html#search-criteria-file-format).
65
+
66
+ ## Contributors
67
+
68
+ - [Manoj Kotteda](https://github.com/orgs/dcspy/people/manojkotteda)
69
+ - Darshan Baral
@@ -0,0 +1,13 @@
1
+ dcpmessage/__init__.py,sha256=-y3OO-k0JChl6mStbgLMgDZ-HMyu9h9Zn7MC5q58UBA,36
2
+ dcpmessage/credentials.py,sha256=A3dmT0StQJDRKZVWFlpwqZrj3I76NM4DDnPsgscPDo8,4516
3
+ dcpmessage/dcp_message.py,sha256=SQgK6e1FLOLjBTTAdhwURUY0_j_DQo7Gwlk7mdzfxjc,4155
4
+ dcpmessage/exceptions.py,sha256=qbkQNZ7bVoJh-2-jeUpZZkKFit3lbkovHWtTiyW9HxE,5943
5
+ dcpmessage/ldds_client.py,sha256=MmSuotfA7vhGX7j7peKmu8cQKApjYlS-0xfJmpNfyfI,8319
6
+ dcpmessage/ldds_message.py,sha256=75nipjgSHDLYWCeO4YtX-aR9NpsYidsyz3BGonEfFoI,6640
7
+ dcpmessage/logs.py,sha256=3Dj0CyalYxgQN1vwjsLT3xqojA3D9losGzDLiKgOmV8,120
8
+ dcpmessage/search_criteria.py,sha256=qgK-Cmj_oAStB3DzoqHSfGa87Mr4zspzEU2aiHDw-wU,6461
9
+ dcpmessage/utils.py,sha256=U6RFrjkh5Nkz1tS6nis60yUnCu4T6jXkKUDwlYgmQkU,915
10
+ dcpmessage-1.2.2.dist-info/METADATA,sha256=N6wS4u1DTbHXIY9NxDR3bYsU83GyMxwjA7L0TuBz33Q,2105
11
+ dcpmessage-1.2.2.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
12
+ dcpmessage-1.2.2.dist-info/top_level.txt,sha256=EMzYCEnuoZVsSJ6FL4C2py9r3SG7tpO4HEkDCTSLU4E,11
13
+ dcpmessage-1.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (78.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ dcpmessage