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:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -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__]
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)