dcpmessage 1.2.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.
@@ -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,61 @@
1
+ # Introduction
2
+
3
+ The `dcpmessage` package is a Python library designed for retrieving GOES DCS message data from LRGS servers. Initially
4
+ developed for deployment as an AWS Lambda function, its primary purpose is to execute periodic data retrieval for
5
+ specified Data Collection Platforms (DCPs). The decoding, processing, or archiving the received DCP messages should
6
+ be handled by other processes as this tool is intended only for retrieving the messages.
7
+
8
+ ## Installation
9
+
10
+ Download the latest `.tar.gz` from [releases page](https://github.com/dcspy/dcspy/releases) and install it using `pip`
11
+
12
+ ```shell
13
+ pip install dcpmessage-#.#.#.tar.gz
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```python
19
+ from dcpmessage import DcpMessage
20
+
21
+ msg = DcpMessage.get(username="<USERNAME>",
22
+ password="<PASSWORD>",
23
+ search_criteria="<PATH TO SEARCH CRITERIA>",
24
+ host="<HOST>",
25
+ )
26
+ print("\n".join(msg))
27
+ ```
28
+
29
+ ## Search Criteria
30
+
31
+ Path to Search Criteria file should be passed when getting dcp messages. Search Criteria file should be `json`. An
32
+ example is provided below.
33
+
34
+ ```json
35
+ {
36
+ "DRS_SINCE": "now - 1 hour",
37
+ "DRS_UNTIL": "now",
38
+ "SOURCE": [
39
+ "GOES_SELFTIMED",
40
+ "GOES_RANDOM"
41
+ ],
42
+ "DCP_ADDRESS": [
43
+ "address1",
44
+ "address2"
45
+ ]
46
+ }
47
+ ```
48
+
49
+ - NOTE THAT, only following keywords are supported by `dcpmessage` at this point:
50
+ - `DRS_SINCE`: string
51
+ - `DRS_UNTIL`: string
52
+ - `SOURCE` (can be `GOES_SELFTIMED` or `GOES_RANDOM`, or both) : list of strings
53
+ - `DCP_ADDRESS` (can add multiple dcp addresses): list of strings
54
+ - All other keywords will be ignored.
55
+ - For more information about search criteria, check [opendcs
56
+ docs](https://opendcs-env.readthedocs.io/en/stable/legacy-lrgs-userguide.html#search-criteria-file-format).
57
+
58
+ ## Contributors
59
+
60
+ - [Manoj Kotteda](https://github.com/orgs/dcspy/people/manojkotteda)
61
+ - Darshan Baral
@@ -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