diff --git a/python/packages/jumpstarter-driver-someip/README.md b/python/packages/jumpstarter-driver-someip/README.md index 5652d385d..7ef1490ba 100644 --- a/python/packages/jumpstarter-driver-someip/README.md +++ b/python/packages/jumpstarter-driver-someip/README.md @@ -156,6 +156,44 @@ with env() as client: someip.close_connection() ``` +### Server / Provider (act as an ECU) + +The same driver can also *provide* SOME/IP services — offer them via Service +Discovery, answer RPC requests with canned responses, and publish events. This +turns a Jumpstarter exporter into a simulated ECU, useful for exercising a +device-under-test that is a SOME/IP *client* of another ECU. + +RPC handlers run inside the exporter process, so responses are configured +declaratively rather than via a per-request callback: set a canned response for +a `(service_id, method_id)` and the server serves it. This maps naturally onto +getter-style SOME/IP methods; update the response to change what a client reads. + +```python +from jumpstarter.common.utils import env + +with env() as client: + someip = client.someip + + # Offer a service instance (starts the server on first use) + someip.offer_service(0x1801, instance_id=0x0001, major_version=1) + + # Answer an RPC method with a fixed payload (E_OK by default) + someip.set_method_response(0x1801, 0x0005, b"\x01\x02\x03\x04") + # ...or return an error return code + someip.set_method_response(0x1801, 0x0006, b"", return_code=0x01) + + # Publish events to subscribers of an event group + someip.register_event(0x1801, 0x8001, eventgroup_id=1) + someip.publish_event(0x1801, 0x8001, b"\x2d\x00") + # Field events are cached and served to new subscribers + someip.set_field(0x1801, 0x8002, b"\x01") + + # Introspect / tear down + print(someip.list_offered_services()) + someip.stop_offer_service(0x1801, 0x0001) + someip.stop_server() +``` + ## API Reference ```{eval-rst} diff --git a/python/packages/jumpstarter-driver-someip/examples/exporter.yaml b/python/packages/jumpstarter-driver-someip/examples/exporter.yaml index 3f7d14b66..9321b0891 100644 --- a/python/packages/jumpstarter-driver-someip/examples/exporter.yaml +++ b/python/packages/jumpstarter-driver-someip/examples/exporter.yaml @@ -32,3 +32,24 @@ export: transport_mode: UDP remote_host: "192.168.100.10" remote_port: 30490 +--- +# Provider / server mode - offer services, answer RPC, and publish events +# (act as a simulated ECU that a device-under-test's SOME/IP client talks to). +# Bind to the interface the DUT shares; services are offered at runtime via the +# offer_service / set_method_response / publish_event client verbs. +apiVersion: jumpstarter.dev/v1alpha1 +kind: ExporterConfig +metadata: + namespace: default + name: someip-server-exporter +endpoint: "" +token: "" +export: + someip: + type: jumpstarter_driver_someip.driver.SomeIp + config: + host: "192.168.100.1" + 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/client.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/client.py index 377fd2712..500f6b3af 100644 --- a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/client.py +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/client.py @@ -5,6 +5,7 @@ from .common import ( SomeIpEventNotification, SomeIpMessageResponse, + SomeIpOfferedService, SomeIpPayload, SomeIpServiceEntry, ) @@ -34,9 +35,7 @@ def rpc_call( ) -> 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) - ) + return SomeIpMessageResponse.model_validate(self.call("rpc_call", service_id, method_id, msg, timeout)) # --- Raw Messaging --- @@ -52,9 +51,7 @@ def send_message( def receive_message(self, timeout: float = 2.0) -> SomeIpMessageResponse: """Receive a raw SOME/IP message.""" - return SomeIpMessageResponse.model_validate( - self.call("receive_message", timeout) - ) + return SomeIpMessageResponse.model_validate(self.call("receive_message", timeout)) # --- Service Discovery --- @@ -80,9 +77,7 @@ def unsubscribe_eventgroup(self, eventgroup_id: int) -> None: def receive_event(self, timeout: float = 5.0) -> SomeIpEventNotification: """Receive the next event notification.""" - return SomeIpEventNotification.model_validate( - self.call("receive_event", timeout) - ) + return SomeIpEventNotification.model_validate(self.call("receive_event", timeout)) # --- Connection Management --- @@ -93,3 +88,67 @@ def close_connection(self) -> None: def reconnect(self) -> None: """Reconnect to the SOME/IP endpoint.""" self.call("reconnect") + + # --- Server / provider side --- + + def start_server(self) -> None: + """Force-start the SOME/IP server (otherwise started on first offer).""" + self.call("start_server") + + def offer_service( + self, + service_id: int, + instance_id: int = 0x0001, + major_version: int = 1, + minor_version: int = 0, + ) -> None: + """Offer a service instance for discovery (act as the providing ECU).""" + self.call("offer_service", service_id, instance_id, major_version, minor_version) + + def stop_offer_service( + self, + service_id: int, + instance_id: int = 0x0001, + major_version: int = 1, + minor_version: int = 0, + ) -> None: + """Withdraw a previously offered service instance.""" + self.call("stop_offer_service", service_id, instance_id, major_version, minor_version) + + def list_offered_services(self) -> list[SomeIpOfferedService]: + """Return the set of services this server currently offers.""" + result = self.call("list_offered_services") + return [SomeIpOfferedService.model_validate(v) for v in result] + + def set_method_response( + self, + service_id: int, + method_id: int, + payload: bytes, + return_code: int = 0, + ) -> None: + """Configure the canned response the server returns for an RPC method.""" + msg = SomeIpPayload(data=payload.hex()) + self.call("set_method_response", service_id, method_id, msg, return_code) + + def clear_method_response(self, service_id: int, method_id: int) -> None: + """Remove a configured RPC response.""" + self.call("clear_method_response", service_id, method_id) + + def register_event(self, service_id: int, event_id: int, eventgroup_id: int) -> None: + """Register an event for publishing under an event group.""" + self.call("register_event", service_id, event_id, eventgroup_id) + + def publish_event(self, service_id: int, event_id: int, payload: bytes) -> None: + """Publish an event notification to subscribers of its event group.""" + msg = SomeIpPayload(data=payload.hex()) + self.call("publish_event", service_id, event_id, msg) + + def set_field(self, service_id: int, event_id: int, payload: bytes) -> None: + """Set a field event value (served to new subscribers and notified).""" + msg = SomeIpPayload(data=payload.hex()) + self.call("set_field", service_id, event_id, msg) + + def stop_server(self) -> None: + """Stop the SOME/IP server, withdrawing all offers.""" + self.call("stop_server") diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py index d334eb8ea..f4d89609d 100644 --- a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py @@ -63,3 +63,12 @@ class SomeIpEventNotification(BaseModel): @classmethod def _validate_hex(cls, v: str) -> str: return _validate_hex_string(v) + + +class SomeIpOfferedService(BaseModel): + """A service instance the server is currently offering (server-side introspection).""" + + service_id: int = Field(ge=0, le=0xFFFF) + instance_id: int = Field(ge=0, le=0xFFFF) + major_version: int = Field(default=1, ge=0, le=0xFF) + minor_version: int = Field(default=0, ge=0, le=0xFFFFFFFF) diff --git a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py index ad2c29f95..d8d6215a1 100755 --- a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py @@ -5,12 +5,13 @@ import threading from dataclasses import field -from opensomeip import ClientConfig, TransportMode +from opensomeip import ClientConfig, ServerConfig, TransportMode from opensomeip import SomeIpClient as OsipClient +from opensomeip import SomeIpServer as OsipServer from opensomeip.message import Message from opensomeip.sd import SdConfig, ServiceInstance from opensomeip.transport import Endpoint -from opensomeip.types import MessageId +from opensomeip.types import MessageId, MessageType, ReturnCode try: from opensomeip._bridge import get_ext @@ -22,6 +23,7 @@ from .common import ( SomeIpEventNotification, SomeIpMessageResponse, + SomeIpOfferedService, SomeIpPayload, SomeIpServiceEntry, ) @@ -85,6 +87,16 @@ class SomeIp(Driver): _osip_config: ClientConfig = field(init=False, repr=False) _osip_lock: SkipValidation[threading.Lock] = field(init=False, repr=False, default_factory=threading.Lock) + # Server / provider side (offer services, answer RPC, publish events). + _osip_server: OsipServer | None = field(init=False, repr=False, default=None) + _server_lock: SkipValidation[threading.Lock] = field(init=False, repr=False, default_factory=threading.Lock) + # Canned RPC responses keyed by (service_id, method_id) -> (payload bytes, return_code). + _method_responses: SkipValidation[dict] = field(init=False, repr=False, default_factory=dict) + # Methods already registered with the underlying RpcServer (register_method is one-shot). + _registered_methods: SkipValidation[set] = field(init=False, repr=False, default_factory=set) + # Events registered for publishing: (service_id, event_id) -> eventgroup_id. + _registered_events: SkipValidation[dict] = field(init=False, repr=False, default_factory=dict) + @classmethod def client(cls) -> str: return "jumpstarter_driver_someip.client.SomeIpDriverClient" @@ -95,9 +107,7 @@ def __post_init__(self): 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'." - ) + raise ValueError(f"Invalid transport_mode: {self.transport_mode!r}. Must be 'TCP' or 'UDP'.") if get_ext() is None: raise RuntimeError( @@ -149,12 +159,17 @@ def _ensure_client(self) -> OsipClient: return self._osip_client def close(self): - """Stop the opensomeip client.""" + """Stop the opensomeip client and server.""" if self._osip_client is not None: try: self._osip_client.stop() except Exception: logger.warning("failed to close opensomeip client", exc_info=True) + if self._osip_server is not None: + try: + self._osip_server.stop() + except Exception: + logger.warning("failed to close opensomeip server", exc_info=True) super().close() # --- RPC --- @@ -291,3 +306,194 @@ def reconnect(self) -> None: self._osip_client.start() else: self._ensure_client() + + # ===================================================================== + # Server / provider side + # + # Lets this endpoint ACT AS an ECU: offer services via SD, answer RPC + # requests with canned responses, and publish events / fields. This is + # the foundation for building external ECU simulators. + # + # RPC handlers run in the opensomeip receive thread inside the exporter + # process, so they cannot call back to the Jumpstarter client per + # request. Instead the client configures a canned response per method + # (set_method_response) and the in-process handler serves it. Getter- + # style SOME/IP methods (identity + status fields exposed by an ECU) map + # cleanly onto this model; update the response to change what a client + # of the simulated ECU reads. + # ===================================================================== + + def _build_server_config(self) -> ServerConfig: + transport_upper = self.transport_mode.upper() + mode = TransportMode.TCP if transport_upper == "TCP" else TransportMode.UDP + return ServerConfig( + 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, + multicast_group=self.multicast_group if mode == TransportMode.UDP else None, + ) + + def _ensure_server(self) -> OsipServer: + """Create and start the OsipServer on first use (thread-safe).""" + if self._osip_server is None: + with self._server_lock: + if self._osip_server is None: + server = OsipServer(self._build_server_config()) + server.start() + self._osip_server = server + return self._osip_server + + def _make_method_handler(self, service_id: int, method_id: int): + """Build an RPC handler that replies with the currently-configured + canned response for (service_id, method_id). + + The handler is registered once per method; it reads _method_responses + live on each call so set_method_response updates take effect without + re-registration. + """ + key = (service_id, method_id) + + def handler(request: Message) -> Message: + payload, return_code = self._method_responses.get(key, (b"", int(ReturnCode.E_OK))) + rc = ReturnCode(return_code) if return_code in ReturnCode._value2member_map_ else ReturnCode.E_NOT_OK + return Message( + message_id=MessageId(service_id, method_id), + request_id=request.request_id, + message_type=MessageType.RESPONSE if rc == ReturnCode.E_OK else MessageType.ERROR, + return_code=rc, + interface_version=request.interface_version, + payload=payload, + ) + + return handler + + @export + @validate_call(validate_return=True) + def start_server(self) -> None: + """Force-start the SOME/IP server (otherwise started on first offer).""" + self._ensure_server() + + @export + @validate_call(validate_return=True) + def offer_service( + self, + service_id: int, + instance_id: int = 0x0001, + major_version: int = 1, + minor_version: int = 0, + ) -> None: + """Offer a service instance for discovery (acts as the providing ECU).""" + service = ServiceInstance( + service_id=service_id, + instance_id=instance_id, + major_version=major_version, + minor_version=minor_version, + ) + self._ensure_server().offer(service) + + @export + @validate_call(validate_return=True) + def stop_offer_service( + self, + service_id: int, + instance_id: int = 0x0001, + major_version: int = 1, + minor_version: int = 0, + ) -> None: + """Withdraw a previously offered service instance.""" + if self._osip_server is None: + return + service = ServiceInstance( + service_id=service_id, + instance_id=instance_id, + major_version=major_version, + minor_version=minor_version, + ) + self._osip_server.stop_offer(service) + + @export + @validate_call(validate_return=True) + def list_offered_services(self) -> list[SomeIpOfferedService]: + """Return the set of services this server currently offers.""" + if self._osip_server is None: + return [] + result: list[SomeIpOfferedService] = [] + for svc in self._osip_server.offered_services: + result.append( + SomeIpOfferedService( + service_id=svc.service_id, + instance_id=svc.instance_id, + major_version=svc.major_version, + minor_version=svc.minor_version, + ) + ) + return result + + @export + @validate_call(validate_return=True) + def set_method_response( + self, + service_id: int, + method_id: int, + payload: SomeIpPayload, + return_code: int = 0, + ) -> None: + """Configure the canned response the server returns for an RPC method. + + Registers a handler for (service_id, method_id) on first call and + stores the response; subsequent calls just update the stored value. + """ + key = (service_id, method_id) + self._method_responses[key] = (bytes.fromhex(payload.data), return_code) + server = self._ensure_server() + if key not in self._registered_methods: + server.register_method(MessageId(service_id, method_id), self._make_method_handler(service_id, method_id)) + self._registered_methods.add(key) + + @export + @validate_call(validate_return=True) + def clear_method_response(self, service_id: int, method_id: int) -> None: + """Remove a configured RPC response. + + The handler stays registered (opensomeip has no unregister); it falls + back to an empty E_OK reply until reconfigured. + """ + self._method_responses.pop((service_id, method_id), None) + + @export + @validate_call(validate_return=True) + def register_event(self, service_id: int, event_id: int, eventgroup_id: int) -> None: + """Register an event for publishing under an event group.""" + server = self._ensure_server() + key = (service_id, event_id) + if self._registered_events.get(key) != eventgroup_id: + server.register_event(event_id, eventgroup_id) + self._registered_events[key] = eventgroup_id + + @export + @validate_call(validate_return=True) + def publish_event(self, service_id: int, event_id: int, payload: SomeIpPayload) -> None: + """Publish an event notification to subscribers of its event group.""" + self._ensure_server().publish_event(event_id, bytes.fromhex(payload.data)) + + @export + @validate_call(validate_return=True) + def set_field(self, service_id: int, event_id: int, payload: SomeIpPayload) -> None: + """Set a field event value (served to new subscribers and notified).""" + self._ensure_server().set_field(event_id, bytes.fromhex(payload.data)) + + @export + @validate_call(validate_return=True) + def stop_server(self) -> None: + """Stop the SOME/IP server, withdrawing all offers.""" + if self._osip_server is not None: + try: + self._osip_server.stop() + except Exception: + logger.warning("failed to stop opensomeip server", exc_info=True) + self._osip_server = None + self._registered_methods.clear() + self._registered_events.clear() 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 index adecc022e..3e38e7283 100644 --- a/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py +++ b/python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver_test.py @@ -3,9 +3,17 @@ from unittest.mock import MagicMock, patch import pytest +from opensomeip.message import Message +from opensomeip.types import MessageId, MessageType, RequestId, ReturnCode from pydantic import ValidationError -from .common import SomeIpEventNotification, SomeIpMessageResponse, SomeIpPayload, SomeIpServiceEntry +from .common import ( + SomeIpEventNotification, + SomeIpMessageResponse, + SomeIpOfferedService, + SomeIpPayload, + SomeIpServiceEntry, +) from .driver import SomeIp from jumpstarter.client.core import DriverError from jumpstarter.common.utils import serve @@ -81,12 +89,12 @@ def test_someip_send_message(mock_osip_cls): driver = SomeIp(host="127.0.0.1", port=30490) with serve(driver) as client: - client.send_message(0x1234, 0x0001, b"\xAA\xBB") + 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" + assert sent_msg.payload == b"\xaa\xbb" @patch("jumpstarter_driver_someip.driver.OsipClient") @@ -412,8 +420,13 @@ 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: { - "service_id": 1, "method_id": 1, "client_id": 1, "session_id": 1, - "message_type": 0, "return_code": 0, "payload": "AA", + "service_id": 1, + "method_id": 1, + "client_id": 1, + "session_id": 1, + "message_type": 0, + "return_code": 0, + "payload": "AA", }, SomeIpServiceEntry: {"service_id": 1, "instance_id": 1}, SomeIpEventNotification: {"service_id": 1, "event_id": 1, "payload": "AA"}, @@ -450,6 +463,7 @@ def test_someip_tcp_transport_mode(mock_osip_cls): config = mock_osip_cls.call_args[0][0] from opensomeip import TransportMode + assert config.transport_mode == TransportMode.TCP @@ -571,18 +585,18 @@ def stateful_client(stateful_osip): 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") + 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") + 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") + resp = stateful_client.rpc_call(0x9999, 0x0001, b"\xde\xad") assert resp.service_id == 0x9999 assert resp.payload == "dead" @@ -601,7 +615,7 @@ def test_stateful_multiple_rpc_calls(stateful_client, stateful_osip): 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") + stateful_osip.register_rpc_response(0xAAAA, 0x0001, b"\xca\xfe") resp = stateful_client.rpc_call(0xAAAA, 0x0001, b"\x00") assert resp.payload == "cafe" @@ -611,7 +625,7 @@ def test_stateful_custom_rpc_response(stateful_client, stateful_osip): 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") + 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 @@ -738,7 +752,7 @@ def test_stateful_discover_then_rpc_to_each_instance(stateful_client, stateful_o assert len(services) == 2 for svc in services: - resp = stateful_client.rpc_call(svc.service_id, 0x0001, b"\xAA") + 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 @@ -752,7 +766,7 @@ def test_stateful_subscribe_receive_unsubscribe(stateful_client, stateful_osip): stateful_client.subscribe_eventgroup(1) assert 1 in stateful_osip._subscribed_eventgroups - stateful_osip.inject_event(0x1234, 0x8001, b"\xCA\xFE") + 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 @@ -857,7 +871,7 @@ def test_stateful_messaging_with_reconnect(stateful_client, stateful_osip): 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") + stateful_osip.inject_event(0x1234, 0x8001, b"\xaa") e1 = stateful_client.receive_event(timeout=1.0) assert e1.payload == "aa" @@ -865,7 +879,7 @@ def test_stateful_event_session_with_reconnect(stateful_client, stateful_osip): assert stateful_osip._subscribed_eventgroups == set() stateful_client.subscribe_eventgroup(1) - stateful_osip.inject_event(0x1234, 0x8002, b"\xBB") + stateful_osip.inject_event(0x1234, 0x8002, b"\xbb") e2 = stateful_client.receive_event(timeout=1.0) assert e2.payload == "bb" @@ -885,7 +899,7 @@ def test_stateful_discover_rpc_events_workflow(stateful_client, stateful_osip): assert resp2.payload == "01020304" stateful_client.subscribe_eventgroup(1) - stateful_osip.inject_event(0x1234, 0x8001, b"\xEE") + stateful_osip.inject_event(0x1234, 0x8001, b"\xee") event = stateful_client.receive_event(timeout=1.0) assert event.payload == "ee" @@ -896,6 +910,259 @@ def test_stateful_discover_rpc_events_workflow(stateful_client, stateful_osip): assert stateful_osip._started is False +# ========================================================================= +# Server / provider side tests +# +# The server verbs let the endpoint ACT AS an ECU (offer services, answer +# RPC with canned responses, publish events). We patch the opensomeip +# SomeIpServer so these run without real networking, and assert the driver +# drives the underlying server API correctly. +# ========================================================================= + + +class _FakeOsipServer: + """Minimal stand-in for opensomeip.SomeIpServer for server-side unit tests.""" + + def __init__(self, config=None): + self.config = config + self.started = False + self._offered: set = set() + self.handlers: dict = {} + self.registered_events: dict = {} + self.register_event_calls: list = [] + self.published: list = [] + self.fields: dict = {} + + def start(self): + self.started = True + + def stop(self): + self.started = False + + def offer(self, service): + self._offered.add((service.service_id, service.instance_id, service.major_version, service.minor_version)) + + def stop_offer(self, service): + self._offered.discard((service.service_id, service.instance_id, service.major_version, service.minor_version)) + + @property + def offered_services(self): + out = [] + for sid, iid, maj, minr in self._offered: + out.append( + type( + "Svc", + (), + { + "service_id": sid, + "instance_id": iid, + "major_version": maj, + "minor_version": minr, + }, + )() + ) + return out + + def register_method(self, message_id, handler): + self.handlers[(message_id.service_id, message_id.method_id)] = handler + + def register_event(self, event_id, eventgroup_id): + self.register_event_calls.append((event_id, eventgroup_id)) + self.registered_events[event_id] = eventgroup_id + + def publish_event(self, event_id, payload): + self.published.append((event_id, payload)) + + def set_field(self, event_id, payload): + self.fields[event_id] = payload + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_offer_and_list(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.offer_service(0x1801, 0x0001, major_version=2) + offered = client.list_offered_services() + assert len(offered) == 1 + assert isinstance(offered[0], SomeIpOfferedService) + assert offered[0].service_id == 0x1801 + assert offered[0].instance_id == 0x0001 + assert offered[0].major_version == 2 + assert fake.started is True + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_stop_offer(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.offer_service(0x1801, 0x0001) + assert len(client.list_offered_services()) == 1 + client.stop_offer_service(0x1801, 0x0001) + assert client.list_offered_services() == [] + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_set_method_response_registers_handler(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.set_method_response(0x1801, 0x0005, b"\xde\xad\xbe\xef") + # Handler registered exactly once for the method + assert (0x1801, 0x0005) in fake.handlers + + # Invoke the registered handler as the RpcServer would, and check the reply + + req = Message(message_id=MessageId(0x1801, 0x0005), request_id=RequestId(0x0001, 0x0007)) + resp = fake.handlers[(0x1801, 0x0005)](req) + assert resp.payload == b"\xde\xad\xbe\xef" + assert resp.return_code == ReturnCode.E_OK + assert resp.message_type == MessageType.RESPONSE + # Response echoes the request's request_id (client/session correlation) + assert resp.request_id.session_id == 0x0007 + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_method_response_updates_without_reregister(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.set_method_response(0x1801, 0x0005, b"\x01") + handler = fake.handlers[(0x1801, 0x0005)] + client.set_method_response(0x1801, 0x0005, b"\x02") + # Same handler object reused (registered once); response value updated live + assert fake.handlers[(0x1801, 0x0005)] is handler + + req = Message(message_id=MessageId(0x1801, 0x0005), request_id=RequestId(1, 1)) + assert handler(req).payload == b"\x02" + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_method_response_error_code(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.set_method_response(0x1801, 0x0006, b"", return_code=0x01) + + req = Message(message_id=MessageId(0x1801, 0x0006), request_id=RequestId(1, 1)) + resp = fake.handlers[(0x1801, 0x0006)](req) + assert resp.return_code == ReturnCode.E_NOT_OK + assert resp.message_type == MessageType.ERROR + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_clear_method_response_falls_back_to_empty_ok(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.set_method_response(0x1801, 0x0005, b"\xaa") + handler = fake.handlers[(0x1801, 0x0005)] + client.clear_method_response(0x1801, 0x0005) + + req = Message(message_id=MessageId(0x1801, 0x0005), request_id=RequestId(1, 1)) + resp = handler(req) + assert resp.payload == b"" + assert resp.return_code == ReturnCode.E_OK + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_register_and_publish_event(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.register_event(0x1801, 0x8001, eventgroup_id=1) + assert fake.registered_events[0x8001] == 1 + + client.publish_event(0x1801, 0x8001, b"\x2d\x00") # e.g. signal level payload + assert fake.published == [(0x8001, b"\x2d\x00")] + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_register_event_idempotent(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.register_event(0x1801, 0x8001, eventgroup_id=1) + client.register_event(0x1801, 0x8001, eventgroup_id=1) + # not re-registered with the underlying server for the same (event, group) + assert fake.register_event_calls == [(0x8001, 1)] + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_set_field(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.set_field(0x1801, 0x8002, b"\x01") + assert fake.fields[0x8002] == b"\x01" + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_stop_server_clears_state(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + client.set_method_response(0x1801, 0x0005, b"\xaa") + client.register_event(0x1801, 0x8001, eventgroup_id=1) + client.stop_server() + assert fake.started is False + + # After stop, a new offer builds a fresh server and re-registers cleanly + fake2 = _FakeOsipServer() + mock_server_cls.return_value = fake2 + client.offer_service(0x1802, 0x0001) + assert fake2.started is True + assert len(client.list_offered_services()) == 1 + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_list_offered_when_not_started(mock_server_cls): + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + with serve(driver) as client: + # No offer yet -> server never created -> empty list, no crash + assert client.list_offered_services() == [] + mock_server_cls.assert_not_called() + + +@patch("jumpstarter_driver_someip.driver.OsipServer") +def test_server_lazy_start(mock_server_cls): + """Server is not created at construction; created on first server verb.""" + fake = _FakeOsipServer() + mock_server_cls.return_value = fake + + driver = SomeIp(host="127.0.0.1", port=30490) + mock_server_cls.assert_not_called() + with serve(driver) as client: + mock_server_cls.assert_not_called() + client.start_server() + mock_server_cls.assert_called_once() + assert fake.started is True + + # ========================================================================= # Wire-level integration tests with MockSomeIpServer # @@ -931,7 +1198,7 @@ def test_someip_simulated_send_receive(mock_someip_server): transport_mode="TCP", ) with serve(driver) as client: - client.send_message(0x1234, 0x0001, b"\xAA\xBB\xCC") + 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"