Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions python/packages/jumpstarter-driver-someip/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
21 changes: 21 additions & 0 deletions python/packages/jumpstarter-driver-someip/examples/exporter.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .common import (
SomeIpEventNotification,
SomeIpMessageResponse,
SomeIpOfferedService,
SomeIpPayload,
SomeIpServiceEntry,
)
Expand Down Expand Up @@ -93,3 +94,75 @@ 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.

``service_id`` is accepted for API symmetry but currently unused;
events are addressed by ``event_id`` only.
"""
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).

``service_id`` is accepted for API symmetry but currently unused;
fields are addressed by ``event_id`` only.
"""
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")
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
from unittest.mock import MagicMock, patch

import pytest
from opensomeip.message import Message as OsipMessage
from opensomeip.types import MessageId as OsipMessageId
from opensomeip.types import RequestId as OsipRequestId

# =========================================================================
# Wire-protocol constants
Expand Down Expand Up @@ -388,6 +391,122 @@ def unregister_service(self, service_id: int, instance_id: int):
]


# =========================================================================
# StatefulOsipServer / LoopbackOsipClient - simulated-ECU loopback pair
#
# The server fake tracks offers, RPC handlers, and events like a real
# opensomeip.SomeIpServer. The loopback client dispatches RPC calls to the
# server's registered handlers and discovers the server's offers, so tests
# can exercise the full simulated-ECU workflow (offer + canned response +
# client RPC) through the gRPC boundary with a single driver.
# =========================================================================


class StatefulOsipServer:
"""A drop-in replacement for ``opensomeip.SomeIpServer`` that tracks
offers, RPC handlers, and events, and loops published events back to an
attached ``LoopbackOsipClient``.
"""

def __init__(self, config=None) -> None:
self._started = False
self._config = config
self._offered: list = []
self.handlers: dict = {}
self.registered_events: dict = {}
self.fields: dict = {}
self._client: LoopbackOsipClient | None = None

def attach_client(self, client: LoopbackOsipClient) -> None:
self._client = client

def start(self):
self._started = True

def stop(self):
self._started = False
self._offered.clear()

def offer(self, service):
self._offered.append(service)

def stop_offer(self, service):
self._offered = [
s
for s in self._offered
if not (s.service_id == service.service_id and s.instance_id == service.instance_id)
]

@property
def offered_services(self):
return list(self._offered)

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.registered_events[event_id] = eventgroup_id

def publish_event(self, event_id, payload):
"""Deliver the event to the attached client if it is subscribed."""
eventgroup_id = self.registered_events.get(event_id)
client = self._client
if client is not None and eventgroup_id in client._subscribed_eventgroups:
client.inject_event(0x0000, event_id, payload)

def set_field(self, event_id, payload):
self.fields[event_id] = payload
self.publish_event(event_id, payload)


class LoopbackOsipClient(StatefulOsipClient):
"""A ``StatefulOsipClient`` wired to a ``StatefulOsipServer``.

RPC calls dispatch to the server's registered method handlers (built as
real ``opensomeip`` request messages) and service discovery reflects the
server's current offers.
"""

def __init__(self, server: StatefulOsipServer, config=None) -> None:
super().__init__(config)
self._server = server
# Discovery and RPC reflect the server side, not a static registry.
self._registered_services = []
self._rpc_responses = {}

def call(self, message_id, *, payload: bytes = b"", timeout: float = 5.0):
self._require_started()
sid = message_id.service_id
mid = message_id.method_id
self._rpc_history.append((sid, mid, payload))
handler = self._server.handlers.get((sid, mid))
if handler is None:
raise TimeoutError(f"no handler registered for service 0x{sid:04X} method 0x{mid:04X}")
request = OsipMessage(
message_id=OsipMessageId(sid, mid),
request_id=OsipRequestId(0x0001, len(self._rpc_history)),
payload=payload,
)
return handler(request)
Comment on lines +485 to +490

@coderabbitai coderabbitai Bot Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

opensomeip 0.1.5 python Message class fields interface_version message_type defaults

💡 Result:

