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 +1 -0
- dcpmessage/credentials.py +139 -0
- dcpmessage/dcp_message.py +118 -0
- dcpmessage/exceptions.py +152 -0
- dcpmessage/ldds_client.py +240 -0
- dcpmessage/ldds_message.py +232 -0
- dcpmessage/logs.py +9 -0
- dcpmessage/search_criteria.py +207 -0
- dcpmessage/utils.py +29 -0
- dcpmessage-1.2.2.dist-info/METADATA +69 -0
- dcpmessage-1.2.2.dist-info/RECORD +13 -0
- dcpmessage-1.2.2.dist-info/WHEEL +5 -0
- dcpmessage-1.2.2.dist-info/top_level.txt +1 -0
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
|
dcpmessage/exceptions.py
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
dcpmessage
|