diff --git a/python/docs/source/reference/package-apis/drivers/index.md b/python/docs/source/reference/package-apis/drivers/index.md index 7cf745ea..a7f524cf 100644 --- a/python/docs/source/reference/package-apis/drivers/index.md +++ b/python/docs/source/reference/package-apis/drivers/index.md @@ -75,6 +75,8 @@ Drivers for automotive diagnostic protocols: diagnostics over DoIP transport * **[UDS over CAN](uds-can.md)** (`jumpstarter-driver-uds-can`) - UDS diagnostics over CAN/ISO-TP transport +* **[SOME/IP](someip.md)** (`jumpstarter-driver-someip`) - SOME/IP protocol + operations (RPC, service discovery, events) via opensomeip ### Debug and Programming Drivers @@ -129,6 +131,7 @@ sdwire.md shell.md ssh.md snmp.md +someip.md tasmota.md tmt.md tftp.md diff --git a/python/docs/source/reference/package-apis/drivers/someip.md b/python/docs/source/reference/package-apis/drivers/someip.md new file mode 120000 index 00000000..4a08f508 --- /dev/null +++ b/python/docs/source/reference/package-apis/drivers/someip.md @@ -0,0 +1 @@ +../../../../../packages/jumpstarter-driver-someip/README.md \ No newline at end of file diff --git a/python/packages/jumpstarter-driver-someip/.gitignore b/python/packages/jumpstarter-driver-someip/.gitignore new file mode 100644 index 00000000..cbc5d672 --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +.coverage +coverage.xml diff --git a/python/packages/jumpstarter-driver-someip/README.md b/python/packages/jumpstarter-driver-someip/README.md new file mode 100644 index 00000000..d6408cab --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/README.md @@ -0,0 +1,163 @@ +# SOME/IP Driver + +`jumpstarter-driver-someip` provides SOME/IP (Scalable service-Oriented MiddlewarE over IP) +protocol operations for Jumpstarter. This driver wraps the +[opensomeip](https://github.com/vtz/opensomeip-python) Python binding to enable remote +RPC calls, service discovery, raw messaging, and event subscriptions with automotive +ECUs over Ethernet. + +## Installation + +```shell +pip3 install --extra-index-url https://pkg.jumpstarter.dev/simple/ jumpstarter-driver-someip +``` + +## Configuration + +| Parameter | Type | Default | Description | +|-------------------|--------|---------------|--------------------------------------------| +| `host` | str | required | Local IP address to bind | +| `port` | int | 30490 | Local SOME/IP port | +| `transport_mode` | str | `UDP` | Transport protocol: `UDP` or `TCP` | +| `multicast_group` | str | `239.127.0.1` | SD multicast group address | +| `multicast_port` | int | 30490 | SD multicast port | + +### UDP (default) + +```yaml +export: + someip: + type: jumpstarter_driver_someip.driver.SomeIp + config: + host: "192.168.1.100" + port: 30490 + transport_mode: UDP + multicast_group: "239.127.0.1" + multicast_port: 30490 +``` + +### TCP + +```yaml +export: + someip: + type: jumpstarter_driver_someip.driver.SomeIp + config: + host: "192.168.1.100" + port: 30490 + transport_mode: TCP +``` + +## API Reference + +### RPC + +- `rpc_call(service_id, method_id, payload, timeout=5.0)` — Make a SOME/IP RPC call and return the response + +### Raw Messaging + +- `send_message(service_id, method_id, payload)` — Send a raw SOME/IP message +- `receive_message(timeout=2.0)` — Receive a raw SOME/IP message + +### Service Discovery + +- `find_service(service_id, instance_id=0xFFFF, timeout=5.0)` — Find services via SOME/IP-SD; use `instance_id=0xFFFF` (default) to match any instance + +### Events + +- `subscribe_eventgroup(eventgroup_id)` — Subscribe to a SOME/IP event group +- `unsubscribe_eventgroup(eventgroup_id)` — Unsubscribe from a SOME/IP event group +- `receive_event(timeout=5.0)` — Receive next event notification + +### Connection Management + +- `close_connection()` — Close the SOME/IP connection +- `reconnect()` — Reconnect to the SOME/IP endpoint + +## Example Usage + +### RPC Call + +```python +from jumpstarter.common.utils import env + +with env() as client: + someip = client.someip + + response = someip.rpc_call(0x1234, 0x0001, b"\x01\x02\x03") + print(f"Response: {bytes.fromhex(response.payload)}") + print(f"Return code: {response.return_code}") +``` + +### Service Discovery + RPC + +```python +from jumpstarter.common.utils import env + +with env() as client: + someip = client.someip + + # Discover available services + services = someip.find_service(0x1234, timeout=3.0) + for svc in services: + print(f"Found: service={svc.service_id:#06x} instance={svc.instance_id:#06x}") + + # Call the first discovered service + if services: + resp = someip.rpc_call(0x1234, 0x0001, b"\x10\x20") + print(f"RPC result: {resp.payload}") +``` + +### Event Subscription + +```python +from jumpstarter.common.utils import env + +with env() as client: + someip = client.someip + + # Subscribe to event group 1 + someip.subscribe_eventgroup(1) + + # Wait for event notifications + try: + event = someip.receive_event(timeout=10.0) + print(f"Event service={event.service_id:#06x} id={event.event_id:#06x}") + print(f"Payload: {bytes.fromhex(event.payload)}") + finally: + someip.unsubscribe_eventgroup(1) +``` + +### Raw Messaging + +```python +from jumpstarter.common.utils import env + +with env() as client: + someip = client.someip + + someip.send_message(0x1234, 0x0001, b"\xAA\xBB") + msg = someip.receive_message(timeout=2.0) + print(f"Received from service={msg.service_id:#06x}: {msg.payload}") +``` + +### Connection Management + +```python +from jumpstarter.common.utils import env + +with env() as client: + someip = client.someip + + # Perform operations... + someip.rpc_call(0x1234, 0x0001, b"\x01") + + # Reconnect after network disruption + someip.reconnect() + + # Continue operations + someip.rpc_call(0x1234, 0x0001, b"\x02") + + # Clean up + someip.close_connection() +``` diff --git a/python/packages/jumpstarter-driver-someip/examples/exporter.yaml b/python/packages/jumpstarter-driver-someip/examples/exporter.yaml new file mode 100644 index 00000000..ab5ab176 --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/examples/exporter.yaml @@ -0,0 +1,16 @@ +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: someip-exporter +endpoint: "" +token: "" +export: + someip: + type: jumpstarter_driver_someip.driver.SomeIp + config: + host: "192.168.1.100" + port: 30490 + transport_mode: UDP + multicast_group: "239.127.0.1" + multicast_port: 30490 diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/__init__.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/client.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/client.py new file mode 100644 index 00000000..fe50cf7d --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/client.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .common import ( + SomeIpEventNotification, + SomeIpMessageResponse, + SomeIpPayload, + SomeIpServiceEntry, +) +from jumpstarter.client import DriverClient + + +@dataclass(kw_only=True) +class SomeIpDriverClient(DriverClient): + """Client interface for SOME/IP operations. + + Provides methods for RPC calls, raw messaging, service discovery, + and event subscriptions over the Jumpstarter remoting layer. + """ + + # --- RPC --- + + def rpc_call( + self, + service_id: int, + method_id: int, + payload: bytes, + timeout: float = 5.0, + ) -> SomeIpMessageResponse: + """Make a SOME/IP RPC call and return the response.""" + msg = SomeIpPayload(data=payload.hex()) + return SomeIpMessageResponse.model_validate( + self.call("rpc_call", service_id, method_id, msg, timeout) + ) + + # --- Raw Messaging --- + + def send_message( + self, + service_id: int, + method_id: int, + payload: bytes, + ) -> None: + """Send a raw SOME/IP message.""" + msg = SomeIpPayload(data=payload.hex()) + self.call("send_message", service_id, method_id, msg) + + def receive_message(self, timeout: float = 2.0) -> SomeIpMessageResponse: + """Receive a raw SOME/IP message.""" + return SomeIpMessageResponse.model_validate( + self.call("receive_message", timeout) + ) + + # --- Service Discovery --- + + def find_service( + self, + service_id: int, + instance_id: int = 0xFFFF, + timeout: float = 5.0, + ) -> list[SomeIpServiceEntry]: + """Find services via SOME/IP-SD.""" + result = self.call("find_service", service_id, instance_id, timeout) + return [SomeIpServiceEntry.model_validate(v) for v in result] + + # --- Events --- + + def subscribe_eventgroup(self, eventgroup_id: int) -> None: + """Subscribe to a SOME/IP event group.""" + self.call("subscribe_eventgroup", eventgroup_id) + + def unsubscribe_eventgroup(self, eventgroup_id: int) -> None: + """Unsubscribe from a SOME/IP event group.""" + self.call("unsubscribe_eventgroup", eventgroup_id) + + def receive_event(self, timeout: float = 5.0) -> SomeIpEventNotification: + """Receive the next event notification.""" + return SomeIpEventNotification.model_validate( + self.call("receive_event", timeout) + ) + + # --- Connection Management --- + + def close_connection(self) -> None: + """Close the SOME/IP connection.""" + self.call("close_connection") + + def reconnect(self) -> None: + """Reconnect to the SOME/IP endpoint.""" + self.call("reconnect") diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py new file mode 100644 index 00000000..d334eb8e --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import re + +from pydantic import BaseModel, Field, field_validator + +_HEX_RE = re.compile(r"([0-9a-fA-F]{2})*") + + +def _validate_hex_string(v: str) -> str: + if not _HEX_RE.fullmatch(v): + raise ValueError(f"payload must be a hex string of even length, got {v!r}") + return v + + +class SomeIpPayload(BaseModel): + """Hex-encoded SOME/IP payload for safe gRPC transport.""" + + data: str + + @field_validator("data") + @classmethod + def _validate_hex(cls, v: str) -> str: + return _validate_hex_string(v) + + +class SomeIpMessageResponse(BaseModel): + """A received SOME/IP message.""" + + service_id: int = Field(ge=0, le=0xFFFF) + method_id: int = Field(ge=0, le=0xFFFF) + client_id: int = Field(ge=0, le=0xFFFF) + session_id: int = Field(ge=0, le=0xFFFF) + protocol_version: int = 1 + interface_version: int = 1 + message_type: int + return_code: int + payload: str + + @field_validator("payload") + @classmethod + def _validate_hex(cls, v: str) -> str: + return _validate_hex_string(v) + + +class SomeIpServiceEntry(BaseModel): + """A SOME/IP service instance (for SD results).""" + + service_id: int = Field(ge=0, le=0xFFFF) + instance_id: int = Field(ge=0, le=0xFFFF) + major_version: int = 1 + minor_version: int = 0 + + +class SomeIpEventNotification(BaseModel): + """A SOME/IP event notification.""" + + service_id: int = Field(ge=0, le=0xFFFF) + event_id: int = Field(ge=0, le=0xFFFF) + payload: str + + @field_validator("payload") + @classmethod + def _validate_hex(cls, v: str) -> str: + return _validate_hex_string(v) diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py new file mode 100644 index 00000000..54d457a3 --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py @@ -0,0 +1,394 @@ +"""Test fixtures for the SOME/IP driver. + +Provides: +- ``MockSomeIpServer``: a minimal TCP server that speaks the SOME/IP wire + protocol (header + payload) for integration testing. +- ``StatefulOsipClient``: a drop-in replacement for ``opensomeip.SomeIpClient`` + that enforces SOME/IP state rules (connection lifecycle, service registry, + event subscriptions, message ordering). Used by the stateful scenario tests + to exercise realistic multi-step workflows through the full gRPC boundary. +""" + +from __future__ import annotations + +import queue +import socket +import struct +import threading +from unittest.mock import MagicMock + +import pytest + +# ========================================================================= +# Wire-protocol constants +# ========================================================================= + +PROTOCOL_VERSION = 0x01 +INTERFACE_VERSION = 0x01 + +MSG_TYPE_REQUEST = 0x00 +MSG_TYPE_RESPONSE = 0x80 +MSG_TYPE_NOTIFICATION = 0x02 +MSG_TYPE_ERROR = 0x81 + +RC_OK = 0x00 +RC_NOT_OK = 0x01 +RC_UNKNOWN_SERVICE = 0x02 +RC_UNKNOWN_METHOD = 0x03 + +HEADER_SIZE = 16 + + +# ========================================================================= +# Wire-protocol helpers +# ========================================================================= + + +def _pack_someip( + service_id: int, + method_id: int, + client_id: int, + session_id: int, + message_type: int, + return_code: int, + payload: bytes, +) -> bytes: + """Pack a SOME/IP message into wire format (big-endian).""" + length = 8 + len(payload) + header = struct.pack( + "!HHIHHBBBB", + service_id, + method_id, + length, + client_id, + session_id, + PROTOCOL_VERSION, + INTERFACE_VERSION, + message_type, + return_code, + ) + return header + payload + + +def _read_someip_message(conn: socket.socket) -> tuple[int, int, int, int, int, int, bytes] | None: + """Read a single SOME/IP message from a TCP socket. + + Returns (service_id, method_id, client_id, session_id, + message_type, return_code, payload) or None on disconnect. + """ + header = b"" + while len(header) < HEADER_SIZE: + chunk = conn.recv(HEADER_SIZE - len(header)) + if not chunk: + return None + header += chunk + + service_id, method_id, length, client_id, session_id, proto_ver, iface_ver, msg_type, ret_code = struct.unpack( + "!HHIHHBBBB", header + ) + + payload_len = length - 8 + payload = b"" + while len(payload) < payload_len: + chunk = conn.recv(payload_len - len(payload)) + if not chunk: + return None + payload += chunk + + return service_id, method_id, client_id, session_id, msg_type, ret_code, payload + + +# ========================================================================= +# MockSomeIpServer — minimal TCP server for wire-level integration tests +# ========================================================================= + + +class MockSomeIpServer: + """Minimal SOME/IP TCP server for integration testing. + + Handles RPC requests by echoing the payload back in a response. + """ + + def __init__(self): + self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind(("127.0.0.1", 0)) + self._server.listen(2) + self._server.settimeout(1.0) + self.port = self._server.getsockname()[1] + self._running = True + self._clients: list[socket.socket] = [] + self._thread = threading.Thread(target=self._accept_loop, daemon=True) + self._thread.start() + + def _accept_loop(self): + while self._running: + try: + conn, _ = self._server.accept() + conn.settimeout(1.0) + self._clients.append(conn) + handler = threading.Thread(target=self._handle_client, args=(conn,), daemon=True) + handler.start() + except OSError: + pass + + def _handle_client(self, conn: socket.socket): + try: + while self._running: + try: + result = _read_someip_message(conn) + if result is None: + break + service_id, method_id, client_id, session_id, msg_type, ret_code, payload = result + responses = self._dispatch( + service_id, method_id, client_id, session_id, msg_type, payload + ) + for resp in responses: + conn.sendall(resp) + except OSError: + break + finally: + conn.close() + + def _dispatch( + self, + service_id: int, + method_id: int, + client_id: int, + session_id: int, + msg_type: int, + payload: bytes, + ) -> list[bytes]: + if msg_type == MSG_TYPE_REQUEST: + return [ + _pack_someip( + service_id, + method_id, + client_id, + session_id, + MSG_TYPE_RESPONSE, + RC_OK, + payload, + ) + ] + return [] + + def stop(self): + self._running = False + self._server.close() + for c in self._clients: + try: + c.close() + except OSError: + pass + self._thread.join(timeout=3) + + +@pytest.fixture +def mock_someip_server(): + """Start a MockSomeIpServer on a dynamic port and yield the port number.""" + server = MockSomeIpServer() + try: + yield server.port + finally: + server.stop() + + +# ========================================================================= +# StatefulOsipClient — drop-in for opensomeip.SomeIpClient +# +# Tracks connection state, service registry, event subscriptions, +# message history, and enforces ordering rules. Designed to be +# injected via ``@patch("jumpstarter_driver_someip.driver.OsipClient")``. +# ========================================================================= + + +class _FakeMessageId: + def __init__(self, service_id: int, method_id: int): + self.service_id = service_id + self.method_id = method_id + + +class _FakeRequestId: + def __init__(self, client_id: int = 0x0001, session_id: int = 0x0001): + self.client_id = client_id + self.session_id = session_id + + +class _FakeMessage: + """Mimics ``opensomeip.message.Message`` with the attributes the driver reads.""" + + def __init__( + self, + service_id: int, + method_id: int, + payload: bytes, + *, + message_type: int = MSG_TYPE_RESPONSE, + return_code: int = RC_OK, + client_id: int = 0x0001, + session_id: int = 0x0001, + ): + self.message_id = _FakeMessageId(service_id, method_id) + self.request_id = _FakeRequestId(client_id, session_id) + self.protocol_version = PROTOCOL_VERSION + self.interface_version = INTERFACE_VERSION + self.message_type = message_type + self.return_code = return_code + self.payload = payload + + +class _FakeReceiver: + """Mimics the opensomeip MessageReceiver with ``_sync_queue``.""" + + def __init__(self): + self._sync_queue: queue.Queue = queue.Queue() + + +class _FakeTransport: + def __init__(self): + self.receiver = _FakeReceiver() + + +class _FakeServiceInstance: + """Mimics ``opensomeip.sd.ServiceInstance``.""" + + def __init__(self, service_id: int, instance_id: int, major_version: int = 1, minor_version: int = 0): + self.service_id = service_id + self.instance_id = instance_id + self.major_version = major_version + self.minor_version = minor_version + + +class SomeIpNotStarted(RuntimeError): + pass + + +class StatefulOsipClient: + """A drop-in replacement for ``opensomeip.SomeIpClient`` that enforces + SOME/IP state rules. + + Tracks: + - Connection lifecycle (start/stop) + - Registered services (for ``find`` / SD) + - Event subscriptions + - RPC call history and configurable responses + - Sent messages (for verification) + - Inbound message queue (for ``receive_message``) + """ + + def __init__(self, config=None) -> None: + self._started = False + self._config = config + + self._registered_services: list[_FakeServiceInstance] = [ + _FakeServiceInstance(0x1234, 0x0001), + _FakeServiceInstance(0x1234, 0x0002, major_version=2), + _FakeServiceInstance(0x5678, 0x0001), + ] + + self._subscribed_eventgroups: set[int] = set() + + self._rpc_responses: dict[tuple[int, int], bytes] = { + (0x1234, 0x0001): b"\x0A\x0B\x0C", + (0x1234, 0x0002): b"\x01\x02\x03\x04", + } + self._rpc_history: list[tuple[int, int, bytes]] = [] + + self._sent_messages: list[_FakeMessage] = [] + + self.transport = _FakeTransport() + + self._event_notifications: list[_FakeMessage] = [] + self._event_receiver = _FakeReceiver() + + self.event_subscriber = MagicMock() + self.event_subscriber.notifications.return_value = self._event_receiver + + def _require_started(self): + if not self._started: + raise SomeIpNotStarted("Client not started — call start() first") + + def start(self): + self._started = True + + def stop(self): + self._started = False + self._subscribed_eventgroups.clear() + + def call(self, message_id, *, payload: bytes = b"", timeout: float = 5.0): + """Simulate an RPC call. Returns a canned response or echoes the payload.""" + self._require_started() + sid = message_id.service_id + mid = message_id.method_id + self._rpc_history.append((sid, mid, payload)) + + resp_payload = self._rpc_responses.get((sid, mid), payload) + return _FakeMessage(sid, mid, resp_payload) + + def send(self, msg): + """Record a sent message and optionally echo it back into the receive queue.""" + self._require_started() + self._sent_messages.append(msg) + echo = _FakeMessage( + msg.message_id.service_id, + msg.message_id.method_id, + msg.payload, + message_type=MSG_TYPE_RESPONSE, + ) + self.transport.receiver._sync_queue.put(echo) + + def find(self, service, *, callback=None): + """Simulate service discovery by calling back with matching registered services.""" + self._require_started() + for svc in self._registered_services: + if svc.service_id == service.service_id: + if service.instance_id == 0xFFFF or svc.instance_id == service.instance_id: + if callback: + callback(svc) + + def subscribe_events(self, eventgroup_id: int): + self._require_started() + self._subscribed_eventgroups.add(eventgroup_id) + + def unsubscribe_events(self, eventgroup_id: int): + self._require_started() + self._subscribed_eventgroups.discard(eventgroup_id) + + # -- test helpers -- + + def inject_event(self, service_id: int, event_id: int, payload: bytes): + """Push a fake event notification into the event receiver queue.""" + msg = _FakeMessage(service_id, event_id, payload, message_type=MSG_TYPE_NOTIFICATION) + self._event_receiver._sync_queue.put(msg) + + def inject_message(self, service_id: int, method_id: int, payload: bytes): + """Push a fake inbound message into the transport receiver queue.""" + msg = _FakeMessage(service_id, method_id, payload) + self.transport.receiver._sync_queue.put(msg) + + def register_rpc_response(self, service_id: int, method_id: int, payload: bytes): + """Configure a canned RPC response for a specific service/method pair.""" + self._rpc_responses[(service_id, method_id)] = payload + + def register_service( + self, service_id: int, instance_id: int, major_version: int = 1, minor_version: int = 0 + ): + """Add a service to the SD registry.""" + self._registered_services.append( + _FakeServiceInstance(service_id, instance_id, major_version, minor_version) + ) + + def unregister_service(self, service_id: int, instance_id: int): + """Remove a service from the SD registry.""" + self._registered_services = [ + s + for s in self._registered_services + if not (s.service_id == service_id and s.instance_id == instance_id) + ] + + +@pytest.fixture +def stateful_osip(): + """Provide a fresh StatefulOsipClient instance.""" + return StatefulOsipClient() diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py new file mode 100644 index 00000000..022e39d0 --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import logging +import queue +import threading +from dataclasses import field + +from opensomeip import ClientConfig, TransportMode +from opensomeip import SomeIpClient as OsipClient +from opensomeip.message import Message +from opensomeip.sd import SdConfig, ServiceInstance +from opensomeip.transport import Endpoint +from opensomeip.types import MessageId +from pydantic import ConfigDict, validate_call +from pydantic.dataclasses import dataclass + +from .common import ( + SomeIpEventNotification, + SomeIpMessageResponse, + SomeIpPayload, + SomeIpServiceEntry, +) +from jumpstarter.driver import Driver, export + +logger = logging.getLogger(__name__) + +_VALID_TRANSPORT_MODES = {"TCP", "UDP"} + + +def _message_to_response(msg: Message) -> SomeIpMessageResponse: + return SomeIpMessageResponse( + service_id=msg.message_id.service_id, + method_id=msg.message_id.method_id, + client_id=msg.request_id.client_id, + session_id=msg.request_id.session_id, + protocol_version=int(msg.protocol_version), + interface_version=msg.interface_version, + message_type=int(msg.message_type), + return_code=int(msg.return_code), + payload=msg.payload.hex(), + ) + + +def _receive_from_queue(receiver: object, timeout: float, error_msg: str) -> Message: + """Receive a message from a MessageReceiver's internal queue. + + opensomeip's MessageReceiver exposes __iter__/__next__ but no public + blocking-with-timeout method. We access the internal _sync_queue as a + pragmatic workaround until a public API is provided. + """ + sync_queue = getattr(receiver, "_sync_queue", None) + if sync_queue is None: + raise RuntimeError("opensomeip MessageReceiver missing _sync_queue; API may have changed") + try: + return sync_queue.get(timeout=timeout) + except queue.Empty: + raise TimeoutError(error_msg) from None + + +@dataclass(kw_only=True, config=ConfigDict(arbitrary_types_allowed=True)) +class SomeIp(Driver): + """SOME/IP driver wrapping the opensomeip Python binding. + + Provides remote access to SOME/IP protocol operations including + RPC calls, service discovery, raw messaging, and event subscriptions + per the SOME/IP specification. + """ + + host: str + port: int = 30490 + transport_mode: str = "UDP" + multicast_group: str = "239.127.0.1" + multicast_port: int = 30490 + + _osip_client: OsipClient = field(init=False, repr=False) + + @classmethod + def client(cls) -> str: + return "jumpstarter_driver_someip.client.SomeIpDriverClient" + + def __post_init__(self): + if hasattr(super(), "__post_init__"): + super().__post_init__() + + transport_upper = self.transport_mode.upper() + if transport_upper not in _VALID_TRANSPORT_MODES: + raise ValueError( + f"Invalid transport_mode: {self.transport_mode!r}. Must be 'TCP' or 'UDP'." + ) + mode = TransportMode.TCP if transport_upper == "TCP" else TransportMode.UDP + + config = ClientConfig( + local_endpoint=Endpoint(self.host, self.port), + sd_config=SdConfig( + multicast_endpoint=Endpoint(self.multicast_group, self.multicast_port), + unicast_endpoint=Endpoint(self.host, self.port), + ), + transport_mode=mode, + ) + self._osip_client = OsipClient(config) + self._osip_client.start() + + def close(self): + """Stop the opensomeip client.""" + try: + self._osip_client.stop() + except Exception: + logger.warning("failed to close opensomeip client", exc_info=True) + super().close() + + # --- RPC --- + + @export + @validate_call(validate_return=True) + def rpc_call( + self, + service_id: int, + method_id: int, + payload: SomeIpPayload, + timeout: float = 5.0, + ) -> SomeIpMessageResponse: + """Make a SOME/IP RPC call and return the response.""" + response = self._osip_client.call( + MessageId(service_id, method_id), + payload=bytes.fromhex(payload.data), + timeout=timeout, + ) + return _message_to_response(response) + + # --- Raw Messaging --- + + @export + @validate_call(validate_return=True) + def send_message( + self, + service_id: int, + method_id: int, + payload: SomeIpPayload, + ) -> None: + """Send a raw SOME/IP message.""" + msg = Message( + message_id=MessageId(service_id, method_id), + payload=bytes.fromhex(payload.data), + ) + self._osip_client.send(msg) + + @export + @validate_call(validate_return=True) + def receive_message(self, timeout: float = 2.0) -> SomeIpMessageResponse: + """Receive a raw SOME/IP message.""" + receiver = self._osip_client.transport.receiver + msg = _receive_from_queue(receiver, timeout, f"No message received within {timeout}s") + return _message_to_response(msg) + + # --- Service Discovery --- + + @export + @validate_call(validate_return=True) + def find_service( + self, + service_id: int, + instance_id: int = 0xFFFF, + timeout: float = 5.0, + ) -> list[SomeIpServiceEntry]: + """Find services via SOME/IP-SD.""" + service = ServiceInstance( + service_id=service_id, + instance_id=instance_id, + ) + found: list[SomeIpServiceEntry] = [] + event = threading.Event() + lock = threading.Lock() + + def on_found(svc: ServiceInstance) -> None: + with lock: + found.append( + SomeIpServiceEntry( + service_id=svc.service_id, + instance_id=svc.instance_id, + major_version=svc.major_version, + minor_version=svc.minor_version, + ) + ) + event.set() + + self._osip_client.find(service, callback=on_found) + event.wait(timeout=timeout) + with lock: + return list(found) + + @export + @validate_call(validate_return=True) + def subscribe_eventgroup(self, eventgroup_id: int) -> None: + """Subscribe to a SOME/IP event group.""" + self._osip_client.subscribe_events(eventgroup_id) + + @export + @validate_call(validate_return=True) + def unsubscribe_eventgroup(self, eventgroup_id: int) -> None: + """Unsubscribe from a SOME/IP event group.""" + self._osip_client.unsubscribe_events(eventgroup_id) + + @export + @validate_call(validate_return=True) + def receive_event(self, timeout: float = 5.0) -> SomeIpEventNotification: + """Receive the next event notification.""" + receiver = self._osip_client.event_subscriber.notifications() + msg = _receive_from_queue(receiver, timeout, f"No event received within {timeout}s") + return SomeIpEventNotification( + service_id=msg.message_id.service_id, + event_id=msg.message_id.method_id, + payload=msg.payload.hex(), + ) + + # --- Connection Management --- + + @export + @validate_call(validate_return=True) + def close_connection(self) -> None: + """Close the SOME/IP connection.""" + try: + self._osip_client.stop() + except Exception: + logger.warning("failed to stop opensomeip client during close_connection", exc_info=True) + + @export + @validate_call(validate_return=True) + def reconnect(self) -> None: + """Reconnect to the SOME/IP endpoint.""" + try: + self._osip_client.stop() + except Exception: + logger.warning("failed to stop opensomeip client during reconnect", exc_info=True) + self._osip_client.start() diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py new file mode 100644 index 00000000..049b21d3 --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py @@ -0,0 +1,780 @@ +import os +import queue as _queue +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from .common import SomeIpEventNotification, SomeIpMessageResponse, SomeIpPayload, SomeIpServiceEntry +from .driver import SomeIp +from jumpstarter.client.core import DriverError +from jumpstarter.common.utils import serve + +# ========================================================================= +# Mock helpers (for isolated unit tests) +# ========================================================================= + + +def _make_mock_message(): + mock_response = MagicMock() + mock_response.message_id.service_id = 0x1234 + mock_response.message_id.method_id = 0x0001 + mock_response.request_id.client_id = 0x0001 + mock_response.request_id.session_id = 0x0001 + mock_response.protocol_version = 1 + mock_response.interface_version = 1 + mock_response.message_type = 0x80 + mock_response.return_code = 0x00 + mock_response.payload = b"\x01\x02\x03" + return mock_response + + +def _make_mock_osip_client(): + """Build a mock OsipClient wired to return canned messages. + + The driver reads from opensomeip's internal ``_sync_queue`` on the + ``MessageReceiver`` (no public blocking-with-timeout API exists yet). + We replicate that structure here so the driver's ``_receive_from_queue`` + helper works as expected. + """ + mock = MagicMock() + + mock_response = _make_mock_message() + mock.call.return_value = mock_response + + sync_queue = _queue.Queue() + sync_queue.put(mock_response) + mock.transport.receiver._sync_queue = sync_queue + + event_queue = _queue.Queue() + event_queue.put(mock_response) + mock_event_receiver = MagicMock() + mock_event_receiver._sync_queue = event_queue + mock.event_subscriber.notifications.return_value = mock_event_receiver + + return mock + + +# ========================================================================= +# Unit tests — happy paths +# ========================================================================= + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_rpc_call(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + resp = client.rpc_call(0x1234, 0x0001, b"\x01\x02\x03") + assert resp.service_id == 0x1234 + assert resp.method_id == 0x0001 + assert resp.payload == "010203" + assert resp.return_code == 0x00 + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_send_message(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.send_message(0x1234, 0x0001, b"\xAA\xBB") + mock_client.send.assert_called_once() + sent_msg = mock_client.send.call_args[0][0] + assert sent_msg.message_id.service_id == 0x1234 + assert sent_msg.message_id.method_id == 0x0001 + assert sent_msg.payload == b"\xAA\xBB" + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_receive_message(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + resp = client.receive_message(timeout=1.0) + assert resp.service_id == 0x1234 + assert resp.payload == "010203" + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_subscribe_eventgroup(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.subscribe_eventgroup(1) + mock_client.subscribe_events.assert_called_once_with(1) + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_unsubscribe_eventgroup(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.unsubscribe_eventgroup(1) + mock_client.unsubscribe_events.assert_called_once_with(1) + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_receive_event(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + resp = client.receive_event(timeout=1.0) + assert resp.service_id == 0x1234 + assert resp.event_id == 0x0001 + assert resp.payload == "010203" + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_find_service(mock_osip_cls): + mock_client = _make_mock_osip_client() + + def fake_find(service, *, callback=None): + svc = MagicMock() + svc.service_id = service.service_id + svc.instance_id = 0x0001 + svc.major_version = 1 + svc.minor_version = 0 + if callback: + callback(svc) + + mock_client.find.side_effect = fake_find + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + results = client.find_service(0x1234, timeout=0.1) + assert len(results) == 1 + assert results[0].service_id == 0x1234 + assert results[0].instance_id == 0x0001 + assert results[0].major_version == 1 + mock_client.find.assert_called_once() + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_find_service_no_results(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_client.find.side_effect = lambda service, *, callback=None: None + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + results = client.find_service(0x9999, timeout=0.1) + assert results == [] + mock_client.find.assert_called_once() + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_find_service_forwards_instance_id(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_client.find.side_effect = lambda service, *, callback=None: None + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.find_service(0x1234, instance_id=0x0042, timeout=0.1) + call_args = mock_client.find.call_args + service_arg = call_args[0][0] + assert service_arg.service_id == 0x1234 + assert service_arg.instance_id == 0x0042 + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_close_connection(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.close_connection() + mock_client.stop.assert_called() + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_reconnect(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.reconnect() + assert mock_client.stop.call_count >= 1 + assert mock_client.start.call_count >= 1 + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_reconnect_survives_stop_failure(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_osip_cls.return_value = mock_client + mock_client.stop.side_effect = [RuntimeError("stop failed"), None] + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.reconnect() + assert mock_client.start.call_count >= 2 + + +# ========================================================================= +# Error-path tests +# ========================================================================= + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_rpc_call_timeout(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_client.call.side_effect = TimeoutError("No response from service") + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + with pytest.raises(DriverError, match="No response from service"): + client.rpc_call(0x1234, 0x0001, b"\x01") + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_receive_message_timeout(mock_osip_cls): + mock_client = _make_mock_osip_client() + mock_client.transport.receiver._sync_queue = _queue.Queue() + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + with pytest.raises(DriverError, match="No message received"): + client.receive_message(timeout=0.1) + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_receive_event_timeout(mock_osip_cls): + mock_client = _make_mock_osip_client() + empty_receiver = MagicMock() + empty_receiver._sync_queue = _queue.Queue() + mock_client.event_subscriber.notifications.return_value = empty_receiver + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + with pytest.raises(DriverError, match="No event received"): + client.receive_event(timeout=0.1) + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_connection_error(mock_osip_cls): + mock_osip_cls.return_value.start.side_effect = ConnectionRefusedError("Connection refused") + + with pytest.raises(ConnectionRefusedError, match="Connection refused"): + SomeIp(host="192.168.1.100", port=30490) + + +# ========================================================================= +# Config validation tests +# ========================================================================= + + +def test_someip_missing_required_host(): + with pytest.raises(ValidationError, match="host"): + SomeIp(port=30490) + + +def test_someip_invalid_port_type(): + with pytest.raises(ValidationError): + SomeIp(host="127.0.0.1", port="not_a_port") + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_invalid_transport_mode(mock_osip_cls): + mock_osip_cls.return_value = _make_mock_osip_client() + with pytest.raises(ValueError, match="Invalid transport_mode"): + SomeIp(host="127.0.0.1", transport_mode="INVALID") + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_custom_config_forwarded(mock_osip_cls): + """Verify non-default config values are passed to opensomeip.""" + mock_osip_cls.return_value = _make_mock_osip_client() + + SomeIp( + host="10.0.0.1", + port=9999, + transport_mode="TCP", + multicast_group="239.1.1.1", + multicast_port=31000, + ) + + mock_osip_cls.assert_called_once() + config = mock_osip_cls.call_args[0][0] + assert config.local_endpoint.ip == "10.0.0.1" + assert config.local_endpoint.port == 9999 + assert config.sd_config.multicast_endpoint.ip == "239.1.1.1" + assert config.sd_config.multicast_endpoint.port == 31000 + + +@pytest.mark.parametrize("odd_hex", ["A", "ABC", "12345", "0"]) +def test_someip_rejects_odd_length_hex_payload(odd_hex): + """Odd-length hex strings are not valid byte sequences and must be rejected.""" + with pytest.raises(ValidationError, match="even length"): + SomeIpPayload(data=odd_hex) + + +@pytest.mark.parametrize("even_hex", ["", "AA", "0102", "aabbccdd"]) +def test_someip_accepts_valid_hex_payload(even_hex): + """Even-length hex strings (including empty) are accepted.""" + p = SomeIpPayload(data=even_hex) + assert p.data == even_hex + + +@pytest.mark.parametrize( + "model_cls, field, value", + [ + (SomeIpMessageResponse, "service_id", 0x1FFFF), + (SomeIpMessageResponse, "method_id", -1), + (SomeIpServiceEntry, "service_id", 0x10000), + (SomeIpServiceEntry, "instance_id", 0x10000), + (SomeIpEventNotification, "service_id", 0x10000), + (SomeIpEventNotification, "event_id", 0x10000), + ], +) +def test_someip_rejects_out_of_range_16bit_ids(model_cls, field, value): + """16-bit SOME/IP ID fields must reject values outside 0..0xFFFF.""" + defaults = { + SomeIpMessageResponse: dict( + service_id=1, method_id=1, client_id=1, session_id=1, + message_type=0, return_code=0, payload="AA", + ), + SomeIpServiceEntry: dict(service_id=1, instance_id=1), + SomeIpEventNotification: dict(service_id=1, event_id=1, payload="AA"), + } + kwargs = {**defaults[model_cls], field: value} + with pytest.raises(ValidationError): + model_cls(**kwargs) + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_close_connection_survives_stop_failure(mock_osip_cls): + """close_connection must not propagate exceptions from stop().""" + mock_client = _make_mock_osip_client() + mock_client.stop.side_effect = [RuntimeError("stop failed"), None] + mock_osip_cls.return_value = mock_client + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.close_connection() + + +@patch("jumpstarter_driver_someip.driver.OsipClient") +def test_someip_tcp_transport_mode(mock_osip_cls): + """Verify TCP transport mode is forwarded correctly.""" + mock_osip_cls.return_value = _make_mock_osip_client() + + SomeIp(host="127.0.0.1", transport_mode="TCP") + + config = mock_osip_cls.call_args[0][0] + from opensomeip import TransportMode + assert config.transport_mode == TransportMode.TCP + + +# ========================================================================= +# Stateful integration tests +# +# These use a StatefulOsipClient (conftest.py) that behaves like a real +# SOME/IP service: it tracks connection state, service registry, event +# subscriptions, RPC history, and sent messages. Each test exercises a +# realistic multi-step workflow through the full gRPC boundary. +# ========================================================================= + + +def _stateful_client_ctx(stateful_osip): + """Context manager: serve() a SomeIp driver backed by the stateful mock.""" + with patch( + "jumpstarter_driver_someip.driver.OsipClient", + return_value=stateful_osip, + ): + instance = SomeIp(host="127.0.0.1", port=30490) + with serve(instance) as c: + yield c + + +@pytest.fixture +def stateful_client(stateful_osip): + yield from _stateful_client_ctx(stateful_osip) + + +# -- RPC workflows --------------------------------------------------------- + + +def test_stateful_rpc_call_returns_canned_response(stateful_client, stateful_osip): + """RPC call to a known service/method returns the pre-configured response.""" + resp = stateful_client.rpc_call(0x1234, 0x0001, b"\xFF") + assert resp.service_id == 0x1234 + assert resp.method_id == 0x0001 + assert resp.payload == "0a0b0c" + assert resp.return_code == 0x00 + assert len(stateful_osip._rpc_history) == 1 + assert stateful_osip._rpc_history[0] == (0x1234, 0x0001, b"\xFF") + + +def test_stateful_rpc_call_unknown_echoes_payload(stateful_client, stateful_osip): + """RPC call to an unknown service/method echoes the request payload.""" + resp = stateful_client.rpc_call(0x9999, 0x0001, b"\xDE\xAD") + assert resp.service_id == 0x9999 + assert resp.payload == "dead" + + +def test_stateful_multiple_rpc_calls(stateful_client, stateful_osip): + """Multiple sequential RPC calls are tracked independently.""" + stateful_client.rpc_call(0x1234, 0x0001, b"\x01") + stateful_client.rpc_call(0x1234, 0x0002, b"\x02") + stateful_client.rpc_call(0x5678, 0x0001, b"\x03") + + assert len(stateful_osip._rpc_history) == 3 + assert stateful_osip._rpc_history[0][0] == 0x1234 + assert stateful_osip._rpc_history[1][2] == b"\x02" + assert stateful_osip._rpc_history[2][0] == 0x5678 + + +def test_stateful_custom_rpc_response(stateful_client, stateful_osip): + """Register a custom RPC response and verify it's returned.""" + stateful_osip.register_rpc_response(0xAAAA, 0x0001, b"\xCA\xFE") + resp = stateful_client.rpc_call(0xAAAA, 0x0001, b"\x00") + assert resp.payload == "cafe" + + +# -- send / receive messaging workflow ------------------------------------- + + +def test_stateful_send_then_receive(stateful_client, stateful_osip): + """send_message echoes into the receive queue; receive_message reads it.""" + stateful_client.send_message(0x1234, 0x0001, b"\xAA\xBB") + resp = stateful_client.receive_message(timeout=1.0) + assert resp.service_id == 0x1234 + assert resp.method_id == 0x0001 + assert resp.payload == "aabb" + assert len(stateful_osip._sent_messages) == 1 + + +def test_stateful_inject_message(stateful_client, stateful_osip): + """Injected messages appear in the receive queue.""" + stateful_osip.inject_message(0x5678, 0x0002, b"\x01\x02\x03") + resp = stateful_client.receive_message(timeout=1.0) + assert resp.service_id == 0x5678 + assert resp.method_id == 0x0002 + assert resp.payload == "010203" + + +def test_stateful_multiple_messages_fifo(stateful_client, stateful_osip): + """Multiple injected messages are received in FIFO order.""" + stateful_osip.inject_message(0x1111, 0x0001, b"\x01") + stateful_osip.inject_message(0x2222, 0x0002, b"\x02") + stateful_osip.inject_message(0x3333, 0x0003, b"\x03") + + r1 = stateful_client.receive_message(timeout=1.0) + r2 = stateful_client.receive_message(timeout=1.0) + r3 = stateful_client.receive_message(timeout=1.0) + + assert r1.service_id == 0x1111 + assert r2.service_id == 0x2222 + assert r3.service_id == 0x3333 + + +# -- service discovery workflow -------------------------------------------- + + +def test_stateful_find_service_all_instances(stateful_client): + """find_service with wildcard instance_id returns all matching services.""" + results = stateful_client.find_service(0x1234, timeout=0.1) + assert len(results) == 2 + ids = {r.instance_id for r in results} + assert ids == {0x0001, 0x0002} + + +def test_stateful_find_service_specific_instance(stateful_client): + """find_service with specific instance_id returns only that instance.""" + results = stateful_client.find_service(0x1234, instance_id=0x0001, timeout=0.1) + assert len(results) == 1 + assert results[0].instance_id == 0x0001 + assert results[0].major_version == 1 + + +def test_stateful_find_service_not_found(stateful_client): + """find_service for a non-existent service returns empty list.""" + results = stateful_client.find_service(0xDEAD, timeout=0.1) + assert results == [] + + +def test_stateful_find_service_version_info(stateful_client): + """find_service returns correct version information.""" + results = stateful_client.find_service(0x1234, instance_id=0x0002, timeout=0.1) + assert len(results) == 1 + assert results[0].major_version == 2 + assert results[0].minor_version == 0 + + +def test_stateful_find_different_services(stateful_client): + """find_service for different service IDs returns independent results.""" + results_1234 = stateful_client.find_service(0x1234, timeout=0.1) + results_5678 = stateful_client.find_service(0x5678, timeout=0.1) + + assert len(results_1234) == 2 + assert len(results_5678) == 1 + assert results_5678[0].service_id == 0x5678 + assert results_5678[0].instance_id == 0x0001 + + +def test_stateful_find_service_dynamic_registration(stateful_client, stateful_osip): + """Services registered after startup appear in subsequent discoveries.""" + results_before = stateful_client.find_service(0xAAAA, timeout=0.1) + assert results_before == [] + + stateful_osip.register_service(0xAAAA, 0x0001, major_version=3, minor_version=1) + results_after = stateful_client.find_service(0xAAAA, timeout=0.1) + assert len(results_after) == 1 + assert results_after[0].service_id == 0xAAAA + assert results_after[0].major_version == 3 + assert results_after[0].minor_version == 1 + + +def test_stateful_find_service_dynamic_unregistration(stateful_client, stateful_osip): + """Services removed from the registry no longer appear in discoveries.""" + results_before = stateful_client.find_service(0x5678, timeout=0.1) + assert len(results_before) == 1 + + stateful_osip.unregister_service(0x5678, 0x0001) + results_after = stateful_client.find_service(0x5678, timeout=0.1) + assert results_after == [] + + +def test_stateful_find_service_after_reconnect(stateful_client, stateful_osip): + """Service registry persists across reconnect.""" + results_before = stateful_client.find_service(0x1234, timeout=0.1) + assert len(results_before) == 2 + + stateful_client.reconnect() + + results_after = stateful_client.find_service(0x1234, timeout=0.1) + assert len(results_after) == 2 + + +def test_stateful_find_service_default_instance_wildcard(stateful_client): + """find_service without explicit instance_id uses 0xFFFF wildcard.""" + results_explicit = stateful_client.find_service(0x1234, instance_id=0xFFFF, timeout=0.1) + results_default = stateful_client.find_service(0x1234, timeout=0.1) + + assert len(results_explicit) == len(results_default) + explicit_ids = {r.instance_id for r in results_explicit} + default_ids = {r.instance_id for r in results_default} + assert explicit_ids == default_ids + + +def test_stateful_discover_then_rpc_to_each_instance(stateful_client, stateful_osip): + """Discover all instances, then make RPC calls to each one.""" + services = stateful_client.find_service(0x1234, timeout=0.1) + assert len(services) == 2 + + for svc in services: + resp = stateful_client.rpc_call(svc.service_id, 0x0001, b"\xAA") + assert resp.service_id == svc.service_id + + assert len(stateful_osip._rpc_history) == 2 + + +# -- event subscription workflow ------------------------------------------- + + +def test_stateful_subscribe_receive_unsubscribe(stateful_client, stateful_osip): + """Full event lifecycle: subscribe, receive events, unsubscribe.""" + stateful_client.subscribe_eventgroup(1) + assert 1 in stateful_osip._subscribed_eventgroups + + stateful_osip.inject_event(0x1234, 0x8001, b"\xCA\xFE") + event = stateful_client.receive_event(timeout=1.0) + assert event.service_id == 0x1234 + assert event.event_id == 0x8001 + assert event.payload == "cafe" + + stateful_client.unsubscribe_eventgroup(1) + assert 1 not in stateful_osip._subscribed_eventgroups + + +def test_stateful_multiple_events_fifo(stateful_client, stateful_osip): + """Multiple events are received in FIFO order.""" + stateful_client.subscribe_eventgroup(1) + + stateful_osip.inject_event(0x1234, 0x8001, b"\x01") + stateful_osip.inject_event(0x1234, 0x8002, b"\x02") + stateful_osip.inject_event(0x1234, 0x8003, b"\x03") + + e1 = stateful_client.receive_event(timeout=1.0) + e2 = stateful_client.receive_event(timeout=1.0) + e3 = stateful_client.receive_event(timeout=1.0) + + assert e1.event_id == 0x8001 + assert e2.event_id == 0x8002 + assert e3.event_id == 0x8003 + + stateful_client.unsubscribe_eventgroup(1) + + +def test_stateful_subscribe_multiple_eventgroups(stateful_client, stateful_osip): + """Subscribing to multiple event groups tracks all of them.""" + stateful_client.subscribe_eventgroup(1) + stateful_client.subscribe_eventgroup(2) + stateful_client.subscribe_eventgroup(3) + + assert stateful_osip._subscribed_eventgroups == {1, 2, 3} + + stateful_client.unsubscribe_eventgroup(2) + assert stateful_osip._subscribed_eventgroups == {1, 3} + + +def test_stateful_event_timeout_when_no_events(stateful_client): + """receive_event times out when no events are available.""" + with pytest.raises(DriverError, match="No event received"): + stateful_client.receive_event(timeout=0.1) + + +# -- connection management workflows --------------------------------------- + + +def test_stateful_reconnect_resets_subscriptions(stateful_client, stateful_osip): + """reconnect() stops and restarts the client, clearing subscriptions.""" + stateful_client.subscribe_eventgroup(1) + assert 1 in stateful_osip._subscribed_eventgroups + + stateful_client.reconnect() + assert stateful_osip._started is True + assert stateful_osip._subscribed_eventgroups == set() + + +def test_stateful_close_then_reconnect(stateful_client, stateful_osip): + """close_connection + reconnect restores the client.""" + stateful_client.close_connection() + assert stateful_osip._started is False + + stateful_client.reconnect() + assert stateful_osip._started is True + + +# -- end-to-end composite workflows ---------------------------------------- + + +def test_stateful_full_rpc_session(stateful_client, stateful_osip): + """Simulate a complete RPC session: discover, call, verify, disconnect.""" + services = stateful_client.find_service(0x1234, timeout=0.1) + assert len(services) >= 1 + + resp = stateful_client.rpc_call(0x1234, 0x0001, b"\x01\x02\x03") + assert resp.service_id == 0x1234 + assert resp.return_code == 0x00 + + assert len(stateful_osip._rpc_history) == 1 + + stateful_client.close_connection() + assert stateful_osip._started is False + + +def test_stateful_messaging_with_reconnect(stateful_client, stateful_osip): + """Send messages, reconnect, verify the client is operational again.""" + stateful_client.send_message(0x1234, 0x0001, b"\x01") + resp = stateful_client.receive_message(timeout=1.0) + assert resp.payload == "01" + + stateful_client.reconnect() + + stateful_client.send_message(0x5678, 0x0002, b"\x02") + resp = stateful_client.receive_message(timeout=1.0) + assert resp.payload == "02" + + assert len(stateful_osip._sent_messages) == 2 + + +def test_stateful_event_session_with_reconnect(stateful_client, stateful_osip): + """Subscribe, receive events, reconnect, re-subscribe, receive again.""" + stateful_client.subscribe_eventgroup(1) + stateful_osip.inject_event(0x1234, 0x8001, b"\xAA") + e1 = stateful_client.receive_event(timeout=1.0) + assert e1.payload == "aa" + + stateful_client.reconnect() + assert stateful_osip._subscribed_eventgroups == set() + + stateful_client.subscribe_eventgroup(1) + stateful_osip.inject_event(0x1234, 0x8002, b"\xBB") + e2 = stateful_client.receive_event(timeout=1.0) + assert e2.payload == "bb" + + stateful_client.unsubscribe_eventgroup(1) + + +def test_stateful_discover_rpc_events_workflow(stateful_client, stateful_osip): + """Full workflow: discover services, make RPC calls, subscribe to events, + receive notifications, and clean up.""" + services = stateful_client.find_service(0x1234, timeout=0.1) + assert len(services) == 2 + + resp1 = stateful_client.rpc_call(0x1234, 0x0001, b"\x10") + assert resp1.payload == "0a0b0c" + + resp2 = stateful_client.rpc_call(0x1234, 0x0002, b"\x20") + assert resp2.payload == "01020304" + + stateful_client.subscribe_eventgroup(1) + stateful_osip.inject_event(0x1234, 0x8001, b"\xEE") + event = stateful_client.receive_event(timeout=1.0) + assert event.payload == "ee" + + stateful_client.unsubscribe_eventgroup(1) + stateful_client.close_connection() + + assert len(stateful_osip._rpc_history) == 2 + assert stateful_osip._started is False + + +# ========================================================================= +# Wire-level integration tests with MockSomeIpServer +# +# opensomeip uses Service Discovery to locate services, so connecting to a +# raw TCP mock server requires a full SD-capable SOME/IP environment. +# These tests are intended for CI environments with proper SOME/IP networking +# and are skipped by default. Set SOMEIP_INTEGRATION_TESTS=1 to enable. +# ========================================================================= + +_RUN_INTEGRATION = os.environ.get("SOMEIP_INTEGRATION_TESTS", "0") == "1" + + +@pytest.mark.skipif(not _RUN_INTEGRATION, reason="SOMEIP_INTEGRATION_TESTS not set") +def test_someip_simulated_rpc_call(mock_someip_server): + driver = SomeIp( + host="127.0.0.1", + port=mock_someip_server, + transport_mode="TCP", + ) + with serve(driver) as client: + resp = client.rpc_call(0x1234, 0x0001, b"\x01\x02\x03") + assert resp.service_id == 0x1234 + assert resp.method_id == 0x0001 + assert resp.return_code == 0x00 + assert resp.payload == "010203" + + +@pytest.mark.skipif(not _RUN_INTEGRATION, reason="SOMEIP_INTEGRATION_TESTS not set") +def test_someip_simulated_send_receive(mock_someip_server): + driver = SomeIp( + host="127.0.0.1", + port=mock_someip_server, + transport_mode="TCP", + ) + with serve(driver) as client: + client.send_message(0x1234, 0x0001, b"\xAA\xBB\xCC") + resp = client.receive_message(timeout=2.0) + assert resp.service_id == 0x1234 + assert resp.payload == "aabbcc" diff --git a/python/packages/jumpstarter-driver-someip/pyproject.toml b/python/packages/jumpstarter-driver-someip/pyproject.toml new file mode 100644 index 00000000..3eadd1d9 --- /dev/null +++ b/python/packages/jumpstarter-driver-someip/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "jumpstarter-driver-someip" +dynamic = ["version", "urls"] +description = "SOME/IP (Scalable service-Oriented MiddlewarE over IP) driver for Jumpstarter" +readme = "README.md" +license = "Apache-2.0" +authors = [ + { name = "Vinicius Zein", email = "vtzein@gmail.com" }, +] +requires-python = ">=3.11" +dependencies = [ + "jumpstarter", + "opensomeip>=0.1.2,<0.2.0", +] + +[project.entry-points."jumpstarter.drivers"] +SomeIp = "jumpstarter_driver_someip.driver:SomeIp" + +[dependency-groups] +dev = [ + "pytest-cov>=6.0.0", + "pytest>=8.3.3", +] + +[tool.hatch.metadata.hooks.vcs.urls] +Homepage = "https://jumpstarter.dev" +source_archive = "https://github.com/jumpstarter-dev/repo/archive/{commit_hash}.zip" + +[tool.hatch.version] +source = "vcs" +raw-options = { 'root' = '../../../' } + +[tool.pytest.ini_options] +addopts = "--cov --cov-report=html --cov-report=xml" +log_cli = true +log_cli_level = "INFO" +testpaths = ["jumpstarter_driver_someip"] + +[build-system] +requires = ["hatchling", "hatch-vcs", "hatch-pin-jumpstarter"] +build-backend = "hatchling.build" + +[tool.hatch.build.hooks.pin_jumpstarter] +name = "pin_jumpstarter" diff --git a/python/uv.lock b/python/uv.lock index fdce7987..bd01b6dd 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -35,6 +35,7 @@ members = [ "jumpstarter-driver-sdwire", "jumpstarter-driver-shell", "jumpstarter-driver-snmp", + "jumpstarter-driver-someip", "jumpstarter-driver-ssh", "jumpstarter-driver-ssh-mitm", "jumpstarter-driver-tasmota", @@ -2416,6 +2417,32 @@ dev = [ { name = "pytest-cov", specifier = ">=6.0.0" }, ] +[[package]] +name = "jumpstarter-driver-someip" +source = { editable = "packages/jumpstarter-driver-someip" } +dependencies = [ + { name = "jumpstarter" }, + { name = "opensomeip" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "jumpstarter", editable = "packages/jumpstarter" }, + { name = "opensomeip", specifier = ">=0.1.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, +] + [[package]] name = "jumpstarter-driver-ssh" source = { editable = "packages/jumpstarter-driver-ssh" } @@ -3492,6 +3519,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/ec/d149ed82a5cc175460e044e040d2e09e496c74e699112c6ee9d1828ff6a4/opendal-0.45.20-cp313-cp313t-win_amd64.whl", hash = "sha256:5af03824ffca796a2c77b570760bb7ddc754e9485f882fed5cc834aab4772cbf", size = 14951593, upload-time = "2025-05-26T07:02:09.722Z" }, ] +[[package]] +name = "opensomeip" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/8a/e588ff9c51a70cae76ba05c33f08c2185dbe4374f3d1a9e051246c467f4e/opensomeip-0.1.2.tar.gz", hash = "sha256:bed1d4a9c4d721df04b8561b6e164bf72b7daf26ca2fd9d017582ed8a0ae3146", size = 708564, upload-time = "2026-03-16T02:13:52.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/9b/741e0bcf53458772bb656c8acfc384cc896d97524322c80c83f074c285dd/opensomeip-0.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e49b4d3381c0b0f64049ba925b63d63518363069f8012055ae912167255f1606", size = 733345, upload-time = "2026-03-16T02:13:22.637Z" }, + { url = "https://files.pythonhosted.org/packages/28/80/9ce395197657710d21be98c512d4b48db97adc3f103236f182bf260d5c32/opensomeip-0.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e921a99cc90afc312625357d510ce23e37f0af316592f33dda12e4c0b7214a9", size = 682655, upload-time = "2026-03-16T02:13:24.203Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5a718932e8a7bb8d60f36760f4b9ffb721f9c5f26b91b9a24492ad708ac2/opensomeip-0.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:be6fea1e526c516cc3fff1c5b2baca45d9c5671e42728e5bd04e55d0b4589e75", size = 813009, upload-time = "2026-03-16T02:13:25.563Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/b1c7bae8ae3e891b5d21c6999331ed3c6e03230fbc7067ac035a2d72c1f6/opensomeip-0.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b45d5bfaabb58e65bf53d96390a089b8491d557ea0d2c295bfb8cff9585ced73", size = 863228, upload-time = "2026-03-16T02:13:27.108Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/ea4171eb5e110256f910c1a2f34a6bb2d8194967610b054835c9b3708746/opensomeip-0.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:854650158dd4ea16ed0f7b880d6099ec21e5ac3bee9f3688b8ec09260ed993dc", size = 464390, upload-time = "2026-03-16T02:13:28.397Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f5/6160a68c762a60897113cff8c7c0eeb408b4fcf15ab2c0ca29400e7137a1/opensomeip-0.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:13ec15ddd0de1278e549bc92d76fb53821e3fd122b5cf53c164ff463a7cb9919", size = 740882, upload-time = "2026-03-16T02:13:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/7c/14/60fa197be90d241c8af3bdefb055ba1eb06898e3aa2e8349651c8206280c/opensomeip-0.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a3c2510b65f48f6d20a8c78d0411493360c6cbb9029183d6562e5fca7d9d9f8", size = 684413, upload-time = "2026-03-16T02:13:31.377Z" }, + { url = "https://files.pythonhosted.org/packages/bf/dc/685caf1925e94681baab1b5ababb12f4143c66424ab866104ccf9f9348c1/opensomeip-0.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:714daf6b740611074ab090aa0b5b4506612da9dae451b303d378c25bb2d9be45", size = 813161, upload-time = "2026-03-16T02:13:33.029Z" }, + { url = "https://files.pythonhosted.org/packages/bd/31/8dccbdb46e93cfce3cb86e0cb9b00ab9516563b2a392ead3e4fbd61bef4c/opensomeip-0.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c47ca15337f98a727b83cffe08c03cc380d28f5e6d1641c5d9e51e05703b30c7", size = 865382, upload-time = "2026-03-16T02:13:34.775Z" }, + { url = "https://files.pythonhosted.org/packages/d5/16/7235a5e5f6df5e74a8d03524ae773292e98a9717d96238f951da84107f6b/opensomeip-0.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:0df4698e7b307c31c255ed71122df8f2e2368cd5867f3df57163dce2f686ce6b", size = 464833, upload-time = "2026-03-16T02:13:36.347Z" }, + { url = "https://files.pythonhosted.org/packages/7a/67/e600cd1bdcdbea9845cd56abd0d07f73ecfc2efe1cde14c6286c20f7e787/opensomeip-0.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdcbf88f291f16e5aee021f58c6dde5c018bb18d941fc833423cd7a1a54b5d52", size = 740903, upload-time = "2026-03-16T02:13:37.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b7/4f2cfefc2d5105b3c9202e83061a7fe1e8af77881ef244798f5b13af2d49/opensomeip-0.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:67b5722f1328e5bf70ee45a6a7e29266a0bacfe2c3fe6977ec7d13c31a21c37b", size = 684437, upload-time = "2026-03-16T02:13:39.306Z" }, + { url = "https://files.pythonhosted.org/packages/4f/df/537c222ee56b8af8b94d966ebb1d369b6da99dd046a51bdaee1111f70468/opensomeip-0.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a18f578b2d59c6bd29e6696767b1e6b38a531602986843b6de952bd0b378023a", size = 813354, upload-time = "2026-03-16T02:13:40.602Z" }, + { url = "https://files.pythonhosted.org/packages/43/88/94530ce0be558e82dc9c32f2db865c67b06386ee70058244e559643a26b0/opensomeip-0.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:850dcb89e7facb0dcb3ac00b530b3b85331f101659be5edb87ed24e512a9399b", size = 865877, upload-time = "2026-03-16T02:13:42.181Z" }, + { url = "https://files.pythonhosted.org/packages/6b/70/cb9a260889cf995fc2218e4fc1bd8bf56f1385de47fce6a0ef17be28f0ba/opensomeip-0.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:d7fdf78552a0ff353c198dd360b44fd164a644e941d5b3260851851dc541df77", size = 464901, upload-time = "2026-03-16T02:13:43.521Z" }, + { url = "https://files.pythonhosted.org/packages/31/c8/4ee56bbd60c83a8322b37cc605fca0e2b53d980319bc5fd6f86ea11cb79d/opensomeip-0.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9e6a1209e4a980327d567c21c9a6e1ceefbcf8375bd7070854df2a96c896d96c", size = 741579, upload-time = "2026-03-16T02:13:45.251Z" }, + { url = "https://files.pythonhosted.org/packages/d9/04/100d849ddcfe85381b55cba519b31c44e8f2d37b986971172d6720934c76/opensomeip-0.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b65fe597f5fc9e803f58f70571d916cf8a3b0319dc27dcbc84de0904f2e0d5c5", size = 685563, upload-time = "2026-03-16T02:13:46.6Z" }, + { url = "https://files.pythonhosted.org/packages/a3/92/d67ab65bc315b7c32c4d9aabd2314e025760299e2313006f1ed0867704e2/opensomeip-0.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f3e0e82a22e06def7a5489360acd4565a59805372ccef9ff367e5b484c127dfa", size = 816253, upload-time = "2026-03-16T02:13:47.95Z" }, + { url = "https://files.pythonhosted.org/packages/3e/eb/a214891964a17d8385ecb1b472e08832d6b6673b1ed5ebf5ab21d569c59b/opensomeip-0.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9be1a1be19566084067b93f8dc1c822f66975459601a25359b4b4e729be3f445", size = 866281, upload-time = "2026-03-16T02:13:50.032Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e4/f662e91ad0328cd627fa03c0b3638dfd851f6af609b4d53a6863ccb1a29f/opensomeip-0.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:4bde6cfaa83b79719d5a06b3c0440c8544ede9938e010ebe0c814d6fc1a0e841", size = 475923, upload-time = "2026-03-16T02:13:51.33Z" }, +] + [[package]] name = "oras" version = "0.2.37"