In the opensomeip 0.1.5 library, the Message class is defined as a dataclass within the opensomeip.message module [1][2]. While specific default values for the interface_version and message_type fields are not explicitly documented in the public summary, they are standard components of the SOME/IP header [3]. It is important to distinguish the library opensomeip (v0.1.5) from other Python packages such as pysomeip [4][5]. The opensomeip library is primarily a Python wrapper/interface for a C++17 implementation [6][7]. For the SOME/IP protocol generally, these fields follow the specification [3]: - interface_version: An 8-bit field representing the Major Version of the Service Interface [3]. - message_type: An 8-bit field used to differentiate message types (e.g., REQUEST, RESPONSE, ERROR) [3]. If you are programmatically inspecting the opensomeip 0.1.5 Message dataclass, you can verify the default values in your local environment using the following Python snippet: import dataclasses from opensomeip.message import Message print(dataclasses.field_defaults(Message)) If you find that the opensomeip package does not expose these fields directly in the Python dataclass constructor due to its underlying C++ architecture, you may need to interface with the RpcClient or MessageId objects provided by the library to set these headers [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== repo files =="
git ls-files python/packages/jumpstarter-driver-someip | sed -n '1,120p'

echo "== conftest relevant =="
fd -a 'conftest.py' python/packages/jumpstarter-driver-someip | while read -r f; do
  rel="${f#$(pwd)/}"
  echo "--- $rel"
  wc -l "$rel"
  sed -n '440,505p' "$rel"
done

echo "== driver relevant =="
for f in python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/*.py; do
  case "$f" in
    *driver.py | *client.py | *server.py) ;;
    *) continue ;;
  esac
  echo "--- $f"
  wc -l "$f"
  rg -n "interface_version|request_id|message_type|return_code|OsipMessage\\(" "$f" -C 3 || true
done

Repository: jumpstarter-dev/jumpstarter

Length of output: 7080


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== opensomeip package version in requirements =="
rg -n "opensomeip|jumpstarter-driver-someip" python/pyproject.toml python/packages/jumpstarter-driver-someip/pyproject.toml poetry.lock uv.lock requirements*.txt -S 2>/dev/null || true

echo "== imports / constants in conftest driver and common =="
for f in python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py \
         python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py \
         python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/common.py; do
  echo "--- $f"
  sed -n '1,80p' "$f"
done

echo "== inspect installed opensomeip if available =="
uv run python3 - <<'PY' 2>/dev/null || true
import inspect
try:
    import opensomeip
    print("opensomeip", opensomeip.__version__ if hasattr(opensomeip, "__version__") else getattr(opensomeip, "__version__", "?"))
except Exception as e:
    print("import error:", e)
    raise SystemExit

from opensomeip.message import Message, MessageId, OsipRequestId, ReturnCode, MessageType
print("Message", Message)
try:
    print("Message sig", inspect.signature(Message))
except Exception as e:
    print("Message sig error", e)
try:
    from dataclasses import fields
    for f in fields(Message):
        print("field", f.name, "default", f.default, "default_factory", f.default_factory)
except Exception as e:
    print("fields error", e)
try:
    m = Message(message_id=MessageId(0x1234, 0x5678), request_id=OsipRequestId(1, 2), payload=b"X")
    print("constructed", m)
    print("fields", {k:getattr(m,k,None) for k in ["message_id","request_id","message_type","return_code","interface_version","payload","protocol_version"]})
except Exception as e:
    print("constructed error", type(e).__name__ + ":", e)
PY

Repository: jumpstarter-dev/jumpstarter

Length of output: 7823


🌐 Web query:

vtz opensomeip 0.1.5 Message source interface_version message_type

💡 Result:

The opensomeip library (v0.1.5) provides Python bindings for the open-source C++17 implementation of the Scalable service-Oriented MiddlewarE over IP (SOME/IP) protocol [1][2]. In the context of the SOME/IP protocol, which this library implements, the fields interface_version and message_type are standard components of the SOME/IP header [3][4]: 1. Interface Version: This is an 8-bit field representing the Major Version of the service interface [3][4]. It is used to manage service definitions, detect mismatches, and allow debugging tools to identify the specific interface version in use [4]. 2. Message Type: This is an 8-bit field used to identify the type of message being transmitted [3][4]. Common values include: - 0x00: REQUEST (expects a response) [4] - 0x01: REQUEST_NO_RETURN (fire-and-forget) [4] - 0x02: NOTIFICATION (event notification) [4] - 0x80: RESPONSE (a response message) [4] - 0x81: ERROR (an error response) [4] Additionally, the third-highest bit of the Message Type (0x20) acts as a TP-Flag, which, when set, indicates that the message is a segment for the SOME/IP-TP (Transport Protocol) [3]. For specific implementation details within the opensomeip library, you should refer to the official documentation and source code hosted at the project's repository [1][2].

Citations:


🌐 Web query:

vtz opensomeip 0.1.5 Message __init__ Python dataclass source

💡 Result:

In the opensomeip Python package (v0.1.5), the Message class is implemented as a Python dataclass within the opensomeip.message module [1][2]. As a dataclass, its structure and initialization rely on standard Python dataclass mechanics [1]. While the exact source code for the init method is automatically generated by the @dataclass decorator at runtime, the class is defined to hold SOME/IP header fields and the message payload [1]. This design is part of the project's goal to provide a "Pythonic" API that utilizes plain Python objects rather than opaque C++ wrappers for its public-facing types [1][2]. To interact with this class in your code, you can import it as follows: from opensomeip.message import Message Since it is a dataclass, you initialize it by passing the required fields directly to the constructor (e.g., Message(message_id=..., request_id=...,...)) [1]. You can verify the specific fields available by inspecting the Message class definition in the opensomeip/message.py file within the source repository [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== opensomeip source from pypi tarball, if wget is available =="
tmp="$(mktemp -d)"
cd "$tmp"
if command -v wget >/dev/null 2>&1; then
  wget -q https://files.pythonhosted.org/packages/source/o/opensomeip/opensomeip-0.1.5.tar.gz
  tar -xzf opensomeip-0.1.5.tar.gz
  if [ -f opensomeip-0.1.5/opensomeip/message.py ]; then
    sed -n '1,260p' opensomeip-0.1.5/opensomeip/message.py
  else
    echo "message.py not found"
    find opensomeip-0.1.5 -name '*.py' -maxdepth 3 | sort
  fi
else
  echo "wget unavailable"
fi

Repository: jumpstarter-dev/jumpstarter

Length of output: 246


Set SOME/IP response headers for the loopback request.

OsipMessage is created with only message_id, request_id and payload, but SomeIp._make_method_handler reads request.interface_version and passes request.message_type when building the response. Set an explicit interface_version and message_type so this path does not rely on undefined defaults.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py`
around lines 485 - 490, Update the OsipMessage construction in the loopback
request helper to set explicit SOME/IP response headers: provide an
interface_version and message_type matching the values expected by
SomeIp._make_method_handler. Preserve the existing message_id, request_id, and
payload behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Can you fix this for me?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py \
  --items all --type function,method --match '_make_method_handler|set_method_response'

rg -n -A35 -B8 'def _make_method_handler\b|def set_method_response\b' \
  python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/driver.py

rg -n -C3 '\bopensomeip\b' \
  python/pyproject.toml \
  python/packages/jumpstarter-driver-someip/pyproject.toml \
  uv.lock 2>/dev/null || true

Length of output: 6177


🏁 Script executed:

#!/bin/bash
set -euo pipefail

make lint-fix
make pkg-test-jumpstarter-driver-someip
make pkg-ty-jumpstarter-driver-someip

Length of output: 470


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '475,495p' \
  python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py

git diff -- \
  python/packages/jumpstarter-driver-someip/jumpstarter_driver_someip/conftest.py

Length of output: 1225


@kirkbrauer, the change is not present in the current branch. LoopbackOsipClient.call still omits both header arguments.

Apply this update:

request = OsipMessage(
    message_id=OsipMessageId(sid, mid),
    request_id=OsipRequestId(0x0001, len(self._rpc_history)),
    interface_version=INTERFACE_VERSION,
    message_type=MSG_TYPE_REQUEST,
    payload=payload,
)

Validation is inconclusive because make is unavailable in this environment. Run the required package checks in a development environment that has the project toolchain.


🧬 Code Graph Analysis Results

driver.py

  • Lines 479–496: register_event stores event-to-event-group mappings and delegates registration to the SOME/IP server; publish_event converts hexadecimal payload data to bytes and publishes by event ID.
  • Lines 500–507: set_field converts hexadecimal payload data to bytes and delegates field updates to the SOME/IP server. service_id is accepted but unused.

driver_test.py

  • Lines 930–971: Test fake server lifecycle and event APIs. start/stop toggle state; offer/stop_offer manage offered services; offered_services exposes service metadata; register_method, register_event, publish_event, and set_field record or store server operations.

client.py

  • Lines 144–164: Client wrappers for event registration, event publication, and field updates. Payloads are hex-encoded into SomeIpPayload before invoking the corresponding RPC.

You are interacting with an AI system.


def find(self, service, *, callback=None):
self._require_started()
for svc in self._server.offered_services:
if svc.service_id == service.service_id:
if service.instance_id == 0xFFFF or svc.instance_id == service.instance_id:
if callback:
callback(svc)


@pytest.fixture
def loopback_pair():
"""Provide a wired (StatefulOsipServer, LoopbackOsipClient) pair."""
server = StatefulOsipServer()
client = LoopbackOsipClient(server)
server.attach_client(client)
return server, client


@pytest.fixture(autouse=True)
def _mock_get_ext():
"""Ensure the native-extension guard passes in all tests by default."""
Expand Down
Loading
Loading