From c59e925a691f75a618a4d2a08e90760a10af3cde Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 15 Apr 2025 20:39:55 +0200 Subject: [PATCH 01/29] Small clean-up before pull Signed-off-by: Vlad Iftime --- .gitignore | 1 - src/s2python/authorization/client.py | 73 ---------------------------- 2 files changed, 74 deletions(-) diff --git a/.gitignore b/.gitignore index 3f96451..5baf340 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,3 @@ venv dist/ build/ %LOCALAPPDATA% -s2-python/src/s2python/specification/s2-pairing/s2-over-ip-pairing.yaml diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index bf76be5..e69de29 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -1,73 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any, Dict - - -class AbstractConnectionClient(ABC): - """Abstract class for handling the /requestConnection endpoint.""" - - def request_connection(self) -> Any: - """Orchestrate the connection request flow: build → execute → handle.""" - request_data = self.build_connection_request() - response_data = self.execute_connection_request(request_data) - return self.handle_connection_response(response_data) - - @abstractmethod - def build_connection_request(self) -> Dict: - """ - Build the payload for the ConnectionRequest schema. - Returns a dictionary with keys: s2ClientNodeId, supportedProtocols. - """ - pass - - @abstractmethod - def execute_connection_request(self, request_data: Dict) -> Dict: - """ - Execute the POST request to /requestConnection. - Implementations should send the request_data to the endpoint - and return the JSON response as a dictionary. - """ - pass - - @abstractmethod - def handle_connection_response(self, response_data: Dict) -> Any: - """ - Process the ConnectionDetails response (e.g., extract challenge and connection URI). - The response_data contains keys: selectedProtocol, challenge, connectionUri. - """ - pass - - -class AbstractPairingClient(ABC): - """Abstract class for handling the /requestPairing endpoint.""" - - def request_pairing(self) -> Any: - """Orchestrate the pairing request flow: build → execute → handle.""" - request_data = self.build_pairing_request() - response_data = self.execute_pairing_request(request_data) - return self.handle_pairing_response(response_data) - - @abstractmethod - def build_pairing_request(self) -> Dict: - """ - Build the payload for the PairingRequest schema. - Returns a dictionary with keys: token, publicKey, s2ClientNodeId, - s2ClientNodeDescription, supportedProtocols. - """ - pass - - @abstractmethod - def execute_pairing_request(self, request_data: Dict) -> Dict: - """ - Execute the POST request to /requestPairing. - Implementations should send the request_data to the endpoint - and return the JSON response as a dictionary. - """ - pass - - @abstractmethod - def handle_pairing_response(self, response_data: Dict) -> Any: - """ - Process the PairingResponse (e.g., extract server details). - The response_data contains keys: s2ServerNodeId, serverNodeDescription, requestConnectionUri. - """ - pass From 7355d685697a4393bb512e07403e45a368c2eefe Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 15 Apr 2025 22:57:29 +0200 Subject: [PATCH 02/29] Started the abstract class for a client using PR 85 as inspiration Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 387 +++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index e69de29..4ff553c 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -0,0 +1,387 @@ +""" +S2 protocol client for handling pairing and secure connections. +""" + +import abc +import base64 +import http.client +import json +import ssl +import uuid +import datetime +import logging +from pathlib import Path +from typing import Dict, Optional, Tuple, Union, List, Any, cast, Mapping + +# Type annotation for requests, even though stubs might be missing +import requests +from requests import Response + +import websockets.client +from jwskate import JweCompact, Jwk, Jwt, SignedJwt +from pydantic import AnyUrl, BaseModel + +from s2python.generated.gen_s2_pairing import ( + ConnectionDetails, + ConnectionRequest, + PairingRequest, + PairingResponse, + PairingToken, + S2NodeDescription, + Protocols, +) + + +REQTEST_TIMEOUT = 10 +PAIRING_TIMEOUT = datetime.timedelta(minutes=5) +KEY_ALGORITHM = "RSA-OAEP-256" + +# Set up module-level logger +logger = logging.getLogger(__name__) + + +class PairingDetails(BaseModel): + """Contains all details from the pairing process.""" + + pairing_response: PairingResponse + connection_details: ConnectionDetails + decrypted_challenge_str: Optional[str] = None + + +class S2AbstractClient(abc.ABC): + """Abstract client for handling S2 protocol pairing and connections. + + Client handles: + - HTTP client with TLS + - Storage of connection request URI + - Storage of public/private key pairs + - Challenge solving + - Websocket connection establishment + """ + + def __init__( + self, + pairing_uri: Optional[str] = None, + token: Optional[PairingToken] = None, + node_description: Optional[S2NodeDescription] = None, + verify_certificate: Union[bool, str] = False, + client_node_id: Optional[uuid.UUID] = None, + supported_protocols: Optional[List[Protocols]] = None, + ) -> None: + """Initialize the client with configuration parameters. + + Args: + pairing_uri: URI for the pairing request + token: Pairing token for authentication + node_description: S2 node description + verify_certificate: Whether to verify SSL certificates (or path to CA cert) + client_node_id: Client node UUID (generated if not provided) + supported_protocols: List of supported protocols + """ + # Connection and authentication info + self.pairing_uri = pairing_uri + self.token = token + self.node_description = node_description + self.verify_certificate = verify_certificate + self.client_node_id = client_node_id if client_node_id else uuid.uuid4() + self.supported_protocols = supported_protocols or [Protocols.WebSocketSecure] + + # Internal state + self._connection_request_uri: Optional[str] = None + self._public_key: Optional[str] = None + self._private_key: Optional[str] = None + self._key_pair: Optional[Jwk] = None + self._pairing_response: Optional[PairingResponse] = None + self._connection_details: Optional[ConnectionDetails] = None + self._pairing_details: Optional[PairingDetails] = None + + @property + def connection_request_uri(self) -> Optional[str]: + """Get the stored connection request URI.""" + return self._connection_request_uri + + def store_connection_request_uri(self, uri: str) -> None: + """Store the connection request URI. + + If the provided URI is empty, None, or doesn't contain 'requestConnection', + it will attempt to derive it from the pairing URI by replacing 'requestPairing' + with 'requestConnection'. + + Args: + uri: The connection request URI from the pairing response + """ + if uri is not None and uri.strip() != "" and "requestConnection" in uri: + self._connection_request_uri = uri + elif self.pairing_uri is not None and "requestPairing" in self.pairing_uri: + # Fall back to constructing the URI from the pairing URI + self._connection_request_uri = self.pairing_uri.replace("requestPairing", "requestConnection") + else: + # No valid URI could be determined + self._connection_request_uri = None + + def generate_key_pair(self) -> Tuple[str, str]: + """Generate a public/private key pair. + + Returns: + Tuple[str, str]: (public_key, private_key) pair as base64 encoded strings + """ + self._key_pair = Jwk.generate_for_alg(KEY_ALGORITHM).with_kid_thumbprint() + return self._key_pair.public_jwk().to_pem(), self._key_pair.private_jwk().to_pem() + + def store_key_pair(self, public_key: str, private_key: str) -> None: + """Store the public/private key pair. + + Args: + public_key: Base64 encoded public key + private_key: Base64 encoded private key + """ + self._public_key = public_key + self._private_key = private_key + # Attempt to parse the private key into a Jwk if it's not already set + if self._key_pair is None and private_key: + try: + self._key_pair = Jwk.from_pem(private_key) + except Exception as e: + logger.warning(f"Failed to parse private key as Jwk: {e}") + + def load_key_pair(self, key_file_path: Union[str, Path]) -> Tuple[str, str]: + """Load public/private key pair from file. + + Args: + key_file_path: Path to the key file + + Returns: + Tuple[str, str]: (public_key, private_key) pair + """ + # This method should be implemented in concrete subclasses + raise NotImplementedError("Subclasses must implement load_key_pair") + + def _make_https_request( + self, + url: str, + method: str = "GET", + data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[int, str]: + """Make an HTTPS request. + + Args: + url: Target URL + method: HTTP method (GET, POST, etc.) + data: Request body data + headers: HTTP headers + + Returns: + Tuple[int, str]: (status_code, response_text) + """ + # Using requests library with verification settings from instance + response: Response = requests.request( + method=method, + url=url, + json=data, + headers=headers or {"Content-Type": "application/json"}, + verify=self.verify_certificate, + timeout=REQTEST_TIMEOUT, + ) + return response.status_code, response.text + + def request_pairing(self) -> PairingResponse: + """Send a pairing request to the server using client configuration. + + Returns: + PairingResponse: The server's response to the pairing request + + Raises: + ValueError: If pairing_uri or token is not set, or if the request fails + """ + if not self.pairing_uri: + raise ValueError("Pairing URI not set. Set pairing_uri before calling request_pairing.") + + if not self.token: + raise ValueError("Pairing token not set. Set token before calling request_pairing.") + + # Ensure we have keys + if not self._public_key: + public_key, private_key = self.generate_key_pair() + self.store_key_pair(public_key, private_key) + + # Create pairing request + pairing_request = PairingRequest( + token=self.token, + publicKey=self._public_key, + s2ClientNodeId=self.client_node_id, + s2ClientNodeDescription=self.node_description, + supportedProtocols=self.supported_protocols, + ) + + # Make request using requests directly + response: Response = requests.post( + url=self.pairing_uri, + json=pairing_request.model_dump(exclude_none=True), + verify=self.verify_certificate, + timeout=REQTEST_TIMEOUT, + ) + + # Parse response + if response.status_code != 200: + raise ValueError(f"Pairing request failed with status {response.status_code}: {response.text}") + + pairing_response = PairingResponse.model_validate(response.json()) + + # Store for later use + self._pairing_response = pairing_response + self.store_connection_request_uri(str(pairing_response.requestConnectionUri)) + + return pairing_response + + def request_connection(self) -> ConnectionDetails: + """Request connection details from the server. + + Returns: + ConnectionDetails: The connection details returned by the server + + Raises: + ValueError: If connection request URI is not set or if the request fails + """ + if not self._connection_request_uri: + raise ValueError("Connection request URI not set. Call request_pairing first.") + + # Create connection request + connection_request = ConnectionRequest( + s2ClientNodeId=self.client_node_id, + supportedProtocols=self.supported_protocols, + ) + + # Make request + response: Response = requests.post( + url=self._connection_request_uri, + json=connection_request.model_dump(exclude_none=True), + verify=self.verify_certificate, + timeout=REQTEST_TIMEOUT, + ) + + # Parse response + if response.status_code != 200: + raise ValueError(f"Connection request failed with status {response.status_code}: {response.text}") + + connection_details = ConnectionDetails.model_validate(response.json()) + + # Handle relative WebSocket URI paths + if ( + connection_details.connectionUri is not None + and not str(connection_details.connectionUri).startswith("ws://") + and not str(connection_details.connectionUri).startswith("wss://") + ): + + # If websocket address doesn't start with ws:// or wss:// assume it's relative to the pairing URI + if self.pairing_uri: + base_uri = self.pairing_uri + # Convert to WebSocket protocol and remove the requestPairing path + ws_base = ( + base_uri.replace("http://", "ws://") + .replace("https://", "wss://") + .replace("requestPairing", "") + .rstrip("/") + ) + + # Combine with the relative path from connectionUri + relative_path = str(connection_details.connectionUri).lstrip("/") + + # Create complete URL + full_ws_url = f"{ws_base}/{relative_path}" + + try: + # Update the connection details with the new URL + connection_data = connection_details.model_dump() + # Replace the URI with the full WebSocket URL + connection_data["connectionUri"] = full_ws_url + # Recreate the ConnectionDetails object + connection_details = ConnectionDetails.model_validate(connection_data) + logger.debug(f"Updated relative WebSocket URI to absolute: {full_ws_url}") + except Exception as e: + logger.warning(f"Failed to update WebSocket URI: {e}") + else: + # Log a warning but don't modify the URI if we can't create a proper absolute URI + logger.warning( + "Received relative WebSocket URI but pairing_uri is not available to create absolute URL" + ) + + # Store for later use + self._connection_details = connection_details + + return connection_details + + def solve_challenge(self, challenge: Optional[str] = None) -> str: + """Solve the connection challenge using the public key. + + If no challenge is provided, uses the challenge from connection_details. + + The challenge is a JWE (JSON Web Encryption) that must be decrypted using + the client's public key, then encoded as a base64 string. + + Args: + challenge: The challenge string from the server (optional) + + Returns: + str: The solution to the challenge (base64 encoded decrypted challenge) + + Raises: + ValueError: If no challenge is provided and connection_details is not set + ValueError: If the public key is not available + RuntimeError: If challenge decryption fails + """ + if challenge is None: + if not self._connection_details or not self._connection_details.challenge: + raise ValueError("Challenge not provided and not available in connection details") + challenge = self._connection_details.challenge + + if not self._key_pair and not self._public_key: + raise ValueError("Public key is not available. Generate or load a key pair first.") + + try: + # If we have a jwskate Jwk object, use it directly + if self._key_pair: + rsa_key_pair = self._key_pair + # Otherwise try to parse the public key + elif self._public_key: + rsa_key_pair = Jwk.from_pem(self._public_key) + else: + raise ValueError("No public key available") + + # Decrypt the JWE challenge - get result as bytes and convert to string + jwe_compact = JweCompact(challenge) + decrypted_bytes = jwe_compact.decrypt(rsa_key_pair) + # Make sure we have a proper string + if hasattr(decrypted_bytes, "decode"): + decrypted_string = decrypted_bytes.decode("utf-8") + else: + decrypted_string = str(decrypted_bytes) + + # Parse the JSON payload + challenge_mapping: Mapping[str, Any] = json.loads(decrypted_string) + + # Create an unprotected JWT from the challenge + jwt_token = Jwt.unprotected(challenge_mapping) + jwt_token_str = str(jwt_token) + + # Encode the token as base64 + decrypted_challenge_str: str = base64.b64encode(jwt_token_str.encode("utf-8")).decode("utf-8") + + # Store the pairing details if we have all required components + if self._pairing_response and self._connection_details: + self._pairing_details = PairingDetails( + pairing_response=self._pairing_response, + connection_details=self._connection_details, + decrypted_challenge_str=decrypted_challenge_str, + ) + + print(f"Decrypted challenge: {decrypted_challenge_str}") + return decrypted_challenge_str + + except Exception as e: + error_msg = f"Failed to solve challenge: {e}" + print(error_msg) + raise RuntimeError(error_msg) from e + + + From af98f84705782e427f0c1e9ed6c3357105a800df Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 15 Apr 2025 23:17:53 +0200 Subject: [PATCH 03/29] Chore: Fixing tests Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 4ff553c..a13460f 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -4,22 +4,19 @@ import abc import base64 -import http.client import json -import ssl import uuid import datetime import logging from pathlib import Path -from typing import Dict, Optional, Tuple, Union, List, Any, cast, Mapping +from typing import Dict, Optional, Tuple, Union, List, Any, Mapping # Type annotation for requests, even though stubs might be missing import requests from requests import Response -import websockets.client -from jwskate import JweCompact, Jwk, Jwt, SignedJwt -from pydantic import AnyUrl, BaseModel +from jwskate import JweCompact, Jwk, Jwt +from pydantic import BaseModel from s2python.generated.gen_s2_pairing import ( ConnectionDetails, @@ -56,9 +53,9 @@ class S2AbstractClient(abc.ABC): - Storage of connection request URI - Storage of public/private key pairs - Challenge solving - - Websocket connection establishment """ + # pylint: disable=too-many-instance-attributes def __init__( self, pairing_uri: Optional[str] = None, @@ -141,8 +138,8 @@ def store_key_pair(self, public_key: str, private_key: str) -> None: if self._key_pair is None and private_key: try: self._key_pair = Jwk.from_pem(private_key) - except Exception as e: - logger.warning(f"Failed to parse private key as Jwk: {e}") + except (ValueError, TypeError, KeyError) as e: + logger.warning("Failed to parse private key as Jwk: %s", e) def load_key_pair(self, key_file_path: Union[str, Path]) -> Tuple[str, str]: """Load public/private key pair from file. @@ -297,9 +294,9 @@ def request_connection(self) -> ConnectionDetails: connection_data["connectionUri"] = full_ws_url # Recreate the ConnectionDetails object connection_details = ConnectionDetails.model_validate(connection_data) - logger.debug(f"Updated relative WebSocket URI to absolute: {full_ws_url}") - except Exception as e: - logger.warning(f"Failed to update WebSocket URI: {e}") + logger.debug("Updated relative WebSocket URI to absolute: %s", full_ws_url) + except (ValueError, TypeError, KeyError) as e: + logger.warning("Failed to update WebSocket URI: %s", e) else: # Log a warning but don't modify the URI if we can't create a proper absolute URI logger.warning( @@ -378,10 +375,7 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: print(f"Decrypted challenge: {decrypted_challenge_str}") return decrypted_challenge_str - except Exception as e: + except (ValueError, TypeError, KeyError, json.JSONDecodeError) as e: error_msg = f"Failed to solve challenge: {e}" print(error_msg) raise RuntimeError(error_msg) from e - - - From 54b246609580cd8aa0e895d7855d22b8386eae39 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 15 Apr 2025 23:26:17 +0200 Subject: [PATCH 04/29] Chore: Fixing tests added extra dependencies Signed-off-by: Vlad Iftime --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 239a3de..c23a404 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,8 @@ classifiers = [ [project.optional-dependencies] ws = [ "websockets~=13.1", + "jwskate~=0.11", + "requests~=2.32.3", ] fastapi = [ "fastapi", From cc3da10b29a59255d3b9f7101cffaf8b86dc0f78 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 15 Apr 2025 23:27:31 +0200 Subject: [PATCH 05/29] Chore: Fixing tests Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index a13460f..1adcbf9 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -7,7 +7,6 @@ import json import uuid import datetime -import logging from pathlib import Path from typing import Dict, Optional, Tuple, Union, List, Any, Mapping @@ -33,9 +32,6 @@ PAIRING_TIMEOUT = datetime.timedelta(minutes=5) KEY_ALGORITHM = "RSA-OAEP-256" -# Set up module-level logger -logger = logging.getLogger(__name__) - class PairingDetails(BaseModel): """Contains all details from the pairing process.""" @@ -56,7 +52,8 @@ class S2AbstractClient(abc.ABC): """ # pylint: disable=too-many-instance-attributes - def __init__( + # pylint: disable=too-many-arguments + def __init__( self, pairing_uri: Optional[str] = None, token: Optional[PairingToken] = None, @@ -139,7 +136,7 @@ def store_key_pair(self, public_key: str, private_key: str) -> None: try: self._key_pair = Jwk.from_pem(private_key) except (ValueError, TypeError, KeyError) as e: - logger.warning("Failed to parse private key as Jwk: %s", e) + print(f"Failed to parse private key as Jwk: {e}") def load_key_pair(self, key_file_path: Union[str, Path]) -> Tuple[str, str]: """Load public/private key pair from file. @@ -294,12 +291,12 @@ def request_connection(self) -> ConnectionDetails: connection_data["connectionUri"] = full_ws_url # Recreate the ConnectionDetails object connection_details = ConnectionDetails.model_validate(connection_data) - logger.debug("Updated relative WebSocket URI to absolute: %s", full_ws_url) + print(f"Updated relative WebSocket URI to absolute: {full_ws_url}") except (ValueError, TypeError, KeyError) as e: - logger.warning("Failed to update WebSocket URI: %s", e) + print(f"Failed to update WebSocket URI: {e}") else: # Log a warning but don't modify the URI if we can't create a proper absolute URI - logger.warning( + print( "Received relative WebSocket URI but pairing_uri is not available to create absolute URL" ) From 53d4cb19c1b8ad01ab9a49c044222f2a72f2bb41 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 15 Apr 2025 23:30:17 +0200 Subject: [PATCH 06/29] Chore: Fixing tests Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 1adcbf9..2f317ed 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -53,7 +53,7 @@ class S2AbstractClient(abc.ABC): # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-arguments - def __init__( + def __init__( self, pairing_uri: Optional[str] = None, token: Optional[PairingToken] = None, From c7bce1b65cf7809c58350031e711f9dbd3d3df77 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Fri, 18 Apr 2025 12:57:06 +0200 Subject: [PATCH 07/29] Added a mock server for testing only Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 49 +- .../communication/examples/example_frbc_rm.py | 204 ++++++ .../examples/example_pairing_frbc_rm.py | 96 +++ .../communication/examples/mock_s2_server.py | 126 ++++ .../examples/mock_s2_websocket.py | 83 +++ src/s2python/generated/gen_s2_pairing.py | 108 ++-- src/s2python/reception_status_awaiter.py | 60 -- src/s2python/s2_connection.py | 587 ------------------ src/s2python/s2_control_type.py | 2 +- tests/unit/reception_status_awaiter_test.py | 2 +- 10 files changed, 596 insertions(+), 721 deletions(-) create mode 100644 src/s2python/communication/examples/example_frbc_rm.py create mode 100644 src/s2python/communication/examples/example_pairing_frbc_rm.py create mode 100644 src/s2python/communication/examples/mock_s2_server.py create mode 100644 src/s2python/communication/examples/mock_s2_websocket.py delete mode 100644 src/s2python/reception_status_awaiter.py delete mode 100644 src/s2python/s2_connection.py diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 2f317ed..93c9e55 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -84,6 +84,8 @@ def __init__( self._connection_request_uri: Optional[str] = None self._public_key: Optional[str] = None self._private_key: Optional[str] = None + self._public_jwk: Optional[Jwk] = None + self._private_jwk: Optional[Jwk] = None self._key_pair: Optional[Jwk] = None self._pairing_response: Optional[PairingResponse] = None self._connection_details: Optional[ConnectionDetails] = None @@ -119,36 +121,25 @@ def generate_key_pair(self) -> Tuple[str, str]: Returns: Tuple[str, str]: (public_key, private_key) pair as base64 encoded strings """ + print("Generating key pair") self._key_pair = Jwk.generate_for_alg(KEY_ALGORITHM).with_kid_thumbprint() - return self._key_pair.public_jwk().to_pem(), self._key_pair.private_jwk().to_pem() + self._public_jwk = self._key_pair + self._private_jwk = self._key_pair + return ( + self._public_jwk.to_pem(), + self._private_jwk.to_pem(), + ) def store_key_pair(self, public_key: str, private_key: str) -> None: """Store the public/private key pair. - + #! TODO: Use Sqlite3 to store the key pair Args: public_key: Base64 encoded public key private_key: Base64 encoded private key """ + print("Storing key pair") self._public_key = public_key self._private_key = private_key - # Attempt to parse the private key into a Jwk if it's not already set - if self._key_pair is None and private_key: - try: - self._key_pair = Jwk.from_pem(private_key) - except (ValueError, TypeError, KeyError) as e: - print(f"Failed to parse private key as Jwk: {e}") - - def load_key_pair(self, key_file_path: Union[str, Path]) -> Tuple[str, str]: - """Load public/private key pair from file. - - Args: - key_file_path: Path to the key file - - Returns: - Tuple[str, str]: (public_key, private_key) pair - """ - # This method should be implemented in concrete subclasses - raise NotImplementedError("Subclasses must implement load_key_pair") def _make_https_request( self, @@ -200,21 +191,24 @@ def request_pairing(self) -> PairingResponse: self.store_key_pair(public_key, private_key) # Create pairing request + print("Creating pairing request") pairing_request = PairingRequest( token=self.token, publicKey=self._public_key, - s2ClientNodeId=self.client_node_id, + s2ClientNodeId=str(self.client_node_id), s2ClientNodeDescription=self.node_description, supportedProtocols=self.supported_protocols, ) - # Make request using requests directly + # Make pairing request + print("Making pairing request") response: Response = requests.post( url=self.pairing_uri, json=pairing_request.model_dump(exclude_none=True), verify=self.verify_certificate, timeout=REQTEST_TIMEOUT, ) + print(f"Pairing request response: {response.status_code} {response.text}") # Parse response if response.status_code != 200: @@ -242,14 +236,17 @@ def request_connection(self) -> ConnectionDetails: # Create connection request connection_request = ConnectionRequest( - s2ClientNodeId=self.client_node_id, + s2ClientNodeId=self.client_node_id, # Will be converted to string by model_dump supportedProtocols=self.supported_protocols, ) + # Dump the model to JSON, handling UUID conversion + json_connection_request = connection_request.model_dump(exclude_none=True) + # Make request response: Response = requests.post( url=self._connection_request_uri, - json=connection_request.model_dump(exclude_none=True), + json=json_connection_request, verify=self.verify_certificate, timeout=REQTEST_TIMEOUT, ) @@ -296,9 +293,7 @@ def request_connection(self) -> ConnectionDetails: print(f"Failed to update WebSocket URI: {e}") else: # Log a warning but don't modify the URI if we can't create a proper absolute URI - print( - "Received relative WebSocket URI but pairing_uri is not available to create absolute URL" - ) + print("Received relative WebSocket URI but pairing_uri is not available to create absolute URL") # Store for later use self._connection_details = connection_details diff --git a/src/s2python/communication/examples/example_frbc_rm.py b/src/s2python/communication/examples/example_frbc_rm.py new file mode 100644 index 0000000..7b2b6ff --- /dev/null +++ b/src/s2python/communication/examples/example_frbc_rm.py @@ -0,0 +1,204 @@ +import argparse +from functools import partial +import logging +import sys +import uuid +import signal +import datetime +from typing import Any, Callable, Optional + +from s2python.common import ( + EnergyManagementRole, + Duration, + Role, + RoleType, + Commodity, + Currency, + NumberRange, + PowerRange, + CommodityQuantity, +) +from s2python.frbc import ( + FRBCInstruction, + FRBCSystemDescription, + FRBCActuatorDescription, + FRBCStorageDescription, + FRBCOperationMode, + FRBCOperationModeElement, + FRBCFillLevelTargetProfile, + FRBCFillLevelTargetProfileElement, + FRBCStorageStatus, + FRBCActuatorStatus, +) +from s2python.communication.s2_connection import S2Connection, AssetDetails +from s2python.s2_control_type import FRBCControlType, NoControlControlType +from s2python.message import S2Message + +logger = logging.getLogger("s2python") +logger.addHandler(logging.StreamHandler(sys.stdout)) +logger.setLevel(logging.DEBUG) + + +class MyFRBCControlType(FRBCControlType): + def handle_instruction( + self, conn: S2Connection, msg: S2Message, send_okay: Callable[[], None] + ) -> None: + if not isinstance(msg, FRBCInstruction): + raise RuntimeError( + f"Expected an FRBCInstruction but received a message of type {type(msg)}." + ) + print(f"I have received the message {msg} from {conn}") + + def activate(self, conn: S2Connection) -> None: + print("The control type FRBC is now activated.") + + print("Time to send a FRBC SystemDescription") + actuator_id = uuid.uuid4() + operation_mode_id = uuid.uuid4() + conn.send_msg_and_await_reception_status_sync( + FRBCSystemDescription( + message_id=uuid.uuid4(), + valid_from=datetime.datetime.now(tz=datetime.timezone.utc), + actuators=[ + FRBCActuatorDescription( + id=actuator_id, + operation_modes=[ + FRBCOperationMode( + id=operation_mode_id, + elements=[ + FRBCOperationModeElement( + fill_level_range=NumberRange( + start_of_range=0.0, end_of_range=100.0 + ), + fill_rate=NumberRange( + start_of_range=-5.0, end_of_range=5.0 + ), + power_ranges=[ + PowerRange( + start_of_range=-200.0, + end_of_range=200.0, + commodity_quantity=CommodityQuantity.ELECTRIC_POWER_L1, + ) + ], + ) + ], + diagnostic_label="Load & unload battery", + abnormal_condition_only=False, + ) + ], + transitions=[], + timers=[], + supported_commodities=[Commodity.ELECTRICITY], + ) + ], + storage=FRBCStorageDescription( + fill_level_range=NumberRange( + start_of_range=0.0, end_of_range=100.0 + ), + fill_level_label="%", + diagnostic_label="Imaginary battery", + provides_fill_level_target_profile=True, + provides_leakage_behaviour=False, + provides_usage_forecast=False, + ), + ) + ) + print("Also send the target profile") + + conn.send_msg_and_await_reception_status_sync( + FRBCFillLevelTargetProfile( + message_id=uuid.uuid4(), + start_time=datetime.datetime.now(tz=datetime.timezone.utc), + elements=[ + FRBCFillLevelTargetProfileElement( + duration=Duration.from_milliseconds(30_000), + fill_level_range=NumberRange( + start_of_range=20.0, end_of_range=30.0 + ), + ), + FRBCFillLevelTargetProfileElement( + duration=Duration.from_milliseconds(300_000), + fill_level_range=NumberRange( + start_of_range=40.0, end_of_range=50.0 + ), + ), + ], + ) + ) + + print("Also send the storage status.") + conn.send_msg_and_await_reception_status_sync( + FRBCStorageStatus(message_id=uuid.uuid4(), present_fill_level=10.0) + ) + + print("Also send the actuator status.") + conn.send_msg_and_await_reception_status_sync( + FRBCActuatorStatus( + message_id=uuid.uuid4(), + actuator_id=actuator_id, + active_operation_mode_id=operation_mode_id, + operation_mode_factor=0.5, + ) + ) + + def deactivate(self, conn: S2Connection) -> None: + print("The control type FRBC is now deactivated.") + + +class MyNoControlControlType(NoControlControlType): + def activate(self, conn: S2Connection) -> None: + print("The control type NoControl is now activated.") + + def deactivate(self, conn: S2Connection) -> None: + print("The control type NoControl is now deactivated.") + + +def stop( + s2_connection: S2Connection, signal_num: int, _current_stack_frame: Any +) -> None: + print(f"Received signal {signal_num}. Will stop S2 connection.") + s2_connection.stop() + + +def start_s2_session( + url: str, + client_node_id: uuid.UUID = uuid.uuid4(), + bearer_token: Optional[str] = None, +) -> None: + s2_conn = S2Connection( + url=url, + role=EnergyManagementRole.RM, + control_types=[MyFRBCControlType(), MyNoControlControlType()], + asset_details=AssetDetails( + resource_id=client_node_id, + name="Some asset", + instruction_processing_delay=Duration.from_milliseconds(20), + roles=[ + Role(role=RoleType.ENERGY_CONSUMER, commodity=Commodity.ELECTRICITY) + ], + currency=Currency.EUR, + provides_forecast=False, + provides_power_measurements=[CommodityQuantity.ELECTRIC_POWER_L1], + ), + reconnect=True, + verify_certificate=False, + bearer_token=bearer_token, + ) + signal.signal(signal.SIGINT, partial(stop, s2_conn)) + signal.signal(signal.SIGTERM, partial(stop, s2_conn)) + + s2_conn.start_as_rm() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="A simple S2 reseource manager example." + ) + parser.add_argument( + "endpoint", + type=str, + help="WebSocket endpoint uri for the server (CEM) e.g. ws://localhost:8080/websocket/s2/my-first-websocket-rm", + ) + args = parser.parse_args() + + start_s2_session(args.endpoint) diff --git a/src/s2python/communication/examples/example_pairing_frbc_rm.py b/src/s2python/communication/examples/example_pairing_frbc_rm.py new file mode 100644 index 0000000..3e40829 --- /dev/null +++ b/src/s2python/communication/examples/example_pairing_frbc_rm.py @@ -0,0 +1,96 @@ +import argparse +import uuid +import logging +import ssl +from typing import Optional + +from s2python.communication.examples.example_frbc_rm import start_s2_session +from s2python.authorization.client import S2AbstractClient +from s2python.generated.gen_s2_pairing import ( + S2NodeDescription, + Deployment, + PairingToken, + S2Role, +) + +logger = logging.getLogger("s2python") + + +class S2PairingClient(S2AbstractClient): + """Implementation of S2AbstractClient for pairing example.""" + + def solve_challenge(self, challenge: Optional[str] = None) -> str: + """Solve the challenge using the key pair.""" + return super().solve_challenge(challenge) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="A simple S2 resource manager example." + ) + parser.add_argument( + "--endpoint", + type=str, + help="Rest endpoint to start S2 pairing. E.g. https://localhost/requestPairing", + ) + parser.add_argument( + "--pairing_token", + type=str, + help="The pairing token for the endpoint. You should get this from the S2 server e.g. ca14fda4", + ) + parser.add_argument( + "--verify-ssl", + action="store_true", + help="Verify SSL certificates (default: False)", + default=False, + ) + args = parser.parse_args() + + # Configure logging + logging.basicConfig(level=logging.INFO) + + # Create node description + node_description = S2NodeDescription( + brand="TNO", + logoUri="https://www.tno.nl/publish/pages/5604/tno-logo-1484x835_003_.jpg", + type="demo frbc example", + modelName="S2 pairing example stub", + userDefinedName="TNO S2 pairing example for frbc", + role=S2Role.RM, + deployment=Deployment.LAN, + ) + + # Create a client to perform the pairing + client = S2PairingClient( + pairing_uri=args.endpoint, + token=PairingToken(token=args.pairing_token, ), + node_description=node_description, + verify_certificate=args.verify_ssl, + ) + + try: + # Request pairing + logger.info("Initiating pairing with endpoint: %s", args.endpoint) + pairing_response = client.request_pairing() + logger.info("Pairing request successful, requesting connection...") + + # Request connection details + connection_details = client.request_connection() + logger.info("Connection request successful") + + # Solve challenge + challenge_result = client.solve_challenge() + logger.info("Challenge solved successfully") + + # Log connection details + logger.info("Connection URI: %s", connection_details.connectionUri) + + # Start S2 session with the connection details + logger.info("Starting S2 session...") + start_s2_session( + str(connection_details.connectionUri), + bearer_token=challenge_result, + ) + + except Exception as e: + logger.error("Error during pairing process: %s", e) diff --git a/src/s2python/communication/examples/mock_s2_server.py b/src/s2python/communication/examples/mock_s2_server.py new file mode 100644 index 0000000..21c9177 --- /dev/null +++ b/src/s2python/communication/examples/mock_s2_server.py @@ -0,0 +1,126 @@ +import http.server +import socketserver +import json +from typing import Any +import uuid +from urllib.parse import urlparse, parse_qs +import ssl +import threading +import logging +import random +import string + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("mock_s2_server") + + +def generate_token() -> str: + """ + Generate a random alphanumeric token with exactly 32 characters. + + Returns: + str: A string of 32 random alphanumeric characters matching pattern ^[0-9a-zA-Z]{32}$ + """ + # Define the character set: uppercase letters, lowercase letters, and digits + chars = string.ascii_letters + string.digits + + # Generate a 32-character token by randomly selecting from the character set + token = "".join(random.choice(chars) for _ in range(32)) + + return token + + +# Generate random token for pairing +PAIRING_TOKEN = generate_token() +SERVER_NODE_ID = str(uuid.uuid4()) +WS_PORT = 8080 +HTTP_PORT = 8000 + + +class MockS2Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: + content_length = int(self.headers.get("Content-Length", 0)) + post_data = self.rfile.read(content_length).decode("utf-8") + + try: + request_json = json.loads(post_data) + logger.info(f"Received request at {self.path}: {request_json}") + + if self.path == "/requestPairing": + # Handle pairing request + if request_json.get("token") == PAIRING_TOKEN: + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + + # Create pairing response + response = { + "s2ServerNodeId": SERVER_NODE_ID, + "serverNodeDescription": { + "brand": "Mock S2 Server", + "type": "Test Server", + "modelName": "Mock Model", + "logoUri": "http://example.com/logo.png", + "userDefinedName": "Mock Server", + "role": "CEM", + "deployment": "LAN", + }, + "requestConnectionUri": f"http://localhost:{HTTP_PORT}/requestConnection", + } + + self.wfile.write(json.dumps(response).encode()) + logger.info("Pairing request successful") + else: + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "Invalid token"}).encode()) + logger.error("Invalid pairing token") + + elif self.path == "/requestConnection": + # Handle connection request + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + + # Create challenge (normally would be a JWE) + challenge = "mock_challenge_string" + + # Create connection details response + response = { + "connectionUri": f"ws://localhost:{WS_PORT}/s2/mock-websocket", + "challenge": challenge, + } + + self.wfile.write(json.dumps(response).encode()) + logger.info("Connection request successful") + + else: + self.send_response(404) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": "Endpoint not found"}).encode()) + logger.error(f"Unknown endpoint: {self.path}") + + except Exception as e: + self.send_response(500) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"error": str(e)}).encode()) + logger.error(f"Error handling request: {e}") + + def log_message(self, format: str, *args: Any) -> None: + logger.info(format % args) + + +def run_server() -> None: + with socketserver.TCPServer(("localhost", HTTP_PORT), MockS2Handler) as httpd: + logger.info(f"Mock S2 Server running at http://localhost:{HTTP_PORT}") + logger.info(f"Use pairing token: {PAIRING_TOKEN}") + logger.info(f"Pairing endpoint: http://localhost:{HTTP_PORT}/requestPairing") + httpd.serve_forever() + + +if __name__ == "__main__": + run_server() diff --git a/src/s2python/communication/examples/mock_s2_websocket.py b/src/s2python/communication/examples/mock_s2_websocket.py new file mode 100644 index 0000000..8172d1a --- /dev/null +++ b/src/s2python/communication/examples/mock_s2_websocket.py @@ -0,0 +1,83 @@ +import asyncio +import websockets +import logging +import json +import uuid +from datetime import datetime, timezone + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("mock_s2_websocket") + +# WebSocket server port +WS_PORT = 8080 + + +# Handle client connection +async def handle_connection( + websocket: websockets.WebSocketServerProtocol, path: str +) -> None: + client_id = str(uuid.uuid4()) + logger.info(f"Client {client_id} connected on path: {path}") + + try: + # Send handshake message to client + handshake = { + "type": "Handshake", + "messageId": str(uuid.uuid4()), + "protocolVersion": "0.0.2-beta", + "timestamp": datetime.now(timezone.utc).isoformat(), + } + await websocket.send(json.dumps(handshake)) + logger.info(f"Sent handshake to client {client_id}") + + # Listen for messages + async for message in websocket: + try: + data = json.loads(message) + logger.info(f"Received message from client {client_id}: {data}") + + # Extract message type + message_type = data.get("type", "") + message_id = data.get("messageId", str(uuid.uuid4())) + + # Send reception status + reception_status = { + "type": "ReceptionStatus", + "messageId": str(uuid.uuid4()), + "refMessageId": message_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "status": "OK", + } + await websocket.send(json.dumps(reception_status)) + logger.info(f"Sent reception status for message {message_id}") + + # Handle specific message types + if message_type == "HandshakeResponse": + logger.info("Received handshake response") + + # For FRBC messages, you could add specific handling here + + except json.JSONDecodeError: + logger.error(f"Invalid JSON received from client {client_id}") + except Exception as e: + logger.error(f"Error processing message from client {client_id}: {e}") + + except websockets.exceptions.ConnectionClosed: + logger.info(f"Connection with client {client_id} closed") + except Exception as e: + logger.error(f"Error with client {client_id}: {e}") + finally: + logger.info(f"Client {client_id} disconnected") + + +async def start_server() -> None: + server = await websockets.serve(handle_connection, "localhost", WS_PORT) + logger.info(f"WebSocket server started on ws://localhost:{WS_PORT}") + + # Keep the server running + await server.wait_closed() + + +if __name__ == "__main__": + asyncio.run(start_server()) diff --git a/src/s2python/generated/gen_s2_pairing.py b/src/s2python/generated/gen_s2_pairing.py index 15a6b05..1cd2c06 100644 --- a/src/s2python/generated/gen_s2_pairing.py +++ b/src/s2python/generated/gen_s2_pairing.py @@ -1,70 +1,88 @@ # generated by datamodel-codegen: -# filename: s2-over-ip-pairing.yaml -# timestamp: 2025-04-15T14:41:29+00:00 +# filename: s2-over-ip-pairing +# timestamp: 2025-02-28T14:52:45+00:00 from __future__ import annotations from enum import Enum -from typing import List, Optional -from uuid import UUID +from typing import List -from pydantic import AnyUrl, AwareDatetime, BaseModel, RootModel, constr +from pydantic import BaseModel, ConfigDict, Field -class Protocols(Enum): - WebSocketSecure = 'WebSocketSecure' +class S2Role(str, Enum): + CEM = "CEM" + RM = "RM" -class S2Role(Enum): - CEM = 'CEM' - RM = 'RM' +class Deployment(str, Enum): + WAN = "WAN" + LAN = "LAN" -class Deployment(Enum): - WAN = 'WAN' - LAN = 'LAN' - - -class PairingToken(RootModel[constr(pattern=r'^[0-9a-zA-Z]{32}$')]): - root: constr(pattern=r'^[0-9a-zA-Z]{32}$') +class Protocols(str, Enum): + WebSocketSecure = "WebSocketSecure" +class PairingToken(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + token: str class PairingInfo(BaseModel): - pairingUri: Optional[AnyUrl] = None - token: Optional[PairingToken] = None - validUntil: Optional[AwareDatetime] = None + model_config = ConfigDict( + extra="forbid", + ) + pairingUri: str + token: str + validUntil: str -class ConnectionRequest(BaseModel): - s2ClientNodeId: Optional[UUID] = None - supportedProtocols: Optional[List[Protocols]] = None +class S2NodeDescription(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + brand: str + logoUri: str + type: str + modelName: str + userDefinedName: str + role: S2Role + deployment: Deployment -class ConnectionDetails(BaseModel): - selectedProtocol: Optional[Protocols] = None - challenge: Optional[str] = None - connectionUri: Optional[AnyUrl] = None +class PairingRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + token: PairingToken + publicKey: str + s2ClientNodeId: str + s2ClientNodeDescription: S2NodeDescription + supportedProtocols: List[Protocols] -class S2NodeDescription(BaseModel): - brand: Optional[str] = None - logoUri: Optional[AnyUrl] = None - type: Optional[str] = None - modelName: Optional[str] = None - userDefinedName: Optional[str] = None - role: Optional[S2Role] = None - deployment: Optional[Deployment] = None +class PairingResponse(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + s2ServerNodeId: str + serverNodeDescription: S2NodeDescription + requestConnectionUri: str -class PairingRequest(BaseModel): - token: Optional[PairingToken] = None - publicKey: Optional[str] = None - s2ClientNodeId: Optional[UUID] = None - s2ClientNodeDescription: Optional[S2NodeDescription] = None - supportedProtocols: Optional[List[Protocols]] = None +class ConnectionRequest(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + s2ClientNodeId: str + supportedProtocols: List[Protocols] -class PairingResponse(BaseModel): - s2ServerNodeId: Optional[UUID] = None - serverNodeDescription: Optional[S2NodeDescription] = None - requestConnectionUri: Optional[AnyUrl] = None +class ConnectionDetails(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + selectedProtocol: Protocols + challenge: str + connectionUri: str diff --git a/src/s2python/reception_status_awaiter.py b/src/s2python/reception_status_awaiter.py deleted file mode 100644 index 5c4bd42..0000000 --- a/src/s2python/reception_status_awaiter.py +++ /dev/null @@ -1,60 +0,0 @@ -"""ReceptationStatusAwaiter class which notifies any coroutine waiting for a certain reception status message. - -Copied from -https://github.com/flexiblepower/s2-analyzer/blob/main/backend/s2_analyzer_backend/reception_status_awaiter.py under -Apache2 license on 31-08-2024. -""" - -import asyncio -import uuid -from typing import Dict - -from s2python.common import ReceptionStatus - - -class ReceptionStatusAwaiter: - received: Dict[uuid.UUID, ReceptionStatus] - awaiting: Dict[uuid.UUID, asyncio.Event] - - def __init__(self) -> None: - self.received = {} - self.awaiting = {} - - async def wait_for_reception_status( - self, message_id: uuid.UUID, timeout_reception_status: float - ) -> ReceptionStatus: - if message_id in self.received: - reception_status = self.received[message_id] - else: - if message_id in self.awaiting: - received_event = self.awaiting[message_id] - else: - received_event = asyncio.Event() - self.awaiting[message_id] = received_event - - await asyncio.wait_for(received_event.wait(), timeout_reception_status) - reception_status = self.received[message_id] - - if message_id in self.awaiting: - del self.awaiting[message_id] - - return reception_status - - async def receive_reception_status(self, reception_status: ReceptionStatus) -> None: - if not isinstance(reception_status, ReceptionStatus): - raise RuntimeError( - f"Expected a ReceptionStatus but received message {reception_status}" - ) - - if reception_status.subject_message_id in self.received: - raise RuntimeError( - f"ReceptationStatus for message_subject_id {reception_status.subject_message_id} has already " - f"been received!" - ) - - self.received[reception_status.subject_message_id] = reception_status - awaiting = self.awaiting.get(reception_status.subject_message_id) - - if awaiting: - awaiting.set() - del self.awaiting[reception_status.subject_message_id] diff --git a/src/s2python/s2_connection.py b/src/s2python/s2_connection.py deleted file mode 100644 index efbd366..0000000 --- a/src/s2python/s2_connection.py +++ /dev/null @@ -1,587 +0,0 @@ -try: - import websockets -except ImportError as exc: - raise ImportError( - "The 'websockets' package is required. Run 'pip install s2-python[ws]' to use this feature." - ) from exc - -import asyncio -import json -import logging -import time -import threading -import uuid -import ssl -from dataclasses import dataclass -from typing import Any, Optional, List, Type, Dict, Callable, Awaitable, Union - -from websockets.asyncio.client import ( - ClientConnection as WSConnection, - connect as ws_connect, -) - -from s2python.common import ( - ReceptionStatusValues, - ReceptionStatus, - Handshake, - EnergyManagementRole, - Role, - HandshakeResponse, - ResourceManagerDetails, - Duration, - Currency, - SelectControlType, -) -from s2python.generated.gen_s2 import CommodityQuantity -from s2python.reception_status_awaiter import ReceptionStatusAwaiter -from s2python.s2_control_type import S2ControlType -from s2python.s2_parser import S2Parser -from s2python.s2_validation_error import S2ValidationError -from s2python.message import S2Message -from s2python.version import S2_VERSION - -logger = logging.getLogger("s2python") - - -@dataclass -class AssetDetails: # pylint: disable=too-many-instance-attributes - resource_id: uuid.UUID - - provides_forecast: bool - provides_power_measurements: List[CommodityQuantity] - - instruction_processing_delay: Duration - roles: List[Role] - currency: Optional[Currency] = None - - name: Optional[str] = None - manufacturer: Optional[str] = None - model: Optional[str] = None - firmware_version: Optional[str] = None - serial_number: Optional[str] = None - - def to_resource_manager_details( - self, control_types: List[S2ControlType] - ) -> ResourceManagerDetails: - return ResourceManagerDetails( - available_control_types=[ - control_type.get_protocol_control_type() - for control_type in control_types - ], - currency=self.currency, - firmware_version=self.firmware_version, - instruction_processing_delay=self.instruction_processing_delay, - manufacturer=self.manufacturer, - message_id=uuid.uuid4(), - model=self.model, - name=self.name, - provides_forecast=self.provides_forecast, - provides_power_measurement_types=self.provides_power_measurements, - resource_id=self.resource_id, - roles=self.roles, - serial_number=self.serial_number, - ) - - -S2MessageHandler = Union[ - Callable[["S2Connection", S2Message, Callable[[], None]], None], - Callable[["S2Connection", S2Message, Awaitable[None]], Awaitable[None]], -] - - -class SendOkay: - status_is_send: threading.Event - connection: "S2Connection" - subject_message_id: uuid.UUID - - def __init__(self, connection: "S2Connection", subject_message_id: uuid.UUID): - self.status_is_send = threading.Event() - self.connection = connection - self.subject_message_id = subject_message_id - - async def run_async(self) -> None: - self.status_is_send.set() - - await self.connection.respond_with_reception_status( - subject_message_id=self.subject_message_id, - status=ReceptionStatusValues.OK, - diagnostic_label="Processed okay.", - ) - - def run_sync(self) -> None: - self.status_is_send.set() - - self.connection.respond_with_reception_status_sync( - subject_message_id=self.subject_message_id, - status=ReceptionStatusValues.OK, - diagnostic_label="Processed okay.", - ) - - async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: - if not self.status_is_send.is_set(): - logger.warning( - "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " - "Sending it now.", - type_msg, - self.subject_message_id, - ) - await self.run_async() - - def ensure_send_sync(self, type_msg: Type[S2Message]) -> None: - if not self.status_is_send.is_set(): - logger.warning( - "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " - "Sending it now.", - type_msg, - self.subject_message_id, - ) - self.run_sync() - - -class MessageHandlers: - handlers: Dict[Type[S2Message], S2MessageHandler] - - def __init__(self) -> None: - self.handlers = {} - - async def handle_message(self, connection: "S2Connection", msg: S2Message) -> None: - """Handle the S2 message using the registered handler. - - :param connection: The S2 conncetion the `msg` is received from. - :param msg: The S2 message - """ - handler = self.handlers.get(type(msg)) - if handler is not None: - send_okay = SendOkay(connection, msg.message_id) # type: ignore[attr-defined, union-attr] - - try: - if asyncio.iscoroutinefunction(handler): - await handler(connection, msg, send_okay.run_async()) # type: ignore[arg-type] - await send_okay.ensure_send_async(type(msg)) - else: - - def do_message() -> None: - handler(connection, msg, send_okay.run_sync) # type: ignore[arg-type] - send_okay.ensure_send_sync(type(msg)) - - eventloop = asyncio.get_event_loop() - await eventloop.run_in_executor(executor=None, func=do_message) - except Exception: - if not send_okay.status_is_send.is_set(): - await connection.respond_with_reception_status( - subject_message_id=msg.message_id, # type: ignore[attr-defined, union-attr] - status=ReceptionStatusValues.PERMANENT_ERROR, - diagnostic_label=f"While processing message {msg.message_id} " # type: ignore[attr-defined, union-attr] # pylint: disable=line-too-long - f"an unrecoverable error occurred.", - ) - raise - else: - logger.warning( - "Received a message of type %s but no handler is registered. Ignoring the message.", - type(msg), - ) - - def register_handler( - self, msg_type: Type[S2Message], handler: S2MessageHandler - ) -> None: - """Register a coroutine function or a normal function as the handler for a specific S2 message type. - - :param msg_type: The S2 message type to attach the handler to. - :param handler: The function (asynchronuous or normal) which should handle the S2 message. - """ - self.handlers[msg_type] = handler - - -class S2Connection: # pylint: disable=too-many-instance-attributes - url: str - reconnect: bool - reception_status_awaiter: ReceptionStatusAwaiter - ws: Optional[WSConnection] - s2_parser: S2Parser - control_types: List[S2ControlType] - role: EnergyManagementRole - asset_details: AssetDetails - - _thread: threading.Thread - - _handlers: MessageHandlers - _current_control_type: Optional[S2ControlType] - _received_messages: asyncio.Queue - - _eventloop: asyncio.AbstractEventLoop - _stop_event: asyncio.Event - _restart_connection_event: asyncio.Event - _verify_certificate: bool - _bearer_token: Optional[str] - - def __init__( # pylint: disable=too-many-arguments - self, - url: str, - role: EnergyManagementRole, - control_types: List[S2ControlType], - asset_details: AssetDetails, - reconnect: bool = False, - verify_certificate: bool = True, - bearer_token: Optional[str] = None, - ) -> None: - self.url = url - self.reconnect = reconnect - self.reception_status_awaiter = ReceptionStatusAwaiter() - self.ws = None - self.s2_parser = S2Parser() - - self._handlers = MessageHandlers() - self._current_control_type = None - - self._eventloop = asyncio.new_event_loop() - - self.control_types = control_types - self.role = role - self.asset_details = asset_details - self._verify_certificate = verify_certificate - - self._handlers.register_handler( - SelectControlType, self.handle_select_control_type_as_rm - ) - self._handlers.register_handler(Handshake, self.handle_handshake) - self._handlers.register_handler(HandshakeResponse, self.handle_handshake_response_as_rm) - self._bearer_token = bearer_token - - def start_as_rm(self) -> None: - self._run_eventloop(self._run_as_rm()) - - def _run_eventloop(self, main_task: Awaitable[None]) -> None: - self._thread = threading.current_thread() - logger.debug("Starting eventloop") - try: - self._eventloop.run_until_complete(main_task) - except asyncio.CancelledError: - pass - logger.debug("S2 connection thread has stopped.") - - def stop(self) -> None: - """Stops the S2 connection. - - Note: Ensure this method is called from a different thread than the thread running the S2 connection. - Otherwise it will block waiting on the coroutine _do_stop to terminate successfully but it can't run - the coroutine. A `RuntimeError` will be raised to prevent the indefinite block. - """ - if threading.current_thread() == self._thread: - raise RuntimeError( - "Do not call stop from the thread running the S2 connection. This results in an infinite block!" - ) - if self._eventloop.is_running(): - asyncio.run_coroutine_threadsafe(self._do_stop(), self._eventloop).result() - self._thread.join() - logger.info("Stopped the S2 connection.") - - async def _do_stop(self) -> None: - logger.info("Will stop the S2 connection.") - self._stop_event.set() - - async def _run_as_rm(self) -> None: - logger.debug("Connecting as S2 resource manager.") - - self._stop_event = asyncio.Event() - - first_run = True - - while (first_run or self.reconnect) and not self._stop_event.is_set(): - first_run = False - self._restart_connection_event = asyncio.Event() - await self._connect_and_run() - time.sleep(1) - - logger.debug("Finished S2 connection eventloop.") - - async def _connect_and_run(self) -> None: - self._received_messages = asyncio.Queue() - await self._connect_ws() - if self.ws: - - async def wait_till_stop() -> None: - await self._stop_event.wait() - - async def wait_till_connection_restart() -> None: - await self._restart_connection_event.wait() - - background_tasks = [ - self._eventloop.create_task(self._receive_messages()), - self._eventloop.create_task(wait_till_stop()), - self._eventloop.create_task(self._connect_as_rm()), - self._eventloop.create_task(wait_till_connection_restart()), - ] - - (done, pending) = await asyncio.wait( - background_tasks, return_when=asyncio.FIRST_COMPLETED - ) - if self._current_control_type: - self._current_control_type.deactivate(self) - self._current_control_type = None - - for task in done: - try: - await task - except asyncio.CancelledError: - pass - except ( - websockets.ConnectionClosedError, - websockets.ConnectionClosedOK, - ): - logger.info("The other party closed the websocket connection.") - - for task in pending: - try: - task.cancel() - await task - except asyncio.CancelledError: - pass - - await self.ws.close() - await self.ws.wait_closed() - - async def _connect_ws(self) -> None: - try: - # set up connection arguments for SSL and bearer token, if required - connection_kwargs: Dict[str, Any] = {} - if self.url.startswith("wss://") and not self._verify_certificate: - connection_kwargs["ssl"] = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - connection_kwargs["ssl"].check_hostname = False - connection_kwargs["ssl"].verify_mode = ssl.CERT_NONE - - if self._bearer_token: - connection_kwargs["additional_headers"] = { - "Authorization": f"Bearer {self._bearer_token}" - } - - self.ws = await ws_connect(uri=self.url, **connection_kwargs) - except (EOFError, OSError) as e: - logger.info("Could not connect due to: %s", str(e)) - - async def _connect_as_rm(self) -> None: - await self.send_msg_and_await_reception_status_async( - Handshake( - message_id=uuid.uuid4(), - role=self.role, - supported_protocol_versions=[S2_VERSION], - ) - ) - logger.debug( - "Send handshake to CEM. Expecting Handshake and HandshakeResponse from CEM." - ) - - await self._handle_received_messages() - - async def handle_handshake( - self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] - ) -> None: - if not isinstance(message, Handshake): - logger.error( - "Handler for Handshake received a message of the wrong type: %s", - type(message), - ) - return - - logger.debug( - "%s supports S2 protocol versions: %s", - message.role, - message.supported_protocol_versions, - ) - await send_okay - - async def handle_handshake_response_as_rm( - self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] - ) -> None: - if not isinstance(message, HandshakeResponse): - logger.error( - "Handler for HandshakeResponse received a message of the wrong type: %s", - type(message), - ) - return - - logger.debug("Received HandshakeResponse %s", message.to_json()) - - logger.debug( - "CEM selected to use version %s", message.selected_protocol_version - ) - await send_okay - logger.debug("Handshake complete. Sending first ResourceManagerDetails.") - - await self.send_msg_and_await_reception_status_async( - self.asset_details.to_resource_manager_details(self.control_types) - ) - - async def handle_select_control_type_as_rm( - self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] - ) -> None: - if not isinstance(message, SelectControlType): - logger.error( - "Handler for SelectControlType received a message of the wrong type: %s", - type(message), - ) - return - - await send_okay - - logger.debug( - "CEM selected control type %s. Activating control type.", - message.control_type, - ) - - control_types_by_protocol_name = { - c.get_protocol_control_type(): c for c in self.control_types - } - selected_control_type: Optional[S2ControlType] = ( - control_types_by_protocol_name.get(message.control_type) - ) - - if self._current_control_type is not None: - await self._eventloop.run_in_executor( - None, self._current_control_type.deactivate, self - ) - - self._current_control_type = selected_control_type - - if self._current_control_type is not None: - await self._eventloop.run_in_executor( - None, self._current_control_type.activate, self - ) - self._current_control_type.register_handlers(self._handlers) - - async def _receive_messages(self) -> None: - """Receives all incoming messages in the form of a generator. - - Will also receive the ReceptionStatus messages but instead of yielding these messages, they are routed - to any calls of `send_msg_and_await_reception_status`. - """ - if self.ws is None: - raise RuntimeError( - "Cannot receive messages if websocket connection is not yet established." - ) - - logger.info("S2 connection has started to receive messages.") - - async for message in self.ws: - try: - s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) - except json.JSONDecodeError: - await self._send_and_forget( - ReceptionStatus( - subject_message_id=uuid.UUID("00000000-0000-0000-0000-000000000000"), - status=ReceptionStatusValues.INVALID_DATA, - diagnostic_label="Not valid json.", - ) - ) - except S2ValidationError as e: - json_msg = json.loads(message) - message_id = json_msg.get("message_id") - if message_id: - await self.respond_with_reception_status( - subject_message_id=message_id, - status=ReceptionStatusValues.INVALID_MESSAGE, - diagnostic_label=str(e), - ) - else: - await self.respond_with_reception_status( - subject_message_id=uuid.UUID("00000000-0000-0000-0000-000000000000"), - status=ReceptionStatusValues.INVALID_DATA, - diagnostic_label="Message appears valid json but could not find a message_id field.", - ) - else: - logger.debug("Received message %s", s2_msg.to_json()) - - if isinstance(s2_msg, ReceptionStatus): - logger.debug( - "Message is a reception status for %s so registering in cache.", - s2_msg.subject_message_id, - ) - await self.reception_status_awaiter.receive_reception_status(s2_msg) - else: - await self._received_messages.put(s2_msg) - - async def _send_and_forget(self, s2_msg: S2Message) -> None: - if self.ws is None: - raise RuntimeError( - "Cannot send messages if websocket connection is not yet established." - ) - - json_msg = s2_msg.to_json() - logger.debug("Sending message %s", json_msg) - try: - await self.ws.send(json_msg) - except websockets.ConnectionClosedError as e: - logger.error("Unable to send message %s due to %s", s2_msg, str(e)) - self._restart_connection_event.set() - - async def respond_with_reception_status( - self, subject_message_id: uuid.UUID, status: ReceptionStatusValues, diagnostic_label: str - ) -> None: - logger.debug( - "Responding to message %s with status %s", subject_message_id, status - ) - await self._send_and_forget( - ReceptionStatus( - subject_message_id=subject_message_id, - status=status, - diagnostic_label=diagnostic_label, - ) - ) - - def respond_with_reception_status_sync( - self, subject_message_id: uuid.UUID, status: ReceptionStatusValues, diagnostic_label: str - ) -> None: - asyncio.run_coroutine_threadsafe( - self.respond_with_reception_status( - subject_message_id, status, diagnostic_label - ), - self._eventloop, - ).result() - - async def send_msg_and_await_reception_status_async( - self, - s2_msg: S2Message, - timeout_reception_status: float = 5.0, - raise_on_error: bool = True, - ) -> ReceptionStatus: - await self._send_and_forget(s2_msg) - logger.debug( - "Waiting for ReceptionStatus for %s %s seconds", - s2_msg.message_id, # type: ignore[attr-defined, union-attr] - timeout_reception_status, - ) - try: - reception_status = await self.reception_status_awaiter.wait_for_reception_status( - s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined, union-attr] - ) - except TimeoutError: - logger.error( - "Did not receive a reception status on time for %s", - s2_msg.message_id, # type: ignore[attr-defined, union-attr] - ) - self._stop_event.set() - raise - - if reception_status.status != ReceptionStatusValues.OK and raise_on_error: - raise RuntimeError( - f"ReceptionStatus was not OK but rather {reception_status.status}" - ) - - return reception_status - - def send_msg_and_await_reception_status_sync( - self, - s2_msg: S2Message, - timeout_reception_status: float = 5.0, - raise_on_error: bool = True, - ) -> ReceptionStatus: - return asyncio.run_coroutine_threadsafe( - self.send_msg_and_await_reception_status_async( - s2_msg, timeout_reception_status, raise_on_error - ), - self._eventloop, - ).result() - - async def _handle_received_messages(self) -> None: - while True: - msg = await self._received_messages.get() - await self._handlers.handle_message(self, msg) diff --git a/src/s2python/s2_control_type.py b/src/s2python/s2_control_type.py index 135f775..32c520c 100644 --- a/src/s2python/s2_control_type.py +++ b/src/s2python/s2_control_type.py @@ -8,7 +8,7 @@ from s2python.message import S2Message if typing.TYPE_CHECKING: - from s2python.s2_connection import S2Connection, MessageHandlers + from s2python.communication.s2_connection import S2Connection, MessageHandlers class S2ControlType(abc.ABC): diff --git a/tests/unit/reception_status_awaiter_test.py b/tests/unit/reception_status_awaiter_test.py index fb06630..22e9ef4 100644 --- a/tests/unit/reception_status_awaiter_test.py +++ b/tests/unit/reception_status_awaiter_test.py @@ -16,7 +16,7 @@ InstructionStatus, InstructionStatusUpdate, ) -from s2python.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.communication.reception_status_awaiter import ReceptionStatusAwaiter class ReceptionStatusAwaiterTest(IsolatedAsyncioTestCase): From 5294be3a434340eee2ee41271edf9cb8f12fc5b1 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Fri, 18 Apr 2025 13:07:44 +0200 Subject: [PATCH 08/29] Pairing is succesful using the token from the mock server Signed-off-by: Vlad Iftime --- .../communication/examples/mock_s2_server.py | 17 +++- .../communication/examples/test_pairing.py | 99 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/s2python/communication/examples/test_pairing.py diff --git a/src/s2python/communication/examples/mock_s2_server.py b/src/s2python/communication/examples/mock_s2_server.py index 21c9177..f704cce 100644 --- a/src/s2python/communication/examples/mock_s2_server.py +++ b/src/s2python/communication/examples/mock_s2_server.py @@ -45,11 +45,24 @@ def do_POST(self) -> None: try: request_json = json.loads(post_data) - logger.info(f"Received request at {self.path}: {request_json}") + logger.info(f"Received request at {self.path} ") + # logger.info(f"Request body: {request_json}") if self.path == "/requestPairing": # Handle pairing request - if request_json.get("token") == PAIRING_TOKEN: + # The token in the S2 protocol is a PairingToken object with a token field + token_obj = request_json.get("token", {}) + + # Handle case where token is directly the string or a dict with token field + if isinstance(token_obj, dict) and "token" in token_obj: + request_token_string = token_obj["token"] + else: + request_token_string = token_obj + + logger.info(f"Extracted token: {request_token_string}") + logger.info(f"Expected token: {PAIRING_TOKEN}") + + if request_token_string == PAIRING_TOKEN: self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() diff --git a/src/s2python/communication/examples/test_pairing.py b/src/s2python/communication/examples/test_pairing.py new file mode 100644 index 0000000..2eb19b8 --- /dev/null +++ b/src/s2python/communication/examples/test_pairing.py @@ -0,0 +1,99 @@ +import argparse +import logging +import sys +from typing import Optional + +from s2python.authorization.client import S2AbstractClient +from s2python.generated.gen_s2_pairing import ( + S2NodeDescription, + Deployment, + PairingToken, + S2Role, +) + +# Set up logging to show more details +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logger = logging.getLogger("test_pairing") + + +class TestPairingClient(S2AbstractClient): + """Implementation of S2AbstractClient for testing the pairing process.""" + + def solve_challenge(self, challenge: Optional[str] = None) -> str: + """For testing purposes, we just return the challenge itself.""" + if challenge is None: + if not self._connection_details or not self._connection_details.challenge: + raise ValueError("Challenge not provided and not available in connection details") + challenge = self._connection_details.challenge + + # For our mock server, we just return the challenge as is + logger.info(f"Mock solving challenge: {challenge}") + return challenge + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Test S2 pairing with mock server") + parser.add_argument( + "--endpoint", + type=str, + default="http://localhost:8000/requestPairing", + help="Rest endpoint to start S2 pairing. E.g. http://localhost:8000/requestPairing", + ) + parser.add_argument( + "--token", + type=str, + required=True, + help="The pairing token shown by the mock server", + ) + args = parser.parse_args() + + logger.info(f"Testing with endpoint: {args.endpoint}") + logger.info(f"Using token: {args.token}") + + # Create node description + node_description = S2NodeDescription( + brand="Test Client", + logoUri="http://example.com/logo.png", + type="test client", + modelName="S2 test client", + userDefinedName="S2 Test Client", + role=S2Role.RM, + deployment=Deployment.LAN, + ) + + # Create the PairingToken object + token = PairingToken(token=args.token) + logger.info(f"Created PairingToken object: {token}") + logger.info(f"PairingToken as dict: {token.model_dump()}") + + # Create a client to perform the pairing + client = TestPairingClient( + pairing_uri=args.endpoint, + token=token, + node_description=node_description, + verify_certificate=False, + ) + + try: + # Request pairing + logger.info(f"Initiating pairing with endpoint: {args.endpoint}") + pairing_response = client.request_pairing() + logger.info(f"Pairing request successful: {pairing_response}") + + # Request connection details + logger.info(f"Requesting connection from: {client.connection_request_uri}") + connection_details = client.request_connection() + logger.info(f"Connection request successful: {connection_details}") + + # Solve challenge + challenge_result = client.solve_challenge() + logger.info(f"Challenge solved successfully: {challenge_result}") + + logger.info("Test completed successfully!") + + except Exception as e: + logger.error(f"Error during pairing process: {e}", exc_info=True) From 164d467e7943723c4dbb95d4f306ba52b36200e4 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Fri, 18 Apr 2025 13:16:17 +0200 Subject: [PATCH 09/29] Connection to the REST server works. Now the server needs to create the challange Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 17 ++-- .../communication/examples/mock_s2_server.py | 1 + .../communication/examples/test_pairing.py | 99 ------------------- 3 files changed, 8 insertions(+), 109 deletions(-) delete mode 100644 src/s2python/communication/examples/test_pairing.py diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 93c9e55..1edec0d 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -236,26 +236,23 @@ def request_connection(self) -> ConnectionDetails: # Create connection request connection_request = ConnectionRequest( - s2ClientNodeId=self.client_node_id, # Will be converted to string by model_dump + s2ClientNodeId=str(self.client_node_id), supportedProtocols=self.supported_protocols, ) - # Dump the model to JSON, handling UUID conversion - json_connection_request = connection_request.model_dump(exclude_none=True) - - # Make request - response: Response = requests.post( + # Make a POST request to the connection request URI + connection_response: Response = requests.post( url=self._connection_request_uri, - json=json_connection_request, + json=connection_request.model_dump(exclude_none=True), verify=self.verify_certificate, timeout=REQTEST_TIMEOUT, ) # Parse response - if response.status_code != 200: - raise ValueError(f"Connection request failed with status {response.status_code}: {response.text}") + if connection_response.status_code != 200: + raise ValueError(f"Connection request failed with status {connection_response.status_code}: {connection_response.text}") - connection_details = ConnectionDetails.model_validate(response.json()) + connection_details = ConnectionDetails.model_validate(connection_response.json()) # Handle relative WebSocket URI paths if ( diff --git a/src/s2python/communication/examples/mock_s2_server.py b/src/s2python/communication/examples/mock_s2_server.py index f704cce..5d1e11b 100644 --- a/src/s2python/communication/examples/mock_s2_server.py +++ b/src/s2python/communication/examples/mock_s2_server.py @@ -104,6 +104,7 @@ def do_POST(self) -> None: response = { "connectionUri": f"ws://localhost:{WS_PORT}/s2/mock-websocket", "challenge": challenge, + "selectedProtocol": "WebSocketSecure", } self.wfile.write(json.dumps(response).encode()) diff --git a/src/s2python/communication/examples/test_pairing.py b/src/s2python/communication/examples/test_pairing.py deleted file mode 100644 index 2eb19b8..0000000 --- a/src/s2python/communication/examples/test_pairing.py +++ /dev/null @@ -1,99 +0,0 @@ -import argparse -import logging -import sys -from typing import Optional - -from s2python.authorization.client import S2AbstractClient -from s2python.generated.gen_s2_pairing import ( - S2NodeDescription, - Deployment, - PairingToken, - S2Role, -) - -# Set up logging to show more details -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - handlers=[logging.StreamHandler(sys.stdout)], -) -logger = logging.getLogger("test_pairing") - - -class TestPairingClient(S2AbstractClient): - """Implementation of S2AbstractClient for testing the pairing process.""" - - def solve_challenge(self, challenge: Optional[str] = None) -> str: - """For testing purposes, we just return the challenge itself.""" - if challenge is None: - if not self._connection_details or not self._connection_details.challenge: - raise ValueError("Challenge not provided and not available in connection details") - challenge = self._connection_details.challenge - - # For our mock server, we just return the challenge as is - logger.info(f"Mock solving challenge: {challenge}") - return challenge - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Test S2 pairing with mock server") - parser.add_argument( - "--endpoint", - type=str, - default="http://localhost:8000/requestPairing", - help="Rest endpoint to start S2 pairing. E.g. http://localhost:8000/requestPairing", - ) - parser.add_argument( - "--token", - type=str, - required=True, - help="The pairing token shown by the mock server", - ) - args = parser.parse_args() - - logger.info(f"Testing with endpoint: {args.endpoint}") - logger.info(f"Using token: {args.token}") - - # Create node description - node_description = S2NodeDescription( - brand="Test Client", - logoUri="http://example.com/logo.png", - type="test client", - modelName="S2 test client", - userDefinedName="S2 Test Client", - role=S2Role.RM, - deployment=Deployment.LAN, - ) - - # Create the PairingToken object - token = PairingToken(token=args.token) - logger.info(f"Created PairingToken object: {token}") - logger.info(f"PairingToken as dict: {token.model_dump()}") - - # Create a client to perform the pairing - client = TestPairingClient( - pairing_uri=args.endpoint, - token=token, - node_description=node_description, - verify_certificate=False, - ) - - try: - # Request pairing - logger.info(f"Initiating pairing with endpoint: {args.endpoint}") - pairing_response = client.request_pairing() - logger.info(f"Pairing request successful: {pairing_response}") - - # Request connection details - logger.info(f"Requesting connection from: {client.connection_request_uri}") - connection_details = client.request_connection() - logger.info(f"Connection request successful: {connection_details}") - - # Solve challenge - challenge_result = client.solve_challenge() - logger.info(f"Challenge solved successfully: {challenge_result}") - - logger.info("Test completed successfully!") - - except Exception as e: - logger.error(f"Error during pairing process: {e}", exc_info=True) From caa666ec96cf674faac8ce366b6fbb91b72991ec Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Mon, 21 Apr 2025 13:51:36 +0200 Subject: [PATCH 10/29] Created a default_client and made the abstract client use functions that are expected to be overwritten Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 146 ++++++++---------- .../examples/example_pairing_frbc_rm.py | 13 +- 2 files changed, 67 insertions(+), 92 deletions(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 1edec0d..83981af 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -49,6 +49,11 @@ class S2AbstractClient(abc.ABC): - Storage of connection request URI - Storage of public/private key pairs - Challenge solving + + This class serves as an interface that developers can extend to implement + S2 protocol functionality with their preferred technology stack. + Concrete implementations should override the abstract methods marked + with @abc.abstractmethod. """ # pylint: disable=too-many-instance-attributes @@ -115,32 +120,32 @@ def store_connection_request_uri(self, uri: str) -> None: # No valid URI could be determined self._connection_request_uri = None + @abc.abstractmethod def generate_key_pair(self) -> Tuple[str, str]: """Generate a public/private key pair. + This method should be implemented by concrete subclasses to use their + preferred cryptographic libraries or key management systems. + Returns: Tuple[str, str]: (public_key, private_key) pair as base64 encoded strings """ - print("Generating key pair") - self._key_pair = Jwk.generate_for_alg(KEY_ALGORITHM).with_kid_thumbprint() - self._public_jwk = self._key_pair - self._private_jwk = self._key_pair - return ( - self._public_jwk.to_pem(), - self._private_jwk.to_pem(), - ) + pass + @abc.abstractmethod def store_key_pair(self, public_key: str, private_key: str) -> None: """Store the public/private key pair. - #! TODO: Use Sqlite3 to store the key pair + + This method should be implemented by concrete subclasses to store keys + according to their security requirements (e.g., secure storage, HSM, etc.). + Args: public_key: Base64 encoded public key private_key: Base64 encoded private key """ - print("Storing key pair") - self._public_key = public_key - self._private_key = private_key + pass + @abc.abstractmethod def _make_https_request( self, url: str, @@ -150,6 +155,9 @@ def _make_https_request( ) -> Tuple[int, str]: """Make an HTTPS request. + This method should be implemented by concrete subclasses to use their + preferred HTTP client library or framework. + Args: url: Target URL method: HTTP method (GET, POST, etc.) @@ -159,16 +167,7 @@ def _make_https_request( Returns: Tuple[int, str]: (status_code, response_text) """ - # Using requests library with verification settings from instance - response: Response = requests.request( - method=method, - url=url, - json=data, - headers=headers or {"Content-Type": "application/json"}, - verify=self.verify_certificate, - timeout=REQTEST_TIMEOUT, - ) - return response.status_code, response.text + pass def request_pairing(self) -> PairingResponse: """Send a pairing request to the server using client configuration. @@ -202,19 +201,19 @@ def request_pairing(self) -> PairingResponse: # Make pairing request print("Making pairing request") - response: Response = requests.post( + status_code, response_text = self._make_https_request( url=self.pairing_uri, - json=pairing_request.model_dump(exclude_none=True), - verify=self.verify_certificate, - timeout=REQTEST_TIMEOUT, + method="POST", + data=pairing_request.model_dump(exclude_none=True), + headers={"Content-Type": "application/json"}, ) - print(f"Pairing request response: {response.status_code} {response.text}") + print(f"Pairing request response: {status_code} {response_text}") # Parse response - if response.status_code != 200: - raise ValueError(f"Pairing request failed with status {response.status_code}: {response.text}") + if status_code != 200: + raise ValueError(f"Pairing request failed with status {status_code}: {response_text}") - pairing_response = PairingResponse.model_validate(response.json()) + pairing_response = PairingResponse.model_validate(json.loads(response_text)) # Store for later use self._pairing_response = pairing_response @@ -241,18 +240,18 @@ def request_connection(self) -> ConnectionDetails: ) # Make a POST request to the connection request URI - connection_response: Response = requests.post( + status_code, response_text = self._make_https_request( url=self._connection_request_uri, - json=connection_request.model_dump(exclude_none=True), - verify=self.verify_certificate, - timeout=REQTEST_TIMEOUT, + method="POST", + data=connection_request.model_dump(exclude_none=True), + headers={"Content-Type": "application/json"}, ) # Parse response - if connection_response.status_code != 200: - raise ValueError(f"Connection request failed with status {connection_response.status_code}: {connection_response.text}") + if status_code != 200: + raise ValueError(f"Connection request failed with status {status_code}: {response_text}") - connection_details = ConnectionDetails.model_validate(connection_response.json()) + connection_details = ConnectionDetails.model_validate(json.loads(response_text)) # Handle relative WebSocket URI paths if ( @@ -297,6 +296,7 @@ def request_connection(self) -> ConnectionDetails: return connection_details + @abc.abstractmethod def solve_challenge(self, challenge: Optional[str] = None) -> str: """Solve the connection challenge using the public key. @@ -316,55 +316,35 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: ValueError: If the public key is not available RuntimeError: If challenge decryption fails """ - if challenge is None: - if not self._connection_details or not self._connection_details.challenge: - raise ValueError("Challenge not provided and not available in connection details") - challenge = self._connection_details.challenge - - if not self._key_pair and not self._public_key: - raise ValueError("Public key is not available. Generate or load a key pair first.") - - try: - # If we have a jwskate Jwk object, use it directly - if self._key_pair: - rsa_key_pair = self._key_pair - # Otherwise try to parse the public key - elif self._public_key: - rsa_key_pair = Jwk.from_pem(self._public_key) - else: - raise ValueError("No public key available") - - # Decrypt the JWE challenge - get result as bytes and convert to string - jwe_compact = JweCompact(challenge) - decrypted_bytes = jwe_compact.decrypt(rsa_key_pair) - # Make sure we have a proper string - if hasattr(decrypted_bytes, "decode"): - decrypted_string = decrypted_bytes.decode("utf-8") - else: - decrypted_string = str(decrypted_bytes) + pass - # Parse the JSON payload - challenge_mapping: Mapping[str, Any] = json.loads(decrypted_string) + @abc.abstractmethod + def establish_secure_connection(self) -> Any: + """Establish a secure connection to the server. - # Create an unprotected JWT from the challenge - jwt_token = Jwt.unprotected(challenge_mapping) - jwt_token_str = str(jwt_token) + This method should be implemented by concrete subclasses to establish + a secure connection using the connection details and solved challenge. + Implementations needs to use WebSocket Secure. - # Encode the token as base64 - decrypted_challenge_str: str = base64.b64encode(jwt_token_str.encode("utf-8")).decode("utf-8") + Returns: + Any: A connection object or handler specific to the implementation - # Store the pairing details if we have all required components - if self._pairing_response and self._connection_details: - self._pairing_details = PairingDetails( - pairing_response=self._pairing_response, - connection_details=self._connection_details, - decrypted_challenge_str=decrypted_challenge_str, - ) + Raises: + ValueError: If connection details or solved challenge are not available + RuntimeError: If connection establishment fails + """ + pass + + @abc.abstractmethod + def close_connection(self) -> None: + """Close the connection to the server. - print(f"Decrypted challenge: {decrypted_challenge_str}") - return decrypted_challenge_str + This method should be implemented by concrete subclasses to properly + close the connection established by establish_secure_connection. + """ + pass - except (ValueError, TypeError, KeyError, json.JSONDecodeError) as e: - error_msg = f"Failed to solve challenge: {e}" - print(error_msg) - raise RuntimeError(error_msg) from e + @property + def pairing_details(self) -> Optional[PairingDetails]: + """Get the stored pairing details.""" + return self._pairing_details diff --git a/src/s2python/communication/examples/example_pairing_frbc_rm.py b/src/s2python/communication/examples/example_pairing_frbc_rm.py index 3e40829..2d8487c 100644 --- a/src/s2python/communication/examples/example_pairing_frbc_rm.py +++ b/src/s2python/communication/examples/example_pairing_frbc_rm.py @@ -5,24 +5,18 @@ from typing import Optional from s2python.communication.examples.example_frbc_rm import start_s2_session -from s2python.authorization.client import S2AbstractClient +from s2python.authorization.default_client import S2DefaultClient from s2python.generated.gen_s2_pairing import ( S2NodeDescription, Deployment, PairingToken, S2Role, + Protocols, ) logger = logging.getLogger("s2python") -class S2PairingClient(S2AbstractClient): - """Implementation of S2AbstractClient for pairing example.""" - - def solve_challenge(self, challenge: Optional[str] = None) -> str: - """Solve the challenge using the key pair.""" - return super().solve_challenge(challenge) - if __name__ == "__main__": parser = argparse.ArgumentParser( @@ -61,11 +55,12 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: ) # Create a client to perform the pairing - client = S2PairingClient( + client = S2DefaultClient( pairing_uri=args.endpoint, token=PairingToken(token=args.pairing_token, ), node_description=node_description, verify_certificate=args.verify_ssl, + supported_protocols=[Protocols.WebSocketSecure], ) try: From 9840519e118e9512cd43d0d515222b05801e3ac6 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Mon, 21 Apr 2025 13:52:26 +0200 Subject: [PATCH 11/29] Created a default_client and made the abstract client use functions that are expected to be overwritten Signed-off-by: Vlad Iftime --- src/s2python/authorization/__init__.py | 0 src/s2python/authorization/default_client.py | 222 +++++++ src/s2python/communication/__init__.py | 0 .../communication/reception_status_awaiter.py | 60 ++ src/s2python/communication/s2_connection.py | 599 ++++++++++++++++++ 5 files changed, 881 insertions(+) create mode 100644 src/s2python/authorization/__init__.py create mode 100644 src/s2python/authorization/default_client.py create mode 100644 src/s2python/communication/__init__.py create mode 100644 src/s2python/communication/reception_status_awaiter.py create mode 100644 src/s2python/communication/s2_connection.py diff --git a/src/s2python/authorization/__init__.py b/src/s2python/authorization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/s2python/authorization/default_client.py b/src/s2python/authorization/default_client.py new file mode 100644 index 0000000..b775bcf --- /dev/null +++ b/src/s2python/authorization/default_client.py @@ -0,0 +1,222 @@ +""" +Default implementation of the S2 protocol client. + +This module provides a concrete implementation of the S2AbstractClient +for developers to use directly or as a reference for their own implementations. +""" + +import base64 +import json +import uuid +from typing import Dict, Optional, Tuple, Union, List, Any, Mapping + +import requests +from requests import Response + +from jwskate import JweCompact, Jwk, Jwt + +from s2python.generated.gen_s2_pairing import ( + ConnectionDetails, + PairingToken, + S2NodeDescription, + Protocols, +) +from s2python.authorization.client import S2AbstractClient, REQTEST_TIMEOUT, KEY_ALGORITHM, PairingDetails + + +class S2DefaultClient(S2AbstractClient): + """Default implementation of the S2AbstractClient using the requests library for HTTP + and jwskate for cryptographic operations. + + This implementation can be used directly or as a reference for custom implementations. + """ + + def __init__( + self, + pairing_uri: Optional[str] = None, + token: Optional[PairingToken] = None, + node_description: Optional[S2NodeDescription] = None, + verify_certificate: Union[bool, str] = False, + client_node_id: Optional[uuid.UUID] = None, + supported_protocols: Optional[List[Protocols]] = None, + ) -> None: + """Initialize the default client with configuration parameters.""" + super().__init__( + pairing_uri, + token, + node_description, + verify_certificate, + client_node_id, + supported_protocols, + ) + # Additional state for this implementation + self._ws_connection: Optional[Dict[str, Any]] = None + + def generate_key_pair(self) -> Tuple[str, str]: + """Generate a public/private key pair using jwskate library. + + Returns: + Tuple[str, str]: (public_key, private_key) pair as PEM encoded strings + """ + print("Generating key pair") + self._key_pair = Jwk.generate_for_alg(KEY_ALGORITHM).with_kid_thumbprint() + self._public_jwk = self._key_pair + self._private_jwk = self._key_pair + return ( + self._public_jwk.to_pem(), + self._private_jwk.to_pem(), + ) + + def store_key_pair(self, public_key: str, private_key: str) -> None: + """Store the public/private key pair in memory. + + In a production implementation, this might use a secure storage mechanism + like a keystore, HSM, or encrypted database. + + Args: + public_key: PEM encoded public key + private_key: PEM encoded private key + """ + print("Storing key pair") + self._public_key = public_key + self._private_key = private_key + + def _make_https_request( + self, + url: str, + method: str = "GET", + data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + ) -> Tuple[int, str]: + """Make an HTTPS request using the requests library. + + Args: + url: Target URL + method: HTTP method (GET, POST, etc.) + data: Request body data + headers: HTTP headers + + Returns: + Tuple[int, str]: (status_code, response_text) + """ + # Using requests library with verification settings from instance + response: Response = requests.request( + method=method, + url=url, + json=data, + headers=headers or {"Content-Type": "application/json"}, + verify=self.verify_certificate, + timeout=REQTEST_TIMEOUT, + ) + return response.status_code, response.text + + def solve_challenge(self, challenge: Optional[str] = None) -> str: + """Solve the connection challenge using the public key. + + If no challenge is provided, uses the challenge from connection_details. + + Args: + challenge: The challenge string from the server (optional) + + Returns: + str: The solution to the challenge (base64 encoded decrypted challenge) + + Raises: + ValueError: If no challenge is provided and connection_details is not set + ValueError: If the public key is not available + RuntimeError: If challenge decryption fails + """ + if challenge is None: + if not self._connection_details or not self._connection_details.challenge: + raise ValueError("Challenge not provided and not available in connection details") + challenge = self._connection_details.challenge + + if not self._key_pair and not self._public_key: + raise ValueError("Public key is not available. Generate or load a key pair first.") + + try: + # If we have a jwskate Jwk object, use it directly + if self._key_pair: + rsa_key_pair = self._key_pair + # Otherwise try to parse the public key + elif self._public_key: + rsa_key_pair = Jwk.from_pem(self._public_key) + else: + raise ValueError("No public key available") + + # Decrypt the JWE challenge - get result as bytes and convert to string + jwe_compact = JweCompact(challenge) + decrypted_bytes = jwe_compact.decrypt(rsa_key_pair) + # Make sure we have a proper string + if hasattr(decrypted_bytes, "decode"): + decrypted_string = decrypted_bytes.decode("utf-8") + else: + decrypted_string = str(decrypted_bytes) + + # Parse the JSON payload + challenge_mapping: Mapping[str, Any] = json.loads(decrypted_string) + + # Create an unprotected JWT from the challenge + jwt_token = Jwt.unprotected(challenge_mapping) + jwt_token_str = str(jwt_token) + + # Encode the token as base64 + decrypted_challenge_str: str = base64.b64encode(jwt_token_str.encode("utf-8")).decode("utf-8") + + # Store the pairing details if we have all required components + if self._pairing_response and self._connection_details: + self._pairing_details = PairingDetails( + pairing_response=self._pairing_response, + connection_details=self._connection_details, + decrypted_challenge_str=decrypted_challenge_str, + ) + + print(f"Decrypted challenge: {decrypted_challenge_str}") + return decrypted_challenge_str + + except (ValueError, TypeError, KeyError, json.JSONDecodeError) as e: + error_msg = f"Failed to solve challenge: {e}" + print(error_msg) + raise RuntimeError(error_msg) from e + + def establish_secure_connection(self) -> Dict[str, Any]: + """Establish a secure WebSocket connection. + + This implementation would establish a WebSocket connection + using the connection details and solved challenge. + + Note: This is a placeholder implementation. In a real implementation, + this would use a WebSocket library like websocket-client or websockets. + + Returns: + Dict[str, Any]: A WebSocket connection object + + Raises: + ValueError: If connection details or solved challenge are not available + RuntimeError: If connection establishment fails + """ + if not self._connection_details: + raise ValueError("Connection details not available. Call request_connection first.") + + if not self._pairing_details or not self._pairing_details.decrypted_challenge_str: + raise ValueError("Challenge solution not available. Call solve_challenge first.") + + + print(f"Would establish WebSocket connection to {self._connection_details.connectionUri}") + print(f"Using solved challenge: {self._pairing_details.decrypted_challenge_str}") + + # Placeholder for the connection object + self._ws_connection = {"status": "connected", "uri": str(self._connection_details.connectionUri)} + + return self._ws_connection + + def close_connection(self) -> None: + """Close the WebSocket connection. + + TODO: Implement + """ + if self._ws_connection: + + + print("Would close WebSocket connection") + self._ws_connection = None diff --git a/src/s2python/communication/__init__.py b/src/s2python/communication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/s2python/communication/reception_status_awaiter.py b/src/s2python/communication/reception_status_awaiter.py new file mode 100644 index 0000000..5c4bd42 --- /dev/null +++ b/src/s2python/communication/reception_status_awaiter.py @@ -0,0 +1,60 @@ +"""ReceptationStatusAwaiter class which notifies any coroutine waiting for a certain reception status message. + +Copied from +https://github.com/flexiblepower/s2-analyzer/blob/main/backend/s2_analyzer_backend/reception_status_awaiter.py under +Apache2 license on 31-08-2024. +""" + +import asyncio +import uuid +from typing import Dict + +from s2python.common import ReceptionStatus + + +class ReceptionStatusAwaiter: + received: Dict[uuid.UUID, ReceptionStatus] + awaiting: Dict[uuid.UUID, asyncio.Event] + + def __init__(self) -> None: + self.received = {} + self.awaiting = {} + + async def wait_for_reception_status( + self, message_id: uuid.UUID, timeout_reception_status: float + ) -> ReceptionStatus: + if message_id in self.received: + reception_status = self.received[message_id] + else: + if message_id in self.awaiting: + received_event = self.awaiting[message_id] + else: + received_event = asyncio.Event() + self.awaiting[message_id] = received_event + + await asyncio.wait_for(received_event.wait(), timeout_reception_status) + reception_status = self.received[message_id] + + if message_id in self.awaiting: + del self.awaiting[message_id] + + return reception_status + + async def receive_reception_status(self, reception_status: ReceptionStatus) -> None: + if not isinstance(reception_status, ReceptionStatus): + raise RuntimeError( + f"Expected a ReceptionStatus but received message {reception_status}" + ) + + if reception_status.subject_message_id in self.received: + raise RuntimeError( + f"ReceptationStatus for message_subject_id {reception_status.subject_message_id} has already " + f"been received!" + ) + + self.received[reception_status.subject_message_id] = reception_status + awaiting = self.awaiting.get(reception_status.subject_message_id) + + if awaiting: + awaiting.set() + del self.awaiting[reception_status.subject_message_id] diff --git a/src/s2python/communication/s2_connection.py b/src/s2python/communication/s2_connection.py new file mode 100644 index 0000000..71e3326 --- /dev/null +++ b/src/s2python/communication/s2_connection.py @@ -0,0 +1,599 @@ +try: + import websockets +except ImportError as exc: + raise ImportError( + "The 'websockets' package is required. Run 'pip install s2-python[ws]' to use this feature." + ) from exc + +import asyncio +import json +import logging +import time +import threading +import uuid +import ssl +from dataclasses import dataclass +from typing import Any, Optional, List, Type, Dict, Callable, Awaitable, Union + +from websockets.asyncio.client import ( + ClientConnection as WSConnection, + connect as ws_connect, +) + +from s2python.common import ( + ReceptionStatusValues, + ReceptionStatus, + Handshake, + EnergyManagementRole, + Role, + HandshakeResponse, + ResourceManagerDetails, + Duration, + Currency, + SelectControlType, +) +from s2python.generated.gen_s2 import CommodityQuantity +from s2python.communication.reception_status_awaiter import ReceptionStatusAwaiter +from s2python.s2_control_type import S2ControlType +from s2python.s2_parser import S2Parser +from s2python.s2_validation_error import S2ValidationError +from s2python.message import S2Message +from s2python.version import S2_VERSION + +logger = logging.getLogger("s2python") + + +@dataclass +class AssetDetails: # pylint: disable=too-many-instance-attributes + resource_id: uuid.UUID + + provides_forecast: bool + provides_power_measurements: List[CommodityQuantity] + + instruction_processing_delay: Duration + roles: List[Role] + currency: Optional[Currency] = None + + name: Optional[str] = None + manufacturer: Optional[str] = None + model: Optional[str] = None + firmware_version: Optional[str] = None + serial_number: Optional[str] = None + + def to_resource_manager_details( + self, control_types: List[S2ControlType] + ) -> ResourceManagerDetails: + return ResourceManagerDetails( + available_control_types=[ + control_type.get_protocol_control_type() + for control_type in control_types + ], + currency=self.currency, + firmware_version=self.firmware_version, + instruction_processing_delay=self.instruction_processing_delay, + manufacturer=self.manufacturer, + message_id=uuid.uuid4(), + model=self.model, + name=self.name, + provides_forecast=self.provides_forecast, + provides_power_measurement_types=self.provides_power_measurements, + resource_id=self.resource_id, + roles=self.roles, + serial_number=self.serial_number, + ) + + +S2MessageHandler = Union[ + Callable[["S2Connection", S2Message, Callable[[], None]], None], + Callable[["S2Connection", S2Message, Awaitable[None]], Awaitable[None]], +] + + +class SendOkay: + status_is_send: threading.Event + connection: "S2Connection" + subject_message_id: uuid.UUID + + def __init__(self, connection: "S2Connection", subject_message_id: uuid.UUID): + self.status_is_send = threading.Event() + self.connection = connection + self.subject_message_id = subject_message_id + + async def run_async(self) -> None: + self.status_is_send.set() + + await self.connection.respond_with_reception_status( + subject_message_id=self.subject_message_id, + status=ReceptionStatusValues.OK, + diagnostic_label="Processed okay.", + ) + + def run_sync(self) -> None: + self.status_is_send.set() + + self.connection.respond_with_reception_status_sync( + subject_message_id=self.subject_message_id, + status=ReceptionStatusValues.OK, + diagnostic_label="Processed okay.", + ) + + async def ensure_send_async(self, type_msg: Type[S2Message]) -> None: + if not self.status_is_send.is_set(): + logger.warning( + "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " + "Sending it now.", + type_msg, + self.subject_message_id, + ) + await self.run_async() + + def ensure_send_sync(self, type_msg: Type[S2Message]) -> None: + if not self.status_is_send.is_set(): + logger.warning( + "Handler for message %s %s did not call send_okay / function to send the ReceptionStatus. " + "Sending it now.", + type_msg, + self.subject_message_id, + ) + self.run_sync() + + +class MessageHandlers: + handlers: Dict[Type[S2Message], S2MessageHandler] + + def __init__(self) -> None: + self.handlers = {} + + async def handle_message(self, connection: "S2Connection", msg: S2Message) -> None: + """Handle the S2 message using the registered handler. + + :param connection: The S2 conncetion the `msg` is received from. + :param msg: The S2 message + """ + handler = self.handlers.get(type(msg)) + if handler is not None: + send_okay = SendOkay(connection, msg.message_id) # type: ignore[attr-defined, union-attr] + + try: + if asyncio.iscoroutinefunction(handler): + await handler(connection, msg, send_okay.run_async()) # type: ignore[arg-type] + await send_okay.ensure_send_async(type(msg)) + else: + + def do_message() -> None: + handler(connection, msg, send_okay.run_sync) # type: ignore[arg-type] + send_okay.ensure_send_sync(type(msg)) + + eventloop = asyncio.get_event_loop() + await eventloop.run_in_executor(executor=None, func=do_message) + except Exception: + if not send_okay.status_is_send.is_set(): + await connection.respond_with_reception_status( + subject_message_id=msg.message_id, # type: ignore[attr-defined, union-attr] + status=ReceptionStatusValues.PERMANENT_ERROR, + diagnostic_label=f"While processing message {msg.message_id} " # type: ignore[attr-defined, union-attr] # pylint: disable=line-too-long + f"an unrecoverable error occurred.", + ) + raise + else: + logger.warning( + "Received a message of type %s but no handler is registered. Ignoring the message.", + type(msg), + ) + + def register_handler( + self, msg_type: Type[S2Message], handler: S2MessageHandler + ) -> None: + """Register a coroutine function or a normal function as the handler for a specific S2 message type. + + :param msg_type: The S2 message type to attach the handler to. + :param handler: The function (asynchronuous or normal) which should handle the S2 message. + """ + self.handlers[msg_type] = handler + + +class S2Connection: # pylint: disable=too-many-instance-attributes + url: str + reconnect: bool + reception_status_awaiter: ReceptionStatusAwaiter + ws: Optional[WSConnection] + s2_parser: S2Parser + control_types: List[S2ControlType] + role: EnergyManagementRole + asset_details: AssetDetails + + _thread: threading.Thread + + _handlers: MessageHandlers + _current_control_type: Optional[S2ControlType] + _received_messages: asyncio.Queue + + _eventloop: asyncio.AbstractEventLoop + _stop_event: asyncio.Event + _restart_connection_event: asyncio.Event + _verify_certificate: bool + _bearer_token: Optional[str] + + def __init__( # pylint: disable=too-many-arguments + self, + url: str, + role: EnergyManagementRole, + control_types: List[S2ControlType], + asset_details: AssetDetails, + reconnect: bool = False, + verify_certificate: bool = True, + bearer_token: Optional[str] = None, + ) -> None: + self.url = url + self.reconnect = reconnect + self.reception_status_awaiter = ReceptionStatusAwaiter() + self.ws = None + self.s2_parser = S2Parser() + + self._handlers = MessageHandlers() + self._current_control_type = None + + self._eventloop = asyncio.new_event_loop() + + self.control_types = control_types + self.role = role + self.asset_details = asset_details + self._verify_certificate = verify_certificate + + self._handlers.register_handler( + SelectControlType, self.handle_select_control_type_as_rm + ) + self._handlers.register_handler(Handshake, self.handle_handshake) + self._handlers.register_handler( + HandshakeResponse, self.handle_handshake_response_as_rm + ) + self._bearer_token = bearer_token + + def start_as_rm(self) -> None: + self._run_eventloop(self._run_as_rm()) + + def _run_eventloop(self, main_task: Awaitable[None]) -> None: + self._thread = threading.current_thread() + logger.debug("Starting eventloop") + try: + self._eventloop.run_until_complete(main_task) + except asyncio.CancelledError: + pass + logger.debug("S2 connection thread has stopped.") + + def stop(self) -> None: + """Stops the S2 connection. + + Note: Ensure this method is called from a different thread than the thread running the S2 connection. + Otherwise it will block waiting on the coroutine _do_stop to terminate successfully but it can't run + the coroutine. A `RuntimeError` will be raised to prevent the indefinite block. + """ + if threading.current_thread() == self._thread: + raise RuntimeError( + "Do not call stop from the thread running the S2 connection. This results in an infinite block!" + ) + if self._eventloop.is_running(): + asyncio.run_coroutine_threadsafe(self._do_stop(), self._eventloop).result() + self._thread.join() + logger.info("Stopped the S2 connection.") + + async def _do_stop(self) -> None: + logger.info("Will stop the S2 connection.") + self._stop_event.set() + + async def _run_as_rm(self) -> None: + logger.debug("Connecting as S2 resource manager.") + + self._stop_event = asyncio.Event() + + first_run = True + + while (first_run or self.reconnect) and not self._stop_event.is_set(): + first_run = False + self._restart_connection_event = asyncio.Event() + await self._connect_and_run() + time.sleep(1) + + logger.debug("Finished S2 connection eventloop.") + + async def _connect_and_run(self) -> None: + self._received_messages = asyncio.Queue() + await self._connect_ws() + if self.ws: + + async def wait_till_stop() -> None: + await self._stop_event.wait() + + async def wait_till_connection_restart() -> None: + await self._restart_connection_event.wait() + + background_tasks = [ + self._eventloop.create_task(self._receive_messages()), + self._eventloop.create_task(wait_till_stop()), + self._eventloop.create_task(self._connect_as_rm()), + self._eventloop.create_task(wait_till_connection_restart()), + ] + + (done, pending) = await asyncio.wait( + background_tasks, return_when=asyncio.FIRST_COMPLETED + ) + if self._current_control_type: + self._current_control_type.deactivate(self) + self._current_control_type = None + + for task in done: + try: + await task + except asyncio.CancelledError: + pass + except ( + websockets.ConnectionClosedError, + websockets.ConnectionClosedOK, + ): + logger.info("The other party closed the websocket connection.") + + for task in pending: + try: + task.cancel() + await task + except asyncio.CancelledError: + pass + + await self.ws.close() + await self.ws.wait_closed() + + async def _connect_ws(self) -> None: + try: + # set up connection arguments for SSL and bearer token, if required + connection_kwargs: Dict[str, Any] = {} + if self.url.startswith("wss://") and not self._verify_certificate: + connection_kwargs["ssl"] = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + connection_kwargs["ssl"].check_hostname = False + connection_kwargs["ssl"].verify_mode = ssl.CERT_NONE + + if self._bearer_token: + connection_kwargs["additional_headers"] = { + "Authorization": f"Bearer {self._bearer_token}" + } + + self.ws = await ws_connect(uri=self.url, **connection_kwargs) + except (EOFError, OSError) as e: + logger.info("Could not connect due to: %s", str(e)) + + async def _connect_as_rm(self) -> None: + await self.send_msg_and_await_reception_status_async( + Handshake( + message_id=uuid.uuid4(), + role=self.role, + supported_protocol_versions=[S2_VERSION], + ) + ) + logger.debug( + "Send handshake to CEM. Expecting Handshake and HandshakeResponse from CEM." + ) + + await self._handle_received_messages() + + async def handle_handshake( + self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] + ) -> None: + if not isinstance(message, Handshake): + logger.error( + "Handler for Handshake received a message of the wrong type: %s", + type(message), + ) + return + + logger.debug( + "%s supports S2 protocol versions: %s", + message.role, + message.supported_protocol_versions, + ) + await send_okay + + async def handle_handshake_response_as_rm( + self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] + ) -> None: + if not isinstance(message, HandshakeResponse): + logger.error( + "Handler for HandshakeResponse received a message of the wrong type: %s", + type(message), + ) + return + + logger.debug("Received HandshakeResponse %s", message.to_json()) + + logger.debug( + "CEM selected to use version %s", message.selected_protocol_version + ) + await send_okay + logger.debug("Handshake complete. Sending first ResourceManagerDetails.") + + await self.send_msg_and_await_reception_status_async( + self.asset_details.to_resource_manager_details(self.control_types) + ) + + async def handle_select_control_type_as_rm( + self, _: "S2Connection", message: S2Message, send_okay: Awaitable[None] + ) -> None: + if not isinstance(message, SelectControlType): + logger.error( + "Handler for SelectControlType received a message of the wrong type: %s", + type(message), + ) + return + + await send_okay + + logger.debug( + "CEM selected control type %s. Activating control type.", + message.control_type, + ) + + control_types_by_protocol_name = { + c.get_protocol_control_type(): c for c in self.control_types + } + selected_control_type: Optional[ + S2ControlType + ] = control_types_by_protocol_name.get(message.control_type) + + if self._current_control_type is not None: + await self._eventloop.run_in_executor( + None, self._current_control_type.deactivate, self + ) + + self._current_control_type = selected_control_type + + if self._current_control_type is not None: + await self._eventloop.run_in_executor( + None, self._current_control_type.activate, self + ) + self._current_control_type.register_handlers(self._handlers) + + async def _receive_messages(self) -> None: + """Receives all incoming messages in the form of a generator. + + Will also receive the ReceptionStatus messages but instead of yielding these messages, they are routed + to any calls of `send_msg_and_await_reception_status`. + """ + if self.ws is None: + raise RuntimeError( + "Cannot receive messages if websocket connection is not yet established." + ) + + logger.info("S2 connection has started to receive messages.") + + async for message in self.ws: + try: + s2_msg: S2Message = self.s2_parser.parse_as_any_message(message) + except json.JSONDecodeError: + await self._send_and_forget( + ReceptionStatus( + subject_message_id=uuid.UUID( + "00000000-0000-0000-0000-000000000000" + ), + status=ReceptionStatusValues.INVALID_DATA, + diagnostic_label="Not valid json.", + ) + ) + except S2ValidationError as e: + json_msg = json.loads(message) + message_id = json_msg.get("message_id") + if message_id: + await self.respond_with_reception_status( + subject_message_id=message_id, + status=ReceptionStatusValues.INVALID_MESSAGE, + diagnostic_label=str(e), + ) + else: + await self.respond_with_reception_status( + subject_message_id=uuid.UUID( + "00000000-0000-0000-0000-000000000000" + ), + status=ReceptionStatusValues.INVALID_DATA, + diagnostic_label="Message appears valid json but could not find a message_id field.", + ) + else: + logger.debug("Received message %s", s2_msg.to_json()) + + if isinstance(s2_msg, ReceptionStatus): + logger.debug( + "Message is a reception status for %s so registering in cache.", + s2_msg.subject_message_id, + ) + await self.reception_status_awaiter.receive_reception_status(s2_msg) + else: + await self._received_messages.put(s2_msg) + + async def _send_and_forget(self, s2_msg: S2Message) -> None: + if self.ws is None: + raise RuntimeError( + "Cannot send messages if websocket connection is not yet established." + ) + + json_msg = s2_msg.to_json() + logger.debug("Sending message %s", json_msg) + try: + await self.ws.send(json_msg) + except websockets.ConnectionClosedError as e: + logger.error("Unable to send message %s due to %s", s2_msg, str(e)) + self._restart_connection_event.set() + + async def respond_with_reception_status( + self, + subject_message_id: uuid.UUID, + status: ReceptionStatusValues, + diagnostic_label: str, + ) -> None: + logger.debug( + "Responding to message %s with status %s", subject_message_id, status + ) + await self._send_and_forget( + ReceptionStatus( + subject_message_id=subject_message_id, + status=status, + diagnostic_label=diagnostic_label, + ) + ) + + def respond_with_reception_status_sync( + self, + subject_message_id: uuid.UUID, + status: ReceptionStatusValues, + diagnostic_label: str, + ) -> None: + asyncio.run_coroutine_threadsafe( + self.respond_with_reception_status( + subject_message_id, status, diagnostic_label + ), + self._eventloop, + ).result() + + async def send_msg_and_await_reception_status_async( + self, + s2_msg: S2Message, + timeout_reception_status: float = 5.0, + raise_on_error: bool = True, + ) -> ReceptionStatus: + await self._send_and_forget(s2_msg) + logger.debug( + "Waiting for ReceptionStatus for %s %s seconds", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + timeout_reception_status, + ) + try: + reception_status = await self.reception_status_awaiter.wait_for_reception_status( + s2_msg.message_id, timeout_reception_status # type: ignore[attr-defined, union-attr] + ) + except TimeoutError: + logger.error( + "Did not receive a reception status on time for %s", + s2_msg.message_id, # type: ignore[attr-defined, union-attr] + ) + self._stop_event.set() + raise + + if reception_status.status != ReceptionStatusValues.OK and raise_on_error: + raise RuntimeError( + f"ReceptionStatus was not OK but rather {reception_status.status}" + ) + + return reception_status + + def send_msg_and_await_reception_status_sync( + self, + s2_msg: S2Message, + timeout_reception_status: float = 5.0, + raise_on_error: bool = True, + ) -> ReceptionStatus: + return asyncio.run_coroutine_threadsafe( + self.send_msg_and_await_reception_status_async( + s2_msg, timeout_reception_status, raise_on_error + ), + self._eventloop, + ).result() + + async def _handle_received_messages(self) -> None: + while True: + msg = await self._received_messages.get() + await self._handlers.handle_message(self, msg) From 8001f5b833f14d3c945852ad0fce20c76b276441 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Mon, 21 Apr 2025 13:55:29 +0200 Subject: [PATCH 12/29] Chore: fixing tests Signed-off-by: Vlad Iftime --- src/s2python/authorization/client.py | 41 +++++++++------ src/s2python/authorization/default_client.py | 52 +++++++++++++------ .../examples/example_pairing_frbc_rm.py | 8 ++- src/s2python/generated/gen_s2_pairing.py | 2 +- 4 files changed, 67 insertions(+), 36 deletions(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 83981af..c323035 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -3,18 +3,13 @@ """ import abc -import base64 import json import uuid import datetime -from pathlib import Path -from typing import Dict, Optional, Tuple, Union, List, Any, Mapping +from typing import Dict, Optional, Tuple, Union, List, Any -# Type annotation for requests, even though stubs might be missing -import requests -from requests import Response -from jwskate import JweCompact, Jwk, Jwt +from jwskate import Jwk from pydantic import BaseModel from s2python.generated.gen_s2_pairing import ( @@ -115,7 +110,9 @@ def store_connection_request_uri(self, uri: str) -> None: self._connection_request_uri = uri elif self.pairing_uri is not None and "requestPairing" in self.pairing_uri: # Fall back to constructing the URI from the pairing URI - self._connection_request_uri = self.pairing_uri.replace("requestPairing", "requestConnection") + self._connection_request_uri = self.pairing_uri.replace( + "requestPairing", "requestConnection" + ) else: # No valid URI could be determined self._connection_request_uri = None @@ -179,10 +176,14 @@ def request_pairing(self) -> PairingResponse: ValueError: If pairing_uri or token is not set, or if the request fails """ if not self.pairing_uri: - raise ValueError("Pairing URI not set. Set pairing_uri before calling request_pairing.") + raise ValueError( + "Pairing URI not set. Set pairing_uri before calling request_pairing." + ) if not self.token: - raise ValueError("Pairing token not set. Set token before calling request_pairing.") + raise ValueError( + "Pairing token not set. Set token before calling request_pairing." + ) # Ensure we have keys if not self._public_key: @@ -211,7 +212,9 @@ def request_pairing(self) -> PairingResponse: # Parse response if status_code != 200: - raise ValueError(f"Pairing request failed with status {status_code}: {response_text}") + raise ValueError( + f"Pairing request failed with status {status_code}: {response_text}" + ) pairing_response = PairingResponse.model_validate(json.loads(response_text)) @@ -231,7 +234,9 @@ def request_connection(self) -> ConnectionDetails: ValueError: If connection request URI is not set or if the request fails """ if not self._connection_request_uri: - raise ValueError("Connection request URI not set. Call request_pairing first.") + raise ValueError( + "Connection request URI not set. Call request_pairing first." + ) # Create connection request connection_request = ConnectionRequest( @@ -249,7 +254,9 @@ def request_connection(self) -> ConnectionDetails: # Parse response if status_code != 200: - raise ValueError(f"Connection request failed with status {status_code}: {response_text}") + raise ValueError( + f"Connection request failed with status {status_code}: {response_text}" + ) connection_details = ConnectionDetails.model_validate(json.loads(response_text)) @@ -283,13 +290,17 @@ def request_connection(self) -> ConnectionDetails: # Replace the URI with the full WebSocket URL connection_data["connectionUri"] = full_ws_url # Recreate the ConnectionDetails object - connection_details = ConnectionDetails.model_validate(connection_data) + connection_details = ConnectionDetails.model_validate( + connection_data + ) print(f"Updated relative WebSocket URI to absolute: {full_ws_url}") except (ValueError, TypeError, KeyError) as e: print(f"Failed to update WebSocket URI: {e}") else: # Log a warning but don't modify the URI if we can't create a proper absolute URI - print("Received relative WebSocket URI but pairing_uri is not available to create absolute URL") + print( + "Received relative WebSocket URI but pairing_uri is not available to create absolute URL" + ) # Store for later use self._connection_details = connection_details diff --git a/src/s2python/authorization/default_client.py b/src/s2python/authorization/default_client.py index b775bcf..a33786e 100644 --- a/src/s2python/authorization/default_client.py +++ b/src/s2python/authorization/default_client.py @@ -16,12 +16,16 @@ from jwskate import JweCompact, Jwk, Jwt from s2python.generated.gen_s2_pairing import ( - ConnectionDetails, PairingToken, S2NodeDescription, Protocols, ) -from s2python.authorization.client import S2AbstractClient, REQTEST_TIMEOUT, KEY_ALGORITHM, PairingDetails +from s2python.authorization.client import ( + S2AbstractClient, + REQTEST_TIMEOUT, + KEY_ALGORITHM, + PairingDetails, +) class S2DefaultClient(S2AbstractClient): @@ -128,11 +132,15 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: """ if challenge is None: if not self._connection_details or not self._connection_details.challenge: - raise ValueError("Challenge not provided and not available in connection details") + raise ValueError( + "Challenge not provided and not available in connection details" + ) challenge = self._connection_details.challenge if not self._key_pair and not self._public_key: - raise ValueError("Public key is not available. Generate or load a key pair first.") + raise ValueError( + "Public key is not available. Generate or load a key pair first." + ) try: # If we have a jwskate Jwk object, use it directly @@ -161,7 +169,9 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: jwt_token_str = str(jwt_token) # Encode the token as base64 - decrypted_challenge_str: str = base64.b64encode(jwt_token_str.encode("utf-8")).decode("utf-8") + decrypted_challenge_str: str = base64.b64encode( + jwt_token_str.encode("utf-8") + ).decode("utf-8") # Store the pairing details if we have all required components if self._pairing_response and self._connection_details: @@ -196,17 +206,30 @@ def establish_secure_connection(self) -> Dict[str, Any]: RuntimeError: If connection establishment fails """ if not self._connection_details: - raise ValueError("Connection details not available. Call request_connection first.") - - if not self._pairing_details or not self._pairing_details.decrypted_challenge_str: - raise ValueError("Challenge solution not available. Call solve_challenge first.") - - - print(f"Would establish WebSocket connection to {self._connection_details.connectionUri}") - print(f"Using solved challenge: {self._pairing_details.decrypted_challenge_str}") + raise ValueError( + "Connection details not available. Call request_connection first." + ) + + if ( + not self._pairing_details + or not self._pairing_details.decrypted_challenge_str + ): + raise ValueError( + "Challenge solution not available. Call solve_challenge first." + ) + + print( + f"Would establish WebSocket connection to {self._connection_details.connectionUri}" + ) + print( + f"Using solved challenge: {self._pairing_details.decrypted_challenge_str}" + ) # Placeholder for the connection object - self._ws_connection = {"status": "connected", "uri": str(self._connection_details.connectionUri)} + self._ws_connection = { + "status": "connected", + "uri": str(self._connection_details.connectionUri), + } return self._ws_connection @@ -216,7 +239,6 @@ def close_connection(self) -> None: TODO: Implement """ if self._ws_connection: - print("Would close WebSocket connection") self._ws_connection = None diff --git a/src/s2python/communication/examples/example_pairing_frbc_rm.py b/src/s2python/communication/examples/example_pairing_frbc_rm.py index 2d8487c..7015e3c 100644 --- a/src/s2python/communication/examples/example_pairing_frbc_rm.py +++ b/src/s2python/communication/examples/example_pairing_frbc_rm.py @@ -1,8 +1,5 @@ import argparse -import uuid import logging -import ssl -from typing import Optional from s2python.communication.examples.example_frbc_rm import start_s2_session from s2python.authorization.default_client import S2DefaultClient @@ -17,7 +14,6 @@ logger = logging.getLogger("s2python") - if __name__ == "__main__": parser = argparse.ArgumentParser( description="A simple S2 resource manager example." @@ -57,7 +53,9 @@ # Create a client to perform the pairing client = S2DefaultClient( pairing_uri=args.endpoint, - token=PairingToken(token=args.pairing_token, ), + token=PairingToken( + token=args.pairing_token, + ), node_description=node_description, verify_certificate=args.verify_ssl, supported_protocols=[Protocols.WebSocketSecure], diff --git a/src/s2python/generated/gen_s2_pairing.py b/src/s2python/generated/gen_s2_pairing.py index 1cd2c06..afb5493 100644 --- a/src/s2python/generated/gen_s2_pairing.py +++ b/src/s2python/generated/gen_s2_pairing.py @@ -7,7 +7,7 @@ from enum import Enum from typing import List -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict class S2Role(str, Enum): From 584e49e3c2e9e7cab0125896f2d1b7eaa35151e3 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 6 May 2025 15:32:01 +0200 Subject: [PATCH 13/29] Add .yaml specs and fixed bw compat. Signed-off-by: Vlad Iftime --- specification/s2-over-ip-pairing.yaml | 136 ++++++++++++++++++++++++++ src/__init__.py | 3 + 2 files changed, 139 insertions(+) create mode 100644 specification/s2-over-ip-pairing.yaml create mode 100644 src/__init__.py diff --git a/specification/s2-over-ip-pairing.yaml b/specification/s2-over-ip-pairing.yaml new file mode 100644 index 0000000..0657095 --- /dev/null +++ b/specification/s2-over-ip-pairing.yaml @@ -0,0 +1,136 @@ +openapi: 3.0.3 +info: + version: "0.1" + title: s2-over-ip pairing and connection initiation + description: "Description of the pairing process over IP for S2" +paths: + /requestPairing: + post: + description: Initiate pairing + requestBody: + description: TODO + content: + application/json: + schema: + $ref: '#/components/schemas/PairingRequest' + responses: + '200': + description: TODO + content: + application/json: + schema: + $ref: '#/components/schemas/PairingResponse' + /requestConnection: + post: + description: TODO + requestBody: + description: TODO + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectionRequest' + responses: + '200': + description: TODO + content: + application/json: + schema: + $ref: '#/components/schemas/ConnectionDetails' +components: + schemas: + PairingInfo: + type: object + properties: + pairingUri: + type: string + format: uri + token: + $ref: "#/components/schemas/PairingToken" + validUntil: + type: string + format: date-time + PairingRequest: + type: object + properties: + token: + $ref: "#/components/schemas/PairingToken" + publicKey: + type: string + format: byte + s2ClientNodeId: + type: string + format: uuid + s2ClientNodeDescription: + $ref: "#/components/schemas/S2NodeDescription" + supportedProtocols: + type: array + items: + $ref: "#/components/schemas/Protocols" + PairingResponse: + type: object + properties: + s2ServerNodeId: + type: string + format: uuid + serverNodeDescription: + $ref: "#/components/schemas/S2NodeDescription" + requestConnectionUri: + type: string + format: uri + ConnectionRequest: + type: object + properties: + s2ClientNodeId: + type: string + format: uuid + supportedProtocols: + type: array + items: + $ref: "#/components/schemas/Protocols" + ConnectionDetails: + type: object + properties: + selectedProtocol: + $ref: "#/components/schemas/Protocols" + challenge: + type: string + format: byte + connectionUri: + type: string + format: uri + S2NodeDescription: + type: object + description: TODO nog even over nadenken + properties: + brand: + type: string + logoUri: + type: string + format: uri + type: + type: string + modelName: + type: string + userDefinedName: + type: string + role: + $ref: "#/components/schemas/S2Role" + deployment: + $ref: "#/components/schemas/Deployment" + Protocols: + type: string + enum: + - WebSocketSecure + S2Role: + type: string + enum: + - CEM + - RM + Deployment: + type: string + enum: + - WAN + - LAN + PairingToken: + type: string + pattern: "^[0-9a-zA-Z]{32}$" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..26153bb --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,3 @@ +import sys +from s2python.communication.s2_connection import S2Connection +sys.modules['s2python.s2_connection'] = sys.modules.get('s2python.communication.s2_connection', None) From 71d89b51cabbf408f952fc993d7d1b0797994917 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 15:17:39 +0200 Subject: [PATCH 14/29] dropping python 3.8 support (#117) * dropping python 3.8 adding 3.13 support --- .github/workflows/ci.yml | 19 +++++++++---------- pyproject.toml | 4 ++-- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ca3906..e55622e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,11 +57,11 @@ jobs: strategy: matrix: python: - - "3.8" - "3.9" - "3.10" - "3.11" - - "3.12" # newest Python that is stable + - "3.12" + - "3.13" # newest Python that is stable platform: - ubuntu-latest # - macos-latest @@ -79,8 +79,7 @@ jobs: - name: Run tests run: >- pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' - -- -rFEx --durations 10 --color yes # pytest args + tox --develop # - name: Generate coverage report # run: pipx run coverage lcov -o coverage.lcov # - name: Upload partial coverage report @@ -96,11 +95,11 @@ jobs: strategy: matrix: python: - - "3.8" - "3.9" - "3.10" - "3.11" - - "3.12" # newest Python that is stable + - "3.12" + - "3.13" # newest Python that is stable platform: - ubuntu-latest # - macos-latest @@ -118,18 +117,18 @@ jobs: - name: Run tests run: >- pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e lint --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' + tox -e lint --develop typecheck: needs: prepare strategy: matrix: python: - - "3.8" - "3.9" - "3.10" - "3.11" - - "3.12" # newest Python that is stable + - "3.12" + - "3.13" # newest Python that is stable platform: - ubuntu-latest # - macos-latest @@ -147,7 +146,7 @@ jobs: - name: Run tests run: >- pipx run --python '${{ steps.setup-python.outputs.python-path }}' - tox -e typecheck --installpkg '${{ needs.prepare.outputs.wheel-distribution }}' + tox -e typecheck --develop finalize: needs: [test, lint, typecheck] diff --git a/pyproject.toml b/pyproject.toml index c23a404..9e2c664 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ version = "0.5.0" readme = "README.rst" license = "Apache-2.0" license-files = ["LICENSE"] -requires-python = ">=3.8, < 3.13" +requires-python = ">=3.9, < 3.14" dependencies = [ "pydantic>=2.8.2", "pytz", @@ -20,11 +20,11 @@ dependencies = [ ] classifiers = [ "Development Status :: 4 - Beta", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] [project.urls] "Source code" = "https://github.com/flexiblepower/s2-ws-json-python" From 8552f9ddadc14432df8bcec6a81711d281eefb7b Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Tue, 6 May 2025 15:48:08 +0200 Subject: [PATCH 15/29] Chore: Fix tests Signed-off-by: Vlad Iftime --- src/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/__init__.py b/src/__init__.py index 26153bb..cc145e2 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,3 +1,3 @@ -import sys -from s2python.communication.s2_connection import S2Connection -sys.modules['s2python.s2_connection'] = sys.modules.get('s2python.communication.s2_connection', None) +# import sys +# from s2python.communication.s2_connection import S2Connection +# sys.modules['s2python.s2_connection'] = sys.modules.get('s2python.communication.s2_connection', None) From 51ffb3b5e73f32a0bca7d1ce333ffef07e726c49 Mon Sep 17 00:00:00 2001 From: Maurice Hendrix Date: Tue, 6 May 2025 17:54:45 +0200 Subject: [PATCH 16/29] moved import backwards compatibility fix to __init__.py in the main s2python folder --- src/__init__.py | 3 --- src/s2python/__init__.py | 6 +++++- 2 files changed, 5 insertions(+), 4 deletions(-) delete mode 100644 src/__init__.py diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index cc145e2..0000000 --- a/src/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# import sys -# from s2python.communication.s2_connection import S2Connection -# sys.modules['s2python.s2_connection'] = sys.modules.get('s2python.communication.s2_connection', None) diff --git a/src/s2python/__init__.py b/src/s2python/__init__.py index 0ab0a42..268a22a 100644 --- a/src/s2python/__init__.py +++ b/src/s2python/__init__.py @@ -1,4 +1,4 @@ -from importlib.metadata import PackageNotFoundError, version # pragma: no cover +from importlib.metadata import PackageNotFoundError, version, sys # pragma: no cover try: # Change here if project is renamed and does not equal the package name @@ -8,3 +8,7 @@ __version__ = "unknown" finally: del version, PackageNotFoundError + + from s2python.communication.s2_connection import S2Connection, AssetDetails + sys.modules['s2python.s2_connection'] = sys.modules.get('s2python.communication.s2_connection', None) + From c91c766af39cd9e45e26a9c0dd083a451ed9c1b9 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:27:36 +0200 Subject: [PATCH 17/29] Update src/s2python/authorization/client.py --- src/s2python/authorization/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index c323035..098b3f9 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -127,7 +127,6 @@ def generate_key_pair(self) -> Tuple[str, str]: Returns: Tuple[str, str]: (public_key, private_key) pair as base64 encoded strings """ - pass @abc.abstractmethod def store_key_pair(self, public_key: str, private_key: str) -> None: From bbaaeb40727e6bac693eb0aa8c2c4e774f5a973b Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:28:02 +0200 Subject: [PATCH 18/29] Update src/s2python/authorization/client.py --- src/s2python/authorization/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 098b3f9..2565986 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -139,7 +139,6 @@ def store_key_pair(self, public_key: str, private_key: str) -> None: public_key: Base64 encoded public key private_key: Base64 encoded private key """ - pass @abc.abstractmethod def _make_https_request( From 407745217fd3cc5705f631f14318740289a6e756 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:28:30 +0200 Subject: [PATCH 19/29] Update src/s2python/authorization/client.py --- src/s2python/authorization/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 2565986..5c958e3 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -162,7 +162,6 @@ def _make_https_request( Returns: Tuple[int, str]: (status_code, response_text) """ - pass def request_pairing(self) -> PairingResponse: """Send a pairing request to the server using client configuration. From d8d2803e90a38bda1d30d689b06550cc9d5c0df6 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:28:52 +0200 Subject: [PATCH 20/29] Update src/s2python/authorization/client.py --- src/s2python/authorization/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 5c958e3..b63d8db 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -324,7 +324,6 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: ValueError: If the public key is not available RuntimeError: If challenge decryption fails """ - pass @abc.abstractmethod def establish_secure_connection(self) -> Any: From c08b0866e1fb2404e19fd25ad9d371e3e47cab7a Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:29:05 +0200 Subject: [PATCH 21/29] Update src/s2python/authorization/client.py --- src/s2python/authorization/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index b63d8db..97d2144 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -340,7 +340,6 @@ def establish_secure_connection(self) -> Any: ValueError: If connection details or solved challenge are not available RuntimeError: If connection establishment fails """ - pass @abc.abstractmethod def close_connection(self) -> None: From 5df19600c51821c5a315bc9eccc077122aa22113 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:29:21 +0200 Subject: [PATCH 22/29] Update src/s2python/authorization/client.py --- src/s2python/authorization/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 97d2144..82f03b5 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -348,7 +348,6 @@ def close_connection(self) -> None: This method should be implemented by concrete subclasses to properly close the connection established by establish_secure_connection. """ - pass @property def pairing_details(self) -> Optional[PairingDetails]: From 4c21e8916153d3a24e40403c5aec0fa244aa4256 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:30:29 +0200 Subject: [PATCH 23/29] Update src/s2python/authorization/default_client.py --- src/s2python/authorization/default_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/s2python/authorization/default_client.py b/src/s2python/authorization/default_client.py index a33786e..55e2af0 100644 --- a/src/s2python/authorization/default_client.py +++ b/src/s2python/authorization/default_client.py @@ -241,4 +241,5 @@ def close_connection(self) -> None: if self._ws_connection: print("Would close WebSocket connection") + self._ws_connection.close() self._ws_connection = None From 1639af877768e384dbb044580cc301cccad4f4bc Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:31:23 +0200 Subject: [PATCH 24/29] Update src/s2python/authorization/default_client.py --- src/s2python/authorization/default_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/s2python/authorization/default_client.py b/src/s2python/authorization/default_client.py index 55e2af0..4bc1a8d 100644 --- a/src/s2python/authorization/default_client.py +++ b/src/s2python/authorization/default_client.py @@ -236,7 +236,6 @@ def establish_secure_connection(self) -> Dict[str, Any]: def close_connection(self) -> None: """Close the WebSocket connection. - TODO: Implement """ if self._ws_connection: From 60f36ae70db02fb2ffe45e4947f880f4e65abe87 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:34:29 +0200 Subject: [PATCH 25/29] Update dev-requirements.txt added jwskate to dev-requirements.txt --- dev-requirements.txt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/dev-requirements.txt b/dev-requirements.txt index 5b3daa2..bef8593 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -238,6 +238,15 @@ wheel==0.45.1 # via pip-tools zipp==3.20.2 # via importlib-metadata +jwskate==0.11.1 +binapy==0.8.0 + # via jwskate +cffi==1.17.1 + # via jwskate +cryptography==44.0.2 + # via jwskate +pycparser==2.22 + # via jwskate # The following packages are considered to be unsafe in a requirements file: # pip From 460ba2d0b241b98518f6a8d100753e7d625ec5e5 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Tue, 6 May 2025 18:36:31 +0200 Subject: [PATCH 26/29] Update default_client.py added pylint exception for # pylint: disable=too-many-arguments --- src/s2python/authorization/default_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/s2python/authorization/default_client.py b/src/s2python/authorization/default_client.py index 4bc1a8d..bf69695 100644 --- a/src/s2python/authorization/default_client.py +++ b/src/s2python/authorization/default_client.py @@ -35,6 +35,7 @@ class S2DefaultClient(S2AbstractClient): This implementation can be used directly or as a reference for custom implementations. """ + # pylint: disable=too-many-arguments def __init__( self, pairing_uri: Optional[str] = None, From 575eee7ad1fc9b493376575d338b4a74feabf8d4 Mon Sep 17 00:00:00 2001 From: Dr Maurice Hendrix Date: Mon, 12 May 2025 12:02:25 +0200 Subject: [PATCH 27/29] fixed linting errors (#119) * fixed linting errors * typing error in backwards compatibility fix * fixed missing requests typing * fixed missing import --- dev-requirements.txt | 1 + src/s2python/__init__.py | 8 +++--- src/s2python/authorization/client.py | 18 ++++++------ src/s2python/authorization/default_client.py | 23 ++++++++------- .../examples/example_pairing_frbc_rm.py | 1 + .../communication/examples/mock_s2_server.py | 28 +++++++++---------- .../examples/mock_s2_websocket.py | 24 ++++++++-------- 7 files changed, 53 insertions(+), 50 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index bef8593..2abfdc1 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -247,6 +247,7 @@ cryptography==44.0.2 # via jwskate pycparser==2.22 # via jwskate +types-requests==2.32.0.20250328 # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/src/s2python/__init__.py b/src/s2python/__init__.py index 268a22a..64bfb48 100644 --- a/src/s2python/__init__.py +++ b/src/s2python/__init__.py @@ -1,4 +1,5 @@ -from importlib.metadata import PackageNotFoundError, version, sys # pragma: no cover +import sys # pragma: no cover +from importlib.metadata import PackageNotFoundError, version # pragma: no cover try: # Change here if project is renamed and does not equal the package name @@ -9,6 +10,5 @@ finally: del version, PackageNotFoundError - from s2python.communication.s2_connection import S2Connection, AssetDetails - sys.modules['s2python.s2_connection'] = sys.modules.get('s2python.communication.s2_connection', None) - + from s2python.communication.s2_connection import S2Connection, AssetDetails # pragma: no cover + sys.modules['s2python.s2_connection'] = sys.modules['s2python.communication.s2_connection'] # pragma: no cover diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index 82f03b5..420c5fa 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -6,6 +6,7 @@ import json import uuid import datetime +import logging from typing import Dict, Optional, Tuple, Union, List, Any @@ -27,6 +28,9 @@ PAIRING_TIMEOUT = datetime.timedelta(minutes=5) KEY_ALGORITHM = "RSA-OAEP-256" +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("S2AbstractClient") class PairingDetails(BaseModel): """Contains all details from the pairing process.""" @@ -188,7 +192,7 @@ def request_pairing(self) -> PairingResponse: self.store_key_pair(public_key, private_key) # Create pairing request - print("Creating pairing request") + logger.info("Creating pairing request") pairing_request = PairingRequest( token=self.token, publicKey=self._public_key, @@ -198,14 +202,14 @@ def request_pairing(self) -> PairingResponse: ) # Make pairing request - print("Making pairing request") + logger.info("Making pairing request") status_code, response_text = self._make_https_request( url=self.pairing_uri, method="POST", data=pairing_request.model_dump(exclude_none=True), headers={"Content-Type": "application/json"}, ) - print(f"Pairing request response: {status_code} {response_text}") + logger.info('Pairing request response: %s %s', status_code, response_text) # Parse response if status_code != 200: @@ -290,14 +294,12 @@ def request_connection(self) -> ConnectionDetails: connection_details = ConnectionDetails.model_validate( connection_data ) - print(f"Updated relative WebSocket URI to absolute: {full_ws_url}") + logger.info('Updated relative WebSocket URI to absolute: %s', full_ws_url) except (ValueError, TypeError, KeyError) as e: - print(f"Failed to update WebSocket URI: {e}") + logger.info('Failed to update WebSocket URI: %s', e) else: # Log a warning but don't modify the URI if we can't create a proper absolute URI - print( - "Received relative WebSocket URI but pairing_uri is not available to create absolute URL" - ) + logger.info('Received relative WebSocket URI but pairing_uri is not available to create absolute URL') # Store for later use self._connection_details = connection_details diff --git a/src/s2python/authorization/default_client.py b/src/s2python/authorization/default_client.py index bf69695..0bde5b8 100644 --- a/src/s2python/authorization/default_client.py +++ b/src/s2python/authorization/default_client.py @@ -8,6 +8,7 @@ import base64 import json import uuid +import logging from typing import Dict, Optional, Tuple, Union, List, Any, Mapping import requests @@ -27,6 +28,9 @@ PairingDetails, ) +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("S2DefaultClient") class S2DefaultClient(S2AbstractClient): """Default implementation of the S2AbstractClient using the requests library for HTTP @@ -63,7 +67,7 @@ def generate_key_pair(self) -> Tuple[str, str]: Returns: Tuple[str, str]: (public_key, private_key) pair as PEM encoded strings """ - print("Generating key pair") + logger.info("Generating key pair") self._key_pair = Jwk.generate_for_alg(KEY_ALGORITHM).with_kid_thumbprint() self._public_jwk = self._key_pair self._private_jwk = self._key_pair @@ -82,7 +86,7 @@ def store_key_pair(self, public_key: str, private_key: str) -> None: public_key: PEM encoded public key private_key: PEM encoded private key """ - print("Storing key pair") + logger.info("Storing key pair") self._public_key = public_key self._private_key = private_key @@ -182,12 +186,12 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: decrypted_challenge_str=decrypted_challenge_str, ) - print(f"Decrypted challenge: {decrypted_challenge_str}") + logger.info('Decrypted challenge: %s', decrypted_challenge_str) return decrypted_challenge_str except (ValueError, TypeError, KeyError, json.JSONDecodeError) as e: error_msg = f"Failed to solve challenge: {e}" - print(error_msg) + logger.info(error_msg) raise RuntimeError(error_msg) from e def establish_secure_connection(self) -> Dict[str, Any]: @@ -219,12 +223,8 @@ def establish_secure_connection(self) -> Dict[str, Any]: "Challenge solution not available. Call solve_challenge first." ) - print( - f"Would establish WebSocket connection to {self._connection_details.connectionUri}" - ) - print( - f"Using solved challenge: {self._pairing_details.decrypted_challenge_str}" - ) + logger.info('Establishing WebSocket connection to %s,', self._connection_details.connectionUri) + logger.info('Using solved challenge: %s', self._pairing_details.decrypted_challenge_str) # Placeholder for the connection object self._ws_connection = { @@ -240,6 +240,5 @@ def close_connection(self) -> None: """ if self._ws_connection: - print("Would close WebSocket connection") - self._ws_connection.close() + logger.info("Would close WebSocket connection") self._ws_connection = None diff --git a/src/s2python/communication/examples/example_pairing_frbc_rm.py b/src/s2python/communication/examples/example_pairing_frbc_rm.py index 7015e3c..f192281 100644 --- a/src/s2python/communication/examples/example_pairing_frbc_rm.py +++ b/src/s2python/communication/examples/example_pairing_frbc_rm.py @@ -87,3 +87,4 @@ except Exception as e: logger.error("Error during pairing process: %s", e) + raise e diff --git a/src/s2python/communication/examples/mock_s2_server.py b/src/s2python/communication/examples/mock_s2_server.py index 5d1e11b..c085b63 100644 --- a/src/s2python/communication/examples/mock_s2_server.py +++ b/src/s2python/communication/examples/mock_s2_server.py @@ -3,9 +3,6 @@ import json from typing import Any import uuid -from urllib.parse import urlparse, parse_qs -import ssl -import threading import logging import random import string @@ -39,14 +36,14 @@ def generate_token() -> str: class MockS2Handler(http.server.BaseHTTPRequestHandler): - def do_POST(self) -> None: + def do_POST(self) -> None: # pylint: disable=C0103 content_length = int(self.headers.get("Content-Length", 0)) post_data = self.rfile.read(content_length).decode("utf-8") try: request_json = json.loads(post_data) - logger.info(f"Received request at {self.path} ") - # logger.info(f"Request body: {request_json}") + logger.info('Received request at %s', self.path) + logger.debug('Request body: %s', request_json) if self.path == "/requestPairing": # Handle pairing request @@ -59,8 +56,8 @@ def do_POST(self) -> None: else: request_token_string = token_obj - logger.info(f"Extracted token: {request_token_string}") - logger.info(f"Expected token: {PAIRING_TOKEN}") + logger.info('Extracted token: %s', request_token_string) + logger.info('Expected token: %s', PAIRING_TOKEN) if request_token_string == PAIRING_TOKEN: self.send_response(200) @@ -115,24 +112,25 @@ def do_POST(self) -> None: self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({"error": "Endpoint not found"}).encode()) - logger.error(f"Unknown endpoint: {self.path}") + logger.error('Unknown endpoint: %s', self.path) except Exception as e: self.send_response(500) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({"error": str(e)}).encode()) - logger.error(f"Error handling request: {e}") + logger.error('Error handling request: %s', e) + raise e - def log_message(self, format: str, *args: Any) -> None: - logger.info(format % args) + def log_message(self, format: str, *args: Any) -> None: # pylint: disable=W0622 + logger.info(format % args) # pylint: disable=W1201 def run_server() -> None: with socketserver.TCPServer(("localhost", HTTP_PORT), MockS2Handler) as httpd: - logger.info(f"Mock S2 Server running at http://localhost:{HTTP_PORT}") - logger.info(f"Use pairing token: {PAIRING_TOKEN}") - logger.info(f"Pairing endpoint: http://localhost:{HTTP_PORT}/requestPairing") + logger.info('Mock S2 Server running at: http://localhost:%s', HTTP_PORT) + logger.info('Use pairing token: %s', PAIRING_TOKEN) + logger.info('Pairing endpoint: http://localhost:%s/requestPairing', HTTP_PORT) httpd.serve_forever() diff --git a/src/s2python/communication/examples/mock_s2_websocket.py b/src/s2python/communication/examples/mock_s2_websocket.py index 8172d1a..e8fd991 100644 --- a/src/s2python/communication/examples/mock_s2_websocket.py +++ b/src/s2python/communication/examples/mock_s2_websocket.py @@ -1,9 +1,9 @@ import asyncio -import websockets import logging import json import uuid from datetime import datetime, timezone +import websockets # Set up logging logging.basicConfig(level=logging.INFO) @@ -18,7 +18,7 @@ async def handle_connection( websocket: websockets.WebSocketServerProtocol, path: str ) -> None: client_id = str(uuid.uuid4()) - logger.info(f"Client {client_id} connected on path: {path}") + logger.info('Client %s connected on path: %s', client_id, path) try: # Send handshake message to client @@ -29,13 +29,13 @@ async def handle_connection( "timestamp": datetime.now(timezone.utc).isoformat(), } await websocket.send(json.dumps(handshake)) - logger.info(f"Sent handshake to client {client_id}") + logger.info('Sent handshake to client %s', client_id) # Listen for messages async for message in websocket: try: data = json.loads(message) - logger.info(f"Received message from client {client_id}: {data}") + logger.info('Received message from client %s: %s', client_id, data) # Extract message type message_type = data.get("type", "") @@ -50,7 +50,7 @@ async def handle_connection( "status": "OK", } await websocket.send(json.dumps(reception_status)) - logger.info(f"Sent reception status for message {message_id}") + logger.info('Sent reception status for message %s', message_id) # Handle specific message types if message_type == "HandshakeResponse": @@ -59,21 +59,23 @@ async def handle_connection( # For FRBC messages, you could add specific handling here except json.JSONDecodeError: - logger.error(f"Invalid JSON received from client {client_id}") + logger.error('Invalid JSON received from client %s', client_id) except Exception as e: - logger.error(f"Error processing message from client {client_id}: {e}") + logger.error('Error processing message from client %s: %s', client_id, e) + raise e except websockets.exceptions.ConnectionClosed: - logger.info(f"Connection with client {client_id} closed") + logger.info('Connection with client %s closed', client_id) except Exception as e: - logger.error(f"Error with client {client_id}: {e}") + logger.error('Error with client %s: %s', client_id, e) + raise e finally: - logger.info(f"Client {client_id} disconnected") + logger.info('Client %s disconnected', client_id) async def start_server() -> None: server = await websockets.serve(handle_connection, "localhost", WS_PORT) - logger.info(f"WebSocket server started on ws://localhost:{WS_PORT}") + logger.info('WebSocket server started on ws://localhost:%s', WS_PORT) # Keep the server running await server.wait_closed() From 71c05b9f5063e02f0813f159ae522179fb165cd0 Mon Sep 17 00:00:00 2001 From: VladIftime <49650168+VladIftime@users.noreply.github.com> Date: Fri, 23 May 2025 10:23:22 +0200 Subject: [PATCH 28/29] Update src/s2python/authorization/default_client.py Co-authored-by: Dr Maurice Hendrix --- src/s2python/authorization/default_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/s2python/authorization/default_client.py b/src/s2python/authorization/default_client.py index 0bde5b8..68a280c 100644 --- a/src/s2python/authorization/default_client.py +++ b/src/s2python/authorization/default_client.py @@ -197,7 +197,7 @@ def solve_challenge(self, challenge: Optional[str] = None) -> str: def establish_secure_connection(self) -> Dict[str, Any]: """Establish a secure WebSocket connection. - This implementation would establish a WebSocket connection + This implementation establishes a WebSocket connection using the connection details and solved challenge. Note: This is a placeholder implementation. In a real implementation, From 6d05f1b04b2e0370e12ab91547e109d443e2eb19 Mon Sep 17 00:00:00 2001 From: Vlad Iftime Date: Fri, 23 May 2025 10:25:53 +0200 Subject: [PATCH 29/29] Small update --- src/s2python/authorization/client.py | 32 +++++++--------------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/src/s2python/authorization/client.py b/src/s2python/authorization/client.py index c323035..62bebbf 100644 --- a/src/s2python/authorization/client.py +++ b/src/s2python/authorization/client.py @@ -110,9 +110,7 @@ def store_connection_request_uri(self, uri: str) -> None: self._connection_request_uri = uri elif self.pairing_uri is not None and "requestPairing" in self.pairing_uri: # Fall back to constructing the URI from the pairing URI - self._connection_request_uri = self.pairing_uri.replace( - "requestPairing", "requestConnection" - ) + self._connection_request_uri = self.pairing_uri.replace("requestPairing", "requestConnection") else: # No valid URI could be determined self._connection_request_uri = None @@ -176,14 +174,10 @@ def request_pairing(self) -> PairingResponse: ValueError: If pairing_uri or token is not set, or if the request fails """ if not self.pairing_uri: - raise ValueError( - "Pairing URI not set. Set pairing_uri before calling request_pairing." - ) + raise ValueError("Pairing URI not set. Set pairing_uri before calling request_pairing.") if not self.token: - raise ValueError( - "Pairing token not set. Set token before calling request_pairing." - ) + raise ValueError("Pairing token not set. Set token before calling request_pairing.") # Ensure we have keys if not self._public_key: @@ -212,9 +206,7 @@ def request_pairing(self) -> PairingResponse: # Parse response if status_code != 200: - raise ValueError( - f"Pairing request failed with status {status_code}: {response_text}" - ) + raise ValueError(f"Pairing request failed with status {status_code}: {response_text}") pairing_response = PairingResponse.model_validate(json.loads(response_text)) @@ -234,9 +226,7 @@ def request_connection(self) -> ConnectionDetails: ValueError: If connection request URI is not set or if the request fails """ if not self._connection_request_uri: - raise ValueError( - "Connection request URI not set. Call request_pairing first." - ) + raise ValueError("Connection request URI not set. Call request_pairing first.") # Create connection request connection_request = ConnectionRequest( @@ -254,9 +244,7 @@ def request_connection(self) -> ConnectionDetails: # Parse response if status_code != 200: - raise ValueError( - f"Connection request failed with status {status_code}: {response_text}" - ) + raise ValueError(f"Connection request failed with status {status_code}: {response_text}") connection_details = ConnectionDetails.model_validate(json.loads(response_text)) @@ -290,17 +278,13 @@ def request_connection(self) -> ConnectionDetails: # Replace the URI with the full WebSocket URL connection_data["connectionUri"] = full_ws_url # Recreate the ConnectionDetails object - connection_details = ConnectionDetails.model_validate( - connection_data - ) + connection_details = ConnectionDetails.model_validate(connection_data) print(f"Updated relative WebSocket URI to absolute: {full_ws_url}") except (ValueError, TypeError, KeyError) as e: print(f"Failed to update WebSocket URI: {e}") else: # Log a warning but don't modify the URI if we can't create a proper absolute URI - print( - "Received relative WebSocket URI but pairing_uri is not available to create absolute URL" - ) + print("Received relative WebSocket URI but pairing_uri is not available to create absolute URL") # Store for later use self._connection_details = connection_details