Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter
- CitrineOS core extracted (CSMS OCPP 2.0.1) - OpenOCPP extracted (firmware OCPP 1.6J/2.0.1) - ShapeShifter library installed (pip install -e) - ShapeShifter specification extracted - EVerest extracted TODO updated with progress
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
from .agr_cro_client import ShapeshifterAgrCroClient
|
||||
from .agr_dso_client import ShapeshifterAgrDsoClient
|
||||
from .cro_agr_client import ShapeshifterCroAgrClient
|
||||
from .cro_dso_client import ShapeshifterCroDsoClient
|
||||
from .dso_agr_client import ShapeshifterDsoAgrClient
|
||||
from .dso_cro_client import ShapeshifterDsoCroClient
|
||||
|
||||
__all__ = [
|
||||
"ShapeshifterAgrCroClient",
|
||||
"ShapeshifterAgrDsoClient",
|
||||
"ShapeshifterCroAgrClient",
|
||||
"ShapeshifterCroDsoClient",
|
||||
"ShapeshifterDsoAgrClient",
|
||||
"ShapeshifterDsoCroClient"
|
||||
]
|
||||
|
||||
client_map = {
|
||||
(client.sender_role, client.recipient_role): client
|
||||
for client in [globals()[name] for name in __all__]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
from ..uftp import (
|
||||
AgrPortfolioQuery,
|
||||
AgrPortfolioUpdate,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterAgrCroClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the Aggregator to connect to the CRO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
recipient_role = UsefRole.CRO
|
||||
|
||||
def send_agr_portfolio_update(self, message: AgrPortfolioUpdate) -> None:
|
||||
"""
|
||||
The AGRPortfolioUpdate is used by the AGR to indicate on which
|
||||
Connections it represents prosumers.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_agr_portfolio_query(self, message: AgrPortfolioQuery) -> None:
|
||||
"""
|
||||
The AGRPortfolioQuery is used by the AGR to retrieve additional
|
||||
information on the connections.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
@@ -0,0 +1,92 @@
|
||||
from ..uftp import (
|
||||
DPrognosis,
|
||||
FlexOffer,
|
||||
FlexOfferRevocation,
|
||||
FlexOrderResponse,
|
||||
FlexRequestResponse,
|
||||
FlexReservationUpdateResponse,
|
||||
FlexSettlementResponse,
|
||||
Metering,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterAgrDsoClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the Aggregator to connect to the DSO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.AGR
|
||||
recipient_role = UsefRole.DSO
|
||||
|
||||
def send_d_prognosis(self, message: DPrognosis) -> None:
|
||||
"""
|
||||
D-Prognosis messages are used to communicate D-prognoses between AGRs
|
||||
and DSOs. D-Prognosis messages always contain data for all ISPs for the
|
||||
period they apply to, even if a prognosis is sent after the start of
|
||||
the period, when one or more ISPs are already in the operate or
|
||||
settlement phase. Receiving implementations should ignore the
|
||||
information supplied for those ISPs.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_flex_request_response(self, message: FlexRequestResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexRequest message, the receiving
|
||||
implementation must reply with a FlexRequestResponse, indicating
|
||||
whether the flexibility request was processed successfully.
|
||||
"""
|
||||
return self._send_message(message)
|
||||
|
||||
def send_flex_offer(self, message: FlexOffer) -> None:
|
||||
"""
|
||||
FlexOffer messages are used by AGRs to make DSOs an offer for provision
|
||||
of flexibility. A FlexOffer message contains a list of ISPs and, for
|
||||
each ISP, the change in consumption or production offered and the price
|
||||
for the total amount of flexibility offered. FlexOffer messages can be
|
||||
sent once a FlexRequest message has been received but can also be sent
|
||||
unsolicited. Note that multiple FlexOffer messages may be sent based on
|
||||
a single FlexRequest, e.g. to increase the chance that the DSO will
|
||||
order at least part of its available flexibility. The AGR must make
|
||||
sure that it can actually provide the flexibility offered across all of
|
||||
its FlexOffers.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_revocation(self, message: FlexOfferRevocation) -> None:
|
||||
"""
|
||||
The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
|
||||
previously sent to a DSO. It voids the FlexOffer, even if its validity
|
||||
time has not yet expired. Revocation is not allowed for FlexOffers that
|
||||
already have associated accepted FlexOrders.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_order_response(self, message: FlexOrderResponse) -> None:
|
||||
"""
|
||||
Confirm the flex order.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_settlement_response(self, message: FlexSettlementResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexSettlement message, the AGR must
|
||||
reply with a FlexSettlementResponse, indicating whether the initial
|
||||
message was handled successfully. When a FlexSettlement message is
|
||||
rejected, the DSO should consider all FlexOrderSettlement elements of
|
||||
that message related to potential dispute.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_reservation_update_response(self, message: FlexReservationUpdateResponse) -> None:
|
||||
"""
|
||||
Confirm the flex reservation update.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_metering(self, message: Metering) -> None:
|
||||
"""
|
||||
Send metering data to the DSO.
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,252 @@
|
||||
import sched
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from queue import Queue
|
||||
from threading import Event, Thread
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
from .. import transport
|
||||
from ..exceptions import ClientTransportException
|
||||
from ..logging import logger
|
||||
from ..oauth import OAuthClient, PassthroughOAuthClient
|
||||
from ..uftp import (
|
||||
PayloadMessage,
|
||||
SignedMessage,
|
||||
TestMessage,
|
||||
TestMessageResponse,
|
||||
UsefRole,
|
||||
)
|
||||
|
||||
|
||||
class ShapeshifterClient:
|
||||
"""
|
||||
Basis for all Shapeshifter client.
|
||||
"""
|
||||
|
||||
sender_role: UsefRole
|
||||
recipient_role: UsefRole
|
||||
num_outgoing_workers = 10
|
||||
num_delivery_attempts = 10
|
||||
request_timeout = 30
|
||||
exponential_retry_factor = 1.0
|
||||
exponential_retry_base = 2.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sender_domain: str,
|
||||
signing_key: str,
|
||||
recipient_domain: str,
|
||||
recipient_endpoint: str | None = None,
|
||||
recipient_signing_key: str | None = None,
|
||||
oauth_client: OAuthClient | None = None,
|
||||
version: str = "3.1.0"
|
||||
):
|
||||
"""
|
||||
Shapeshifter client class that allows you to initiate messages to a different party.
|
||||
:param str sender_domain: your sender domain
|
||||
:param str signing_key: your private signing key
|
||||
:param str recipient_domain: the domain of the recipient
|
||||
:param str recipient_endpoint: the full http endpoint URL of the recipient. If omitted,
|
||||
will look up the endpoint using DNS.
|
||||
:param str recipient_signing_key: the public signing key of the recipient. If omitted, will
|
||||
look up the signing key using DNS.
|
||||
:param OAuthClient oauth_client: Optional OAuth client instance for using oauth to authenticate outgoing messages.
|
||||
:param str version: Version number for the shapeshfter protocol (3.0.0 or 3.1.0)
|
||||
"""
|
||||
if recipient_domain is None and recipient_endpoint is None:
|
||||
raise ValueError(
|
||||
"One of recipient_domain or recipient_endpoint must be provided."
|
||||
)
|
||||
|
||||
if version not in ("3.0.0", "3.1.0"):
|
||||
raise ValueError(f"'version' should be one of '3.0.0' or '3.1.0', not '{version}'")
|
||||
|
||||
self.version = version
|
||||
self.sender_domain = sender_domain
|
||||
self.signing_key = signing_key
|
||||
self.recipient_domain = recipient_domain
|
||||
self.recipient_endpoint = recipient_endpoint
|
||||
self.recipient_signing_key = recipient_signing_key
|
||||
|
||||
# The outgoing queue and scheduler are used when queueing
|
||||
# messages for delivery later. This allows the Shapeshifter
|
||||
# UFTP client to handle message retries on an exponential
|
||||
# time schedule, and delivers the result in the provided
|
||||
# callback function.
|
||||
self.outgoing_queue = Queue()
|
||||
self.outgoing_workers = None
|
||||
self.scheduler = sched.scheduler(time.monotonic, time.sleep)
|
||||
self.scheduler_event = Event()
|
||||
self.scheduler_thread = None
|
||||
|
||||
if oauth_client:
|
||||
self.oauth_client = oauth_client
|
||||
else:
|
||||
self.oauth_client = PassthroughOAuthClient()
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Test Messages #
|
||||
# ------------------------------------------------------------ #
|
||||
def send_test_message(self, message: TestMessage | None = None):
|
||||
if message is None:
|
||||
message = TestMessage()
|
||||
|
||||
return self._send_message(message)
|
||||
|
||||
def send_test_message_response(self, message: TestMessageResponse):
|
||||
return self._send_message(message)
|
||||
|
||||
def _send_message(self, message: PayloadMessage) -> None:
|
||||
"""
|
||||
Perform an operation. This will take the message object, pack
|
||||
it up into a SignedMessage, sign and seal it, and send it to
|
||||
the recipient. It returns an unsealed PayloadMessageResponse
|
||||
that contains the functional status of the request. The
|
||||
actual response always arrives asynchronously on your service
|
||||
(which runs separately).
|
||||
"""
|
||||
if not isinstance(message, PayloadMessage):
|
||||
raise TypeError(
|
||||
f"'message' must be a (subclass of) PayloadMessage, you provided: {type(message)}"
|
||||
)
|
||||
|
||||
# Fill the PayloadMessage's fields that are common to all
|
||||
# messages. We don't require the developer to fill these out
|
||||
# every time they create any message, in order to reduce the
|
||||
# duplicated code that would result in, and all of these
|
||||
# properties can be calculated in the framework anyway.
|
||||
message.version = self.version
|
||||
message.sender_domain = self.sender_domain
|
||||
message.recipient_domain = self.recipient_domain
|
||||
message.time_stamp = (
|
||||
message.time_stamp or datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
message.message_id = message.message_id or str(uuid4())
|
||||
message.conversation_id = message.conversation_id or str(uuid4())
|
||||
|
||||
logger.info(f"The PayloadMessage is: {message}")
|
||||
|
||||
# Seal the message using our own private signing key
|
||||
sealed_message = transport.seal_message(message, self.signing_key)
|
||||
|
||||
# Pack up the message into a SignedMessage
|
||||
signed_message = SignedMessage(
|
||||
sender_domain=self.sender_domain,
|
||||
sender_role=self.sender_role,
|
||||
body=sealed_message,
|
||||
)
|
||||
|
||||
# Serialize the message into an XML blob
|
||||
serialized_message = transport.to_xml(signed_message)
|
||||
|
||||
logger.debug(f"Sending message to {self.recipient_endpoint}:")
|
||||
logger.debug(serialized_message)
|
||||
|
||||
# Send the request to the relevant endpoint
|
||||
with self.oauth_client.ensure_authenticated():
|
||||
response = requests.post(
|
||||
self.recipient_endpoint,
|
||||
data=serialized_message,
|
||||
headers={
|
||||
"Content-Type": "text/xml; charset=utf-8",
|
||||
**self.oauth_client.auth_header
|
||||
},
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
if response.status_code != 200:
|
||||
error_msg = (
|
||||
f"Request to {self.recipient_endpoint} was not succesful: "
|
||||
f"HTTP {response.status_code}: {response.text}"
|
||||
)
|
||||
logger.error(error_msg)
|
||||
raise ClientTransportException(error_msg, response=response)
|
||||
|
||||
# ------------------------------------------------------------ #
|
||||
# Methods related to queueing and scheduling outgoing #
|
||||
# messages. #
|
||||
# ------------------------------------------------------------ #
|
||||
|
||||
def _queue_message(self, message, callback, attempt=1):
|
||||
self.outgoing_queue.put((message, callback, attempt))
|
||||
self._run_outgoing_workers()
|
||||
|
||||
def _outgoing_worker(self):
|
||||
while True:
|
||||
message, callback, attempt = self.outgoing_queue.get()
|
||||
try:
|
||||
response = self._send_message(message)
|
||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||
if attempt <= self.num_delivery_attempts:
|
||||
# Reschedule with exponential backoff
|
||||
delay_time = (
|
||||
self.exponential_retry_factor
|
||||
* self.exponential_retry_base**attempt
|
||||
)
|
||||
logger.warning(
|
||||
f"Outgoing message {message.__class__.__name__} to "
|
||||
f"{message.recipient_domain} could not be delivered "
|
||||
f"due to a {exc.__class__.__name__}, will try again in {delay_time:.0f} seconds."
|
||||
)
|
||||
self.scheduler.enter(
|
||||
delay=delay_time,
|
||||
priority=1,
|
||||
action=self._queue_message,
|
||||
argument=((message, callback, attempt + 1)),
|
||||
)
|
||||
self._run_scheduler()
|
||||
else:
|
||||
logger.error(
|
||||
f"Could not deliver {message.__class__.__name__} "
|
||||
f"to {self.recipient_role} at {self.recipient_domain}, "
|
||||
f"even after {self.num_delivery_attempts} attempts."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
callback(response)
|
||||
except Exception as err: # pylint: disable=broad-exception-caught
|
||||
logger.error(
|
||||
"There was an exception during the callback "
|
||||
f"for a {message.__class__.__name__} message: "
|
||||
f"{err.__class__.__name__}: {err}"
|
||||
)
|
||||
finally:
|
||||
self.outgoing_queue.task_done()
|
||||
|
||||
def _run_scheduler(self):
|
||||
"""
|
||||
Make sure the scheduler thread is running and awake.
|
||||
"""
|
||||
if not self.scheduler_thread:
|
||||
self.scheduler_thread = Thread(target=self._scheduler_thread, daemon=True)
|
||||
self.scheduler_thread.start()
|
||||
self.scheduler_event.set()
|
||||
|
||||
def _scheduler_thread(self):
|
||||
"""
|
||||
Intended to run the python scheduler in a background thread.
|
||||
You can wake it up anytime by setting the scheduler event.
|
||||
"""
|
||||
while True:
|
||||
self.scheduler_event.wait()
|
||||
self.scheduler_event.clear()
|
||||
self.scheduler.run()
|
||||
|
||||
def _run_outgoing_workers(self):
|
||||
"""
|
||||
Start up the outgoing queue workers.
|
||||
"""
|
||||
if not self.outgoing_workers:
|
||||
self.outgoing_workers = [
|
||||
Thread(target=self._outgoing_worker, daemon=True)
|
||||
for _ in range(self.num_outgoing_workers)
|
||||
]
|
||||
for thread in self.outgoing_workers:
|
||||
thread.start()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..uftp import AgrPortfolioQueryResponse, AgrPortfolioUpdateResponse, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterCroAgrClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the CRO to connect to the Aggregator.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
recipient_role = UsefRole.AGR
|
||||
|
||||
def send_agr_portfolio_update_response(self, message: AgrPortfolioUpdateResponse) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,33 @@
|
||||
from ..uftp import DsoPortfolioQueryResponse, DsoPortfolioUpdateResponse, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterCroDsoClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the CRO to connect to the DSO.
|
||||
|
||||
There are only two types of messages that the CRO can send to the DSO:
|
||||
|
||||
- DsoPortfolioUpdateResponse
|
||||
- DsoPortfolioQueryResponse
|
||||
|
||||
Each of these comes after the DSO sends a DsoPortfolioUpdate or
|
||||
DsoPortfolioQuery, respectively.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.CRO
|
||||
recipient_role = UsefRole.DSO
|
||||
|
||||
def send_dso_portfolio_update_response(self, message: DsoPortfolioUpdateResponse) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,90 @@
|
||||
from ..uftp import (
|
||||
DPrognosisResponse,
|
||||
FlexOfferResponse,
|
||||
FlexOfferRevocationResponse,
|
||||
FlexOrder,
|
||||
FlexRequest,
|
||||
FlexReservationUpdate,
|
||||
FlexSettlement,
|
||||
MeteringResponse,
|
||||
UsefRole,
|
||||
)
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterDsoAgrClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the DSO to connect to the Aggregator.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
recipient_role = UsefRole.AGR
|
||||
|
||||
def send_d_prognosis_response(self, message: DPrognosisResponse) -> None:
|
||||
"""
|
||||
Confirm reception of the D-prognosis.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_request(self, message: FlexRequest) -> None:
|
||||
"""
|
||||
FlexRequest messages are used by DSOs to request flexibility from AGRs.
|
||||
In addition to one or more ISP elements with Disposition=Requested,
|
||||
indicating the actual need to reduce consumption or production, the
|
||||
message should also include the remaining ISPs for the current period
|
||||
where Disposition=Available.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_response(self, message: FlexOfferResponse) -> None:
|
||||
"""
|
||||
Confirm reception of a flex offer.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_order(self, message: FlexOrder) -> None:
|
||||
"""
|
||||
FlexOrder messages are used by DSOs to purchase flexibility from an AGR
|
||||
based on a previous FlexOffer. A FlexOrder message contains a list of
|
||||
ISPs, with, for each ISP, the change in consumption or production to be
|
||||
realized by the AGR, and the accepted price to be paid by the DSO for
|
||||
this amount of flexibility. This ISP list should be copied from the
|
||||
FlexOffer message without modification: AGR implementations will
|
||||
(and must) reject FlexOrder messages where the ISP list is not exactly
|
||||
the same as offered.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_reservation_update(self, message: FlexReservationUpdate) -> None:
|
||||
"""
|
||||
For bilateral contracts, FlexReservationUpdate messages are used by DSOs
|
||||
to signal to an AGR which part of the contracted volume is still
|
||||
reserved and which part is not needed and may be used for other
|
||||
purposes. For each ISP, a power value is given which indicates how much
|
||||
power is still reserved. Zero power means that no power is reserved for
|
||||
that ISP and the sign of the power indicates the direction.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_settlement(self, message: FlexSettlement) -> None:
|
||||
"""
|
||||
The FlexSettlement message is sent by DSOs on a regular basis
|
||||
(typically monthly) to AGRs, in order to initiate settlement.
|
||||
It includes a list of all FlexOrders placed by the
|
||||
originating party during the settlement period.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_flex_offer_revocation_response(self, message: FlexOfferRevocationResponse) -> None:
|
||||
"""
|
||||
Upon receiving and processing a FlexOfferRevocation message, the
|
||||
receiving implementation must reply with a FlexOfferRevocationResponse,
|
||||
indicating whether the revocation was handled successfully.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_metering_response(self, message: MeteringResponse) -> None:
|
||||
"""
|
||||
Confirm reception of metering data.
|
||||
"""
|
||||
self._send_message(message)
|
||||
@@ -0,0 +1,25 @@
|
||||
from ..uftp import DsoPortfolioQuery, DsoPortfolioUpdate, UsefRole
|
||||
from .base_client import ShapeshifterClient
|
||||
|
||||
|
||||
class ShapeshifterDsoCroClient(ShapeshifterClient):
|
||||
"""
|
||||
Client that allows the DSO to connect to the CRO.
|
||||
"""
|
||||
|
||||
sender_role = UsefRole.DSO
|
||||
recipient_role = UsefRole.CRO
|
||||
|
||||
def send_dso_portfolio_update(self, message: DsoPortfolioUpdate) -> None:
|
||||
"""
|
||||
The DSOPortfolioUpdate is used by the DSO to indicate on which
|
||||
congestion points it wants to engage in flexibility trading.
|
||||
"""
|
||||
self._send_message(message)
|
||||
|
||||
def send_dso_portfolio_query(self, message: DsoPortfolioQuery) -> None:
|
||||
"""
|
||||
DSOPortfolioQuery is used by DSOs to discover which AGRs represent
|
||||
connections on its registered congestion point(s).
|
||||
"""
|
||||
self._send_message(message)
|
||||
Reference in New Issue
Block a user