shapeshifter-uftp 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- shapeshifter_uftp/__init__.py +143 -0
- shapeshifter_uftp/cli.py +62 -0
- shapeshifter_uftp/client/__init__.py +20 -0
- shapeshifter_uftp/client/agr_cro_client.py +25 -0
- shapeshifter_uftp/client/agr_dso_client.py +92 -0
- shapeshifter_uftp/client/base_client.py +240 -0
- shapeshifter_uftp/client/cro_agr_client.py +25 -0
- shapeshifter_uftp/client/cro_dso_client.py +33 -0
- shapeshifter_uftp/client/dso_agr_client.py +90 -0
- shapeshifter_uftp/client/dso_cro_client.py +25 -0
- shapeshifter_uftp/exceptions.py +272 -0
- shapeshifter_uftp/logging.py +37 -0
- shapeshifter_uftp/service/__init__.py +9 -0
- shapeshifter_uftp/service/agr_service.py +356 -0
- shapeshifter_uftp/service/base_service.py +283 -0
- shapeshifter_uftp/service/cro_service.py +135 -0
- shapeshifter_uftp/service/dso_service.py +312 -0
- shapeshifter_uftp/transport.py +250 -0
- shapeshifter_uftp/uftp/__init__.py +169 -0
- shapeshifter_uftp/uftp/agr_cro.py +317 -0
- shapeshifter_uftp/uftp/agr_dso.py +1207 -0
- shapeshifter_uftp/uftp/common.py +205 -0
- shapeshifter_uftp/uftp/cro_dso.py +332 -0
- shapeshifter_uftp/uftp/defaults.py +1 -0
- shapeshifter_uftp/uftp/metering.py +201 -0
- shapeshifter_uftp/uftp/validations.py +32 -0
- shapeshifter_uftp-1.3.0.dist-info/METADATA +21 -0
- shapeshifter_uftp-1.3.0.dist-info/RECORD +31 -0
- shapeshifter_uftp-1.3.0.dist-info/WHEEL +5 -0
- shapeshifter_uftp-1.3.0.dist-info/entry_points.txt +3 -0
- shapeshifter_uftp-1.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
ShapeshifterAgrCroClient,
|
|
3
|
+
ShapeshifterAgrDsoClient,
|
|
4
|
+
ShapeshifterCroAgrClient,
|
|
5
|
+
ShapeshifterCroDsoClient,
|
|
6
|
+
ShapeshifterDsoAgrClient,
|
|
7
|
+
ShapeshifterDsoCroClient,
|
|
8
|
+
)
|
|
9
|
+
from .service import (
|
|
10
|
+
ShapeshifterAgrService,
|
|
11
|
+
ShapeshifterCroService,
|
|
12
|
+
ShapeshifterDsoService,
|
|
13
|
+
)
|
|
14
|
+
from .uftp import (
|
|
15
|
+
AcceptedRejected,
|
|
16
|
+
AgrPortfolioQuery,
|
|
17
|
+
AgrPortfolioQueryResponse,
|
|
18
|
+
AgrPortfolioQueryResponseCongestionPoint,
|
|
19
|
+
AgrPortfolioQueryResponseConnection,
|
|
20
|
+
AgrPortfolioQueryResponseDSOPortfolio,
|
|
21
|
+
AgrPortfolioQueryResponseDSOView,
|
|
22
|
+
AgrPortfolioUpdate,
|
|
23
|
+
AgrPortfolioUpdateConnection,
|
|
24
|
+
AgrPortfolioUpdateResponse,
|
|
25
|
+
ContractSettlement,
|
|
26
|
+
ContractSettlementISP,
|
|
27
|
+
ContractSettlementPeriod,
|
|
28
|
+
DPrognosis,
|
|
29
|
+
DPrognosisISP,
|
|
30
|
+
DPrognosisResponse,
|
|
31
|
+
DsoPortfolioQuery,
|
|
32
|
+
DsoPortfolioQueryCongestionPoint,
|
|
33
|
+
DsoPortfolioQueryConnection,
|
|
34
|
+
DsoPortfolioQueryResponse,
|
|
35
|
+
DsoPortfolioUpdate,
|
|
36
|
+
DsoPortfolioUpdateCongestionPoint,
|
|
37
|
+
DsoPortfolioUpdateConnection,
|
|
38
|
+
DsoPortfolioUpdateResponse,
|
|
39
|
+
FlexMessage,
|
|
40
|
+
FlexOffer,
|
|
41
|
+
FlexOfferOption,
|
|
42
|
+
FlexOfferOptionISP,
|
|
43
|
+
FlexOfferResponse,
|
|
44
|
+
FlexOfferRevocation,
|
|
45
|
+
FlexOfferRevocationResponse,
|
|
46
|
+
FlexOrder,
|
|
47
|
+
FlexOrderISP,
|
|
48
|
+
FlexOrderResponse,
|
|
49
|
+
FlexOrderSettlement,
|
|
50
|
+
FlexOrderSettlementISP,
|
|
51
|
+
FlexOrderSettlementStatus,
|
|
52
|
+
FlexOrderStatus,
|
|
53
|
+
FlexRequest,
|
|
54
|
+
FlexRequestISP,
|
|
55
|
+
FlexRequestResponse,
|
|
56
|
+
FlexReservationUpdate,
|
|
57
|
+
FlexReservationUpdateISP,
|
|
58
|
+
FlexReservationUpdateResponse,
|
|
59
|
+
FlexSettlement,
|
|
60
|
+
FlexSettlementResponse,
|
|
61
|
+
Metering,
|
|
62
|
+
MeteringISP,
|
|
63
|
+
MeteringProfile,
|
|
64
|
+
MeteringProfileEnum,
|
|
65
|
+
MeteringResponse,
|
|
66
|
+
MeteringUnit,
|
|
67
|
+
PayloadMessage,
|
|
68
|
+
PayloadMessageResponse,
|
|
69
|
+
SignedMessage,
|
|
70
|
+
TestMessage,
|
|
71
|
+
TestMessageResponse,
|
|
72
|
+
UsefRole,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
__all__ = [
|
|
76
|
+
"ShapeshifterAgrCroClient",
|
|
77
|
+
"ShapeshifterAgrDsoClient",
|
|
78
|
+
"ShapeshifterCroAgrClient",
|
|
79
|
+
"ShapeshifterCroDsoClient",
|
|
80
|
+
"ShapeshifterDsoAgrClient",
|
|
81
|
+
"ShapeshifterDsoCroClient",
|
|
82
|
+
"ShapeshifterAgrService",
|
|
83
|
+
"ShapeshifterDsoService",
|
|
84
|
+
"ShapeshifterCroService",
|
|
85
|
+
"AcceptedRejected",
|
|
86
|
+
"AgrPortfolioQuery",
|
|
87
|
+
"AgrPortfolioQueryResponse",
|
|
88
|
+
"AgrPortfolioQueryResponseCongestionPoint",
|
|
89
|
+
"AgrPortfolioQueryResponseConnection",
|
|
90
|
+
"AgrPortfolioQueryResponseDSOPortfolio",
|
|
91
|
+
"AgrPortfolioQueryResponseDSOView",
|
|
92
|
+
"AgrPortfolioUpdate",
|
|
93
|
+
"AgrPortfolioUpdateConnection",
|
|
94
|
+
"AgrPortfolioUpdateResponse",
|
|
95
|
+
"ContractSettlement",
|
|
96
|
+
"ContractSettlementISP",
|
|
97
|
+
"ContractSettlementPeriod",
|
|
98
|
+
"DPrognosis",
|
|
99
|
+
"DPrognosisISP",
|
|
100
|
+
"DPrognosisResponse",
|
|
101
|
+
"DsoPortfolioQuery",
|
|
102
|
+
"DsoPortfolioQueryCongestionPoint",
|
|
103
|
+
"DsoPortfolioQueryConnection",
|
|
104
|
+
"DsoPortfolioQueryResponse",
|
|
105
|
+
"DsoPortfolioUpdate",
|
|
106
|
+
"DsoPortfolioUpdateCongestionPoint",
|
|
107
|
+
"DsoPortfolioUpdateConnection",
|
|
108
|
+
"DsoPortfolioUpdateResponse",
|
|
109
|
+
"FlexMessage",
|
|
110
|
+
"FlexOffer",
|
|
111
|
+
"FlexOfferOption",
|
|
112
|
+
"FlexOfferOptionISP",
|
|
113
|
+
"FlexOfferResponse",
|
|
114
|
+
"FlexOfferRevocation",
|
|
115
|
+
"FlexOfferRevocationResponse",
|
|
116
|
+
"FlexOrder",
|
|
117
|
+
"FlexOrderISP",
|
|
118
|
+
"FlexOrderResponse",
|
|
119
|
+
"FlexOrderSettlement",
|
|
120
|
+
"FlexOrderSettlementISP",
|
|
121
|
+
"FlexOrderSettlementStatus",
|
|
122
|
+
"FlexOrderStatus",
|
|
123
|
+
"FlexRequest",
|
|
124
|
+
"FlexRequestISP",
|
|
125
|
+
"FlexRequestResponse",
|
|
126
|
+
"FlexReservationUpdate",
|
|
127
|
+
"FlexReservationUpdateISP",
|
|
128
|
+
"FlexReservationUpdateResponse",
|
|
129
|
+
"FlexSettlement",
|
|
130
|
+
"FlexSettlementResponse",
|
|
131
|
+
"Metering",
|
|
132
|
+
"MeteringISP",
|
|
133
|
+
"MeteringProfile",
|
|
134
|
+
"MeteringProfileEnum",
|
|
135
|
+
"MeteringResponse",
|
|
136
|
+
"MeteringUnit",
|
|
137
|
+
"PayloadMessage",
|
|
138
|
+
"PayloadMessageResponse",
|
|
139
|
+
"SignedMessage",
|
|
140
|
+
"TestMessage",
|
|
141
|
+
"TestMessageResponse",
|
|
142
|
+
"UsefRole",
|
|
143
|
+
]
|
shapeshifter_uftp/cli.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A set of command-line-interface functions that are useful during
|
|
3
|
+
development of Shapeshifter applications.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from argparse import ArgumentParser
|
|
7
|
+
from base64 import b64encode
|
|
8
|
+
|
|
9
|
+
from nacl.bindings import crypto_sign_keypair
|
|
10
|
+
|
|
11
|
+
from . import transport
|
|
12
|
+
from .exceptions import AuthenticationTimeoutException, ServiceDiscoveryException
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def generate_signing_keypair():
|
|
16
|
+
"""
|
|
17
|
+
Generate a signing keypair (private and public) and print them as
|
|
18
|
+
base64-encoded strings. These are the strings that you'd use for
|
|
19
|
+
signing and verifying messages; you pass these to the signing_key
|
|
20
|
+
and recipient_signing_key parameters of the Service or Client
|
|
21
|
+
objects.
|
|
22
|
+
"""
|
|
23
|
+
public, private = crypto_sign_keypair()
|
|
24
|
+
print("-" * 66)
|
|
25
|
+
print("Private key (base64):", b64encode(private).decode())
|
|
26
|
+
print("Public key (base64): ", b64encode(public).decode())
|
|
27
|
+
print("-" * 66)
|
|
28
|
+
|
|
29
|
+
def perform_lookup():
|
|
30
|
+
"""
|
|
31
|
+
Perform a DNS lookup of a participant's version, endpoint and
|
|
32
|
+
public key details. These use the well-known DNS names described
|
|
33
|
+
in the UFTP specification.
|
|
34
|
+
"""
|
|
35
|
+
parser = ArgumentParser()
|
|
36
|
+
parser.add_argument("-d", "--domain", required=True, type=str, help="The sender domain for the other party")
|
|
37
|
+
parser.add_argument("-r", "--role", required=True, type=str, help="The sender role for the other party")
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
|
|
40
|
+
print("-" * 65)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
version = transport.get_version(args.domain)
|
|
44
|
+
print(f"Shapeshifer version: {version}")
|
|
45
|
+
except ServiceDiscoveryException as err:
|
|
46
|
+
print(err)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
endpoint = transport.get_endpoint(args.domain, args.role)
|
|
50
|
+
print(f"Endpoint URL: {endpoint}")
|
|
51
|
+
except ServiceDiscoveryException as err:
|
|
52
|
+
print(err)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
signing_key, decryption_key = transport.get_keys(args.domain, args.role)
|
|
56
|
+
print(f"Signing key: {signing_key}")
|
|
57
|
+
if decryption_key:
|
|
58
|
+
print(f"Decryption Key: {decryption_key}")
|
|
59
|
+
except AuthenticationTimeoutException as err:
|
|
60
|
+
print(err)
|
|
61
|
+
|
|
62
|
+
print("-" * 65)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .agr_cro_client import ShapeshifterAgrCroClient
|
|
2
|
+
from .agr_dso_client import ShapeshifterAgrDsoClient
|
|
3
|
+
from .cro_agr_client import ShapeshifterCroAgrClient
|
|
4
|
+
from .cro_dso_client import ShapeshifterCroDsoClient
|
|
5
|
+
from .dso_agr_client import ShapeshifterDsoAgrClient
|
|
6
|
+
from .dso_cro_client import ShapeshifterDsoCroClient
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ShapeshifterAgrCroClient",
|
|
10
|
+
"ShapeshifterAgrDsoClient",
|
|
11
|
+
"ShapeshifterCroAgrClient",
|
|
12
|
+
"ShapeshifterCroDsoClient",
|
|
13
|
+
"ShapeshifterDsoAgrClient",
|
|
14
|
+
"ShapeshifterDsoCroClient"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
client_map = {
|
|
18
|
+
(client.sender_role, client.recipient_role): client
|
|
19
|
+
for client in [globals()[name] for name in __all__]
|
|
20
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from ..uftp import AgrPortfolioQuery, AgrPortfolioUpdate, PayloadMessageResponse
|
|
2
|
+
from .base_client import ShapeshifterClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ShapeshifterAgrCroClient(ShapeshifterClient):
|
|
6
|
+
"""
|
|
7
|
+
Client that allows the Aggregator to connect to the CRO.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
sender_role = "AGR"
|
|
11
|
+
recipient_role = "CRO"
|
|
12
|
+
|
|
13
|
+
def send_agr_portfolio_update(self, message: AgrPortfolioUpdate) -> PayloadMessageResponse:
|
|
14
|
+
"""
|
|
15
|
+
The AGRPortfolioUpdate is used by the AGR to indicate on which
|
|
16
|
+
Connections it represents prosumers.
|
|
17
|
+
"""
|
|
18
|
+
return self._send_message(message)
|
|
19
|
+
|
|
20
|
+
def send_agr_portfolio_query(self, message: AgrPortfolioQuery) -> PayloadMessageResponse:
|
|
21
|
+
"""
|
|
22
|
+
The AGRPortfolioQuery is used by the AGR to retrieve additional
|
|
23
|
+
information on the connections.
|
|
24
|
+
"""
|
|
25
|
+
return self._send_message(message)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from ..uftp import (
|
|
2
|
+
DPrognosis,
|
|
3
|
+
FlexOffer,
|
|
4
|
+
FlexOfferRevocation,
|
|
5
|
+
FlexOrderResponse,
|
|
6
|
+
FlexRequestResponse,
|
|
7
|
+
FlexReservationUpdateResponse,
|
|
8
|
+
FlexSettlementResponse,
|
|
9
|
+
Metering,
|
|
10
|
+
PayloadMessageResponse,
|
|
11
|
+
)
|
|
12
|
+
from .base_client import ShapeshifterClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ShapeshifterAgrDsoClient(ShapeshifterClient):
|
|
16
|
+
"""
|
|
17
|
+
Client that allows the Aggregator to connect to the DSO.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
sender_role = "AGR"
|
|
21
|
+
recipient_role = "DSO"
|
|
22
|
+
|
|
23
|
+
def send_d_prognosis(self, message: DPrognosis) -> PayloadMessageResponse:
|
|
24
|
+
"""
|
|
25
|
+
D-Prognosis messages are used to communicate D-prognoses between AGRs
|
|
26
|
+
and DSOs. D-Prognosis messages always contain data for all ISPs for the
|
|
27
|
+
period they apply to, even if a prognosis is sent after the start of
|
|
28
|
+
the period, when one or more ISPs are already in the operate or
|
|
29
|
+
settlement phase. Receiving implementations should ignore the
|
|
30
|
+
information supplied for those ISPs.
|
|
31
|
+
"""
|
|
32
|
+
return self._send_message(message)
|
|
33
|
+
|
|
34
|
+
def send_flex_request_response(self, message: FlexRequestResponse) -> PayloadMessageResponse:
|
|
35
|
+
"""
|
|
36
|
+
Upon receiving and processing a FlexRequest message, the receiving
|
|
37
|
+
implementation must reply with a FlexRequestResponse, indicating
|
|
38
|
+
whether the flexibility request was processed successfully.
|
|
39
|
+
"""
|
|
40
|
+
return self._send_message(message)
|
|
41
|
+
|
|
42
|
+
def send_flex_offer(self, message: FlexOffer) -> PayloadMessageResponse:
|
|
43
|
+
"""
|
|
44
|
+
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
|
45
|
+
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
|
46
|
+
each ISP, the change in consumption or production offered and the price
|
|
47
|
+
for the total amount of flexibility offered. FlexOffer messages can be
|
|
48
|
+
sent once a FlexRequest message has been received but can also be sent
|
|
49
|
+
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
|
50
|
+
a single FlexRequest, e.g. to increase the chance that the DSO will
|
|
51
|
+
order at least part of its available flexibility. The AGR must make
|
|
52
|
+
sure that it can actually provide the flexibility offered across all of
|
|
53
|
+
its FlexOffers.
|
|
54
|
+
"""
|
|
55
|
+
return self._send_message(message)
|
|
56
|
+
|
|
57
|
+
def send_flex_offer_revocation(self, message: FlexOfferRevocation) -> PayloadMessageResponse:
|
|
58
|
+
"""
|
|
59
|
+
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
|
60
|
+
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
|
61
|
+
time has not yet expired. Revocation is not allowed for FlexOffers that
|
|
62
|
+
already have associated accepted FlexOrders.
|
|
63
|
+
"""
|
|
64
|
+
return self._send_message(message)
|
|
65
|
+
|
|
66
|
+
def send_flex_order_response(self, message: FlexOrderResponse) -> PayloadMessageResponse:
|
|
67
|
+
"""
|
|
68
|
+
Confirm the flex order.
|
|
69
|
+
"""
|
|
70
|
+
return self._send_message(message)
|
|
71
|
+
|
|
72
|
+
def send_flex_settlement_response(self, message: FlexSettlementResponse) -> PayloadMessageResponse:
|
|
73
|
+
"""
|
|
74
|
+
Upon receiving and processing a FlexSettlement message, the AGR must
|
|
75
|
+
reply with a FlexSettlementResponse, indicating whether the initial
|
|
76
|
+
message was handled successfully. When a FlexSettlement message is
|
|
77
|
+
rejected, the DSO should consider all FlexOrderSettlement elements of
|
|
78
|
+
that message related to potential dispute.
|
|
79
|
+
"""
|
|
80
|
+
return self._send_message(message)
|
|
81
|
+
|
|
82
|
+
def send_flex_reservation_update_response(self, message: FlexReservationUpdateResponse) -> PayloadMessageResponse:
|
|
83
|
+
"""
|
|
84
|
+
Confirm the flex reservation update.
|
|
85
|
+
"""
|
|
86
|
+
return self._send_message(message)
|
|
87
|
+
|
|
88
|
+
def send_metering(self, message: Metering) -> PayloadMessageResponse:
|
|
89
|
+
"""
|
|
90
|
+
Send metering data to the DSO.
|
|
91
|
+
"""
|
|
92
|
+
return self._send_message(message)
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import sched
|
|
2
|
+
import time
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from queue import Queue
|
|
5
|
+
from threading import Event, Thread
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from .. import transport
|
|
11
|
+
from ..exceptions import ClientTransportException
|
|
12
|
+
from ..logging import logger
|
|
13
|
+
from ..uftp import PayloadMessage, PayloadMessageResponse, SignedMessage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ShapeshifterClient:
|
|
17
|
+
"""
|
|
18
|
+
Basis for all Shapeshifter client.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
sender_role: str
|
|
22
|
+
recipient_role: str
|
|
23
|
+
protocol_version = "3.0.0"
|
|
24
|
+
num_outgoing_workers = 10
|
|
25
|
+
num_delivery_attempts = 10
|
|
26
|
+
request_timeout = 30
|
|
27
|
+
exponential_retry_factor = 1
|
|
28
|
+
exponential_retry_base = 2
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
sender_domain: str,
|
|
33
|
+
signing_key: str,
|
|
34
|
+
recipient_domain: str,
|
|
35
|
+
recipient_endpoint: str = None,
|
|
36
|
+
recipient_signing_key: str = None,
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
Shapeshifter client class that allows you to initiate messages to a different party.
|
|
40
|
+
:param sender_domain: your sender domain
|
|
41
|
+
:param signing_key: your private signing key
|
|
42
|
+
:param recipient_domain: the domain of the recipient
|
|
43
|
+
:param recipient_endpoint: the full http endpoint URL of the recipient. If omitted,
|
|
44
|
+
will look up the endpoint using DNS.
|
|
45
|
+
:recipient_signing_key: the public signing key of the recipient. If omitted, will
|
|
46
|
+
look up the signing key using DNS.
|
|
47
|
+
"""
|
|
48
|
+
if recipient_domain is None and recipient_endpoint is None:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"One of recipient_domain or recipient_endpoint must be provided."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self.sender_domain = sender_domain
|
|
54
|
+
self.signing_key = signing_key
|
|
55
|
+
self.recipient_domain = recipient_domain
|
|
56
|
+
self.recipient_endpoint = recipient_endpoint
|
|
57
|
+
self.recipient_signing_key = recipient_signing_key
|
|
58
|
+
|
|
59
|
+
# The outgoing queue and scheduler are used when queueing
|
|
60
|
+
# messages for delivery later. This allows the Shapeshifter
|
|
61
|
+
# UFTP client to handle message retries on an exponential
|
|
62
|
+
# time schedule, and delivers the result in the provided
|
|
63
|
+
# callback function.
|
|
64
|
+
self.outgoing_queue = Queue()
|
|
65
|
+
self.outgoing_workers = None
|
|
66
|
+
self.scheduler = sched.scheduler(time.monotonic, time.sleep)
|
|
67
|
+
self.scheduler_event = Event()
|
|
68
|
+
self.scheduler_thread = None
|
|
69
|
+
|
|
70
|
+
def _send_message(self, message: PayloadMessage) -> PayloadMessageResponse:
|
|
71
|
+
"""
|
|
72
|
+
Perform an operation. This will take the message object, pack
|
|
73
|
+
it up into a SignedMessage, sign and seal it, and send it to
|
|
74
|
+
the recipient. It returns an unsealed PayloadMessageResponse
|
|
75
|
+
that contains the functional status of the request. The
|
|
76
|
+
actual response always arrives asynchronously on your service
|
|
77
|
+
(which runs separately).
|
|
78
|
+
"""
|
|
79
|
+
if not isinstance(message, PayloadMessage):
|
|
80
|
+
raise TypeError(
|
|
81
|
+
f"'message' must be a (subclass of) PayloadMessage, you provided: {type(message)}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Fill the PayloadMessage's fields that are common to all
|
|
85
|
+
# messages. We don't require the developer to fill these out
|
|
86
|
+
# every time they create any message, in order to reduce the
|
|
87
|
+
# duplicated code that would result in, and all of these
|
|
88
|
+
# properties can be calculated in the framework anyway.
|
|
89
|
+
message.version = self.protocol_version
|
|
90
|
+
message.sender_domain = self.sender_domain
|
|
91
|
+
message.sender_role = self.sender_role
|
|
92
|
+
message.recipient_domain = self.recipient_domain
|
|
93
|
+
message.time_stamp = (
|
|
94
|
+
message.time_stamp or datetime.now(timezone.utc).isoformat()
|
|
95
|
+
)
|
|
96
|
+
message.message_id = message.message_id or str(uuid4())
|
|
97
|
+
message.conversation_id = message.conversation_id or str(uuid4())
|
|
98
|
+
|
|
99
|
+
logger.info(f"The PayloadMessage is: {message}")
|
|
100
|
+
|
|
101
|
+
# Seal the message using our own private signing key
|
|
102
|
+
sealed_message = transport.seal_message(message, self.signing_key)
|
|
103
|
+
|
|
104
|
+
# Pack up the message into a SignedMessage
|
|
105
|
+
signed_message = SignedMessage(
|
|
106
|
+
sender_domain=self.sender_domain,
|
|
107
|
+
sender_role=self.sender_role,
|
|
108
|
+
body=sealed_message,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Serialize the message into an XML blob
|
|
112
|
+
serialized_message = transport.to_xml(signed_message)
|
|
113
|
+
|
|
114
|
+
logger.debug(f"Sending message to {self.recipient_endpoint}:")
|
|
115
|
+
logger.debug(serialized_message)
|
|
116
|
+
|
|
117
|
+
# Send the request to the relevant endpoint
|
|
118
|
+
response = requests.post(
|
|
119
|
+
self.recipient_endpoint,
|
|
120
|
+
data=serialized_message,
|
|
121
|
+
headers={"Content-Type": "text/xml; charset=utf-8"},
|
|
122
|
+
timeout=self.request_timeout,
|
|
123
|
+
)
|
|
124
|
+
if response.status_code != 200:
|
|
125
|
+
error_msg = (
|
|
126
|
+
f"Request to {self.recipient_endpoint} was not succesful: "
|
|
127
|
+
f"HTTP {response.status_code}: {response.text}"
|
|
128
|
+
)
|
|
129
|
+
logger.error(error_msg)
|
|
130
|
+
raise ClientTransportException(error_msg, response=response)
|
|
131
|
+
|
|
132
|
+
# If the response was empty, don't attempt to parse it
|
|
133
|
+
if len(response.content) == 0:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
# Instantiate a SignedMessage object from the response bytes.
|
|
137
|
+
sealed_response = transport.parser.from_bytes(response.content)
|
|
138
|
+
|
|
139
|
+
# Use the static key that was provided when the client was
|
|
140
|
+
# initialized, or retrieve the key using DNS. The DNS
|
|
141
|
+
# implementation caches the DNS lookups so they are not that
|
|
142
|
+
# expensive after the first time.
|
|
143
|
+
if not (recipient_signing_key := self.recipient_signing_key):
|
|
144
|
+
recipient_signing_key = transport.get_key(
|
|
145
|
+
self.recipient_domain, self.recipient_role
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Unseal the response using the other party's public signing
|
|
149
|
+
# key and return the response message within it
|
|
150
|
+
return transport.unseal_message(
|
|
151
|
+
message=sealed_response.body, public_key=recipient_signing_key
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------ #
|
|
155
|
+
# Methods related to queueing and scheduling outgoing #
|
|
156
|
+
# messages. #
|
|
157
|
+
# ------------------------------------------------------------ #
|
|
158
|
+
|
|
159
|
+
def _queue_message(self, message, callback, attempt=1):
|
|
160
|
+
self.outgoing_queue.put((message, callback, attempt))
|
|
161
|
+
self._run_outgoing_workers()
|
|
162
|
+
|
|
163
|
+
def _outgoing_worker(self):
|
|
164
|
+
while True:
|
|
165
|
+
message, callback, attempt = self.outgoing_queue.get()
|
|
166
|
+
try:
|
|
167
|
+
response = self._send_message(message)
|
|
168
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
169
|
+
if attempt <= self.num_delivery_attempts:
|
|
170
|
+
# Reschedule with exponential backoff
|
|
171
|
+
delay_time = (
|
|
172
|
+
self.exponential_retry_factor
|
|
173
|
+
* self.exponential_retry_base**attempt
|
|
174
|
+
)
|
|
175
|
+
logger.warning(
|
|
176
|
+
f"Outgoing message {message.__class__.__name__} to "
|
|
177
|
+
f"{message.recipient_domain} could not be delivered "
|
|
178
|
+
f"due to a {exc.__class__.__name__}, will try again in {delay_time:.0f} seconds."
|
|
179
|
+
)
|
|
180
|
+
self.scheduler.enter(
|
|
181
|
+
delay=delay_time,
|
|
182
|
+
priority=1,
|
|
183
|
+
action=self._queue_message,
|
|
184
|
+
argument=((message, callback, attempt + 1)),
|
|
185
|
+
)
|
|
186
|
+
self._run_scheduler()
|
|
187
|
+
else:
|
|
188
|
+
logger.error(
|
|
189
|
+
f"Could not deliver {message.__class__.__name__} "
|
|
190
|
+
f"to {self.recipient_role} at {self.recipient_domain}, "
|
|
191
|
+
f"even after {self.num_delivery_attempts} attempts."
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
try:
|
|
195
|
+
callback(response)
|
|
196
|
+
except Exception as err: # pylint: disable=broad-exception-caught
|
|
197
|
+
logger.error(
|
|
198
|
+
"There was an exception during the callback "
|
|
199
|
+
f"for a {message.__class__.__name__} message: "
|
|
200
|
+
f"{err.__class__.__name__}: {err}"
|
|
201
|
+
)
|
|
202
|
+
finally:
|
|
203
|
+
self.outgoing_queue.task_done()
|
|
204
|
+
|
|
205
|
+
def _run_scheduler(self):
|
|
206
|
+
"""
|
|
207
|
+
Make sure the scheduler thread is running and awake.
|
|
208
|
+
"""
|
|
209
|
+
if not self.scheduler_thread:
|
|
210
|
+
self.scheduler_thread = Thread(target=self._scheduler_thread, daemon=True)
|
|
211
|
+
self.scheduler_thread.start()
|
|
212
|
+
self.scheduler_event.set()
|
|
213
|
+
|
|
214
|
+
def _scheduler_thread(self):
|
|
215
|
+
"""
|
|
216
|
+
Intended to run the python scheduler in a background thread.
|
|
217
|
+
You can wake it up anytime by setting the scheduler event.
|
|
218
|
+
"""
|
|
219
|
+
while True:
|
|
220
|
+
self.scheduler_event.wait()
|
|
221
|
+
self.scheduler_event.clear()
|
|
222
|
+
self.scheduler.run()
|
|
223
|
+
|
|
224
|
+
def _run_outgoing_workers(self):
|
|
225
|
+
"""
|
|
226
|
+
Start up the outgoing queue workers.
|
|
227
|
+
"""
|
|
228
|
+
if not self.outgoing_workers:
|
|
229
|
+
self.outgoing_workers = [
|
|
230
|
+
Thread(target=self._outgoing_worker, daemon=True)
|
|
231
|
+
for _ in range(self.num_outgoing_workers)
|
|
232
|
+
]
|
|
233
|
+
for thread in self.outgoing_workers:
|
|
234
|
+
thread.start()
|
|
235
|
+
|
|
236
|
+
def __enter__(self):
|
|
237
|
+
return self
|
|
238
|
+
|
|
239
|
+
def __exit__(self, *args, **kwargs):
|
|
240
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from ..uftp import AgrPortfolioQueryResponse, AgrPortfolioUpdateResponse
|
|
2
|
+
from .base_client import ShapeshifterClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ShapeshifterCroAgrClient(ShapeshifterClient):
|
|
6
|
+
"""
|
|
7
|
+
Client that allows the CRO to connect to the Aggregator.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
sender_role = "CRO"
|
|
11
|
+
recipient_role = "AGR"
|
|
12
|
+
|
|
13
|
+
def send_agr_portfolio_update_response(self, message: AgrPortfolioUpdateResponse):
|
|
14
|
+
"""
|
|
15
|
+
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
|
16
|
+
congestion points it wants to engage in flexibility trading.
|
|
17
|
+
"""
|
|
18
|
+
return self._send_message(message)
|
|
19
|
+
|
|
20
|
+
def send_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse):
|
|
21
|
+
"""
|
|
22
|
+
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
|
23
|
+
connections on its registered congestion point(s).
|
|
24
|
+
"""
|
|
25
|
+
return self._send_message(message)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from ..uftp import DsoPortfolioQueryResponse, DsoPortfolioUpdateResponse
|
|
2
|
+
from .base_client import ShapeshifterClient
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ShapeshifterCroDsoClient(ShapeshifterClient):
|
|
6
|
+
"""
|
|
7
|
+
Client that allows the CRO to connect to the DSO.
|
|
8
|
+
|
|
9
|
+
There are only two types of messages that the CRO can send to the DSO:
|
|
10
|
+
|
|
11
|
+
- DsoPortfolioUpdateResponse
|
|
12
|
+
- DsoPortfolioQueryResponse
|
|
13
|
+
|
|
14
|
+
Each of these comes after the DSO sends a DsoPortfolioUpdate or
|
|
15
|
+
DsoPortfolioQuery, respectively.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
sender_role = "CRO"
|
|
19
|
+
recipient_role = "DSO"
|
|
20
|
+
|
|
21
|
+
def send_dso_portfolio_update_response(self, message: DsoPortfolioUpdateResponse):
|
|
22
|
+
"""
|
|
23
|
+
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
|
24
|
+
congestion points it wants to engage in flexibility trading.
|
|
25
|
+
"""
|
|
26
|
+
return self._send_message(message)
|
|
27
|
+
|
|
28
|
+
def send_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse):
|
|
29
|
+
"""
|
|
30
|
+
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
|
31
|
+
connections on its registered congestion point(s).
|
|
32
|
+
"""
|
|
33
|
+
return self._send_message(message)
|