pyspartn package
Submodules
pyspartn.exceptions module
SPARTN Custom Exception Types
Created on 10 Feb 2023
- author:
semuadmin
- copyright:
SEMU Consulting © 2023
- license:
BSD 3-Clause
- exception pyspartn.exceptions.SPARTNDecryptionError[source]
Bases:
Exception
SPARTN Decryption error.
pyspartn.socket_wrapper module
socket_wrapper.py
A skeleton socket wrapper which provides basic stream-like read(bytes) and readline() methods.
NB: this will read from a socket indefinitely. It is the responsibility of the calling application to monitor data returned and implement appropriate socket error, timeout or inactivity procedures.
Created on 4 Apr 2022
- author:
semuadmin
- copyright:
SEMU Consulting © 2022
- license:
BSD 3-Clause
- class pyspartn.socket_wrapper.SocketWrapper(sock: socket, **kwargs)[source]
Bases:
object
socket stream class.
- __init__(sock: socket, **kwargs)[source]
Constructor.
- Parameters:
socket (sock) – socket object
bufsize (int) – (kwarg) internal buffer size (4096)
- property buffer: bytearray
Getter for buffer.
- Returns:
buffer
- Return type:
bytearray
pyspartn.spartnhelpers module
Collection of SPARTN helper methods which can be used outside the SPARTNMessage or SPARTNReader classes
Created on 10 Feb 2023
- author:
semuadmin
- copyright:
SEMU Consulting © 2023
- license:
BSD 3-Clause
- pyspartn.spartnhelpers.att2idx(att: str) int [source]
Get integer index corresponding to grouped attribute. e.g. SF019_04 -> 4; SF019_23 -> 23
- Parameters:
att (str) – grouped attribute name e.g. SF019_01
- Returns:
index as integer, or 0 if not grouped
- Return type:
int
- pyspartn.spartnhelpers.att2name(att: str) str [source]
Get name of grouped attribute. e.g. SF019 -> SF019; SF019_23 -> SF019
- Parameters:
att (str) – grouped attribute name e.g. SF019_06
- Returns:
name without index e.g. SF019
- Return type:
str
- pyspartn.spartnhelpers.datadesc(datafield: str) str [source]
Get description of data field.
- Parameters:
datafield (str) – datafield e.g. ‘SF054’
- Returns:
datafield description e.g. “Ionosphere equation type”
- Return type:
str
- pyspartn.spartnhelpers.bitsval(bitfield: bytes, position: int, length: int, typ: str = 'IN', res: float = 1.0, rngmin: float = 0.0) int [source]
Get unisgned integer value of masked bits in bytes.
- Parameters:
bitfield (bytes) – bytes
position (int) – position in bitfield, from leftmost bit
length (int) – length of field in bits
typ (str) – field type (i.e. Integer, Bitmask, Float)
res (float) – field resolution (i.e. scaling factor)
rngmin (float) – field range minimum value
- Returns:
value
- Return type:
int
- Raises:
SPARTNMessageError if end of bitfield
- pyspartn.spartnhelpers.crc_poly(data: int, n: int, poly: int, crc: int = 0, ref_out: bool = False, xor_out: int = 0) int [source]
Configurable CRC algorithm.
- Parameters:
data (int) – data
n (int) – width
poly (int) – polynomial feed value
crc (int) – crc
ref_out – reflection out
xor_out – XOR out
- Returns:
CRC
- Return type:
int
- pyspartn.spartnhelpers.valid_crc(msg: bytes, crc: int, crctype: int) bool [source]
Validate message CRC.
- Parameters:
msg (bytes) – message to which CRC applies
crc (int) – message CRC
cycType (int) – crc type (0-3)
- pyspartn.spartnhelpers.encrypt(pt: bytes, key: bytes, iv: bytes, mode: str = 'CTR') tuple [source]
Encrypt payload The length of the plaintext data must be a multiple of the cipher block length (16 bytes), so padding bytes are added as necessary.
- Parameters:
data (bytes) – plaintext data
key (bytes) – key
iv (bytes) – initialisation vector
mode (str) – cipher mode e.g. CTR, CBC
- Returns:
tuple of (encrypted data, number of padding bytes)
- Return type:
tuple
- pyspartn.spartnhelpers.decrypt(ct: bytes, key: bytes, iv: bytes, mode: str = 'CTR') bytes [source]
Decrypt payload
- Parameters:
ct (bytes) – encrypted data (ciphertext)
key (bytes) – key
iv (bytes) – initialisation vector
mode (str) – cipher mode e.g. CTR, CBC
- Returns:
decrypted data (plaintext)
- Return type:
bytes
- pyspartn.spartnhelpers.escapeall(val: bytes) str [source]
Escape all byte characters e.g. b’\x73’ rather than b`s`
- Parameters:
val (bytes) – bytes
- Returns:
string of escaped bytes
- Return type:
str
- pyspartn.spartnhelpers.timetag2date(timetag32: int) datetime [source]
Convert 32-bit gnsstimetag to datetime.
- Parameters:
timetag (int) – 32-bit gnsstimetag
- Returns:
date
- Return type:
datetime
- pyspartn.spartnhelpers.date2timetag(date: datetime) int [source]
Convert datetime to 32-bit gnsstimetag.
- Parameters:
date (datetime) – date
- Returns:
32-bit gnssTimeTag
- Return type:
int
- pyspartn.spartnhelpers.convert_timetag(timetag16: int, basedate: datetime = datetime.datetime(2024, 11, 20, 8, 28, 45, 772860, tzinfo=datetime.timezone.utc)) int [source]
Convert 16-bit timetag to 32-bit format.
32-bit timetag represents total seconds since 2010-01-01 00:00:00 (TIMEBASE).
16-bit timetag represents seconds past ‘base date’ (the datetime the SPARTN message was originally sent, to the nearest half-day). It requires knowledge of this base date to convert unambiguously to a 32-bit timetag equivalent, e.g.
If base date to nearest half day was “2023-06-27 12:00:00”, a timetag16 of 32580 represents a datetime of:
(2023-06-27 00:00:00 + 12 hours + 32580 seconds) = 2023-06-27 21:03:00
To convert to a 32-bit timetag, calculate number of seconds since TIMEBASE:
(2023-06-27 21:03:00 - 2010-01-01 00:00:00) = 425595780 seconds
All timetag16 are given in their respective constellation timezone : UTC = GPS + 18s = GAL + 18s = QZSS + 18s = BEI + 4s = GLO - 10800s
Since all timetags are in GNSS constellation time and basedate is UTC, we calculate three possible 32-bit timetags : basedate, basedate plus half a day, basedate minus half a day, so all constellations and basedate time reference are tried. We then select the unambiguous resolution the closest in time to the original basedate.
- Parameters:
timetag16 (int) – 16-bit gnssTimeTag
basedate (datetime) – original processing datetime accurate to 3 hours
- Returns:
32-bit gnssTimeTag
- Return type:
int
- pyspartn.spartnhelpers.naive2aware(dt: datetime, tz: timezone = datetime.timezone.utc) datetime [source]
Convert naive datetime to aware.
- Parameters:
dt (datetime) – datetime
tz (timezone) – timezone (utc)
- Returns:
datetime object with UTC timezone
- Return type:
datetime
- pyspartn.spartnhelpers.enc2float(value: int, res: float, rngmin: float = 0) float [source]
Convert encoded floating point value to float.
SPARTN protocol stores floating point numbers in encoded integer format.
- Parameters:
value (int) – encoded value
res (float) – resolution
rngmin (float) – minimum range value
- Returns:
floating point value
- Return type:
float
pyspartn.spartnmessage module
SPARTNMessage class.
The MQTT key, required for payload decryption, can be passed as a keyword or set up as environment variable MQTTKEY.
Created on 10 Feb 2023
- author:
semuadmin
- copyright:
SEMU Consulting © 2023
- license:
BSD 3-Clause
- class pyspartn.spartnmessage.SPARTNMessage(transport: bytes = None, validate: int = 1, decode: bool = False, key: str = 'abcd1234abcd1234abcd1234abcd1234', basedate: object = None, timetags: dict = None)[source]
Bases:
object
SPARTNMessage class.
- __init__(transport: bytes = None, validate: int = 1, decode: bool = False, key: str = 'abcd1234abcd1234abcd1234abcd1234', basedate: object = None, timetags: dict = None)[source]
Constructor.
- Parameters:
transport (bytes) – SPARTN message transport (None)
validate (bool) – validate CRC (True)
decode (bool) – decrypt and decode payloads (False)
key (str) – decryption key as hexadecimal string (Nominal)
basedate (object) – decryption basedate as datetime or 32-bit gnssTimeTag as integer (None). If basedate = TIMEBASE, timetags argument will be used
timetags (dict) – dict of decryption timetags in format {0: 442626332, 1: 449347321, 2: 412947745} where key = msgSubtype (0=GPS, 1=GLO, etc) and value = gnssTimeTag (None)
- Raises:
ParameterError if invalid parameters
- Raises:
SPARTNDecryptionError if unable to decrypt message using key and basedate/timetags provided
- Raises:
SPARTNMessageError if transport, payload or CRC invalid
- property identity: str
Return message identity.
- Returns:
message identity e.g. “SPARTN_1X_OCB_GPS”
- Return type:
str
- property payload: bytes
Return payload.
- Returns:
payload
- Return type:
bytes
pyspartn.spartnreader module
SPARTNReader class.
The SPARTNReader class will parse individual SPARTN messages from any binary stream containing solely SPARTN data e.g. an MQTT /pp/ip topic.
Information sourced from https://www.spartnformat.org/download/ (available in the public domain) © 2021 u-blox AG. All rights reserved.
SPARTN 1X transport layer bit format:
preamble |
framestart |
payload descriptor |
payload |
embedded auth data |
crc |
---|---|---|---|---|---|
8 bits 0x73 ‘s’ |
24 bits |
32-64 bits |
8-8192 bits |
0-512 bits |
8-32 bits |
NB Use of gnssTimeTag for message decryption:
The SPARTN protocol requires a key and basedate to calculate the Initialisation Vector (IV) for encrypted messages (eaf=1). The key is provided by the SPARTN service provider. The basedate is derived in one of two ways:
For messages with unambiguous 32-bit gnssTimeTag values (timeTagtype = 1), the basedate is the gnssTimeTag. No other information is needed.
For messages with ambiguous 16-bit gnssTimeTag values (timeTagtype = 0), the basedate can be derived from a 32-bit gnssTimeTag for the same message subtype (GPS, GLO, etc.) from the same datastream, or provided as an external parameter. SPARTNReader will accumulate any 32-bit gnssTimeTag in the incoming datastream for use in decryption.
Created on 10 Feb 2023
- author:
semuadmin
- copyright:
SEMU Consulting © 2023
- license:
BSD 3-Clause
- class pyspartn.spartnreader.SPARTNReader(datastream, validate: int = 1, quitonerror: int = 1, decode: bool = False, key: str = None, basedate: object = None, bufsize: int = 4096, errorhandler: object = None, timetags: dict = None)[source]
Bases:
object
SPARTNReader class.
- __init__(datastream, validate: int = 1, quitonerror: int = 1, decode: bool = False, key: str = None, basedate: object = None, bufsize: int = 4096, errorhandler: object = None, timetags: dict = None)[source]
Constructor.
- Parameters:
stream (datastream) – input data stream
validate (int) – VALCRC (1) = validate CRC, VALNONE (1) = ignore invalid CRC (1)
quitonerror (int) – ERROR_IGNORE (0) = ignore, ERROR_LOG (1) = log and continue, ERROR_RAISE (2) = (re)raise (1)
decode (bool) – decrypt and decode payload (False)
key (str) – decryption key as hexadecimal string (None)
basedate (object) – decryption basedate as datetime or 32-bit gnssTimeTag as integer (None). If basedate = TIMEBASE, SPARTNMessage will use timetags argument
bufsize (int) – socket recv buffer size (4096)
errorhandler (int) – error handling object or function (None)
timetags (dict) – dict of decryption timetags in format {0: 442626332, 1: 449347321, 2: 412947745} where key = msgSubtype (0=GPS, 1=GLO, etc) and value = gnssTimeTag (None)
- Raises:
ParameterError if invalid parameters
- Raises:
SPARTNDecryptionError if unable to decrypt message using key and basedate/timetags provided
- Raises:
SPARTN***Error if unable to parse message
- read() tuple [source]
Read a single SPARTN message from the stream buffer and return both raw and parsed data.
The ‘quitonerror’ flag determines whether to raise, log or ignore parsing errors. If error and quitonerror = 1, the ‘parsed’ value will contain the error message.
- Returns:
tuple of (raw_data as bytes, parsed_data as SPARTNMessage)
- Return type:
tuple
- Raises:
SPARTN***Error if error during parsing
- property datastream: object
Getter for stream.
- Returns:
data stream
- Return type:
object
- property timetags: dict
Getter for accumulated 32-bit gnssTimeTag time tags from data stream.
Can be used as a source of decryption basedate for each msgSubtype (i.e. GNSS constellation) if no other basedate is supplied.
- Returns:
dict of gnssTimeTag from data stream (key is msgSubtype)
- Return type:
dict
- static parse(message: bytes, validate: int = 1, decode: bool = False, key: str = None, basedate: object = None, timetags: dict = None) SPARTNMessage [source]
Parse SPARTN message to SPARTNMessage object.
- Parameters:
message (bytes) – SPARTN raw message bytes
validate (int) – 0 = ignore invalid CRC, 1 = validate CRC (1)
decode (int) – decode payload True/False
key (str) – decryption key (required if decode = 1)
basedate (object) – basedate as datetime or 32-bit gnssTimeTag as integer (None)
timetags (dict) – dict of accumulated gnssTimeTags from data stream (None)
- Returns:
SPARTNMessage object
- Return type:
- Raises:
SPARTN…Error (if data stream contains invalid data or unknown message type)
pyspartn.spartntables module
SPARTN Bitmask, Lookup and Decode Constants
Created on 10 Feb 2023
Information Sourced from https://www.spartnformat.org/download/ (available in the public domain) © 2021 u-blox AG. All rights reserved.
- author:
semuadmin
- pyspartn.spartntables.SATBITMASKKEY = {'BEI': 'SF094', 'GAL': 'SF093', 'GLO': 'SF012', 'GPS': 'SF011', 'QZS': 'SF095'}
Satellite PRN bitmask keys
- pyspartn.spartntables.SATIODEKEY = {'BEI': 'SF100', 'GAL': 'SF099', 'GLO': 'SF019', 'GPS': 'SF018', 'QZS': 'SF101'}
Satellite IODE keys
- pyspartn.spartntables.SF011_ENUM = {0: 32, 1: 44, 2: 56, 3: 64}
GPS satellite mask length (leftmost 2 bits of SF011)
- pyspartn.spartntables.SF012_ENUM = {0: 24, 1: 36, 2: 48, 3: 63}
GLONASS satellite mask length (leftmost 2 bits of SF012)
- pyspartn.spartntables.SF093_ENUM = {0: 36, 1: 45, 2: 54, 3: 64}
Galileo satellite mask length (leftmost 2 bits of SF093)
- pyspartn.spartntables.SF094_ENUM = {0: 37, 1: 46, 2: 55, 3: 64}
BDS satellite mask length (leftmost 2 bits of SF094)
- pyspartn.spartntables.SF095_ENUM = {0: 10, 1: 40, 2: 48, 3: 64}
QZSS satellite mask length (leftmost 2 bits of SF095)
- pyspartn.spartntables.SATBITMASKLEN = {'SF011': [32, 44, 56, 64], 'SF012': [24, 36, 48, 63], 'SF093': [36, 45, 54, 64], 'SF094': [37, 46, 55, 64], 'SF095': [10, 40, 48, 64]}
Satellite PRN bitmask lengths (PRN values are bitmask position + 1)
- pyspartn.spartntables.PBBITMASKKEY = {'BEI': 'SF103', 'GAL': 'SF102', 'GLO': 'SF026', 'GPS': 'SF025', 'QZS': 'SF104'}
Phase bias bitmask keys
- pyspartn.spartntables.PBBITMASKLEN = {'SF025': ([6, 11], {0: 'L1C', 1: 'L2W', 2: 'L2L', 3: 'L5Q'}), 'SF026': ([5, 9], {0: 'L1C', 1: 'L2C'}), 'SF102': ([8, 15], {0: 'L1C', 1: 'L5Q', 2: 'L7Q'}), 'SF103': ([8, 15], {0: 'L2I', 1: 'L5P', 2: 'L7I', 3: 'L6I', 4: 'L1P', 5: 'L7P', 6: 'L8P'}), 'SF104': ([6, 11], {0: 'L1C', 1: 'L2L', 2: 'L5Q'})}
Phase bias bitmask lengths and enumerations
- pyspartn.spartntables.CBBITMASKKEY = {'BEI': 'SF106', 'GAL': 'SF105', 'GLO': 'SF028', 'GPS': 'SF027', 'QZS': 'SF107'}
Code bias bitmask keys
- pyspartn.spartntables.CBBITMASKLEN = {'SF027': ([6, 11], {0: 'C1C', 1: 'C2W', 2: 'C2L', 3: 'C5Q'}), 'SF028': ([5, 9], {0: 'C1C', 1: 'C2C'}), 'SF105': ([8, 15], {0: 'C1C', 1: 'C5Q', 2: 'C7Q'}), 'SF106': ([8, 15], {0: 'C2I', 1: 'C5P', 2: 'C7I', 3: 'C6I', 4: 'C1P', 5: 'C7P', 6: 'C8P'}), 'SF107': ([6, 11], {0: 'C1C', 1: 'C2L', 2: 'C5Q'})}
Code bias bitmask lengths and enumerations
- pyspartn.spartntables.ALN_ENUM = {0: 8, 1: 12, 2: 16, 3: 32, 4: 64}
Embedded authorisation length enumeration
- pyspartn.spartntables.SF015_ENUM = {0: '0 secs', 1: '1 secs', 2: '5 secs', 3: '10 secs', 4: '30 secs', 5: '60 secs', 6: '120 secs', 7: '320 secs'}
Continuity indicator enumeration
- pyspartn.spartntables.SF024_ENUM = {0: 'unknown', 1: '0.01 m', 2: '0.02 m', 3: '0.05 m', 4: '0.1 m', 5: '0.3 m', 6: '1.0 m', 7: '> 1.0 m'}
User range error (URE) enumeration
- pyspartn.spartntables.SF042_ENUM = {0: 'unknown', 1: '<= 0.010 m', 2: '<= 0.020 m', 3: '<= 0.040 m', 4: '<= 0.080 m', 5: '<= 0.160 m', 6: '<= 0.320 m', 7: '> 0.320 m'}
Troposphere quality enumeration
- pyspartn.spartntables.SF044_ENUM = {0: 'Troposphere small coefficient block', 1: 'Troposphere large coefficient block'}
Troposphere polynomial coefficient size indicator
- pyspartn.spartntables.SF051_ENUM = {0: 'Troposphere small residual', 1: 'Tropospherelarge residual'}
Troposphere residual field size
- pyspartn.spartntables.SF055_ENUM = {0: 'Unknown', 1: '<= 0.03 TECU', 2: '<= 0.05 TECU', 3: '<= 0.07 TECU', 4: '<= 0.14 TECU', 5: '<= 0.28 TECU', 6: '<= 0.56 TECU', 7: '<= 1.12 TECU', 8: '<= 2.24 TECU', 9: '<= 4.48 TECU', 10: '<= 8.96 TECU', 11: '<= 17.92 TECU', 12: '<= 35.84 TECU', 13: '<= 71.68 TECU', 14: '<= 143.36 TECU', 15: '> 143.36 TECU'}
Ionosphere quality enumeration
- pyspartn.spartntables.SF056_ENUM = {0: 'Ionosphere small coefficient block', 1: 'Ionosphere large coefficient block'}
Ionosphere polynomial coefficient size indicator
- pyspartn.spartntables.SF063_ENUM = {0: 'Ionosphere small residual', 1: 'Ionosphere medium residual', 2: 'Ionosphere large residual', 3: 'Ionosphere extra large residual'}
Ionosphere residual field size enumeration
- pyspartn.spartntables.SF070_ENUM = {0: '350 km', 1: '400 km', 2: '450 km', 3: '500 km'}
Ionosphere shell height enumeration
- pyspartn.spartntables.SF077_ENUM = {0: '2.5 deg', 1: '5.0 deg', 2: '10.0 deg', 3: '15.0 deg'}
BPAC area latitude/longitude grid node spacing enumeration
- pyspartn.spartntables.SF081_ENUM = {0: 'small VTEC residual', 1: 'large VTEC residual'}
VTEC size indicator
- pyspartn.spartntables.SF085_ENUM = {0: 'AES', 1: 'ChaCha12', 2: 'ChaCha20'}
Encryption Type enumeration
- pyspartn.spartntables.SF087_ENUM = {0: 96, 1: 128, 2: 192, 3: 256, 4: 512}
Key length enumeration
- pyspartn.spartntables.SF090_ENUM = {0: 'none', 1: 'Ed25519', 2: 'SHA-2', 3: 'SHA-3'}
Group Authentication Type enumeration
- pyspartn.spartntables.SF091_ENUM = {0: 32, 1: 64, 2: 96, 3: 128, 4: 192, 5: 256, 6: 512}
Computed Authentication Data (CAD) Length enumeration
- pyspartn.spartntables.SF096_ENUM = {0: 'Galileo F/NAV', 1: 'Galileo I/NAV', 2: 'Galileo C/NAV'}
Galileo ephemeris type
- pyspartn.spartntables.SF097_ENUM = {0: 'D1 Nav (B1I)', 1: 'D2 Nav (B1I)', 2: 'D1 Nav (B3I)', 3: 'D2 Nav (B3I)', 4: 'B-CNAV1', 5: 'B-CNAV2'}
BDS ephemeris type
- pyspartn.spartntables.SF098_ENUM = {0: 'LNAV (L1C/A)', 1: 'CNAV2 (L1C)', 2: 'CNAV (L2C,L5)'}
QZSS ephemeris type
pyspartn.spartntypes_core module
SPARTN Protocol core globals and constants
Created on 10 Feb 2023
Information Sourced from https://www.spartnformat.org/download/ (available in the public domain) © 2021 u-blox AG. All rights reserved.
- author:
semuadmin
- copyright:
SEMU Consulting © 2023
- license:
BSD 3-Clause
- pyspartn.spartntypes_core.TIMEBASE = datetime.datetime(2010, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
Initial epoch for SPARTN protocol.
- pyspartn.spartntypes_core.DEFAULTKEY = 'abcd1234abcd1234abcd1234abcd1234'
Nominal 32-char hex key.
- pyspartn.spartntypes_core.ERRRAISE = 2
(Re)raise errors
- pyspartn.spartntypes_core.ERRLOG = 1
Log errors and continue
- pyspartn.spartntypes_core.ERRIGNORE = 0
Ignore errors
- pyspartn.spartntypes_core.VALNONE = 0
No validation of CRC or Message ID
- pyspartn.spartntypes_core.VALCRC = 1
Valildate CRC checksum
- pyspartn.spartntypes_core.VALMSGID = 2
Validate Message ID
- pyspartn.spartntypes_core.SPARTN_PREB = b's'
SPARTN preamble byte
pyspartn.spartntypes_get module
SPARTN Protocol core globals and constants
Created on 10 Feb 2023
Information Sourced from https://www.spartnformat.org/download/ (available in the public domain) © 2021 u-blox AG. All rights reserved.
Payload definitions are contained in a series of dictionaries. Repeating and conditional elements are defined as a tuple of (element size/presence designator, element dictionary). The element size/presence designator can take one of the following forms:
- Repeating elements:
an integer representing the fixed size of the repeating element N.
a string representing the name of a preceding attribute containing the size of the repeating element N (note that in some cases the attribute represents N - 1) e.g.
"group": ( # repeating group * (SF030 + 1)
"SF030",
{
"SF031": "Area ID",
etc ...
},
)
- Conditional elements:
a tuple containing a string and either a single value or a list of values, representing the name of a preceding attribute and the value(s) it must take in order for the optional element to be present e.g.
"optSF041-12": (
("SF041+1", [1, 2]), # if SF041I in 1,2
{
"SF055": "Ionosphere quality",
etc ...
}
)
An ‘NB’ prefix indicates that the element size is given by the number of set bits in the attribute, rather than its integer value e.g. ‘NB + “SF011”’ -> if SF011 = 0b0101101, the size of the repeating element is 4.
A ‘+1’ or ‘+2’ suffix indicates that the attribute name must be suffixed with the specified number of nested element indices e.g. ‘SF041+1’ -> ‘SF041_01’
In some instances, the size of the repeating element must be derived from multiple attributes. In these cases the element size is denoted by a composite attribute name which is calculated within spartnmessage.py e.g. ‘PBBMLEN’
- author:
semuadmin
- copyright:
SEMU Consulting © 2023
- license:
BSD 3-Clause
Module contents
Created on 10 Feb 2023
- author:
semuadmin
- copyright:
SEMU Consulting © 2023
- license:
BSD 3-Clause