diff --git a/.github/workflows/build_and_test_host.yml b/.github/workflows/build_and_test_host.yml
index 3e114eaae..b5a2c2caf 100644
--- a/.github/workflows/build_and_test_host.yml
+++ b/.github/workflows/build_and_test_host.yml
@@ -53,6 +53,11 @@ jobs:
- name: Bazel build targets
run: |
bazel build //...
+ - run: df -h
+ if: always()
+ - name: Bazel lint targets
+ run: |
+ bazel test --test_tag_filters=lint //...
- name: Bazel test targets
run: |
bazel test ${{ matrix.targets }} --build_tests_only \
@@ -100,6 +105,9 @@ jobs:
- name: Run Linux QEMU integration tests
run: |
bazel test --config=qemu-integration ${{ matrix.bazel_args }} //quality/... //tests/integration_test/...
+ - name: Run TC8 ITF SOME/IP Conformance Tests
+ run: |
+ bazel test --config=tc8-itf --test_output=all //tests/tc8_conformance/...
- run: df -h
if: always()
@@ -129,3 +137,5 @@ jobs:
- name: Run Linux perf tests (flamegraphs)
run: |
bazel test --config=perf-tests-flamegraphs //tests/benchmarks/... --build_tests_only
+ - run: df -h
+ if: always()
diff --git a/.github/workflows/build_and_test_qnx.yml b/.github/workflows/build_and_test_qnx.yml
index cd891c930..ec7b417e5 100644
--- a/.github/workflows/build_and_test_qnx.yml
+++ b/.github/workflows/build_and_test_qnx.yml
@@ -38,3 +38,22 @@ jobs:
score-qnx-license: ${{ secrets.SCORE_QNX_LICENSE }}
score-qnx-user: ${{ secrets.SCORE_QNX_USER }}
score-qnx-password: ${{ secrets.SCORE_QNX_PASSWORD }}
+
+ # TC8 SOME/IP conformance tests with someipd in QNX8 QEMU (x86_64 only).
+ # --config=tc8-itf-qnx bundles QNX platform, QEMU runner, and tc8 tag filter.
+ tc8_itf_qnx_conformance:
+ uses: eclipse-score/cicd-workflows/.github/workflows/qnx-build.yml@improved-caching
+ permissions:
+ contents: read
+ pull-requests: read
+ actions: write # needed for cache deletion at setup-bazel-cache
+ with:
+ bazel-target: "//src/someipd/... //tests/tc8_conformance/..."
+ bazel-test-target: "//tests/tc8_conformance/..."
+ bazel-config: tc8-itf-qnx
+ credential-helper: ".github/tools/qnx_credential_helper.py"
+ bazel-disk-cache: ${{ github.workflow }}-tc8-itf-qnx
+ secrets:
+ score-qnx-license: ${{ secrets.SCORE_QNX_LICENSE }}
+ score-qnx-user: ${{ secrets.SCORE_QNX_USER }}
+ score-qnx-password: ${{ secrets.SCORE_QNX_PASSWORD }}
diff --git a/REUSE.toml b/REUSE.toml
index 50f682381..018f988f0 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -51,6 +51,7 @@ path = [
"tests/benchmarks/config/*/*.json",
"tests/integration_test/vsomeip-local.json",
"tests/integration_test/vsomeip.json",
+ "tests/benchmarks/config/benchmark_mw_com_config.json",
"tests/tc8_conformance/**/*.json",
"quality/integration_testing/environments/qnx8_qemu/qemu_config.json",
"quality/integration_testing/environments/ubuntu24_04_qemu/*"
diff --git a/docs/architecture/components.rst b/docs/architecture/components.rst
index bae93f870..3cef32e58 100644
--- a/docs/architecture/components.rst
+++ b/docs/architecture/components.rst
@@ -60,7 +60,6 @@ Components
A serializer that does not actually serialize or deserialize data, but only handles messages that are already serialized by the application.
-
.. comp:: SOME/IP Network Daemon
:id: comp__someipd
:status: valid
diff --git a/docs/architecture/index.rst b/docs/architecture/index.rst
index 054ff1de4..968da67e3 100644
--- a/docs/architecture/index.rst
+++ b/docs/architecture/index.rst
@@ -40,3 +40,4 @@ Design decisions
dec_someipgw_registration.rst
dec_someip_api_abstraction.rst
dec_ipc_implementation.rst
+ tc8_conformance_testing.rst
diff --git a/docs/architecture/tc8_conformance_testing.rst b/docs/architecture/tc8_conformance_testing.rst
new file mode 100644
index 000000000..6d5bd10a2
--- /dev/null
+++ b/docs/architecture/tc8_conformance_testing.rst
@@ -0,0 +1,468 @@
+..
+ # *******************************************************************************
+ # Copyright (c) 2026 Contributors to the Eclipse Foundation
+ #
+ # See the NOTICE file(s) distributed with this work for additional
+ # information regarding copyright ownership.
+ #
+ # This program and the accompanying materials are made available under the
+ # terms of the Apache License Version 2.0 which is available at
+ # https://www.apache.org/licenses/LICENSE-2.0
+ #
+ # SPDX-License-Identifier: Apache-2.0
+ # *******************************************************************************
+
+TC8 SOME/IP Conformance Testing
+================================
+
+Overview
+--------
+
+`OPEN Alliance TC8 `_
+defines conformance tests for automotive SOME/IP implementations.
+The TC8 test suite has two scopes:
+
+- **Protocol Conformance**: tests the production ``someipd`` binary at the wire level using the
+ ``someip`` Python package. No application processes are needed.
+
+- **Application Level Tests**: tests the full gateway path from mw::com client
+ through ``gatewayd`` and ``someipd`` to the network, using C++ apps
+ built on ``score::mw::com``. These tests are stack-agnostic.
+
+Both scopes live under ``tests/tc8_conformance/`` and share the ``tc8`` and
+``conformance`` Bazel tags. For setup instructions and test details, see
+``tests/tc8_conformance/README.md``.
+
+Test Scope Overview
+-------------------
+
+.. uml::
+
+ @startuml
+ !theme plain
+ scale max 800 width
+ skinparam packageStyle rectangle
+
+ package "Protocol Conformance" {
+ [pytest] as L1Test
+ [someipd] as L1DUT
+ [gatewayd] as L1GW
+ L1Test -down-> L1DUT : raw SOME/IP\nUDP / TCP
+ L1DUT -right-> L1GW : IPC (idles)
+ }
+
+ package "Application-Level Tests" {
+ [pytest\norchestrator] as L2Orch
+
+ [TC8 Service\n(mw::com Skeleton)] as L2Svc
+ [gatewayd] as L2GW1
+ [someipd] as L2SD1
+
+ [someipd] as L2SD2
+ [gatewayd] as L2GW2
+ [TC8 Client\n(mw::com Proxy)] as L2Cli
+
+ L2Orch .down.> L2Svc
+ L2Orch .down.> L2GW1
+ L2Orch .down.> L2SD1
+ L2Orch .down.> L2SD2
+ L2Orch .down.> L2GW2
+ L2Orch .down.> L2Cli
+
+ L2Svc -right-> L2GW1 : LoLa IPC
+ L2GW1 -right-> L2SD1 : LoLa IPC
+ L2SD1 -right-> L2SD2 : SOME/IP\nUDP / TCP
+ L2SD2 -right-> L2GW2 : LoLa IPC
+ L2GW2 -right-> L2Cli : LoLa IPC
+ }
+
+ L1DUT -[hidden]down-> L2Orch
+ @enduml
+
+Protocol Conformance
+--------------------
+
+Protocol conformance tests exercise the SOME/IP stack at the wire protocol
+level. Tests send raw SOME/IP messages and verify responses against the TC8
+specification.
+
+DUT Binary
+^^^^^^^^^^
+
+The protocol conformance DUT is the production ``someipd`` binary. It uses
+vsomeip directly for all SOME/IP network I/O and service discovery.
+``someipd`` is QM only and is not part of the ASIL-B safety path.
+
+At startup, ``someipd`` reads a FlatBuffer binary config (``-c`` flag) that
+declares the service ID, instance ID, version, and events to offer. The binary
+initialises a vsomeip application and calls ``offer_service()`` for each entry.
+For TC8, the config is ``tc8_someipd_config.bin``, generated at build time from
+``tests/tc8_conformance/config/tc8_someipd_config.json``.
+
+``gatewayd`` is started alongside ``someipd`` as a companion process. In the
+SD-only conformance tests ``gatewayd`` idles (its FlatBuffer config declares no
+service types), but the IPC handshake between ``someipd`` and ``gatewayd`` must
+complete before the test proceeds. ``gatewayd`` becomes active only in ETS
+end-to-end tests where a mw::com application offers or consumes the TC8 service.
+
+``tc8_itf_conftest.py`` launches ``someipd`` first (it becomes the vsomeip
+routing manager), waits for an OfferService multicast, then starts ``gatewayd``.
+Both processes are force-killed (``pkill -9``) during fixture teardown.
+
+Port Isolation and Parallel Execution
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Each Bazel TC8 target runs in its own OS process and receives unique SOME/IP
+port values via the Bazel ``env`` attribute. Three environment variables
+control port assignment:
+
+``TC8_SD_PORT``
+ SOME/IP-SD port. Set in both the vsomeip config template (replacing the
+ ``__TC8_SD_PORT__`` placeholder) and read by the Python SD sender socket
+ at module import time via ``helpers/constants.py``. The SOME/IP-SD
+ protocol requires SD messages to originate from the configured SD port;
+ satisfying this does not require a fixed port, it requires only that
+ both sides use the *same* port, which is guaranteed because both the
+ vsomeip config and the Python constants read the same env var.
+
+``TC8_SVC_PORT``
+ DUT UDP (unreliable) service port. Replaces the ``__TC8_SVC_PORT__``
+ placeholder in config templates.
+
+``TC8_SVC_TCP_PORT``
+ DUT TCP (reliable) service port. Replaces the ``__TC8_SVC_TCP_PORT__``
+ placeholder in config templates. Only set for targets that use reliable
+ transport (``tc8_message_format``, ``tc8_event_notification``,
+ ``tc8_field_conformance``).
+
+All three constants default to the historical static values (30490 / 30509 /
+30510) when the environment variables are not set, preserving backward
+compatibility for local development runs without Bazel.
+
+For the per-target port matrix, see ``tests/tc8_conformance/README.md``.
+
+Skipped Test Categories
+^^^^^^^^^^^^^^^^^^^^^^^
+
+The production stack has no mw::com ETS application to generate events, handle
+methods, or provide field values. The following test modules and individual
+tests are permanently skipped until a mw::com ETS app is added to the repo.
+
+.. list-table:: Skipped TC8 Tests (2026-08-11)
+ :header-rows: 1
+ :widths: 30 20 50
+
+ * - Module / Test
+ - Skip level
+ - Reason
+ * - ``test_event_notification.py``
+ - All tests
+ - No event delivery without mw::com ETS app
+ * - ``test_field_conformance.py``
+ - All tests
+ - No initial field values or getter/setter handling without mw::com ETS app
+ * - ``test_someip_message_format.py``
+ - All tests
+ - No SOME/IP method handling (gatewayd to_num_of_methods=0) without mw::com ETS app
+ * - ``test_service_discovery.py``
+ - 4 individual tests
+ - Require event notifications before SD lifecycle actions
+
+The 4 individually skipped tests in ``test_service_discovery.py`` are:
+
+- ``TestSDSubscribeLifecycle.test_tc8_sd_008_stop_subscribe_ceases_notifications``
+- ``TestSDTTLExpiry.test_tc8_sd_014_ttl_expiry_ceases_notifications``
+- ``TestSDSubscribeLifecycleAdvanced.test_ets_155_resubscribe_after_stop``
+- ``TestSDSubscribeLifecycleAdvanced.test_ets_095_subscribe_ttl_expires_no_events``
+
+Test Module Structure
+^^^^^^^^^^^^^^^^^^^^^
+
+Each TC8 area has a test module (pytest) and one or more helper modules.
+The diagrams below show the dependencies grouped by TC8 domain.
+Blue boxes represent test modules and green boxes represent shared helper
+modules. Dashed arrows indicate internal helper-to-helper dependencies.
+
+Service Discovery (SD)
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+The Service Discovery tests (TC8-SD) verify SOME/IP-SD offer announcements,
+find/subscribe responses, SD phase timing, byte-level SD field values,
+malformed packet robustness, and SD client lifecycle.
+
+.. uml::
+
+ @startuml
+ !theme plain
+ scale max 800 width
+ skinparam component {
+ BackgroundColor<> #E3F2FD
+ BorderColor<> #1565C0
+ BackgroundColor<> #E8F5E9
+ BorderColor<> #2E7D32
+ }
+
+ title Service Discovery — Test Module Dependencies
+
+ [test_service_discovery] <>
+ [test_sd_phases_timing] <>
+ [test_sd_reboot] <>
+ [test_sd_format_compliance] <>
+ [test_sd_robustness] <>
+ [test_sd_client] <>
+
+ [sd_helpers] <>
+ [sd_sender] <>
+ [sd_malformed] <>
+ [someip_assertions] <>
+ [timing] <>
+
+ test_service_discovery --> sd_helpers
+ test_service_discovery --> sd_sender
+ test_service_discovery --> someip_assertions
+ test_service_discovery --> timing
+ test_sd_phases_timing --> timing
+ test_sd_phases_timing --> sd_helpers
+ test_sd_reboot --> sd_helpers
+ test_sd_format_compliance --> sd_helpers
+ test_sd_robustness --> sd_malformed
+ test_sd_robustness --> sd_helpers
+ test_sd_client --> sd_helpers
+ test_sd_client --> sd_sender
+
+ timing ..> sd_helpers : <>
+ @enduml
+
+Message Format, Events, Fields, and TCP Transport (MSG / EVT / FLD / TCP)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+These tests cover message format (TC8-MSG), event notification (TC8-EVT),
+field access (TC8-FLD), and TCP transport binding. Domain-specific helpers
+handle packet construction, subscription workflows, field get/set operations,
+and TCP stream framing.
+
+.. uml::
+
+ @startuml
+ !theme plain
+ scale max 800 width
+ skinparam component {
+ BackgroundColor<> #E3F2FD
+ BorderColor<> #1565C0
+ BackgroundColor<> #E8F5E9
+ BorderColor<> #2E7D32
+ }
+
+ title Message / Event / Field / TCP — Test Module Dependencies
+
+ [test_someip_message_format] <>
+ [test_event_notification] <>
+ [test_field_conformance] <>
+
+ [message_builder] <>
+ [someip_assertions] <>
+ [sd_helpers] <>
+ [sd_sender] <>
+ [event_helpers] <>
+ [field_helpers] <>
+ [tcp_helpers] <>
+ [udp_helpers] <>
+
+ test_someip_message_format --> message_builder
+ test_someip_message_format --> someip_assertions
+ test_someip_message_format --> sd_helpers
+ test_someip_message_format --> tcp_helpers
+ test_someip_message_format --> udp_helpers
+ test_event_notification --> event_helpers
+ test_event_notification --> sd_helpers
+ test_event_notification --> sd_sender
+ test_event_notification --> tcp_helpers
+ test_field_conformance --> field_helpers
+ test_field_conformance --> event_helpers
+ test_field_conformance --> sd_helpers
+
+ event_helpers ..> sd_sender : <>
+ field_helpers ..> message_builder : <>
+ field_helpers ..> tcp_helpers : <>
+ @enduml
+
+Multi-service and Multi-instance
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``test_multi_service.py`` verifies that ``someipd`` correctly handles
+vsomeip configurations that declare multiple service entries, each advertising
+its own distinct UDP port in the SD endpoint option.
+
+.. uml::
+
+ @startuml
+ !theme plain
+ scale max 800 width
+ skinparam component {
+ BackgroundColor<> #E3F2FD
+ BorderColor<> #1565C0
+ BackgroundColor<> #E8F5E9
+ BorderColor<> #2E7D32
+ }
+
+ title Multi-service / Multi-instance — Test Module Dependencies
+
+ [test_multi_service] <>
+
+ [sd_helpers] <>
+ [sd_sender] <>
+
+ test_multi_service --> sd_helpers
+ test_multi_service --> sd_sender
+ @enduml
+
+Application Level Tests
+-----------------------
+
+Application level tests verify the full gateway pipeline end to end.
+A **service** (mw::com Skeleton) and **client** (mw::com Proxy) communicate
+through ``gatewayd`` and ``someipd``. Because both apps use the mw::com API
+only, the same test code works with any SOME/IP binding.
+
+.. note::
+
+ Application level tests are planned. See
+ ``tests/tc8_conformance/application/README.md`` for the intended scope.
+
+Planned Topology
+^^^^^^^^^^^^^^^^
+
+.. uml::
+
+ @startuml
+ !theme plain
+ scale max 800 width
+
+ node "Host" {
+ [TC8 Service\n(mw::com Skeleton)] as Svc
+ [gatewayd] as GW1
+ [someipd] as SD1
+
+ [someipd] as SD2
+ [gatewayd] as GW2
+ [TC8 Client\n(mw::com Proxy)] as Cli
+
+ Svc -right-> GW1 : LoLa IPC
+ GW1 -right-> SD1 : LoLa IPC
+ SD1 -right-> SD2 : SOME/IP\nUDP / TCP
+ SD2 -right-> GW2 : LoLa IPC
+ GW2 -right-> Cli : LoLa IPC
+ }
+
+ [pytest\norchestrator] as Orch
+ Orch .down.> Svc
+ Orch .down.> GW1
+ Orch .down.> SD1
+ Orch .down.> SD2
+ Orch .down.> GW2
+ Orch .down.> Cli
+ @enduml
+
+Stack-Agnostic Design
+^^^^^^^^^^^^^^^^^^^^^
+
+The test apps depend only on ``score::mw::com``. Switching the SOME/IP stack
+requires changing the deployment config, not test code.
+
+.. uml::
+
+ @startuml
+ !theme plain
+ scale max 800 width
+
+ package "Test Code (stack-agnostic)" {
+ class "TC8 Service" {
+ mw::com Skeleton
+ events, fields
+ }
+ class "TC8 Client" {
+ mw::com Proxy
+ subscribe, read
+ }
+ }
+
+ package "Deployment Config (stack-specific)" {
+ class "mw_com_config.json" {
+ binding: |
+ }
+ class "someip_stack.json" {
+ service routing
+ }
+ }
+
+ package "Runtime (swappable)" {
+ class "someipd\n(Stack A)" as vS
+ class "someipd\n(Stack B)" as eS
+ }
+
+ "TC8 Service" ..> "mw_com_config.json" : reads at startup
+ "TC8 Client" ..> "mw_com_config.json" : reads at startup
+ "mw_com_config.json" ..> vS : binds to
+ "mw_com_config.json" ..> eS : or binds to
+
+ note bottom of "TC8 Service"
+ Swapping stacks = change
+ config only.
+ Zero code changes.
+ end note
+ @enduml
+
+Planned Components
+^^^^^^^^^^^^^^^^^^
+
+The application level test design introduces four planned components.
+The **Enhanced Testability Service** (**ETS**) and **Enhanced Testability
+Client** (**ETC**) implement the TC8 service interface defined in OA TC8
+§5.1.4, while the **Test Orchestrator** and **Process Orchestrator** manage
+test and process lifecycle.
+
+.. uml::
+
+ @startuml
+ !theme plain
+ scale max 800 width
+ skinparam classAttributeIconSize 0
+
+ class "Enhanced Testability Service" as ETS {
+ mw::com Skeleton
+ --
+ +offer_tc8_events()
+ +offer_tc8_fields()
+ }
+
+ class "Enhanced Testability Client" as ETC {
+ mw::com Proxy
+ --
+ +subscribe_events()
+ +read_fields()
+ +validate_tc8_values()
+ }
+
+ class "Test Orchestrator" as TO {
+ pytest
+ --
+ +run_end_to_end_tests()
+ }
+
+ class "Process Orchestrator" as PO {
+ helper
+ --
+ +start_stack(config_dir)
+ +stop_stack(handle)
+ +wait_service_available(name, timeout)
+ }
+
+ TO -down-> ETS : starts / stops
+ TO -down-> ETC : starts / stops
+ TO -down-> PO : uses
+ @enduml
+
+``someipd`` and ``gatewayd`` run as the DUT in the QEMU guest. The Python
+socket-based tester and pytest run on the host side. ``tc8_itf_conftest.py``
+manages DUT lifecycle on the QEMU guest. ``TC8_DUT_IP`` addresses the QEMU
+guest and ``TC8_TESTER_IP`` addresses the host TAP interface.
diff --git a/docs/requirements/index.rst b/docs/requirements/index.rst
index 017a1c134..8472dc381 100644
--- a/docs/requirements/index.rst
+++ b/docs/requirements/index.rst
@@ -65,7 +65,7 @@ The following component identifiers are used in requirement IDs and file names:
- Gateway daemon — bridges IPC and SOME/IP, E2E protection, ACL enforcement
* - ``someipd``
- QM
- - SOME/IP stack daemon — wraps vsomeip, handles network I/O and SOME/IP-SD
+ - SOME/IP stack daemon — handles network I/O and SOME/IP-SD
* - ``network_service``
- ASIL-B
- IPC interface between ``gatewayd`` and ``someipd`` (SomeipMessageTransfer)
diff --git a/docs/requirements/stakeholder.rst b/docs/requirements/stakeholder.rst
index 8e42e3e85..a2bffa418 100644
--- a/docs/requirements/stakeholder.rst
+++ b/docs/requirements/stakeholder.rst
@@ -20,18 +20,28 @@ Stakeholder requirements capture high-level needs from users, integrators,
and safety standards. They are the top of the requirements hierarchy —
feature and component requirements derive from these.
-.. stkh_req:: Example Functional Requirement
- :id: stkh_req__docgen_enabled__example
+.. stkh_req:: SOME/IP Wire-Level Protocol Interoperability
+ :id: stkh_req__someip_gw__interoperability
:status: valid
:version: 1
+ :valid_from: v0.0.0
:safety: QM
- :security: YES
+ :security: NO
:reqtype: Functional
- :rationale: Ensure documentation builds are possible for all modules
- :valid_from: v0.0.0
+ :rationale: Automotive ECUs from different manufacturers communicate over SOME/IP. The SOME/IP Gateway network-facing component must conform to the SOME/IP wire-level protocol specification so that it interoperates with any compliant SOME/IP implementation.
+
+ The SOME/IP Gateway shall ensure that its SOME/IP network interface
+ component conforms to the SOME/IP wire-level protocol specification,
+ enabling correct interoperation with SOME/IP-compliant devices.
.. note::
- The requirement above is a placeholder inherited from the module template.
- It should be replaced with proper SOME/IP Gateway stakeholder requirements
- as they are defined.
+ This is a module-local stakeholder requirement. An upstream S-CORE
+ stakeholder requirement for SOME/IP interoperability does not yet exist.
+ If one is defined in a future S-CORE release, the ``:derived_from:`` link
+ on the feature requirement(s) below should be updated to reference it.
+
+ ``feat_req__tc8_conformance__conformance`` (see
+ :doc:`/tc8_conformance/requirements`) derives from this stakeholder
+ requirement: TC8 wire-level conformance testing is the verification
+ activity for this interoperability need.
diff --git a/docs/tc8_conformance/index.rst b/docs/tc8_conformance/index.rst
index 742d186fa..bb601cf79 100644
--- a/docs/tc8_conformance/index.rst
+++ b/docs/tc8_conformance/index.rst
@@ -16,13 +16,45 @@ TC8 SOME/IP Conformance Testing
================================
This section defines the requirements, test specifications, and traceability
-for OPEN Alliance TC8 SOME/IP conformance testing of the ``someipd``
-component (vsomeip 3.6.1 stack).
+for `OPEN Alliance TC8 `_
+SOME/IP conformance testing of the SOME/IP Gateway.
-All tests are **application-less** — they exercise the SOME/IP protocol stack
-directly at the wire level without requiring ``gatewayd`` or application processes.
+The TC8 test suite covers two scopes:
+
+- **Protocol Conformance** — Tests ``someipd`` at the wire level using raw
+ UDP/TCP sockets and the ``someip`` Python package. No application processes
+ are needed. ``someipd`` runs in ``--tc8-standalone`` mode.
+
+- **Application-Level Tests** — Tests the full gateway path
+ (mw::com client → ``gatewayd`` → ``someipd`` → network) using C++ apps
+ built on ``score::mw::com``. These tests are stack-agnostic.
+
+All tests live under ``tests/tc8_conformance/`` and share the ``tc8`` /
+``conformance`` Bazel tags. For the architectural overview, test topology
+diagrams, and module structure, see
+:doc:`/architecture/tc8_conformance_testing`.
+
+.. note::
+
+ Protocol conformance tests run ``someipd`` in ``--tc8-standalone`` mode.
+ Fixture defaults (DUT IP, tester IP, port numbers) are defined in
+ ``tc8_itf_conftest.py``. To run all protocol conformance tests::
+
+ bazel test --config=tc8-itf //tests/tc8_conformance/...
+
+ For QNX x86_64 use ``--config=tc8-itf-qnx``.
.. toctree::
:maxdepth: 2
requirements.rst
+ test_specification.rst
+ traceability.rst
+
+.. seealso::
+
+ :doc:`/architecture/tc8_conformance_testing` — test topology, module
+ dependency diagrams, and planned components.
+
+ :doc:`traceability` — full OA TC8 v3.0 Chapter 5 scope analysis, gap
+ analysis, coverage breakdown, and known stack limitations.
diff --git a/docs/tc8_conformance/requirements.rst b/docs/tc8_conformance/requirements.rst
index 36cd6c6bd..fb741e91e 100644
--- a/docs/tc8_conformance/requirements.rst
+++ b/docs/tc8_conformance/requirements.rst
@@ -15,6 +15,90 @@
TC8 Conformance Test Requirements
=================================
+Overview
+--------
+
+This document defines the requirements for verifying ``someipd`` against
+the OPEN Alliance TC8 SOME/IP test specification.
+
+It belongs to a set of three documents that work together:
+
+.. list-table:: TC8 Conformance Documentation Set
+ :widths: 25 75
+ :header-rows: 1
+
+ * - Document
+ - Purpose
+ * - **requirements.rst** (this file)
+ - Defines *what* must be verified: one feature requirement and
+ multiple component requirements, each linked to the S-CORE
+ requirement hierarchy.
+ * - :doc:`test_specification`
+ - Defines *how* each test runs: purpose, preconditions, stimuli,
+ and expected results.
+ * - :doc:`traceability`
+ - Maps external OA spec test case IDs to internal test IDs,
+ component requirements, and Python test functions.
+
+How the feature / component split works for TC8
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+For TC8 conformance, the split is simple:
+
+* **One feature requirement**
+ (``feat_req__tc8_conformance__conformance``) covers the overall goal:
+ "verify ``someipd`` against OA TC8 SOME/IP at the wire level." This
+ requirement does **not** change when new test areas are added.
+
+* **Many component requirements** — one per testable protocol aspect
+ (e.g., SD offer format, cyclic timing, response headers, TCP
+ transport). Each component requirement:
+
+ - Describes the specific behaviour under test.
+ - References the relevant AUTOSAR PRS or TC8 specification section.
+ - Is verified by one or more pytest functions.
+
+Requirement Areas
+^^^^^^^^^^^^^^^^^
+
+The component requirements are grouped by TC8 test area:
+
+.. list-table::
+ :widths: 30 20 50
+ :header-rows: 1
+
+ * - Area
+ - Req Count
+ - Scope
+ * - Service Discovery
+ - 9
+ - SD offer format, cyclic timing, find response, subscribe lifecycle,
+ subscription TTL expiry, phases timing, endpoint options, reboot
+ detection, multicast eventgroup
+ * - SD Format and Options Compliance
+ - 3
+ - Byte-level field assertions for SD SOME/IP header, offer entry,
+ SubscribeAck entry, StopSubscribeEventgroup entry, IPv4EndpointOption,
+ and IPv4MulticastOption
+ * - SD Robustness
+ - 1
+ - Malformed SD packet handling without crash or state corruption
+ * - SOME/IP Message Format
+ - 3
+ - Response header fields, error return codes, malformed message handling
+ * - Event Notification
+ - 1
+ - Notification delivery lifecycle (subscribe, event ID, multicast, stop)
+ * - Field Conformance
+ - 3
+ - Initial value on subscribe, getter/setter methods, on-change-only field notification
+ * - TCP Transport Binding
+ - 1
+ - TCP reliable transport for RPC, field get/set, event notification
+ * - Multi-service and Multi-instance
+ - 1
+ - Multi-service config loading, per-service port advertisement, SD isolation
+
Feature Requirement
-------------------
@@ -26,7 +110,7 @@ as a formal verification activity for the SOME/IP Gateway's protocol stack.
:status: valid
:version: 1
:tags: tc8, conformance, someip, verification
- :derived_from: stkh_req__docgen_enabled__example
+ :derived_from: stkh_req__someip_gw__interoperability
:satisfied_by: feat__someip_gateway
:safety: QM
:security: NO
@@ -38,9 +122,12 @@ as a formal verification activity for the SOME/IP Gateway's protocol stack.
specifications at the wire protocol level, without requiring application
processes.
- Note: The ``:derived_from:`` link targets a placeholder stakeholder requirement.
- This shall be updated to reference the upstream S-CORE stakeholder requirement
- for SOME/IP interoperability once it is formally defined.
+ **Scope boundary:** The TC8 conformance tests run ``someipd`` in
+ standalone mode (``--tc8-standalone`` flag). They validate the wire-level
+ SOME/IP behavior of ``someipd`` only. They do not exercise the full
+ middleware path from ``score::mw::com`` through ``gatewayd`` to
+ ``someipd``. End-to-end middleware integration is covered by the
+ integration test suite under ``tests/integration``.
Component Requirements — Service Discovery
------------------------------------------
@@ -121,13 +208,31 @@ Specification (AUTOSAR PRS_SOMEIP_SD).
The conformance test suite shall verify that ``someipd`` correctly
handles the SubscribeEventgroup lifecycle: acknowledge valid
subscriptions (SubscribeEventgroupAck), reject unknown eventgroups
- (SubscribeEventgroupNack with TTL=0), and honor StopSubscribeEventgroup
- by ceasing notifications.
+ (SubscribeEventgroupNack with TTL=0), honor StopSubscribeEventgroup
+ by ceasing notifications, and clean up expired subscriptions after
+ the subscription TTL elapses.
Note: Traces to SOME/IP-SD specification sections 4.1.2.4
(SubscribeEventgroup), 4.1.2.5 (StopSubscribeEventgroup),
- and 4.1.2.6 (SubscribeEventgroupAck/Nack).
- Covers TC8-SD-006, TC8-SD-007, and TC8-SD-008 from the test strategy.
+ 4.1.2.6 (SubscribeEventgroupAck/Nack), and 4.1.2.7 (TTL handling).
+ Covers TC8-SD-006, TC8-SD-007, TC8-SD-008, and TC8-SD-014 from the
+ test strategy.
+
+.. comp_req:: TC8 SD Subscription TTL Expiry
+ :id: comp_req__tc8_conformance__sd_ttl_expiry
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery, eventgroup, timing
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that event notifications cease to arrive
+ after the subscription TTL expires: when a tester subscribes with TTL = 1 and no
+ renewal is sent, no further SOME/IP notifications shall be received beyond 2 seconds
+ after the TTL expiry, conforming to OA TC8 SOMEIP_ETS_095.
.. comp_req:: TC8 SD Initial Delay and Repetitions Phase
:id: comp_req__tc8_conformance__sd_phases_timing
@@ -149,6 +254,66 @@ Specification (AUTOSAR PRS_SOMEIP_SD).
(SD Phases — Initial Wait, Repetition, Main Phase).
Covers TC8-SD-009 and TC8-SD-010 from the test strategy.
+.. comp_req:: TC8 SD IPv4 Endpoint Option Validation
+ :id: comp_req__tc8_conformance__sd_endpoint_option
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` includes
+ a valid IPv4EndpointOption in OfferService SD entries, carrying the
+ correct unicast address, port, and L4 protocol (UDP) so that clients
+ can reach the offered service.
+
+ Note: Traces to SOME/IP-SD specification section 4.1.2.4
+ (SD Options — IPv4 Endpoint Option format).
+ Covers TC8-SD-011 from the test strategy.
+
+.. comp_req:: TC8 SD Reboot Detection
+ :id: comp_req__tc8_conformance__sd_reboot
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery, reboot
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` resets its
+ SD state upon restart: the reboot flag (SD flags byte bit 7) shall
+ be set in the first SD message after restart, and the SD session ID
+ shall reset to a low value (≤ 2).
+
+ Note: Traces to SOME/IP-SD specification section 4.1.1
+ (Reboot Detection — session ID and reboot flag handling).
+ Covers TC8-SD-012 from the test strategy.
+
+.. comp_req:: TC8 SD Multicast Eventgroup Option
+ :id: comp_req__tc8_conformance__sd_mcast_eg
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery, multicast
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` includes
+ a multicast IPv4EndpointOption in the SubscribeEventgroupAck for
+ eventgroups configured with a multicast address, so that clients
+ know which multicast group to join for event delivery.
+
+ Note: Traces to SOME/IP-SD specification section 4.1.2.6
+ (SubscribeEventgroupAck options — multicast endpoint).
+ Covers TC8-SD-013 from the test strategy.
+
Component Requirements — SOME/IP Message Format
-----------------------------------------------
@@ -170,8 +335,8 @@ Component Requirements — SOME/IP Message Format
Note: Traces to SOME/IP specification sections 4.1.4
(Protocol Version), 4.1.6 (Message Type), and 4.1.3 (Request ID —
- Client ID / Session ID). Covers TC8-SOMEIP-MSG-001,
- TC8-SOMEIP-MSG-002, TC8-SOMEIP-MSG-005, and TC8-SOMEIP-MSG-008
+ Client ID / Session ID). Covers TC8-MSG-001,
+ TC8-MSG-002, TC8-MSG-005, and TC8-MSG-008
from the test strategy.
.. comp_req:: TC8 SOME/IP Error Return Codes
@@ -187,13 +352,35 @@ Component Requirements — SOME/IP Message Format
The conformance test suite shall verify that ``someipd`` returns
the correct SOME/IP return codes for error conditions:
- ``E_UNKNOWN_SERVICE`` (0x02) for requests to non-existent services,
+ ``E_UNKNOWN_SERVICE`` (0x02) or no response for requests to
+ non-existent services (both are valid stack behaviors),
``E_UNKNOWN_METHOD`` (0x03) for invalid method IDs, and
``E_WRONG_INTERFACE_VERSION`` for interface version mismatches.
Note: Traces to SOME/IP specification section 4.1.7 (Return Code)
- and the return code table (Table 4.14). Covers TC8-SOMEIP-MSG-003,
- TC8-SOMEIP-MSG-004, and TC8-SOMEIP-MSG-006 from the test strategy.
+ and the return code table (Table 4.14). Covers TC8-MSG-003,
+ TC8-MSG-004, and TC8-MSG-006 from the test strategy.
+
+.. comp_req:: TC8 SOME/IP Malformed Message Handling
+ :id: comp_req__tc8_conformance__msg_malformed
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, message_format, robustness
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` does not
+ crash when receiving malformed SOME/IP messages, including truncated
+ messages (shorter than the 8-byte minimum header), messages with an
+ invalid protocol version, and messages whose length field claims more
+ data than the UDP payload contains.
+
+ Note: Traces to SOME/IP specification section 4.1 (Header format
+ validation and error handling). Covers TC8-MSG-007 from the
+ test strategy.
Component Requirements — Event Notification
-------------------------------------------
@@ -216,52 +403,264 @@ Component Requirements — Event Notification
Note: Traces to SOME/IP specification section 5.1 (Events) and
SOME/IP-SD section 4.1.2.4 (SubscribeEventgroup triggering
- notification delivery). Covers TC8-EVT-001 through TC8-EVT-004
- and TC8-EVT-006 from the test strategy.
+ notification delivery). Covers TC8-EVT-001 through TC8-EVT-006
+ from the test strategy.
-Traceability Summary
---------------------
+Component Requirements — Field Conformance
+-------------------------------------------
-The following table links each component requirement to the SOME/IP
-specification section it verifies.
+.. comp_req:: TC8 Field Initial Value on Subscribe
+ :id: comp_req__tc8_conformance__fld_initial_value
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, fields, notification
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
-.. list-table:: TC8 Requirement Traceability Matrix
- :widths: 30 20 30 20
- :header-rows: 1
+ The conformance test suite shall verify that ``someipd`` delivers an initial
+ NOTIFICATION message to a new subscriber of a field eventgroup (``is_field: true``)
+ immediately upon subscription, carrying the last known field value.
+
+ Note: Traces to SOME/IP specification section 5.3 (Fields — initial value
+ notification on subscribe) and AUTOSAR SWS_CM_00719.
+ Covers TC8-FLD-001 and TC8-FLD-002 from the test strategy.
+
+.. comp_req:: TC8 Field Getter and Setter
+ :id: comp_req__tc8_conformance__fld_get_set
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, fields, request_response
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` handles field
+ getter (method 0x0001) and setter (method 0x0002) requests: the getter
+ shall return the current field value in a RESPONSE; the setter shall update
+ the stored field value, respond with E_OK, and immediately notify all
+ active subscribers with the new value.
+
+ Note: Traces to SOME/IP specification section 5.3 (Fields — getter/setter
+ methods) and AUTOSAR SWS_CM_00720/SWS_CM_00721.
+ Covers TC8-FLD-003 and TC8-FLD-004 from the test strategy.
+
+.. comp_req:: TC8 Field Getter/Setter Notification Behavior
+ :id: comp_req__tc8_conformance__fld_getter_setter
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, fields, notification
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` sends a field event
+ notification only when the field value actually changes, not on every cyclic
+ trigger or repeated SET with the same value.
+
+ Note: Traces to OA TC8 v3.0 §5.1.5.7 SOMEIPSRV_RPC_16 (on-change-only
+ notification for fields).
+ Covered by TC8-EVT-007 in ``test_event_notification.py``.
+
+Component Requirements — TCP Transport Binding
+-----------------------------------------------
+
+.. comp_req:: TC8 TCP Transport Binding for RPC
+ :id: comp_req__tc8_conformance__tcp_transport
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, tcp, transport, rpc
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` supports
+ TCP (reliable) transport binding for SOME/IP RPC request/response
+ communication, including correct TCP endpoint advertisement in
+ Service Discovery and successful method invocation over a TCP
+ connection.
+
+ Note: Traces to OA TC8 specification references SOMEIPSRV_RPC_01,
+ SOMEIPSRV_RPC_02, and SOMEIPSRV_OPTIONS_15. Also traces to
+ PRS_SOMEIP_00142 (SOME/IP TCP message framing) and PRS_SOMEIP_00569
+ (unaligned message handling over TCP), covered by TC8-TCP-009.
+ Addresses Gap 1 (TCP transport binding) from the architecture
+ conformance analysis.
+
+.. seealso::
+
+ For the full traceability chain (OA specification → internal TC8 ID →
+ requirement → test function), see :doc:`traceability`.
+
+ For detailed test case specifications (purpose, stimuli, expected results),
+ see :doc:`test_specification`.
+
+Component Requirements — Multi-service and Multi-instance
+-----------------------------------------------------------
+
+.. comp_req:: TC8 Multi-service and Multi-instance Routing
+ :id: comp_req__tc8_conformance__multi_service
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, multi_service, routing
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` accepts a vsomeip
+ configuration containing multiple service entries and, for each offered
+ service, advertises the correct UDP port in the SD OfferService endpoint
+ option. Tests also verify that only the configured service IDs appear in
+ SD traffic and that the multi-service config can be loaded without process
+ failure.
+
+ Note: Traces to OA TC8 specification references SOMEIPSRV_RPC_13
+ (multi-service hosting) and SOMEIPSRV_RPC_14 (per-instance port isolation).
+ Covered by ``test_multi_service.py`` in the ``test_tc8_multi_service`` Bazel target.
+
+Component Requirements — SD Format and Options Compliance
+-----------------------------------------------------------
+
+The following component requirements cover byte-level field assertions for
+SOME/IP-SD messages sent by ``someipd``, corresponding to OA TC8 v3.0 §5.1.5.1
+(FORMAT_*) and §5.1.5.2 (OPTIONS_*).
+
+.. comp_req:: TC8 SD SOME/IP Header and Entry Field Validation
+ :id: comp_req__tc8_conformance__sd_format_fields
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery, format
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` transmits SD
+ OfferService and SubscribeEventgroupAck messages with correct byte-level
+ field values, including: Client-ID = 0x0000 (FORMAT_01), Session-ID
+ starting from 0x0001 (FORMAT_02), Interface Version = 0x01 (FORMAT_04),
+ Message Type = 0x02 Notification (FORMAT_05), Return Code = 0x00 E_OK
+ (FORMAT_06), undefined SD flag bits = 0 (FORMAT_09), reserved entry bytes
+ = 0 (FORMAT_10), entry length = 16 bytes (FORMAT_11), correct option run
+ indices and option counts (FORMAT_12/13), instance ID (FORMAT_15), major
+ version (FORMAT_16), minor version (FORMAT_18) matching the configured
+ values, SubscribeAck entry type = 0x06 (FORMAT_19), SubscribeAck entry
+ length = 16 bytes (FORMAT_20), SubscribeAck option run index (FORMAT_21),
+ SubscribeAck service ID (FORMAT_23), instance ID (FORMAT_24), major version
+ (FORMAT_25), TTL > 0 (FORMAT_26), reserved field = 0 (FORMAT_27), and
+ eventgroup ID (FORMAT_28) matching the subscribe request.
+
+ Note: Traces to OA TC8 v3.0 §5.1.5.1 (SOME/IP-SD header and entry
+ format assertions).
+
+.. comp_req:: TC8 SD IPv4 Endpoint and Multicast Option Field Validation
+ :id: comp_req__tc8_conformance__sd_options_fields
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery, options
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` encodes SD
+ IPv4EndpointOptions with correct sub-field values: option length = 0x0009
+ (OPTIONS_01), option type = 0x04 (OPTIONS_02), reserved byte after type
+ = 0x00 (OPTIONS_03), reserved byte before protocol = 0x00 (OPTIONS_05),
+ and L4 protocol = 0x11 UDP (OPTIONS_06). For multicast eventgroups the
+ suite shall also verify that SubscribeEventgroupAck messages contain
+ IPv4MulticastOptions with correct encoding: length = 0x0009 (OPTIONS_08),
+ type = 0x14 (OPTIONS_09), reserved byte = 0x00 (OPTIONS_10), multicast
+ address matching configuration (OPTIONS_11), reserved byte before port
+ = 0x00 (OPTIONS_12), L4 protocol = 0x11 UDP (OPTIONS_13), and port number
+ matching configuration (OPTIONS_14).
+
+ Note: Traces to OA TC8 v3.0 §5.1.5.2 (SD Options format assertions).
+ Multicast option tests (OPTIONS_08–14) require a non-loopback interface
+ (``@pytest.mark.network``).
+
+.. comp_req:: TC8 SD StopSubscribeEventgroup Entry Wire Format
+ :id: comp_req__tc8_conformance__sd_stop_sub_fmt
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery, format
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that a StopSubscribeEventgroup SD entry
+ has entry type byte ``0x06`` and TTL field (bytes 9–11 of the entry) equal to
+ ``0x000000`` at the wire level, conforming to OA TC8 SOMEIPSRV_SD_MESSAGE_12.
+
+Component Requirements — SD Robustness
+----------------------------------------
+
+.. comp_req:: TC8 SD Robustness — Malformed Packet Survival
+ :id: comp_req__tc8_conformance__sd_robustness
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, service_discovery, robustness
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` survives and
+ remains functional (still responds to a valid FindService) after receiving
+ malformed SOME/IP-SD packets, covering: empty entries arrays (ETS_111),
+ zero-length or malformed options (ETS_112/113), mismatched entries-length
+ fields (ETS_114), entries referencing more options than present (ETS_115),
+ unknown option types (ETS_116/174), overlapping option indices (ETS_117),
+ FindService with unexpected endpoint options (ETS_118), entries-length
+ exceeding the payload (ETS_123/124/125), option lengths extending past the
+ options array (ETS_134/135), option lengths shorter than the minimum
+ (ETS_136), unaligned option lengths (ETS_137), options-array-length
+ mismatches (ETS_138/139), SubscribeEventgroup without endpoint option
+ (ETS_109), with zero IP endpoint (ETS_110), with wrong L4 protocol
+ (ETS_119), for unknown service/instance/eventgroup (ETS_140/141/142/143),
+ with reserved option type (ETS_144), SD with near-wrap or maximum session
+ IDs (ETS_152), SOME/IP length field mismatches (ETS_153), and wrong
+ SOME/IP service ID in the header (ETS_178).
+
+ Note: Traces to OA TC8 v3.0 §5.1.6 (Enhanced Testability Service Tests —
+ SD robustness cases).
+
+Component Requirements — UDP Transport Binding
+-----------------------------------------------
+
+.. comp_req:: TC8 UDP Transport Binding — Multiple Messages per Datagram
+ :id: comp_req__tc8_conformance__udp_transport
+ :status: valid
+ :version: 1
+ :tags: tc8, conformance, udp, transport
+ :derived_from: feat_req__tc8_conformance__conformance
+ :satisfied_by: comp__someipd
+ :safety: QM
+ :security: NO
+ :reqtype: Functional
+
+ The conformance test suite shall verify that ``someipd`` correctly parses
+ a UDP datagram that contains multiple SOME/IP messages packed consecutively,
+ including the case where a message starts at a non-4-byte-aligned byte offset
+ within the datagram. The DUT shall respond to each contained SOME/IP request
+ individually.
- * - Requirement ID
- - TC8 Test IDs
- - SOME/IP Spec Reference
- - Safety
- * - ``comp_req__tc8_conformance__sd_offer_format``
- - TC8-SD-001, -002
- - SOME/IP-SD §4.1.2.1, §4.1.2.3
- - QM
- * - ``comp_req__tc8_conformance__sd_cyclic_timing``
- - TC8-SD-003
- - SOME/IP-SD §4.1.1 (Main Phase)
- - QM
- * - ``comp_req__tc8_conformance__sd_find_response``
- - TC8-SD-004, -005
- - SOME/IP-SD §4.1.2.2
- - QM
- * - ``comp_req__tc8_conformance__sd_sub_lifecycle``
- - TC8-SD-006, -007, -008
- - SOME/IP-SD §4.1.2.4–4.1.2.6
- - QM
- * - ``comp_req__tc8_conformance__sd_phases_timing``
- - TC8-SD-009, -010
- - SOME/IP-SD §4.1.1 (Phases)
- - QM
- * - ``comp_req__tc8_conformance__msg_resp_header``
- - TC8-MSG-001, -002, -005, -008
- - SOME/IP §4.1.3, §4.1.4, §4.1.6
- - QM
- * - ``comp_req__tc8_conformance__msg_error_codes``
- - TC8-MSG-003, -004, -006
- - SOME/IP §4.1.7 (Table 4.14)
- - QM
- * - ``comp_req__tc8_conformance__evt_subscription``
- - TC8-EVT-001–004, -006
- - SOME/IP §5.1, SOME/IP-SD §4.1.2.4
- - QM
+ Note: Traces to PRS_SOMEIP_00142 and PRS_SOMEIP_00569 (unaligned SOME/IP message
+ parsing over UDP).
+ Covered by TC8-UDP-001 in ``test_someip_message_format.py``
+ (``test_tc8_ets_069_unaligned_someip_messages_over_udp``).
diff --git a/docs/tc8_conformance/test_specification.rst b/docs/tc8_conformance/test_specification.rst
new file mode 100644
index 000000000..5f117b767
--- /dev/null
+++ b/docs/tc8_conformance/test_specification.rst
@@ -0,0 +1,3344 @@
+..
+ # *******************************************************************************
+ # Copyright (c) 2026 Contributors to the Eclipse Foundation
+ #
+ # See the NOTICE file(s) distributed with this work for additional
+ # information regarding copyright ownership.
+ #
+ # This program and the accompanying materials are made available under the
+ # terms of the Apache License Version 2.0 which is available at
+ # https://www.apache.org/licenses/LICENSE-2.0
+ #
+ # SPDX-License-Identifier: Apache-2.0
+ # *******************************************************************************
+
+TC8 Test Specifications
+========================
+
+This document provides the detailed test specification for each TC8
+conformance test case. Each entry describes the purpose, preconditions,
+test stimuli, expected results, and requirement traceability.
+
+For the full OA specification mapping see :doc:`traceability`.
+For requirement definitions see :doc:`requirements`.
+
+.. note::
+
+ The "OA Spec Reference" field in each test case references the
+ corresponding section from Chapter 5 of the OPEN Alliance
+ TC8 Automotive Ethernet ECU Test Specification v3.0.
+ See :doc:`traceability` for the full mapping.
+
+.. note:: Execution model
+
+ The host address is ``TC8_TESTER_IP``; the DUT address is ``TC8_DUT_IP``.
+ Both default values are defined in ``tc8_itf_conftest.py``. To run a
+ test::
+
+ bazel test --config=tc8-itf //tests/tc8_conformance:test_tc8_
+
+ For QNX x86_64 use ``--config=tc8-itf-qnx``.
+
+.. note:: Terminology
+
+ Throughout this specification, **"server"** refers to the SOME/IP Service Provider role
+ (the DUT, which offers services and responds to requests), and **"client"** refers to the
+ SOME/IP Service Consumer role (the external test harness, which discovers services and
+ subscribes to events). This usage mirrors TC8 OA §5.1.5 ("SOME/IP Server Tests") and
+ §5.1.6 ("ETS Client / Control") directly.
+
+Service Discovery Tests
+-----------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json``
+
+TC8-SD-001 — Multicast Offer on Startup
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_08
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_001_multicast_offer_on_startup``
+:Requirement: ``comp_req__tc8_conformance__sd_offer_format``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that ``someipd`` sends at least one SD OfferService entry on the
+configured multicast group (``224.244.224.245:30490``) after startup.
+
+**Preconditions:**
+
+- ``someipd`` started with ``--tc8-standalone`` flag
+- Multicast route available (``224.0.0.0/4``)
+
+**Stimuli:**
+None — passive observation of DUT multicast traffic.
+
+**Expected Result:**
+At least one SOME/IP-SD message containing an OfferService entry is
+received on the multicast group within 5 seconds of DUT startup.
+
+TC8-SD-002 — Offer Entry Format
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_14–18
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_002_offer_entry_format``
+:Requirement: ``comp_req__tc8_conformance__sd_offer_format``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that the OfferService entry carries the correct service ID,
+instance ID, major/minor version, and TTL as configured.
+
+**Preconditions:**
+
+- Same as TC8-SD-001.
+
+**Stimuli:**
+None — passive observation.
+
+**Expected Result:**
+OfferService entry has ``service_id=0x1234``, ``instance_id=0x5678``,
+``major_version=0x00``, ``minor_version=0x00000000``, and ``TTL > 0``.
+
+TC8-SD-003 — Cyclic Offer Timing
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_02
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_003_cyclic_offer_timing``
+:Requirement: ``comp_req__tc8_conformance__sd_cyclic_timing``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that OfferService entries repeat at the configured
+``cyclic_offer_delay`` (2000 ms ±20%) during the main phase.
+
+**Preconditions:**
+
+- DUT in SD main phase (wait for repetition phase to complete).
+
+**Stimuli:**
+None — passive observation with timestamps.
+
+**Expected Result:**
+Inter-offer gaps in main phase are within [1600 ms, 2400 ms].
+
+TC8-SD-004 — FindService Known Service
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_171
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_004_find_known_service_unicast_offer``
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that ``someipd`` responds with a unicast OfferService when a
+FindService is sent for a known service.
+
+**Preconditions:**
+
+- DUT offering service ``0x1234``.
+
+**Stimuli:**
+Send SD FindService entry for service ``0x1234`` / instance ``0x5678``.
+
+**Expected Result:**
+Unicast OfferService entry received for the requested service.
+
+TC8-SD-005 — FindService Unknown Service
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.4 — implied by SD_BEHAVIOR_03/04
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_005_find_unknown_service_no_response``
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that ``someipd`` does not respond to a FindService for an
+unknown service.
+
+**Preconditions:**
+
+- DUT running, not offering service ``0xBEEF``.
+
+**Stimuli:**
+Send SD FindService entry for service ``0xBEEF``.
+
+**Expected Result:**
+No OfferService entry received for service ``0xBEEF`` within 2 seconds.
+
+TC8-SD-006 — Subscribe Eventgroup Ack
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_13
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_006_subscribe_valid_eventgroup_ack``
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that ``someipd`` sends SubscribeEventgroupAck (TTL > 0) for a
+valid eventgroup subscription.
+
+**Preconditions:**
+
+- DUT offering eventgroup ``0x4455``.
+
+**Stimuli:**
+Send SD SubscribeEventgroup for service ``0x1234``, eventgroup ``0x4455``.
+
+**Expected Result:**
+SubscribeEventgroupAck with TTL > 0 received.
+
+TC8-SD-007 — Subscribe Unknown Eventgroup Nack
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14; §5.1.6 — SOMEIP_ETS_140
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_007_subscribe_unknown_eventgroup_nack``
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that ``someipd`` sends SubscribeEventgroupNack (TTL = 0) for
+an unknown eventgroup.
+
+**Preconditions:**
+
+- DUT running, eventgroup ``0xBEEF`` not configured.
+
+**Stimuli:**
+Send SD SubscribeEventgroup for eventgroup ``0xBEEF``.
+
+**Expected Result:**
+SubscribeEventgroupAck with TTL = 0 (Nack) received.
+
+TC8-SD-008 — StopSubscribe Ceases Notifications
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_108, SOMEIP_ETS_092
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_008_stop_subscribe_ceases_notifications``
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that notifications cease after StopSubscribeEventgroup (TTL = 0).
+
+**Preconditions:**
+
+- Active subscription to eventgroup ``0x4455``.
+- At least one notification received.
+
+**Stimuli:**
+Send SD SubscribeEventgroup with TTL = 0 (StopSubscribe).
+
+**Expected Result:**
+No further notifications received within 4 seconds.
+
+TC8-SD-009 — Repetition Phase Intervals
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01
+:Test Module: ``test_sd_phases_timing.py``
+:Test Function: ``test_tc8_sd_009_repetition_phase_intervals``
+:Requirement: ``comp_req__tc8_conformance__sd_phases_timing``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that the first inter-offer gap after startup is a Repetition
+Phase gap (shorter than half the cyclic offer delay).
+
+**Preconditions:**
+
+- Multicast socket opened before DUT startup to capture first offer.
+
+**Stimuli:**
+None — passive observation from DUT start.
+
+**Expected Result:**
+First gap < 1000 ms (half of ``cyclic_offer_delay`` 2000 ms).
+
+TC8-SD-010 — Repetition Count
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01
+:Test Module: ``test_sd_phases_timing.py``
+:Test Function: ``test_tc8_sd_010_repetition_count_before_main_phase``
+:Requirement: ``comp_req__tc8_conformance__sd_phases_timing``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify at least ``repetitions_max - 1`` short-gap offers before
+transition to main phase.
+
+**Preconditions:**
+
+- Same as TC8-SD-009.
+
+**Stimuli:**
+None — passive observation from DUT start.
+
+**Expected Result:**
+At least 2 short gaps (< 1000 ms) observed before a long gap (main phase).
+
+TC8-SD-011 — IPv4 Endpoint Option
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_01–07
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_011_offer_ipv4_endpoint_option``
+:Requirement: ``comp_req__tc8_conformance__sd_endpoint_option``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that OfferService SD entries include an IPv4EndpointOption
+with the correct address, port, and L4 protocol.
+
+**Preconditions:**
+
+- DUT offering service on UDP port 30509.
+
+**Stimuli:**
+None — passive observation with option parsing.
+
+**Expected Result:**
+IPv4EndpointOption present with address matching ``host_ip``,
+port = 30509, protocol = UDP.
+
+TC8-SD-012 — Reboot Detection
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_02, FORMAT_07
+:Test Module: ``test_sd_reboot.py``
+:Test Functions:
+ - ``test_tc8_sd_012_reboot_flag_set_after_restart``
+ - ``test_tc8_sd_012_session_id_resets_after_restart``
+:Requirement: ``comp_req__tc8_conformance__sd_reboot``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that ``someipd`` resets SD state on restart: reboot flag set
+(bit 7 = 1) and session ID reset to ≤ 2.
+
+**Preconditions:**
+
+- DUT started, SD messages captured, then DUT terminated.
+
+**Stimuli:**
+Restart ``someipd`` and capture the first post-reboot SD message.
+
+**Expected Result:**
+First post-restart SD message has reboot flag set and session ID ≤ 2.
+
+TC8-SD-013 — Multicast Eventgroup Option
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_08–14
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_013_subscribe_ack_has_multicast_option``
+:Requirement: ``comp_req__tc8_conformance__sd_mcast_eg``
+:DUT Config: ``tc8_someipd_sd.json``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+
+**Purpose:**
+Verify that SubscribeEventgroupAck for a multicast eventgroup includes
+a multicast IPv4EndpointOption.
+
+**Preconditions:**
+
+- Non-loopback network interface (``TC8_TESTER_IP`` set).
+- Eventgroup ``0x4465`` configured with multicast address ``239.0.0.1``.
+
+**Stimuli:**
+Send SD SubscribeEventgroup for eventgroup ``0x4465``.
+
+**Expected Result:**
+SubscribeEventgroupAck contains a multicast IPv4EndpointOption.
+
+TC8-SD-014 — TTL Expiry Cleanup
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_095
+:Test Module: ``test_service_discovery.py``
+:Test Function: ``test_tc8_sd_014_ttl_expiry_ceases_notifications``
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:DUT Config: ``tc8_someipd_sd.json``
+
+**Purpose:**
+Verify that notifications cease after the subscription TTL expires.
+
+**Preconditions:**
+
+- Active subscription with TTL = 3 seconds.
+- At least one notification received before expiry.
+
+**Stimuli:**
+Wait for TTL to expire (3 s + 2 s margin).
+
+**Expected Result:**
+No notifications received in a 3-second window after TTL expiry.
+
+SOME/IP Message Format Tests
+-----------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+TC8-MSG-001 — Protocol Version
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_05
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_msg_001_protocol_version``
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that RESPONSE messages have ``protocol_version = 0x01``.
+
+**Stimuli:**
+Send REQUEST to service ``0x1234``, method ``0x0421``.
+
+**Expected Result:**
+RESPONSE with ``protocol_version == 1``.
+
+TC8-MSG-002 — Message Type
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_07
+:Test Module: ``test_someip_message_format.py``
+:Test Functions:
+ - ``test_tc8_msg_002_message_type_response``
+ - ``test_tc8_msg_002_no_response_for_request_no_return``
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify RESPONSE (0x80) for REQUEST; no response for REQUEST_NO_RETURN.
+
+**Stimuli:**
+
+- Send REQUEST → expect RESPONSE.
+- Send REQUEST_NO_RETURN → expect silence.
+
+**Expected Result:**
+
+- REQUEST produces RESPONSE with ``message_type = 0x80``.
+- REQUEST_NO_RETURN produces no response within 2 seconds.
+
+TC8-MSG-003 — Unknown Service
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_10; §5.1.6 — SOMEIP_ETS_077
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_msg_003_unknown_service``
+:Requirement: ``comp_req__tc8_conformance__msg_error_codes``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify error handling for requests to non-existent services.
+
+**Stimuli:**
+Send REQUEST to service ``0xBEEF``.
+
+**Expected Result:**
+``E_UNKNOWN_SERVICE`` (0x02) or no response (both valid per TC8).
+
+TC8-MSG-004 — Unknown Method
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_12; §5.1.6 — SOMEIP_ETS_076
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_msg_004_unknown_method``
+:Requirement: ``comp_req__tc8_conformance__msg_error_codes``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify error handling for requests with invalid method IDs.
+
+**Stimuli:**
+Send REQUEST to service ``0x1234``, method ``0xBEEF``.
+
+**Expected Result:**
+``E_UNKNOWN_METHOD`` (0x03).
+
+TC8-MSG-005 — Session ID Echo
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_03
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_msg_005_session_id_echo``
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify RESPONSE echoes the REQUEST session ID.
+
+**Stimuli:**
+Send REQUEST with ``session_id = 0x1234``.
+
+**Expected Result:**
+RESPONSE has ``session_id == 0x1234``.
+
+TC8-MSG-006 — Wrong Interface Version
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_074
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_msg_006_wrong_interface_version``
+:Requirement: ``comp_req__tc8_conformance__msg_error_codes``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify error handling for requests with wrong interface version.
+
+**Stimuli:**
+Send REQUEST with ``interface_version = 0xFF``.
+
+**Expected Result:**
+``E_WRONG_INTERFACE_VERSION`` (0x08).
+
+TC8-MSG-007 — Malformed Message Handling
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_054, 055, 058, 078
+:Test Module: ``test_someip_message_format.py``
+:Test Functions:
+ - ``test_tc8_msg_007_truncated_message_no_crash``
+ - ``test_tc8_msg_007_wrong_protocol_version_no_crash``
+ - ``test_tc8_msg_007_oversized_length_field_no_crash``
+:Requirement: ``comp_req__tc8_conformance__msg_malformed``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that DUT does not crash when receiving malformed messages.
+
+**Stimuli:**
+
+- Truncated message (7 bytes, below 8-byte minimum).
+- Message with ``protocol_version = 0xFF``.
+- Message with length field claiming 0x7FF3 bytes (actual payload = 16 bytes).
+
+**Expected Result:**
+DUT process remains alive (``poll() is None``) after each malformed message.
+
+TC8-MSG-008 — Client ID Echo
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_03
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_msg_008_client_id_echo``
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify RESPONSE echoes the REQUEST client ID.
+
+**Stimuli:**
+Send REQUEST with ``client_id = 0x0011``.
+
+**Expected Result:**
+RESPONSE has ``client_id == 0x0011``.
+
+Event Notification Tests
+------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+TC8-EVT-001 — Notification Message Type
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.5 — SOMEIPSRV_BASIC_03; §5.1.6 — SOMEIP_ETS_147
+:Test Module: ``test_event_notification.py``
+:Test Function: ``test_tc8_evt_001_notification_message_type``
+:Requirement: ``comp_req__tc8_conformance__evt_subscription``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that event notifications have ``message_type = NOTIFICATION (0x02)``.
+
+**Preconditions:**
+
+- DUT offering service, subscription acknowledged.
+
+**Stimuli:**
+Subscribe to eventgroup ``0x4455`` and wait for notification.
+
+**Expected Result:**
+Notification received with ``message_type == 0x02``.
+
+TC8-EVT-002 — Correct Event ID
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.5 — SOMEIPSRV_BASIC_03
+:Test Module: ``test_event_notification.py``
+:Test Function: ``test_tc8_evt_002_correct_event_id``
+:Requirement: ``comp_req__tc8_conformance__evt_subscription``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that notification ``method_id`` field carries the correct event ID.
+
+**Stimuli:**
+Subscribe and capture notification.
+
+**Expected Result:**
+``method_id == 0x0777`` (configured event ID).
+
+TC8-EVT-003 — Notification Only to Subscriber
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_147
+:Test Module: ``test_event_notification.py``
+:Test Function: ``test_tc8_evt_003_notification_only_to_subscriber``
+:Requirement: ``comp_req__tc8_conformance__evt_subscription``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that only the subscribed endpoint receives notifications.
+
+**Stimuli:**
+Open two sockets: subscribe on one, leave the other unsubscribed.
+
+**Expected Result:**
+Subscribed socket receives notification; unsubscribed socket receives nothing.
+
+TC8-EVT-004 — No Notification Before Subscribe
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_147 (pre-subscribe)
+:Test Module: ``test_event_notification.py``
+:Test Function: ``test_tc8_evt_004_no_notification_before_subscribe``
+:Requirement: ``comp_req__tc8_conformance__evt_subscription``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that no notifications arrive before subscribing.
+
+**Stimuli:**
+Open a socket without subscribing, listen for 3 seconds.
+
+**Expected Result:**
+No notifications received.
+
+TC8-EVT-005 — Multicast Notification Delivery
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_150
+:Test Module: ``test_event_notification.py``
+:Test Function: ``test_tc8_evt_005_multicast_notification_delivery``
+:Requirement: ``comp_req__tc8_conformance__evt_subscription``
+:DUT Config: ``tc8_someipd_service.json``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+
+**Purpose:**
+Verify that notifications for a multicast eventgroup arrive on the
+multicast address.
+
+**Preconditions:**
+
+- Multicast group ``239.0.0.1:40490`` joinable.
+- Eventgroup ``0x4465`` configured with multicast.
+
+**Stimuli:**
+Subscribe to eventgroup ``0x4465`` and listen on multicast socket.
+
+**Expected Result:**
+NOTIFICATION received on ``239.0.0.1:40490``.
+
+TC8-EVT-006 — StopSubscribe Ceases Notifications
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_108
+:Test Module: ``test_event_notification.py``
+:Test Function: ``test_tc8_evt_006_stop_subscribe_ceases_notifications``
+:Requirement: ``comp_req__tc8_conformance__evt_subscription``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that notifications stop after StopSubscribeEventgroup.
+
+**Preconditions:**
+
+- Active subscription with at least one notification received.
+
+**Stimuli:**
+Send SubscribeEventgroup with TTL = 0.
+
+**Expected Result:**
+No notifications received within 4 seconds after StopSubscribe.
+
+TC8-EVT-007 — Field Notifies Only on Value Change
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_16
+:Test Module: ``test_event_notification.py``
+:Test Function: ``TestEventNotification::test_rpc_16_field_notifies_only_on_change``
+:Requirement: ``comp_req__tc8_conformance__fld_getter_setter``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that a field event notification is sent only when the field value changes,
+not on every cyclic trigger.
+
+**Preconditions:**
+
+- DUT offering service with a field configured for on-change notification.
+- Active subscription acknowledged.
+
+**Stimuli:**
+SET field to value A; observe notifications; SET field to the same value A again;
+observe again.
+
+**Expected Result:**
+Notification sent after first SET (value changed from initial); no duplicate
+notification sent when SET issues the same value a second time.
+
+TC8-EVT-008 — Cyclic Notification Rate
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_15
+:Test Module: ``test_event_notification.py``
+:Test Function: ``TestEventNotificationFormat::test_rpc_15_cyclic_notification_rate``
+:Requirement: ``comp_req__tc8_conformance__evt_subscription``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that cyclic event notifications arrive at the configured cycle period
+(``update-cycle: 500`` ms).
+
+**Preconditions:**
+
+- DUT offering service, subscription acknowledged.
+- Event 0x0777 configured with ``update-cycle: 500`` ms.
+
+**Stimuli:**
+Subscribe to eventgroup ``0x4455``, collect 4 notification timestamps.
+
+**Expected Result:**
+Inter-notification intervals are within [200 ms, 1200 ms] — a 2.4× tolerance
+band accounting for OS jitter and initial-value notification.
+
+Field Conformance Tests
+-----------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+TC8-FLD-001 — Initial Notification on Subscribe
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_121
+:Test Module: ``test_field_conformance.py``
+:Test Function: ``test_tc8_fld_001_initial_notification_on_subscribe``
+:Requirement: ``comp_req__tc8_conformance__fld_initial_value``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that subscribing to a field eventgroup (``is_field: true``)
+triggers an immediate NOTIFICATION with the cached value.
+
+**Preconditions:**
+
+- DUT has sent at least one ``notify()`` so vsomeip has a cached value.
+
+**Stimuli:**
+Subscribe to eventgroup ``0x4455``.
+
+**Expected Result:**
+NOTIFICATION received promptly after subscription acknowledgment.
+
+TC8-FLD-002 — Initial Value Timing
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.6 — SOMEIP_ETS_121
+:Test Module: ``test_field_conformance.py``
+:Test Function: ``test_tc8_fld_002_is_field_sends_initial_value_within_one_second``
+:Requirement: ``comp_req__tc8_conformance__fld_initial_value``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the initial field notification arrives within 1 second of
+subscribe ACK (contrasting with non-field events that only notify on
+the next cycle).
+
+**Stimuli:**
+Subscribe and measure time to first notification.
+
+**Expected Result:**
+Notification received within 1 second.
+
+TC8-FLD-003 — Field Getter
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_03; §5.1.6 — SOMEIP_ETS_166
+:Test Module: ``test_field_conformance.py``
+:Test Function: ``test_tc8_fld_003_getter_returns_current_value``
+:Requirement: ``comp_req__tc8_conformance__fld_get_set``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that a GET request (method ``0x0001``) returns the current
+field value.
+
+**Stimuli:**
+Send REQUEST to method ``0x0001``.
+
+**Expected Result:**
+RESPONSE with ``return_code = E_OK`` and non-empty payload.
+
+TC8-FLD-004 — Field Setter and Notify
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_11; §5.1.6 — SOMEIP_ETS_166
+:Test Module: ``test_field_conformance.py``
+:Test Function: ``test_tc8_fld_004_setter_updates_value_and_notifies``
+:Requirement: ``comp_req__tc8_conformance__fld_get_set``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that a SET request (method ``0x0002``) updates the field value
+and notifies all active subscribers with the new value.
+
+**Preconditions:**
+
+- Active subscription to eventgroup ``0x4455``.
+- Initial field notification drained.
+
+**Stimuli:**
+Send REQUEST to method ``0x0002`` with payload ``0xCAFE``.
+
+**Expected Result:**
+
+- RESPONSE with ``return_code = E_OK``.
+- NOTIFICATION received with payload matching ``0xCAFE``.
+
+TCP Transport Binding Tests
+----------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+TC8-TCP-001 — TCP Request/Response
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_RPC_01 §5.1.5.7
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_rpc_01_tcp_request_response``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT accepts a TCP REQUEST and returns a RESPONSE on
+the same TCP connection.
+
+**Preconditions:**
+
+- DUT started with ``--tc8-standalone`` flag.
+- Service ``0x1234`` offered and SD OfferService received.
+
+**Stimuli:**
+Open TCP connection to service port 30510; send a SOME/IP REQUEST
+(method ``0x0421``, ``message_type=0x00``).
+
+**Expected Result:**
+DUT sends SOME/IP RESPONSE (``message_type=0x80``,
+``return_code=E_OK=0x00``) on the same TCP connection.
+
+TC8-TCP-002 — TCP Session ID Echo
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_RPC_01 §5.1.5.7
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_rpc_01_tcp_session_id_echo``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT echoes the ``session_id`` from a TCP REQUEST in
+the TCP RESPONSE.
+
+**Preconditions:**
+
+- DUT started, service offered.
+
+**Stimuli:**
+Send TCP REQUEST with a known ``session_id`` value.
+
+**Expected Result:**
+TCP RESPONSE carries the same ``session_id`` value.
+
+TC8-TCP-003 — TCP Client ID Echo
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_RPC_01 §5.1.5.7
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_rpc_01_tcp_client_id_echo``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT echoes the ``client_id`` from a TCP REQUEST in
+the TCP RESPONSE.
+
+**Preconditions:**
+
+- DUT started, service offered.
+
+**Stimuli:**
+Send TCP REQUEST with a known ``client_id`` value.
+
+**Expected Result:**
+TCP RESPONSE carries the same ``client_id`` value.
+
+TC8-TCP-004 — Multiple Methods over Single TCP Connection
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_RPC_02 §5.1.5.7
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_rpc_02_tcp_multiple_methods_single_connection``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT handles multiple SOME/IP method calls over a
+single persistent TCP connection.
+
+**Preconditions:**
+
+- DUT started, service offered.
+
+**Stimuli:**
+Open one TCP connection; send two consecutive REQUESTs for different
+methods.
+
+**Expected Result:**
+Both REQUESTs receive valid RESPONSEs on the same TCP connection
+without reconnection.
+
+TC8-TCP-005 — TCP Endpoint Advertised in SD Offer
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_OPTIONS_15 §5.1.5.2
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``test_tc8_sd_options_15_tcp_endpoint_advertised``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT includes a TCP IPv4EndpointOption
+(``L4-Proto=0x06``) in SD OfferService messages.
+
+**Preconditions:**
+
+- DUT starting up; multicast socket opened before DUT launch to
+ capture initial SD traffic.
+
+**Stimuli:**
+Capture SD multicast traffic on DUT startup.
+
+**Expected Result:**
+SD OfferService contains an IPv4EndpointOption with
+``L4-Proto=0x06`` (TCP) in addition to the UDP endpoint option.
+
+TC8-TCP-006 — TCP Field Getter (partial SOMEIPSRV_RPC_17)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_RPC_17 §5.1.5.7 (partial coverage — see note)
+:Test Module: ``test_field_conformance.py``
+:Test Function: ``test_tc8_rpc_17_tcp_field_getter``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT responds to a field GET request (method ``0x0001``)
+over TCP with ``E_OK`` and the current field value.
+
+**Preconditions:**
+
+- DUT started, service offered.
+
+**Stimuli:**
+Open TCP connection to port 30510; send SOME/IP REQUEST for method
+``0x0001``.
+
+**Expected Result:**
+DUT returns RESPONSE with ``return_code=E_OK`` and payload containing
+the current field value.
+
+TC8-TCP-007 — TCP Field Setter (partial SOMEIPSRV_RPC_17)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_RPC_17 §5.1.5.7 (partial coverage — see note)
+:Test Module: ``test_field_conformance.py``
+:Test Function: ``test_tc8_rpc_17_tcp_field_setter``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT processes a field SET request (method ``0x0002``)
+over TCP and updates the field value.
+
+**Preconditions:**
+
+- DUT started, service offered.
+
+**Stimuli:**
+Open TCP connection; send SET REQUEST with new field value; then send
+GET REQUEST to confirm.
+
+**Expected Result:**
+SET RESPONSE with ``E_OK``; subsequent GET confirms the updated value.
+
+TC8-TCP-008 — TCP Event Notification Delivery (partial SOMEIPSRV_RPC_17)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIPSRV_RPC_17 §5.1.5.7 (partial coverage — see note)
+:Test Module: ``test_event_notification.py``
+:Test Function: ``test_tc8_rpc_17_tcp_event_notification_delivery``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose:**
+Verify that the DUT delivers SOME/IP NOTIFICATION messages over TCP
+to a subscribed client.
+
+**Preconditions:**
+
+- DUT started, service offered.
+
+**Stimuli:**
+Subscribe to eventgroup ``0x4475`` (TCP); open TCP connection to port
+30510.
+
+**Expected Result:**
+DUT sends NOTIFICATION messages (``message_type=0x02``,
+``event_id=0x0778``) over the TCP connection.
+
+TC8-TCP-009: Unaligned SOME/IP Messages over TCP (SOMEIP_ETS_068)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIP_ETS_068 (PRS_SOMEIP_00142, PRS_SOMEIP_00569)
+:Test Module: ``test_someip_message_format``
+:Test Function: ``test_tc8_ets_068_unaligned_someip_messages_over_tcp``
+:Requirement ID: ``comp_req__tc8_conformance__tcp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose**
+ Verify that someipd parses a TCP byte stream containing multiple SOME/IP messages
+ packed into a single TCP segment, including the case where one message starts at
+ a non-4-byte-aligned byte offset.
+
+**Preconditions**
+ - someipd running in ``--tc8-standalone`` mode with ``tc8_someipd_service.json``.
+ - TCP port ``DUT_RELIABLE_PORT`` (30510) accepting connections.
+
+**Stimuli**
+ Connect via TCP; send three concatenated SOME/IP REQUEST messages (session IDs
+ 0x0071, 0x0072, 0x0073) as a **single** ``sendall()`` call. Message sizes are
+ 16 / 18 / 16 bytes so the third starts at offset 34 (not 4-byte aligned).
+
+**Expected Result**
+ Receive three SOME/IP RESPONSE messages with ``message_type = RESPONSE (0x80)``,
+ ``service_id = 0x1234``, and session IDs ``{0x0071, 0x0072, 0x0073}`` (any order).
+
+.. note::
+
+ **SOMEIPSRV_RPC_17 partial coverage:** TC8-TCP-006, TC8-TCP-007,
+ and TC8-TCP-008 verify TCP transport for field GET/SET and event
+ notification operations using a single service instance. The full
+ SOMEIPSRV_RPC_17 requirement (each service instance on a separate
+ TCP connection) is not covered by the current implementation. The
+ multi-instance TCP scenario is a known gap that will be addressed
+ when multi-instance vsomeip configurations are available.
+
+TC8-UDP-001: Unaligned SOME/IP Messages over UDP (SOMEIP_ETS_069)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: SOMEIP_ETS_069 (PRS_SOMEIP_00142, PRS_SOMEIP_00569)
+:Test Module: ``test_someip_message_format``
+:Test Function: ``test_tc8_ets_069_unaligned_someip_messages_over_udp``
+:Requirement ID: ``comp_req__tc8_conformance__udp_transport``
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+
+**Purpose**
+ Verify that someipd parses a UDP datagram containing multiple SOME/IP messages
+ where one message starts at a non-4-byte-aligned byte offset within the datagram.
+
+**Preconditions**
+ - someipd running in ``--tc8-standalone`` mode with ``tc8_someipd_service.json``.
+ - UDP port ``DUT_UNRELIABLE_PORT`` (30502 in ``tc8_message_format`` target) accepting requests.
+
+**Stimuli**
+ Create a UDP socket bound to an ephemeral port; send three concatenated SOME/IP
+ REQUEST messages (session IDs 0x0081, 0x0082, 0x0083) as a **single** ``sendto()``
+ call. Message sizes are 16 / 18 / 16 bytes so the third starts at offset 34
+ (not 4-byte aligned).
+
+**Expected Result**
+ Receive three SOME/IP RESPONSE messages with ``message_type = RESPONSE (0x80)``,
+ ``service_id = 0x1234``, and session IDs ``{0x0081, 0x0082, 0x0083}`` (any order).
+ Each response arrives as a separate UDP datagram.
+
+Multi-service and Multi-instance Tests
+---------------------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_multi.json``
+
+TC8-MULTI-001 — Multi-service Config Loads and Primary Service Offered
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_13
+:Test Module: ``test_multi_service``
+:Test Function: ``TestMultiServiceInstanceRouting::test_rpc_13_multi_service_config_loads_and_primary_service_offered``
+:Requirement: ``comp_req__tc8_conformance__multi_service``
+:DUT Config: ``tc8_someipd_multi.json``
+
+**Purpose**
+ Verify that ``someipd`` accepts a vsomeip configuration that declares two
+ service entries and that the primary service (0x1234/0x5678) is correctly
+ offered via SD OfferService after startup.
+
+**Preconditions**
+
+ - ``someipd`` started with ``--tc8-standalone`` and ``tc8_someipd_multi.json``
+ - ``tc8_someipd_multi.json`` declares two service entries (0x1234/0x5678 and
+ 0x5678/0x0001)
+ - Multicast route available (224.0.0.0/4)
+
+**Stimuli**
+ Passive observation of SD OfferService multicast messages.
+
+**Expected Result**
+ - The ``someipd`` process remains alive after startup (multi-service config
+ parsed without crash)
+ - An SD OfferService entry for service_id=0x1234, instance_id=0x5678 is
+ received within 10 seconds
+ - The OfferService entry has TTL > 0
+
+TC8-MULTI-002 — Service Instance UDP Port Advertisement and Isolation
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_14
+:Test Module: ``test_multi_service``
+:Test Functions: ``TestMultiServiceInstanceRouting::test_rpc_14_service_a_advertises_configured_udp_port``,
+ ``TestMultiServiceInstanceRouting::test_rpc_14_no_unexpected_service_ids_in_offers``
+:Requirement: ``comp_req__tc8_conformance__multi_service``
+:DUT Config: ``tc8_someipd_multi.json``
+
+**Purpose**
+ Verify that the offered service advertises the UDP port from its ``unreliable``
+ config field (TC8_SVC_PORT) in the SD IPv4EndpointOption, and that no
+ unexpected service IDs appear in SD traffic.
+
+**Preconditions**
+
+ - Same as TC8-MULTI-001.
+
+**Stimuli**
+ Passive observation of SD OfferService for a 6-second window; extraction of
+ IPv4EndpointOption from each OfferService entry.
+
+**Expected Result**
+ - Service 0x1234 OfferService carries an IPv4 UDP endpoint option with
+ ``port == DUT_UNRELIABLE_PORT`` (TC8_SVC_PORT = 30512)
+ - All observed service IDs are in the set {0x1234, 0x5678} (configured services)
+ - No phantom service IDs are present
+
+SD Format and Options Compliance Tests
+---------------------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json``
+:Test Module: ``test_sd_format_compliance``
+
+TC8-SDF-001 — SD SOME/IP Header: Client ID = 0x0000
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_01
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsOfferService::test_format_01_client_id_is_zero``
+
+**Purpose:**
+Verify the Client-ID field in SOME/IP SD OfferService messages is always 0x0000.
+
+**Preconditions:**
+``someipd`` started in standalone mode; multicast capture socket open before launch.
+
+**Stimulus:**
+Passive capture of the initial multicast OfferService.
+
+**Expected Result:**
+``sd_hdr.client_id == 0x0000``.
+
+TC8-SDF-002 — SD SOME/IP Header: Session ID non-zero
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_02
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsOfferService::test_format_02_session_id_is_nonzero_and_in_range``
+
+**Purpose:**
+Verify the SD Session-ID field is non-zero (never 0x0000) and fits in 16 bits.
+
+**Stimulus:**
+Passive capture of the multicast OfferService.
+
+**Expected Result:**
+``sd_hdr.session_id != 0x0000`` and ``sd_hdr.session_id <= 0xFFFF``.
+
+TC8-SDF-003 — SD SOME/IP Header: Interface Version = 0x01
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_04
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsOfferService::test_format_04_interface_version_is_one``
+
+**Purpose:**
+Verify the SOME/IP interface_version field in SD messages equals 0x01.
+
+**Stimulus:**
+Passive capture of the multicast OfferService.
+
+**Expected Result:**
+``sd_hdr.interface_version == 0x01``.
+
+TC8-SDF-004 — SD SOME/IP Header: Message Type = NOTIFICATION
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_05
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsOfferService::test_format_05_message_type_is_notification``
+
+**Purpose:**
+Verify the SOME/IP message_type field in SD messages equals 0x02 (NOTIFICATION).
+
+**Stimulus:**
+Passive capture of the multicast OfferService.
+
+**Expected Result:**
+``sd_hdr.message_type == SOMEIPMessageType.NOTIFICATION``.
+
+TC8-SDF-005 — SD SOME/IP Header: Return Code = E_OK
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_06
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsOfferService::test_format_06_return_code_is_e_ok``
+
+**Purpose:**
+Verify the SOME/IP return_code field in SD messages equals 0x00 (E_OK).
+
+**Stimulus:**
+Passive capture of the multicast OfferService.
+
+**Expected Result:**
+``sd_hdr.return_code == SOMEIPReturnCode.E_OK``.
+
+TC8-SDF-006 — SD Flags: Reserved Bits are Zero
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_09
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsOfferService::test_format_09_sd_flags_reserved_bits_are_zero``
+
+**Purpose:**
+Verify that bits 5-0 of the SD flags byte (reserved/undefined) are all zero.
+
+**Stimulus:**
+Passive capture of the multicast OfferService.
+
+**Expected Result:**
+``sd_hdr.flags_unknown == 0``.
+
+TC8-SDF-007 — SD Entry: Reserved Byte is Zero
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_10
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsOfferService::test_format_10_sd_entry_reserved_bytes_are_zero``
+
+**Purpose:**
+Verify that byte [2] (index_second_option_run, reserved for Type-1 entries) in the
+16-byte OfferService SD entry is zero.
+
+**Stimulus:**
+Passive capture and raw byte inspection of the OfferService entry.
+
+**Expected Result:**
+``entry_bytes[2] == 0``.
+
+TC8-SDF-008 — SD Entry: Length is 16 Bytes
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_11
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdOfferEntryFields::test_format_11_entry_is_16_bytes``
+
+**Purpose:**
+Verify that each SD entry serialises to exactly 16 bytes.
+
+**Stimulus:**
+Passive capture; entry built and measured.
+
+**Expected Result:**
+``len(entry.build()) == 16``.
+
+TC8-SDF-009 — SD Entry: First Option Run Index is Zero
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_12
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdOfferEntryFields::test_format_12_first_option_run_index_is_zero``
+
+**Purpose:**
+Verify that byte [1] (option_index_1) in the OfferService entry is 0x00.
+
+**Stimulus:**
+Passive capture; raw byte inspection.
+
+**Expected Result:**
+``entry_bytes[1] == 0``.
+
+TC8-SDF-010 — SD Entry: num_options_1 Matches Resolved Options
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_13
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdOfferEntryFields::test_format_13_num_options_matches_options_list``
+
+**Purpose:**
+Verify that the num_options_1 counter field equals the actual number of options
+associated with the entry.
+
+**Stimulus:**
+Passive capture; compare raw entry counter with resolved options list length.
+
+**Expected Result:**
+``raw_entry.num_options_1 == len(entry.options_1)``.
+
+TC8-SDF-011 — SD Entry: Instance ID Matches Config
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_15
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdOfferEntryFields::test_format_15_instance_id_matches_config``
+
+**Purpose:**
+Verify that the OfferService entry instance_id matches the configured value (0x5678).
+
+**Stimulus:**
+Passive capture.
+
+**Expected Result:**
+``entry.instance_id == 0x5678``.
+
+TC8-SDF-012 — SD Entry: Major Version Matches Config
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_16
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdOfferEntryFields::test_format_16_major_version_matches_config``
+
+**Purpose:**
+Verify that the OfferService entry major_version matches the configured value (0x00).
+
+**Stimulus:**
+Passive capture.
+
+**Expected Result:**
+``entry.major_version == 0x00``.
+
+TC8-SDF-013 — SD Entry: Minor Version Matches Config
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_18
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdOfferEntryFields::test_format_18_minor_version_matches_config``
+
+**Purpose:**
+Verify that the OfferService entry minor_version matches the configured value (0x00000000).
+
+**Stimulus:**
+Passive capture.
+
+**Expected Result:**
+``entry.service_minor_version == 0x00000000``.
+
+TC8-SDF-014 — SubscribeAck Entry Type = 0x06
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_19
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_19_ack_entry_type_is_subscribe_ack``
+
+**Purpose:**
+Verify that the SubscribeEventgroupAck SD entry type equals 0x07 (SubscribeAck).
+
+**Preconditions:**
+``someipd`` offering eventgroup 0x4455.
+
+**Stimulus:**
+Send SubscribeEventgroup and capture the Ack.
+
+**Expected Result:**
+``ack.sd_type == SOMEIPSDEntryType.SubscribeAck``.
+
+TC8-SDF-015 — SubscribeAck Entry: 16 Bytes
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_20
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_20_ack_entry_is_16_bytes``
+
+**Purpose:**
+Verify that the SubscribeEventgroupAck entry serialises to exactly 16 bytes.
+
+**Stimulus:**
+Send SubscribeEventgroup; capture Ack; measure serialised length.
+
+**Expected Result:**
+``len(ack.build()) == 16``.
+
+TC8-SDF-016 — SubscribeAck: Option Run Index Correct
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_21
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_21_ack_option_run_index_is_zero_when_no_options``
+
+**Purpose:**
+Verify that the SubscribeAck option run index is 0x00 when no options are attached,
+or a valid index (< 16) when options are present.
+
+**Stimulus:**
+Send SubscribeEventgroup for UDP unicast eventgroup (no multicast option expected).
+
+**Expected Result:**
+``option_index_1 == 0`` when ``num_options_1 == 0``.
+
+TC8-SDF-017 — SubscribeAck: Service ID Matches
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_23
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_23_ack_service_id_matches_config``
+
+**Purpose:**
+Verify that the SubscribeAck entry carries the same service_id as the subscribe request.
+
+**Stimulus:**
+Send SubscribeEventgroup for service 0x1234.
+
+**Expected Result:**
+``ack.service_id == 0x1234``.
+
+TC8-SDF-018 — SubscribeAck: Instance ID Matches
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_24
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_24_ack_instance_id_matches_config``
+
+**Purpose:**
+Verify that the SubscribeAck entry carries the same instance_id as the subscribe request.
+
+**Stimulus:**
+Send SubscribeEventgroup for instance 0x5678.
+
+**Expected Result:**
+``ack.instance_id == 0x5678``.
+
+TC8-SDF-019 — SubscribeAck: Major Version Matches
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_25
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_25_ack_major_version_matches_config``
+
+**Purpose:**
+Verify that the SubscribeAck entry major_version matches the service definition.
+
+**Stimulus:**
+Send SubscribeEventgroup with major_version = 0x00.
+
+**Expected Result:**
+``ack.major_version == 0x00``.
+
+TC8-SDF-020 — SubscribeAck: TTL > 0
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_26
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_26_ack_ttl_is_nonzero``
+
+**Purpose:**
+Verify that a positive SubscribeEventgroupAck has TTL > 0 (TTL = 0 means Nack).
+
+**Stimulus:**
+Send valid SubscribeEventgroup.
+
+**Expected Result:**
+``ack.ttl > 0``.
+
+TC8-SDF-021 — SubscribeAck: Reserved Field = 0
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_27
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_27_ack_reserved_field_is_zero``
+
+**Purpose:**
+Verify that the high 16 bits of the SubscribeAck minver_or_counter field (the reserved
+counter portion) are all zero.
+
+**Stimulus:**
+Send SubscribeEventgroup; inspect raw ack entry fields.
+
+**Expected Result:**
+``(ack.minver_or_counter >> 16) & 0xFFFF == 0``.
+
+TC8-SDF-022 — SubscribeAck: Eventgroup ID Matches
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.1 — SOMEIPSRV_FORMAT_28
+:Requirement: ``comp_req__tc8_conformance__sd_format_fields``
+:Test Function: ``TestSdHeaderFieldsSubscribeAck::test_format_28_ack_eventgroup_id_matches_subscribe``
+
+**Purpose:**
+Verify that the SubscribeAck carries the same eventgroup_id as the subscribe request.
+
+**Stimulus:**
+Send SubscribeEventgroup for eventgroup 0x4455.
+
+**Expected Result:**
+``(ack.minver_or_counter & 0xFFFF) == 0x4455``.
+
+TC8-SDF-023 — Endpoint Option: Length = 0x0009
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_01
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Test Function: ``TestSdOptionsEndpoint::test_options_01_endpoint_option_length_is_nine``
+
+**Purpose:**
+Verify that the IPv4EndpointOption length field in SD OfferService messages equals 0x0009.
+
+**Stimulus:**
+Passive capture; extract first IPv4EndpointOption from the OfferService entry; read raw bytes.
+
+**Expected Result:**
+``length_field == 0x0009``.
+
+TC8-SDF-024 — Endpoint Option: Type = 0x04
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_02
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Test Function: ``TestSdOptionsEndpoint::test_options_02_endpoint_option_type_is_0x04``
+
+**Purpose:**
+Verify that the IPv4EndpointOption type byte equals 0x04.
+
+**Stimulus:**
+Passive capture; inspect raw option byte [2].
+
+**Expected Result:**
+``type_byte == 0x04``.
+
+TC8-SDF-025 — Endpoint Option: Reserved Byte After Type = 0x00
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_03
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Test Function: ``TestSdOptionsEndpoint::test_options_03_endpoint_option_reserved_after_type_is_zero``
+
+**Purpose:**
+Verify that the reserved byte at option offset [3] (after the type byte) equals 0x00.
+
+**Stimulus:**
+Passive capture; inspect raw option byte [3].
+
+**Expected Result:**
+``reserved_byte == 0x00``.
+
+TC8-SDF-026 — Endpoint Option: Reserved Before Protocol = 0x00
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_05
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Test Function: ``TestSdOptionsEndpoint::test_options_05_endpoint_option_reserved_before_protocol_is_zero``
+
+**Purpose:**
+Verify that the reserved byte at option offset [8] (before the L4 protocol byte) equals 0x00.
+
+**Stimulus:**
+Passive capture; inspect raw option byte [8].
+
+**Expected Result:**
+``reserved_byte == 0x00``.
+
+TC8-SDF-027 — Endpoint Option: L4 Protocol = 0x11 (UDP)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_06
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Test Function: ``TestSdOptionsEndpoint::test_options_06_endpoint_option_protocol_is_udp``
+
+**Purpose:**
+Verify that the IPv4EndpointOption L4 protocol field equals 0x11 (UDP).
+
+**Stimulus:**
+Passive capture; read ``opt.l4proto``.
+
+**Expected Result:**
+``opt.l4proto == L4Protocols.UDP``.
+
+TC8-SDF-028 — Multicast Option: Length = 0x0009
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_08
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+:Test Function: ``TestSdOptionsMulticast::test_options_08_multicast_option_length_is_nine``
+
+**Purpose:**
+Verify that the IPv4MulticastOption in SubscribeEventgroupAck has length field 0x0009.
+
+**Preconditions:**
+Non-loopback interface (``TC8_TESTER_IP`` set); eventgroup 0x4465 configured with multicast address.
+
+**Stimulus:**
+Send SubscribeEventgroup for eventgroup 0x4465; capture Ack and extract multicast option.
+
+**Expected Result:**
+``length_field == 0x0009``.
+
+TC8-SDF-029 — Multicast Option: Type = 0x14
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_09
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+:Test Function: ``TestSdOptionsMulticast::test_options_09_multicast_option_type_is_0x14``
+
+**Purpose:**
+Verify that the IPv4MulticastOption type byte equals 0x14 (decimal 20).
+
+**Stimulus:**
+Same as TC8-SDF-028.
+
+**Expected Result:**
+``type_byte == 0x14``.
+
+TC8-SDF-030 — Multicast Option: Reserved = 0x00
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_10
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+:Test Function: ``TestSdOptionsMulticast::test_options_10_multicast_option_reserved_is_zero``
+
+**Purpose:**
+Verify that the reserved byte at offset [3] of the IPv4MulticastOption equals 0x00.
+
+**Stimulus:**
+Same as TC8-SDF-028.
+
+**Expected Result:**
+``reserved_byte == 0x00``.
+
+TC8-SDF-031 — Multicast Option: Address Matches Config
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_11
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+:Test Function: ``TestSdOptionsMulticast::test_options_11_multicast_address_matches_config``
+
+**Purpose:**
+Verify that the IPv4MulticastOption address field equals the configured multicast
+group address (``239.0.0.1``).
+
+**Stimulus:**
+Same as TC8-SDF-028.
+
+**Expected Result:**
+``opt.address == IPv4Address("239.0.0.1")``.
+
+TC8-SDF-032 — Multicast Option: Reserved Before Port = 0x00
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_12
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+:Test Function: ``TestSdOptionsMulticast::test_options_12_multicast_option_reserved_before_port_is_zero``
+
+**Purpose:**
+Verify that the reserved byte at option offset [8] (before the port field) equals 0x00.
+
+**Stimulus:**
+Same as TC8-SDF-028.
+
+**Expected Result:**
+``reserved_byte == 0x00``.
+
+TC8-SDF-033 — Multicast Option: L4 Protocol = 0x11 (UDP)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_13
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+:Test Function: ``TestSdOptionsMulticast::test_options_13_multicast_option_protocol_is_udp``
+
+**Purpose:**
+Verify that the IPv4MulticastOption L4 protocol field equals 0x11 (UDP).
+
+**Stimulus:**
+Same as TC8-SDF-028.
+
+**Expected Result:**
+``opt.l4proto == L4Protocols.UDP``.
+
+TC8-SDF-034 — Multicast Option: Port Matches Config
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.2 — SOMEIPSRV_OPTIONS_14
+:Requirement: ``comp_req__tc8_conformance__sd_options_fields``
+:Marker: ``@pytest.mark.network`` (requires non-loopback interface)
+:Test Function: ``TestSdOptionsMulticast::test_options_14_multicast_port_matches_config``
+
+**Purpose:**
+Verify that the IPv4MulticastOption port field equals the configured multicast port
+(40490).
+
+**Stimulus:**
+Same as TC8-SDF-028.
+
+**Expected Result:**
+``opt.port == 40490``.
+
+TC8-SDF-035 — StopSubscribeEventgroup Entry Wire Format
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_12
+:Requirement: ``comp_req__tc8_conformance__sd_stop_sub_fmt``
+:Test Function: ``TestSdStopSubscribeFormat::test_sd_message_12_stop_subscribe_entry_format``
+
+**Purpose:**
+Verify that a StopSubscribeEventgroup SD entry has entry type byte ``0x06``
+and TTL field (bytes 9–11) equal to ``0x000000`` at the wire level.
+
+**Preconditions:**
+None — pure byte-level format check, no DUT interaction.
+
+**Stimulus:**
+Inline construction of a 16-byte SD entry with TTL=0.
+
+**Expected Result:**
+``entry[0] == 0x06`` and ``entry[9:12] == b'\x00\x00\x00'``.
+
+SD Entry Semantics Tests
+-------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json``
+:Test Module: ``test_service_discovery``
+
+TC8-SDM-001 — FindService Wildcard Instance
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_01
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDVersionMatching::test_sd_message_01_instance_wildcard``
+
+**Purpose:**
+Verify that a FindService with instance_id = 0xFFFF (wildcard) elicits an OfferService
+for the configured instance.
+
+**Stimulus:**
+Send FindService with instance_id = 0xFFFF.
+
+**Expected Result:**
+OfferService received with ``instance_id == 0x5678``.
+
+TC8-SDM-002 — FindService Specific Instance
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_02
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDVersionMatching::test_sd_message_02_instance_specific``
+
+**Purpose:**
+Verify that a FindService with the exact instance_id = 0x5678 elicits an OfferService.
+
+**Stimulus:**
+Send FindService with instance_id = 0x5678.
+
+**Expected Result:**
+OfferService received for service_id = 0x1234, instance_id = 0x5678.
+
+TC8-SDM-003 — FindService Wildcard Major Version
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_03
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDVersionMatching::test_sd_message_03_major_version_wildcard``
+
+**Purpose:**
+Verify that a FindService with major_version = 0xFF (any) elicits an OfferService.
+
+**Stimulus:**
+Send FindService with major_version = 0xFF.
+
+**Expected Result:**
+OfferService received for the configured service.
+
+TC8-SDM-004 — FindService Specific Major Version
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_04
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDVersionMatching::test_sd_message_04_major_version_specific``
+
+**Purpose:**
+Verify that a FindService with the exact major_version = 0x00 elicits an OfferService.
+
+**Stimulus:**
+Send FindService with major_version = 0x00.
+
+**Expected Result:**
+OfferService received for the configured service.
+
+TC8-SDM-005 — FindService Wildcard Minor Version
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_05
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDVersionMatching::test_sd_message_05_minor_version_wildcard``
+
+**Purpose:**
+Verify that a FindService with minor_version = 0xFFFFFFFF (wildcard) elicits an
+OfferService.
+
+**Stimulus:**
+Send FindService with minor_version = 0xFFFFFFFF.
+
+**Expected Result:**
+OfferService received for the configured service.
+
+TC8-SDM-006 — FindService Specific Minor Version
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_06
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDVersionMatching::test_sd_message_06_minor_version_specific``
+
+**Purpose:**
+Verify that a FindService with minor_version = 0x00000000 (exact) elicits an
+OfferService.
+
+**Stimulus:**
+Send FindService with minor_version = 0x00000000.
+
+**Expected Result:**
+OfferService received for the configured service.
+
+TC8-SDM-007 — SubscribeEventgroup: Wrong Major Version Rejected
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeNAck::test_sd_message_14_wrong_major_version``
+
+**Purpose:**
+Verify that a SubscribeEventgroup with a non-matching major version is rejected with
+NAck (SubscribeEventgroupAck TTL = 0) or silently ignored.
+
+**Stimulus:**
+Send SubscribeEventgroup with major_version = 0x7F (not 0x00).
+
+**Expected Result:**
+No positive SubscribeAck (TTL > 0) received; DUT remains functional.
+
+TC8-SDM-008 — SubscribeEventgroup: Wrong Service ID Rejected
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_15
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeNAck::test_sd_message_15_wrong_service_id``
+
+**Purpose:**
+Verify that a SubscribeEventgroup for an unknown service is rejected or ignored.
+
+**Stimulus:**
+Send SubscribeEventgroup with service_id = 0xBEEF.
+
+**Expected Result:**
+No positive SubscribeAck; DUT remains functional.
+
+TC8-SDM-009 — SubscribeEventgroup: Wrong Instance ID Rejected
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_16
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeNAck::test_sd_message_16_wrong_instance_id``
+
+**Purpose:**
+Verify that a SubscribeEventgroup for an unknown instance is rejected or ignored.
+
+**Stimulus:**
+Send SubscribeEventgroup with instance_id = 0xBEEF.
+
+**Expected Result:**
+No positive SubscribeAck; DUT remains functional.
+
+TC8-SDM-010 — SubscribeEventgroup: Unknown Eventgroup Rejected
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_17
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeNAck::test_sd_message_17_unknown_eventgroup_id``
+
+**Purpose:**
+Verify that a SubscribeEventgroup for an unknown eventgroup ID is rejected with NAck.
+
+**Stimulus:**
+Send SubscribeEventgroup with eventgroup_id = 0xBEEF.
+
+**Expected Result:**
+SubscribeAck with TTL = 0 (NAck) received.
+
+TC8-SDM-011 — SubscribeEventgroup: TTL = 0 is StopSubscribe
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_18
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeNAck::test_sd_message_18_ttl_zero_stop_subscribe``
+
+**Purpose:**
+Verify that a SubscribeEventgroup with TTL = 0 is treated as StopSubscribeEventgroup
+and does not elicit a positive ACK.
+
+**Stimulus:**
+Subscribe (TTL > 0) then immediately send another SubscribeEventgroup with TTL = 0.
+
+**Expected Result:**
+No positive ACK after the TTL = 0 message.
+
+TC8-SDM-012 — SubscribeEventgroup: Reserved Bits Set Rejected
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_19
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeNAck::test_sd_message_19_reserved_field_set``
+
+**Purpose:**
+Verify that a SubscribeEventgroup with reserved flag bits set is rejected with NAck.
+
+.. note::
+
+ This test is expected to **FAIL** against vsomeip 3.6.1: the stack sends a
+ positive ACK instead of NAck.
+
+**Stimulus:**
+Send SubscribeEventgroup with reserved SD flags bits set to 1.
+
+**Expected Result:**
+NAck (SubscribeAck TTL = 0) received; no positive ACK.
+
+TC8-SDM-013 — FindService Response Timing (Unicast)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_03
+:Requirement: ``comp_req__tc8_conformance__sd_cyclic_timing``
+:Test Function: ``TestSDFindServiceTiming::test_sd_behavior_03_unicast_findservice_timing``
+
+**Purpose:**
+Verify that a unicast OfferService response to a FindService arrives within the
+configured request-response delay.
+
+**Stimulus:**
+Send unicast FindService; measure time to first OfferService response.
+
+**Expected Result:**
+OfferService received within the configured delay window.
+
+TC8-SDM-014 — FindService Response Timing (Multicast)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_04
+:Requirement: ``comp_req__tc8_conformance__sd_cyclic_timing``
+:Test Function: ``TestSDFindServiceTiming::test_sd_behavior_04_multicast_findservice_timing``
+
+**Purpose:**
+Verify that an OfferService response to a multicast FindService arrives within the
+configured delay.
+
+**Stimulus:**
+Send multicast FindService; measure time to OfferService response.
+
+**Expected Result:**
+OfferService received within the allowed window.
+
+SD Lifecycle Advanced Tests
+----------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json``
+:Test Module: ``test_service_discovery`` (TestSDSubscribeLifecycleAdvanced, TestSDFindServiceAdvanced) and ``test_sd_client``
+
+TC8-SDLC-001 — Two Simultaneous Subscribes
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_088
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_088_two_subscribes_same_session``
+
+**Purpose:**
+Verify that two SubscribeEventgroup entries in one SD message each receive a
+positive ACK.
+
+**Stimulus:**
+Send an SD message containing two SubscribeEventgroup entries.
+
+**Expected Result:**
+Two ACK entries received (one per subscription).
+
+TC8-SDLC-002 — TTL = 0 as StopSubscribe (No NAck)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_092
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_092_ttl_zero_stop_subscribe_no_nack``
+
+**Purpose:**
+Verify that a SubscribeEventgroup with TTL = 0 stops the subscription without
+triggering a NAck from the DUT.
+
+**Stimulus:**
+Subscribe then send SubscribeEventgroup TTL = 0.
+
+**Expected Result:**
+No NAck (SubscribeAck TTL = 0) received after the StopSubscribe.
+
+TC8-SDLC-003 — Subscribe Without Prior RPC Call
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_098
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_098_subscribe_accepted_without_prior_rpc``
+
+**Purpose:**
+Verify that the DUT accepts a SubscribeEventgroup without any prior method call
+(no prerequisite RPC interaction required).
+
+**Stimulus:**
+Send SubscribeEventgroup immediately after service discovery, without any RPC.
+
+**Expected Result:**
+Positive SubscribeAck received.
+
+TC8-SDLC-004 — Non-Standard SD Entry Order Handled
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_107
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_107_find_service_and_subscribe_processed_independently``
+
+**Purpose:**
+Verify that the DUT correctly handles SD messages where FindService and
+SubscribeEventgroup entries appear in a non-standard order.
+
+**Stimulus:**
+Send SD message with FindService followed by SubscribeEventgroup.
+
+**Expected Result:**
+Both entries processed; OfferService and SubscribeAck received.
+
+TC8-SDLC-005 — Subscribe Endpoint IP Matches Tester
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_120
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_120_subscribe_endpoint_ip_matches_tester``
+
+**Purpose:**
+Verify that the DUT delivers events to the IP address specified in the subscribe
+endpoint option, not the IP address the SD message was sourced from.
+
+**Stimulus:**
+Send SubscribeEventgroup with endpoint option IP = tester_ip.
+
+**Expected Result:**
+Events delivered to the tester_ip address.
+
+TC8-SDLC-006 — SD Interface Version = 0x01
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_122
+:Requirement: ``comp_req__tc8_conformance__sd_offer_format``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_122_sd_interface_version_is_one``
+
+**Purpose:**
+Verify that the SD interface_version field in DUT OfferService messages is 0x01.
+
+**Stimulus:**
+Passive capture of SD OfferService.
+
+**Expected Result:**
+``sd_hdr.interface_version == 0x01`` (same as SOMEIPSRV_FORMAT_04, verified in context
+of the lifecycle sequence).
+
+TC8-SDLC-007 — Re-subscribe After StopSubscribe
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_155
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_155_resubscribe_after_stop``
+
+**Purpose:**
+Verify that a subscription can be re-established after a StopSubscribeEventgroup
+(TTL = 0) and that events resume.
+
+**Stimulus:**
+Subscribe, verify events, StopSubscribe, re-Subscribe, verify events resume.
+
+**Expected Result:**
+Events received after re-subscription.
+
+TC8-SDLC-008 — Session ID Increments per OfferService
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_091
+:Requirement: ``comp_req__tc8_conformance__sd_offer_format``
+:Test Function: ``TestSDFindServiceAdvanced::test_ets_091_session_id_increments``
+
+**Purpose:**
+Verify that the SD SOME/IP header session_id increments by 1 between consecutive
+OfferService messages.
+
+**Stimulus:**
+Capture two consecutive OfferService messages.
+
+**Expected Result:**
+``session_id[n+1] == session_id[n] + 1`` (with wrap from 0xFFFF to 0x0001).
+
+TC8-SDLC-009 — Initial Event Sent After Subscribe
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_099
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDFindServiceAdvanced::test_ets_099_initial_event_sent_after_subscribe``
+
+**Purpose:**
+Verify that the DUT sends an initial notification to a new subscriber immediately
+after a successful SubscribeEventgroup ACK.
+
+**Stimulus:**
+Subscribe to eventgroup; wait for first notification.
+
+**Expected Result:**
+NOTIFICATION received promptly after ACK.
+
+TC8-SDLC-010 — Server Does Not Emit FindService
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_100
+:Requirement: ``comp_req__tc8_conformance__sd_offer_format``
+:Test Function: ``TestSDFindServiceAdvanced::test_ets_100_no_findservice_emitted_by_server``
+
+**Purpose:**
+Verify that the DUT (acting as service provider) does not emit FindService entries
+in its SD messages during the main phase.
+
+**Stimulus:**
+Observe SD traffic for 6 seconds; collect all SD entries from the DUT.
+
+**Expected Result:**
+No FindService entries observed in DUT SD traffic.
+
+TC8-SDLC-011 — StopOfferService Ceases Client Events
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_101
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDFindServiceAdvanced::test_ets_101_stop_offer_ceases_client_events``
+
+**Purpose:**
+Verify that receipt of a StopOfferService (OfferService TTL = 0) from a server causes
+the subscribed client to cease receiving events.
+
+.. note::
+
+ This test is currently implemented as ``pytest.skip`` because stopping the DUT's
+ OfferService from an external tester requires a reverse-direction SD client target.
+
+**Stimulus:**
+N/A (skipped).
+
+TC8-SDLC-012 — Multicast FindService: Wildcard Versions
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_128
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDFindServiceAdvanced::test_ets_128_multicast_findservice_version_wildcard``
+
+**Purpose:**
+Verify that a multicast FindService with wildcard major and minor versions elicits
+an OfferService response.
+
+**Stimulus:**
+Send multicast FindService with major_version = 0xFF, minor_version = 0xFFFFFFFF.
+
+**Expected Result:**
+OfferService received for the configured service.
+
+TC8-SDLC-013 — Multicast FindService: Unicast Flag Clear
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_130
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDFindServiceAdvanced::test_ets_130_multicast_findservice_unicast_flag_clear``
+
+**Purpose:**
+Verify that a multicast FindService with the unicast flag cleared (0x00) elicits a
+multicast OfferService response (not a unicast one).
+
+**Stimulus:**
+Send multicast FindService with unicast flag = 0.
+
+**Expected Result:**
+Multicast OfferService received.
+
+TC8-SDLC-014 — Client StopSubscribe Ceases Events
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_084
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Module: ``test_sd_client``
+:Test Function: ``TestSDClientStopSubscribe::test_ets_084_stop_subscribe_ceases_events``
+
+**Purpose:**
+Verify that after a client sends StopSubscribeEventgroup (TTL = 0) the DUT stops
+sending NOTIFICATION messages to that subscriber.
+
+**Preconditions:**
+Active subscription to eventgroup 0x4455; at least one notification received.
+
+**Stimulus:**
+Send SubscribeEventgroup with TTL = 0.
+
+**Expected Result:**
+No notifications received within 4 seconds after StopSubscribe.
+
+TC8-SDLC-015 — Client Reboot: Flag Set After First Restart
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_081
+:Requirement: ``comp_req__tc8_conformance__sd_reboot``
+:Test Module: ``test_sd_client``
+:Test Function: ``TestSDClientReboot::test_ets_081_reboot_flag_set_after_first_restart``
+
+**Purpose:**
+Verify that after a DUT restart the first SD message has the reboot flag (bit 7
+of SD flags byte) set and the session_id resets to a small value.
+
+**Preconditions:**
+DUT started, 3 SD messages drained, DUT terminated.
+
+**Stimulus:**
+Restart DUT; capture first post-reboot SD message.
+
+**Expected Result:**
+``sd_hdr.flag_reboot == True`` and ``outer.session_id <= 2``.
+
+TC8-SDLC-016 — Client Reboot: Flag Set After Second Restart
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_082
+:Requirement: ``comp_req__tc8_conformance__sd_reboot``
+:Test Module: ``test_sd_client``
+:Test Function: ``TestSDClientReboot::test_ets_082_reboot_flag_set_after_second_restart``
+
+**Purpose:**
+Verify that the reboot flag and session ID reset hold across a second consecutive
+restart (not just the first).
+
+**Stimulus:**
+Start → drain → stop → start → drain → stop → start → capture first message.
+
+**Expected Result:**
+``sd_hdr.flag_reboot == True`` and ``outer.session_id <= 2``.
+
+TC8-SDLC-017 — Reboot Detection on Unicast Channel
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_093
+:Requirement: ``comp_req__tc8_conformance__sd_reboot``
+:Test Module: ``test_sd_reboot``
+:Test Function: ``TestSDReboot::test_ets_093_reboot_on_unicast_channel``
+
+**Purpose:**
+Verify that a reboot is detected when the session ID resets on the unicast SD channel
+(not only on multicast).
+
+**Stimulus:**
+Restart DUT; observe first SD unicast message sent by DUT after restart.
+
+**Expected Result:**
+``sd_hdr.flag_reboot == True`` and ``outer.session_id <= 2`` on the unicast channel.
+
+TC8-SDLC-018 — Server Reboot: Session ID Resets
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_094
+:Requirement: ``comp_req__tc8_conformance__sd_reboot``
+:Test Module: ``test_sd_reboot``
+:Test Function: ``TestSDReboot::test_ets_094_server_reboot_session_id_resets``
+
+**Purpose:**
+Verify that after a DUT restart the SD session ID starts from 1 (or 2), not from the
+pre-restart value.
+
+**Stimulus:**
+Capture pre-restart session IDs; restart DUT; capture post-restart session ID.
+
+**Expected Result:**
+Post-restart session ID is ≤ 2, regardless of the pre-restart value.
+
+TC8-SDLC-019 — Subscribe TTL Expiry Stops Events
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_095
+:Requirement: ``comp_req__tc8_conformance__sd_ttl_expiry``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_095_subscribe_ttl_expires_no_events``
+
+**Purpose:**
+Verify that when a subscription's TTL expires without renewal the DUT ceases sending
+event notifications to that subscriber.
+
+**Stimulus:**
+Subscribe with a short TTL; do not renew; wait for TTL to expire; observe events.
+
+**Expected Result:**
+No NOTIFICATION messages received after TTL expiry window.
+
+TC8-SDLC-020 — Initial Event Sent via UDP Unicast
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_105
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_105_initial_event_udp_unicast``
+
+**Purpose:**
+Verify that the first event notification after a unicast subscription is sent via
+UDP unicast to the subscriber's endpoint.
+
+**Stimulus:**
+Subscribe to a unicast eventgroup; capture the first NOTIFICATION after ACK.
+
+**Expected Result:**
+First NOTIFICATION is unicast UDP addressed to the tester's endpoint.
+
+TC8-SDLC-021 — SubscribeEventgroup ACK Received
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_106
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_106_subscribe_eventgroup_ack_received``
+
+**Purpose:**
+Verify that the DUT sends a SubscribeEventgroupAck (entry type 0x06, TTL > 0) in
+response to a valid SubscribeEventgroup.
+
+**Stimulus:**
+Send a valid SubscribeEventgroup for the configured service/eventgroup.
+
+**Expected Result:**
+SubscribeEventgroupAck received with ``entry_type == 0x06`` and ``TTL > 0``.
+
+TC8-SDLC-022 — Initial Field Event After Subscribe
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_121
+:Requirement: ``comp_req__tc8_conformance__fld_initial_value``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_121_initial_field_event_after_subscribe``
+
+**Purpose:**
+Verify that after subscribing to a field eventgroup the DUT sends an initial value
+notification immediately (within one TTL cycle) without requiring a GET request.
+
+**Stimulus:**
+Subscribe to field eventgroup ``0x4455``; observe notifications within 1 second.
+
+**Expected Result:**
+At least one NOTIFICATION received within 1 second of subscription ACK.
+
+TC8-SDLC-023 — Unicast Subscribe Receives ACK
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_173
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_173_unicast_subscribe_receives_ack``
+
+**Purpose:**
+Verify that the DUT sends a SubscribeEventgroupAck via unicast directly to the
+subscriber when a unicast SubscribeEventgroup is received.
+
+**Stimulus:**
+Send SubscribeEventgroup via unicast to the DUT SD port.
+
+**Expected Result:**
+SubscribeEventgroupAck received unicast at tester's endpoint; ``entry_type == 0x06``.
+
+TC8-SDLC-024 — Last Value Delivered via UDP Multicast
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_104
+:Requirement: ``comp_req__tc8_conformance__sd_sub_lifecycle``
+:Test Function: ``TestSDSubscribeLifecycleAdvanced::test_ets_104_last_value_udp_multicast``
+
+**Preconditions:**
+Requires a non-loopback interface; skipped automatically on loopback.
+
+**Stimulus:**
+Subscribe to multicast eventgroup; capture NOTIFICATION on configured multicast group.
+
+**Expected Result:**
+NOTIFICATION is received on the eventgroup's multicast address, not unicast.
+
+TC8-SDLC-025 — Multicast FindService Elicits Offer Response
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_127
+:Requirement: ``comp_req__tc8_conformance__sd_find_response``
+:Test Function: ``TestSDFindServiceAdvanced::test_ets_127_multicast_findservice_response``
+
+**Preconditions:**
+Requires a non-loopback interface; skipped automatically on loopback.
+
+**Stimulus:**
+Send SD FindService for the configured service via multicast.
+
+**Expected Result:**
+DUT responds with OfferService (unicast or multicast) for the known service.
+
+SD Robustness Tests
+--------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_sd.json``
+:Test Module: ``test_sd_robustness``
+
+All robustness tests follow the pattern: inject one malformed SD packet, then send a
+valid FindService and verify the DUT still replies with OfferService (DUT alive
+assertion).
+
+TC8-SDROBUST-001 — Empty Entries Array
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_111
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_111_empty_entries_array``
+
+**Stimulus:**
+Send SD packet with entries_array_length = 0.
+
+**Expected Result:**
+DUT alive (replies to FindService after injection).
+
+TC8-SDROBUST-002 — Zero-Length Option
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_112/113
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_112_empty_option_zero_length``
+
+**Stimulus:**
+Send SubscribeEventgroup with option length = 1 (too short).
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-003 — Entries Length Field Wrong
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_114
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Functions:
+ - ``TestSDMalformedEntries::test_ets_114_entries_length_zero``
+ - ``TestSDMalformedEntries::test_ets_114_entries_length_mismatched``
+
+**Stimulus:**
+Send SD with entries_array_length = 0 (one entry present) and with
+entries_array_length = 8 (not a multiple of 16).
+
+**Expected Result:**
+DUT alive after each injection.
+
+TC8-SDROBUST-004 — Entry References More Options Than Exist
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_115
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_115_entry_refs_more_options_than_exist``
+
+**Stimulus:**
+Send SubscribeEventgroup with num_options_1 = 3 but only 1 option present.
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-005 — Entry Unknown Option Type
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_116
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_116_entry_unknown_option_type``
+
+**Stimulus:**
+Send SubscribeEventgroup with unknown option type 0x77.
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-006 — Two Entries Share Same Option Index
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_117
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_117_two_entries_same_option``
+
+**Stimulus:**
+Send SD with two entries both referencing option index 0.
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-007 — FindService With Endpoint Option
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_118
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_118_find_service_with_endpoint_option``
+
+**Stimulus:**
+Send FindService with an unexpected endpoint option attached.
+
+**Expected Result:**
+DUT responds to the FindService; DUT alive.
+
+TC8-SDROBUST-008 — Entries Length Wildly Too Large
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_123/124
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_123_entries_length_wildly_too_large``
+
+**Stimulus:**
+Send SD with entries_array_length = 0xFFFF (far exceeds actual payload size).
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-009 — Truncated Entry
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_125
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_125_truncated_entry``
+
+**Stimulus:**
+Send SD with entries_array_length = 16 but only 8 bytes of entry data present.
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-010 — Option Length Much Too Large
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_134
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_134_option_length_much_too_large``
+
+**Stimulus:**
+Send SubscribeEventgroup with IPv4EndpointOption length = 0x00FF.
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-011 — Option Length One Too Large
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_135
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_135_option_length_one_too_large``
+
+**Stimulus:**
+Send SubscribeEventgroup with IPv4EndpointOption length = 0x000A (one too large).
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-012 — Option Length Too Short
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_136
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_136_option_length_too_short``
+
+**Stimulus:**
+Send SubscribeEventgroup with IPv4EndpointOption length = 0x0001 (shorter than minimum).
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-013 — Option Length Unaligned
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_137
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_137_option_length_unaligned``
+
+**Stimulus:**
+Send SubscribeEventgroup with IPv4EndpointOption length = 0x000A (unaligned/odd).
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-014 — Options Array Length Too Large
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_138
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_138_options_array_length_too_large``
+
+**Stimulus:**
+Send SubscribeEventgroup with options_array_length = 100 but only 12 bytes of options.
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-015 — Options Array Length Too Short
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_139
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_139_options_array_length_too_short``
+
+**Stimulus:**
+Send SubscribeEventgroup with options_array_length = 2 but 12 bytes of options present.
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-016 — Unknown Option Type 0x77
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_174
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_174_unknown_option_type_0x77``
+
+**Stimulus:**
+Send SD with option type 0x77 (reserved/unknown).
+
+**Expected Result:**
+DUT alive.
+
+TC8-SDROBUST-017 — Subscribe With No Endpoint Option
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_109
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_109_subscribe_no_endpoint_option``
+
+**Stimulus:**
+Send SubscribeEventgroup with num_options_1 = 0 (no endpoint option).
+
+**Expected Result:**
+NAck or silent discard; DUT alive.
+
+TC8-SDROBUST-018 — Subscribe With Zero IP Endpoint
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_110
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_110_subscribe_endpoint_ip_zero``
+
+**Stimulus:**
+Send SubscribeEventgroup with endpoint IP = 0.0.0.0.
+
+**Expected Result:**
+NAck or silent discard; DUT alive.
+
+TC8-SDROBUST-019 — Subscribe With Unknown L4 Protocol
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_119
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_119_subscribe_unknown_l4proto``
+
+**Stimulus:**
+Send SubscribeEventgroup with L4 protocol byte = 0x00 (unknown).
+
+**Expected Result:**
+NAck or silent discard; DUT alive.
+
+TC8-SDROBUST-020 — Subscribe Unknown Service ID
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_140
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_140_subscribe_unknown_service_id``
+
+**Stimulus:**
+Send SubscribeEventgroup for service_id = 0xDEAD (not offered).
+
+**Expected Result:**
+No positive SubscribeAck; DUT alive.
+
+TC8-SDROBUST-021 — Subscribe Unknown Instance ID
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_141
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_141_subscribe_unknown_instance_id``
+
+**Stimulus:**
+Send SubscribeEventgroup for correct service_id but unknown instance_id = 0xBEEF.
+
+**Expected Result:**
+No positive SubscribeAck; DUT alive.
+
+TC8-SDROBUST-022 — Subscribe Unknown Eventgroup ID
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_142
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_142_subscribe_unknown_eventgroup_id``
+
+**Stimulus:**
+Send SubscribeEventgroup for correct service/instance but unknown eventgroup 0xDEAD.
+
+**Expected Result:**
+NAck or silent discard; DUT alive.
+
+TC8-SDROBUST-023 — Subscribe All IDs Unknown
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_143
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_143_subscribe_all_ids_unknown``
+
+**Stimulus:**
+Send SubscribeEventgroup with service_id, instance_id, and eventgroup_id all unknown.
+
+**Expected Result:**
+No positive SubscribeAck; DUT alive.
+
+TC8-SDROBUST-024 — Subscribe With Reserved Option Type
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_144
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_144_subscribe_reserved_option_type``
+
+**Stimulus:**
+Send SubscribeEventgroup with reserved option type 0x20.
+
+**Expected Result:**
+NAck or silent discard; DUT alive.
+
+TC8-SDROBUST-025 — Near-Wrap and Maximum Session IDs
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_152
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Functions:
+ - ``TestSDMessageFramingErrors::test_ets_152_high_session_id_0xfffe``
+ - ``TestSDMessageFramingErrors::test_ets_152_session_id_0xffff``
+ - ``TestSDMessageFramingErrors::test_ets_152_session_id_one``
+
+**Stimulus:**
+Send FindService with session_id = 0xFFFE, then 0xFFFF, then 0x0001.
+
+**Expected Result:**
+DUT alive after each injection.
+
+TC8-SDROBUST-026 — SOME/IP Length Field Mismatch
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_153
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Functions:
+ - ``TestSDMessageFramingErrors::test_ets_153_someip_length_too_small``
+ - ``TestSDMessageFramingErrors::test_ets_153_someip_length_too_large``
+
+**Stimulus:**
+Send SD with SOME/IP length = 8 (smaller than payload) then length = 0x1000 (larger).
+
+**Expected Result:**
+DUT alive after each injection.
+
+TC8-SDROBUST-027 — Wrong SOME/IP Service ID in SD Header
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_178
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMessageFramingErrors::test_ets_178_wrong_someip_service_id``
+
+**Stimulus:**
+Send SD packet with SOME/IP service_id = 0x1234 (not 0xFFFF).
+
+**Expected Result:**
+DUT silently discards (not recognized as SD); DUT alive.
+
+TC8-SDROBUST-028 — SOME/IP Length Field Way Too Long
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_058
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMessageFramingErrors::test_ets_058_someip_length_way_too_long``
+
+**Stimulus:**
+Send SD packet where the SOME/IP length field is set to 0xFFFFFFFF (far beyond actual
+packet size).
+
+**Expected Result:**
+DUT silently discards the oversized-length SD message; DUT alive.
+
+TC8-SDROBUST-029 — Empty Options Array With Subscribe
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_113
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_113_empty_options_array_with_subscribe``
+
+**Stimulus:**
+Send SubscribeEventgroup entry where options_array_length = 0 but the entry
+references option index 0.
+
+**Expected Result:**
+DUT discards or NAcks the subscribe; DUT alive.
+
+TC8-SDROBUST-030 — Entries Length Too Long By Small Margin
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_124
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedEntries::test_ets_124_entries_length_too_long_by_small_margin``
+
+**Stimulus:**
+Send SD packet where entries_array_length is exactly 4 bytes larger than the
+actual entries data present.
+
+**Expected Result:**
+DUT discards the malformed SD message; DUT alive.
+
+TC8-SDROBUST-031 — Subscribe With Non-Routable Endpoint IP
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_154
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_154_subscribe_nonroutable_endpoint_ip``
+
+**Stimulus:**
+Send SubscribeEventgroup with an IPv4EndpointOption whose IP address is a
+non-routable address (e.g., 0.0.0.1).
+
+**Expected Result:**
+DUT rejects or ignores the subscribe; DUT alive.
+
+TC8-SDROBUST-032 — Subscribe Endpoint IP Not Matching Subscriber
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_162
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_162_subscribe_endpoint_ip_not_subscriber``
+
+**Stimulus:**
+Send SubscribeEventgroup where the IPv4EndpointOption IP address differs from the
+actual sender IP.
+
+**Expected Result:**
+DUT rejects or ignores the subscribe; DUT alive.
+
+TC8-SDROBUST-033 — Subscribe Endpoint IP Is the DUT Address
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_163
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDSubscribeEdgeCases::test_ets_163_subscribe_endpoint_dut_address``
+
+**Stimulus:**
+Send SubscribeEventgroup with an IPv4EndpointOption whose IP address is the DUT's
+own loopback or service address.
+
+**Expected Result:**
+DUT rejects or ignores the subscribe (loopback endpoint invalid); DUT alive.
+
+TC8-SDROBUST-034 — Unreferenced Option Ignored
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_175
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_175_unreferenced_option_ignored``
+
+**Stimulus:**
+Send an SD OfferService where the options array contains one option but the
+entry's option run has num_options_1 = 0 (entry references no options).
+
+**Expected Result:**
+DUT processes the entry normally (unreferenced option ignored); DUT alive.
+
+TC8-SDROBUST-035 — Trailing Data After Options Array
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_176
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_176_trailing_data_after_options_array``
+
+**Stimulus:**
+Send SD packet where extra trailing bytes appear after the options array
+(options_array_length is correct but UDP payload is longer).
+
+**Expected Result:**
+DUT discards or tolerates the trailing data; DUT alive.
+
+TC8-SDROBUST-036 — Trailing Data With Wrong Options Length
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_177
+:Requirement: ``comp_req__tc8_conformance__sd_robustness``
+:Test Function: ``TestSDMalformedOptions::test_ets_177_trailing_data_wrong_options_length``
+
+**Stimulus:**
+Send SD packet where options_array_length is inflated to include trailing garbage
+bytes beyond the real options data.
+
+**Expected Result:**
+DUT discards the malformed SD message; DUT alive.
+
+SOME/IP Message Protocol Compliance Tests
+------------------------------------------
+
+:DUT Config: ``tests/tc8_conformance/config/tc8_someipd_service.json``
+:Test Module: ``test_someip_message_format``
+
+TC8-MSG-009 — Valid Service ID Gets Response
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.5 — SOMEIPSRV_BASIC_01
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipBasicIdentifiers::test_basic_01_correct_service_id_gets_response``
+
+**Purpose:**
+Verify that a REQUEST to a valid, offered service ID elicits a RESPONSE.
+
+**Stimulus:**
+Send REQUEST to service 0x1234, method 0x0421.
+
+**Expected Result:**
+RESPONSE received with message_type = 0x80.
+
+TC8-MSG-010 — Unknown Service ID: E_UNKNOWN_SERVICE
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.5 — SOMEIPSRV_BASIC_02
+:Requirement: ``comp_req__tc8_conformance__msg_error_codes``
+:Test Function: ``TestSomeipBasicIdentifiers::test_basic_02_unknown_service_id_no_response_or_error``
+
+**Purpose:**
+Verify that a REQUEST to an unknown service ID is rejected with E_UNKNOWN_SERVICE.
+
+.. note::
+
+ This test is expected to **FAIL** against vsomeip 3.6.1: the stack silently
+ drops requests for unknown services rather than responding with E_UNKNOWN_SERVICE.
+
+**Stimulus:**
+Send REQUEST to service 0xDEAD (not offered).
+
+**Expected Result:**
+ERROR response with return_code = E_UNKNOWN_SERVICE (0x02).
+
+TC8-MSG-011 — Event Method ID: No RESPONSE
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.5 — SOMEIPSRV_BASIC_03
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipBasicIdentifiers::test_basic_03_event_method_id_no_response``
+
+**Purpose:**
+Verify that a REQUEST using an event method ID (bit 15 = 1) does not elicit a
+RESPONSE (0x80). An ERROR (0x81) response is permitted.
+
+**Stimulus:**
+Send REQUEST with method_id = 0x8001 (event ID range).
+
+**Expected Result:**
+No RESPONSE (0x80) received within the timeout.
+
+TC8-MSG-012 — Response Source Address Correct
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_01
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_onwire_01_response_source_address``
+
+**Purpose:**
+Verify that RESPONSE messages originate from the DUT's service endpoint address
+and port (as advertised in the SD OfferService).
+
+**Stimulus:**
+Send REQUEST; observe UDP source address of the RESPONSE.
+
+**Expected Result:**
+RESPONSE source IP matches the DUT's service endpoint; source port equals the
+advertised unreliable port.
+
+TC8-MSG-013 — Method ID MSB = 0 in RESPONSE
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_02
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_onwire_02_method_id_msb_zero_in_response``
+
+**Purpose:**
+Verify that RESPONSE messages have bit 15 of the method_id field equal to 0.
+
+**Stimulus:**
+Send REQUEST; inspect method_id in the RESPONSE.
+
+**Expected Result:**
+``(response.method_id & 0x8000) == 0``.
+
+TC8-MSG-014 — Request ID Reuse Across Sequential Requests
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_04
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_onwire_04_request_id_reuse``
+
+**Purpose:**
+Verify that the DUT correctly handles sequential requests that reuse the same
+session_id value.
+
+**Stimulus:**
+Send two consecutive REQUESTs with the same session_id.
+
+**Expected Result:**
+Both receive valid RESPONSEs with the echoed session_id.
+
+TC8-MSG-015 — Interface Version Echoed in RESPONSE
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_06
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_onwire_06_interface_version_echoed``
+
+**Purpose:**
+Verify that the RESPONSE interface_version matches the REQUEST interface_version.
+
+**Stimulus:**
+Send REQUEST with interface_version = 0x01.
+
+**Expected Result:**
+RESPONSE has interface_version = 0x01.
+
+TC8-MSG-016 — Normal RESPONSE Return Code = E_OK
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.6 — SOMEIPSRV_ONWIRE_11
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_onwire_11_normal_response_return_code_ok``
+
+**Purpose:**
+Verify that a successful RESPONSE carries return_code = 0x00 (E_OK).
+
+**Stimulus:**
+Send valid REQUEST to a known method.
+
+**Expected Result:**
+``response.return_code == 0x00``.
+
+TC8-MSG-017 — Message ID Echoed in RESPONSE (RPC_18)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_18
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_rpc_18_message_id_echoed``
+
+**Purpose:**
+Verify that the RESPONSE message_id (service_id + method_id) matches the REQUEST.
+
+**Stimulus:**
+Send REQUEST; compare message_id in RESPONSE.
+
+**Expected Result:**
+RESPONSE service_id and method_id match the REQUEST.
+
+TC8-MSG-018 — Interface Version Copied From Request (RPC_20)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_20
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_rpc_20_interface_version_copied_from_request``
+
+**Purpose:**
+Verify that the DUT copies the interface_version from the REQUEST into the RESPONSE.
+
+**Stimulus:**
+Send REQUEST with a specific interface_version.
+
+**Expected Result:**
+RESPONSE interface_version matches the REQUEST value.
+
+TC8-MSG-019 — Fire-and-Forget: No Error Response (RPC_05)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_05
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_05_fire_and_forget_no_error``
+
+**Purpose:**
+Verify that REQUEST_NO_RETURN (fire-and-forget) messages do not elicit an error
+response.
+
+**Stimulus:**
+Send REQUEST_NO_RETURN to a valid method.
+
+**Expected Result:**
+No response (ERROR or RESPONSE) received within the timeout.
+
+TC8-MSG-020 — Return Code Upper Bits Zero (RPC_06)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_06
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_06_return_code_upper_bits_zero``
+
+**Purpose:**
+Verify that bits 7-5 of the return_code field in RESPONSE messages are zero.
+
+**Stimulus:**
+Send valid REQUEST; inspect RESPONSE return_code.
+
+**Expected Result:**
+``(response.return_code & 0xE0) == 0``.
+
+TC8-MSG-021 — Inbound Return Code Upper Bits Ignored (RPC_07)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_07
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_07_request_with_return_code_bits_set``
+
+**Purpose:**
+Verify that the DUT ignores the two MSBs of the return_code field in inbound
+REQUEST messages (still responds normally).
+
+**Stimulus:**
+Send REQUEST with return_code = 0xC0 (two MSBs set).
+
+**Expected Result:**
+RESPONSE received with E_OK.
+
+TC8-MSG-022 — No Reply for REQUEST With Non-Zero Return Code (RPC_08)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_08
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_08_request_with_error_return_code_no_reply``
+
+**Purpose:**
+Verify that the DUT does not reply to a REQUEST that has a non-zero return_code
+(per spec, such requests should be silently discarded).
+
+.. note::
+
+ This test is expected to **FAIL** against vsomeip 3.6.1: the stack replies
+ to such messages.
+
+**Stimulus:**
+Send REQUEST with return_code = 0x01 (non-zero).
+
+**Expected Result:**
+No RESPONSE received within the timeout.
+
+TC8-MSG-023 — ERROR Response Has No Payload (RPC_09)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_09
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_09_error_response_no_payload``
+
+**Purpose:**
+Verify that ERROR responses carry no payload beyond the 8-byte SOME/IP header.
+
+**Stimulus:**
+Trigger an error response (e.g., unknown method).
+
+**Expected Result:**
+ERROR response has ``payload == b""`` (zero payload bytes).
+
+TC8-MSG-024 — Fire-and-Forget Reserved Type: No Error (RPC_10)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.5.7 — SOMEIPSRV_RPC_10
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_rpc_10_fire_and_forget_reserved_type_no_error``
+
+**Purpose:**
+Verify that a fire-and-forget message sent to a wrong/reserved service ID does not
+elicit an error response.
+
+**Stimulus:**
+Send REQUEST_NO_RETURN to an unknown service.
+
+**Expected Result:**
+No error response received.
+
+TC8-MSG-025 — Burst 10 Sequential Requests (ETS_004)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_004
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_004_burst_10_sequential_requests``
+
+**Purpose:**
+Verify that the DUT responds correctly to a burst of 10 sequential REQUEST messages.
+
+**Stimulus:**
+Send 10 consecutive REQUESTs.
+
+**Expected Result:**
+10 RESPONSE messages received, all with E_OK.
+
+TC8-MSG-026 — Empty Payload Request (ETS_054)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_054
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_054_empty_payload_request``
+
+**Purpose:**
+Verify that a REQUEST with an empty payload (length = 8, no payload bytes) is
+handled correctly and elicits E_OK.
+
+**Stimulus:**
+Send REQUEST with zero-length payload.
+
+**Expected Result:**
+RESPONSE with return_code = E_OK.
+
+TC8-MSG-027 — Fire-and-Forget Wrong Service: No Error (ETS_059)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_059
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_059_fire_and_forget_wrong_service_no_error``
+
+**Purpose:**
+Verify that a REQUEST_NO_RETURN to an unknown service does not elicit an error
+response.
+
+**Stimulus:**
+Send REQUEST_NO_RETURN to service_id = 0xDEAD.
+
+**Expected Result:**
+No response received.
+
+TC8-MSG-028 — Two Sequential Requests (ETS_061)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_061
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_061_two_sequential_requests``
+
+**Purpose:**
+Verify that two sequential REQUESTs each receive a correct RESPONSE.
+
+**Stimulus:**
+Send two consecutive REQUESTs with different session_ids.
+
+**Expected Result:**
+Two RESPONSEs received with matching session_ids and E_OK.
+
+TC8-MSG-029 — NOTIFICATION as REQUEST Ignored (ETS_075)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_075
+:Requirement: ``comp_req__tc8_conformance__msg_malformed``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_075_notification_as_request_ignored``
+
+**Purpose:**
+Verify that the DUT ignores a NOTIFICATION message (message_type = 0x02) sent
+as if it were a REQUEST (i.e., directed at the service port).
+
+**Stimulus:**
+Send SOME/IP message with message_type = 0x02 (NOTIFICATION) to the service port.
+
+**Expected Result:**
+No RESPONSE or ERROR received within the timeout.
+
+TC8-MSG-030 — RESPONSE Uses Big-Endian Byte Order (ETS_005)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_005
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:Test Function: ``TestSomeipResponseFields::test_ets_005_response_uses_big_endian_byte_order``
+
+**Purpose:**
+Verify that all multi-byte fields in a SOME/IP RESPONSE header (length, request_id,
+interface_version, etc.) are encoded in big-endian byte order as required by the
+SOME/IP protocol specification.
+
+**Stimulus:**
+Send REQUEST; capture RESPONSE bytes; inspect raw byte ordering of length and
+request_id fields.
+
+**Expected Result:**
+Raw bytes of ``length`` and ``request_id`` fields match the big-endian encoding
+of the decoded values (``struct.pack(">I", ...)``).
+
+TC8-MSG-031 — Oversized Length Field Does Not Crash DUT (ETS_058)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Reference: §5.1.6 — SOMEIP_ETS_058
+:Requirement: ``comp_req__tc8_conformance__msg_malformed``
+:Test Function: ``TestSomeipFireAndForgetAndErrors::test_ets_058_oversized_length_field_no_crash``
+
+**Purpose:**
+Verify that the DUT does not crash or freeze when a SOME/IP message is received
+with a length field value that far exceeds the actual UDP payload size.
+
+**Stimulus:**
+Send SOME/IP REQUEST where the length header field is set to 0xFFFFFFFF while the
+UDP payload is only 16 bytes.
+
+**Expected Result:**
+DUT discards the malformed message; a subsequent valid REQUEST receives a correct
+RESPONSE (DUT alive).
+
+TC8-MSG-032 — Session ID Echoed in Error Response (RPC_19)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+:OA Spec Reference: §5.1.5.7 — SOMEIPSRV_RPC_19
+:Test Module: ``test_someip_message_format.py``
+:Test Function: ``TestSomeipResponseFields::test_rpc_19_session_id_echoed_in_error``
+:Requirement: ``comp_req__tc8_conformance__msg_resp_header``
+:DUT Config: ``tc8_someipd_service.json``
+
+**Purpose:**
+Verify that error response session_id equals the request session_id.
+
+**Preconditions:**
+
+- DUT started, service offered.
+
+**Stimuli:**
+Send REQUEST with a known ``session_id`` to an unknown method (triggering
+E_UNKNOWN_METHOD error response).
+
+**Expected Result:**
+Error RESPONSE has ``session_id`` matching the REQUEST value
+(per PRS_SOMEIP_00137).
diff --git a/docs/tc8_conformance/traceability.rst b/docs/tc8_conformance/traceability.rst
new file mode 100644
index 000000000..f6fd07ee3
--- /dev/null
+++ b/docs/tc8_conformance/traceability.rst
@@ -0,0 +1,1259 @@
+..
+ # *******************************************************************************
+ # Copyright (c) 2026 Contributors to the Eclipse Foundation
+ #
+ # See the NOTICE file(s) distributed with this work for additional
+ # information regarding copyright ownership.
+ #
+ # This program and the accompanying materials are made available under the
+ # terms of the Apache License Version 2.0 which is available at
+ # https://www.apache.org/licenses/LICENSE-2.0
+ #
+ # SPDX-License-Identifier: Apache-2.0
+ # *******************************************************************************
+
+TC8 Conformance Traceability
+=============================
+
+This document provides the single source of truth for tracing
+OPEN Alliance TC8 test cases from the external specification through
+the project's internal requirements to the implementing test functions.
+
+Source Document
+---------------
+
+- **Title:** OA Automotive Ethernet ECU Test Specification — Layers 3–7
+- **Version:** v3.0
+- **Chapter:** 5 — SOME/IP
+- **Publisher:** OPEN Alliance SIG
+
+.. note::
+
+ OA Spec References use test case identifiers from Chapter 5 of the
+ OA Automotive Ethernet ECU Test Specification v3.0 (October 2019).
+ ``SOMEIPSRV_*`` IDs are from §5.1.5 (SOME/IP Server Tests) and
+ ``SOMEIP_ETS_*`` IDs are from §5.1.6 (Enhanced Testability Service Tests).
+
+Full Traceability Matrix
+------------------------
+
+The following table links each OA TC8 specification test case to the
+project's internal test ID, component requirement, and implementing
+Python test function(s). All requirement IDs use the
+``comp_req__tc8_conformance__`` prefix (omitted for brevity).
+
+Service Discovery
+^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_08
+ - TC8-SD-001
+ - ``sd_offer_format``
+ - ``test_service_discovery::test_tc8_sd_001_multicast_offer_on_startup``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_14–18
+ - TC8-SD-002
+ - ``sd_offer_format``
+ - ``test_service_discovery::test_tc8_sd_002_offer_entry_format``
+ * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_02
+ - TC8-SD-003
+ - ``sd_cyclic_timing``
+ - ``test_service_discovery::test_tc8_sd_003_cyclic_offer_timing``
+ * - §5.1.6 — SOMEIP_ETS_171
+ - TC8-SD-004
+ - ``sd_find_response``
+ - ``test_service_discovery::test_tc8_sd_004_find_known_service_unicast_offer``
+ * - §5.1.5.4 — implied by SD_BEHAVIOR_03/04
+ - TC8-SD-005
+ - ``sd_find_response``
+ - ``test_service_discovery::test_tc8_sd_005_find_unknown_service_no_response``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_13
+ - TC8-SD-006
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::test_tc8_sd_006_subscribe_valid_eventgroup_ack``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14; §5.1.6 — SOMEIP_ETS_140
+ - TC8-SD-007
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::test_tc8_sd_007_subscribe_unknown_eventgroup_nack``
+ * - §5.1.6 — SOMEIP_ETS_108, SOMEIP_ETS_092
+ - TC8-SD-008
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::test_tc8_sd_008_stop_subscribe_ceases_notifications``
+ * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01
+ - TC8-SD-009
+ - ``sd_phases_timing``
+ - ``test_sd_phases_timing::test_tc8_sd_009_repetition_phase_intervals``
+ * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_01
+ - TC8-SD-010
+ - ``sd_phases_timing``
+ - ``test_sd_phases_timing::test_tc8_sd_010_repetition_count_before_main_phase``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_01–07
+ - TC8-SD-011
+ - ``sd_endpoint_option``
+ - ``test_service_discovery::test_tc8_sd_011_offer_ipv4_endpoint_option``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_02, FORMAT_07
+ - TC8-SD-012
+ - ``sd_reboot``
+ - | ``test_sd_reboot::test_tc8_sd_012_reboot_flag_set_after_restart``
+ | ``test_sd_reboot::test_tc8_sd_012_session_id_resets_after_restart``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_08–14
+ - TC8-SD-013
+ - ``sd_mcast_eg``
+ - ``test_service_discovery::test_tc8_sd_013_subscribe_ack_has_multicast_option``
+ * - §5.1.6 — SOMEIP_ETS_095
+ - TC8-SD-014
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::test_tc8_sd_014_ttl_expiry_ceases_notifications``
+
+SOME/IP Message Format
+^^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_05
+ - TC8-MSG-001
+ - ``msg_resp_header``
+ - ``test_someip_message_format::test_tc8_msg_001_protocol_version``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_07
+ - TC8-MSG-002
+ - ``msg_resp_header``
+ - | ``test_someip_message_format::test_tc8_msg_002_message_type_response``
+ | ``test_someip_message_format::test_tc8_msg_002_no_response_for_request_no_return``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_10; §5.1.6 — SOMEIP_ETS_077
+ - TC8-MSG-003
+ - ``msg_error_codes``
+ - ``test_someip_message_format::test_tc8_msg_003_unknown_service``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_12; §5.1.6 — SOMEIP_ETS_076
+ - TC8-MSG-004
+ - ``msg_error_codes``
+ - ``test_someip_message_format::test_tc8_msg_004_unknown_method``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_03
+ - TC8-MSG-005
+ - ``msg_resp_header``
+ - ``test_someip_message_format::test_tc8_msg_005_session_id_echo``
+ * - §5.1.6 — SOMEIP_ETS_074
+ - TC8-MSG-006
+ - ``msg_error_codes``
+ - ``test_someip_message_format::test_tc8_msg_006_wrong_interface_version``
+ * - §5.1.6 — SOMEIP_ETS_054, 055, 058, 078
+ - TC8-MSG-007
+ - ``msg_malformed``
+ - | ``test_someip_message_format::test_tc8_msg_007_truncated_message_no_crash``
+ | ``test_someip_message_format::test_tc8_msg_007_wrong_protocol_version_no_crash``
+ | ``test_someip_message_format::test_tc8_msg_007_oversized_length_field_no_crash``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_03
+ - TC8-MSG-008
+ - ``msg_resp_header``
+ - ``test_someip_message_format::test_tc8_msg_008_client_id_echo``
+
+Event Notification
+^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.5 — SOMEIPSRV_BASIC_03; §5.1.6 — SOMEIP_ETS_147
+ - TC8-EVT-001
+ - ``evt_subscription``
+ - ``test_event_notification::test_tc8_evt_001_notification_message_type``
+ * - §5.1.5.5 — SOMEIPSRV_BASIC_03
+ - TC8-EVT-002
+ - ``evt_subscription``
+ - ``test_event_notification::test_tc8_evt_002_correct_event_id``
+ * - §5.1.6 — SOMEIP_ETS_147
+ - TC8-EVT-003
+ - ``evt_subscription``
+ - ``test_event_notification::test_tc8_evt_003_notification_only_to_subscriber``
+ * - §5.1.6 — SOMEIP_ETS_147 (pre-subscribe)
+ - TC8-EVT-004
+ - ``evt_subscription``
+ - ``test_event_notification::test_tc8_evt_004_no_notification_before_subscribe``
+ * - §5.1.6 — SOMEIP_ETS_150
+ - TC8-EVT-005
+ - ``evt_subscription``
+ - ``test_event_notification::test_tc8_evt_005_multicast_notification_delivery``
+ * - §5.1.6 — SOMEIP_ETS_108
+ - TC8-EVT-006
+ - ``evt_subscription``
+ - ``test_event_notification::test_tc8_evt_006_stop_subscribe_ceases_notifications``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_16
+ - TC8-EVT-007
+ - ``fld_getter_setter``
+ - ``test_event_notification::TestEventNotification::test_rpc_16_field_notifies_only_on_change``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_15
+ - TC8-EVT-008
+ - ``evt_subscription``
+ - ``test_event_notification::TestEventNotificationFormat::test_rpc_15_cyclic_notification_rate``
+
+Field Conformance
+^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.6 — SOMEIP_ETS_121
+ - TC8-FLD-001
+ - ``fld_initial_value``
+ - ``test_field_conformance::test_tc8_fld_001_initial_notification_on_subscribe``
+ * - §5.1.6 — SOMEIP_ETS_121
+ - TC8-FLD-002
+ - ``fld_initial_value``
+ - ``test_field_conformance::test_tc8_fld_002_is_field_sends_initial_value_within_one_second``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_03; §5.1.6 — SOMEIP_ETS_166
+ - TC8-FLD-003
+ - ``fld_get_set``
+ - ``test_field_conformance::test_tc8_fld_003_getter_returns_current_value``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_11; §5.1.6 — SOMEIP_ETS_166
+ - TC8-FLD-004
+ - ``fld_get_set``
+ - ``test_field_conformance::test_tc8_fld_004_setter_updates_value_and_notifies``
+
+TCP Transport Binding
+^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.7 — SOMEIPSRV_RPC_01
+ - TC8-TCP-001
+ - ``tcp_transport``
+ - ``test_someip_message_format::test_tc8_rpc_01_tcp_request_response``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_01
+ - TC8-TCP-002
+ - ``tcp_transport``
+ - ``test_someip_message_format::test_tc8_rpc_01_tcp_session_id_echo``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_01
+ - TC8-TCP-003
+ - ``tcp_transport``
+ - ``test_someip_message_format::test_tc8_rpc_01_tcp_client_id_echo``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_02
+ - TC8-TCP-004
+ - ``tcp_transport``
+ - ``test_someip_message_format::test_tc8_rpc_02_tcp_multiple_methods_single_connection``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_15
+ - TC8-TCP-005
+ - ``tcp_transport``
+ - ``test_someip_message_format::test_tc8_sd_options_15_tcp_endpoint_advertised``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_17 **(partial — single-instance only)**
+ - TC8-TCP-006
+ - ``tcp_transport``
+ - ``test_field_conformance::test_tc8_rpc_17_tcp_field_getter``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_17 **(partial — single-instance only)**
+ - TC8-TCP-007
+ - ``tcp_transport``
+ - ``test_field_conformance::test_tc8_rpc_17_tcp_field_setter``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_17 **(partial — single-instance only)**
+ - TC8-TCP-008
+ - ``tcp_transport``
+ - ``test_event_notification::test_tc8_rpc_17_tcp_event_notification_delivery``
+ * - SOMEIP_ETS_068
+ - TC8-TCP-009
+ - ``tcp_transport``
+ - ``test_someip_message_format::test_tc8_ets_068_unaligned_someip_messages_over_tcp``
+
+.. note::
+
+ **SOMEIPSRV_RPC_17 partial coverage:** TC8-TCP-006, TC8-TCP-007,
+ and TC8-TCP-008 verify TCP transport for field GET/SET and event
+ notification operations using a single service instance; the full
+ SOMEIPSRV_RPC_17 requirement (each service instance on a separate
+ TCP connection) is not covered — multi-instance TCP is a known gap.
+
+UDP Transport Binding
+^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - SOMEIP_ETS_069
+ - TC8-UDP-001
+ - ``udp_transport``
+ - ``test_someip_message_format::test_tc8_ets_069_unaligned_someip_messages_over_udp``
+
+Multi-service and Multi-instance
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.7 — SOMEIPSRV_RPC_13
+ - TC8-MULTI-001
+ - ``multi_service``
+ - ``test_multi_service::TestMultiServiceInstanceRouting::test_rpc_13_multi_service_config_loads_and_primary_service_offered``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_14
+ - TC8-MULTI-002
+ - ``multi_service``
+ - | ``test_multi_service::TestMultiServiceInstanceRouting::test_rpc_14_service_a_advertises_configured_udp_port``
+ | ``test_multi_service::TestMultiServiceInstanceRouting::test_rpc_14_no_unexpected_service_ids_in_offers``
+
+SD Format and Options Compliance
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_01
+ - TC8-SDF-001
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_01_client_id_is_zero``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_02
+ - TC8-SDF-002
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_02_session_id_is_nonzero_and_in_range``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_04
+ - TC8-SDF-003
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_04_interface_version_is_one``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_05
+ - TC8-SDF-004
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_05_message_type_is_notification``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_06
+ - TC8-SDF-005
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_06_return_code_is_e_ok``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_09
+ - TC8-SDF-006
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_09_sd_flags_reserved_bits_are_zero``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_10
+ - TC8-SDF-007
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsOfferService::test_format_10_sd_entry_reserved_bytes_are_zero``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_11
+ - TC8-SDF-008
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_11_entry_is_16_bytes``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_12
+ - TC8-SDF-009
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_12_first_option_run_index_is_zero``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_13
+ - TC8-SDF-010
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_13_num_options_matches_options_list``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_15
+ - TC8-SDF-011
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_15_instance_id_matches_config``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_16
+ - TC8-SDF-012
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_16_major_version_matches_config``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_18
+ - TC8-SDF-013
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdOfferEntryFields::test_format_18_minor_version_matches_config``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_19
+ - TC8-SDF-014
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_19_ack_entry_type_is_subscribe_ack``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_20
+ - TC8-SDF-015
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_20_ack_entry_is_16_bytes``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_21
+ - TC8-SDF-016
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_21_ack_option_run_index_is_zero_when_no_options``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_23
+ - TC8-SDF-017
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_23_ack_service_id_matches_config``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_24
+ - TC8-SDF-018
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_24_ack_instance_id_matches_config``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_25
+ - TC8-SDF-019
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_25_ack_major_version_matches_config``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_26
+ - TC8-SDF-020
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_26_ack_ttl_is_nonzero``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_27
+ - TC8-SDF-021
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_27_ack_reserved_field_is_zero``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_28
+ - TC8-SDF-022
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdHeaderFieldsSubscribeAck::test_format_28_ack_eventgroup_id_matches_subscribe``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_01
+ - TC8-SDF-023
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_01_endpoint_option_length_is_nine``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_02
+ - TC8-SDF-024
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_02_endpoint_option_type_is_0x04``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_03
+ - TC8-SDF-025
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_03_endpoint_option_reserved_after_type_is_zero``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_05
+ - TC8-SDF-026
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_05_endpoint_option_reserved_before_protocol_is_zero``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_06
+ - TC8-SDF-027
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsEndpoint::test_options_06_endpoint_option_protocol_is_udp``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_08
+ - TC8-SDF-028
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_08_multicast_option_length_is_nine``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_09
+ - TC8-SDF-029
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_09_multicast_option_type_is_0x14``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_10
+ - TC8-SDF-030
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_10_multicast_option_reserved_is_zero``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_11
+ - TC8-SDF-031
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_11_multicast_address_matches_config``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_12
+ - TC8-SDF-032
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_12_multicast_option_reserved_before_port_is_zero``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_13
+ - TC8-SDF-033
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_13_multicast_option_protocol_is_udp``
+ * - §5.1.5.2 — SOMEIPSRV_OPTIONS_14
+ - TC8-SDF-034
+ - ``sd_options_fields``
+ - ``test_sd_format_compliance::TestSdOptionsMulticast::test_options_14_multicast_port_matches_config``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_12
+ - TC8-SDF-035
+ - ``sd_stop_sub_fmt``
+ - ``test_sd_format_compliance::TestSdStopSubscribeFormat::test_sd_message_12_stop_subscribe_entry_format``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_03
+ - TC8-SDF-036
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_03_protocol_version_is_one``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_08
+ - TC8-SDF-037
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_08_unicast_flag_set``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_14
+ - TC8-SDF-038
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_14_entry_type_is_offer``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_17
+ - TC8-SDF-039
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_17_ttl_is_nonzero``
+ * - §5.1.5.1 — SOMEIPSRV_FORMAT_22
+ - TC8-SDF-040
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdMissingFormatFields::test_format_22_ack_num_options_1_matches``
+
+.. note::
+
+ TC8-SDF-028 through TC8-SDF-034 (SOMEIPSRV_OPTIONS_08–14, multicast option
+ fields) require a non-loopback interface and are skipped on loopback with
+ ``@pytest.mark.network``.
+
+.. note::
+
+ **TC8-SDF-037:** The test function ``test_format_08_unicast_flag_set``
+ verifies the **Unicast Flag (bit 6)** of the SD Flags byte, which corresponds
+ to ``SOMEIPSRV_FORMAT_08`` in the OA spec. ``SOMEIPSRV_FORMAT_07`` (Reboot
+ Flag) is a separate requirement covered by ``TC8-SD-012``.
+
+SD Entry Semantics
+^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_01
+ - TC8-SDM-001
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDVersionMatching::test_sd_message_01_instance_wildcard``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_02
+ - TC8-SDM-002
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDVersionMatching::test_sd_message_02_instance_specific``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_03
+ - TC8-SDM-003
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDVersionMatching::test_sd_message_03_major_version_wildcard``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_04
+ - TC8-SDM-004
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDVersionMatching::test_sd_message_04_major_version_specific``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_05
+ - TC8-SDM-005
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDVersionMatching::test_sd_message_05_minor_version_wildcard``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_06
+ - TC8-SDM-006
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDVersionMatching::test_sd_message_06_minor_version_specific``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_14
+ - TC8-SDM-007
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_14_wrong_major_version``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_15
+ - TC8-SDM-008
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_15_wrong_service_id``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_16
+ - TC8-SDM-009
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_16_wrong_instance_id``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_17
+ - TC8-SDM-010
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_17_unknown_eventgroup_id``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_18
+ - TC8-SDM-011
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_18_ttl_zero_stop_subscribe``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_19
+ - TC8-SDM-012
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_19_reserved_field_set``
+ * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_03
+ - TC8-SDM-013
+ - ``sd_cyclic_timing``
+ - ``test_service_discovery::TestSDFindServiceTiming::test_sd_behavior_03_unicast_findservice_timing``
+ * - §5.1.5.4 — SOMEIPSRV_SD_BEHAVIOR_04
+ - TC8-SDM-014
+ - ``sd_cyclic_timing``
+ - ``test_service_discovery::TestSDFindServiceTiming::test_sd_behavior_04_multicast_findservice_timing``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_07
+ - TC8-SDM-015
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_07_offer_entry_type_byte``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_08
+ - TC8-SDM-016
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_08_offer_entry_option_run2_index_zero``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_09
+ - TC8-SDM-017
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_09_offer_entry_num_options_2_zero``
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_11
+ - TC8-SDM-018
+ - ``sd_format_fields``
+ - ``test_sd_format_compliance::TestSdEntryOptionFields::test_sd_message_11_subscribe_entry_type_byte``
+
+.. note::
+
+ **SOMEIPSRV_SD_MESSAGE_08 dual coverage:** ``TC8-SD-001`` verifies that an
+ OfferService message is *present* on multicast at startup (behavioural
+ assertion). ``TC8-SDM-016`` verifies the *option_index_2 byte* in the
+ serialised entry is zero — a distinct field-level assertion from the same
+ spec requirement.
+
+.. note::
+
+ TC8-SDM-012 (SOMEIPSRV_SD_MESSAGE_19) is expected to **FAIL** against
+ vsomeip 3.6.1: the stack sends a positive ACK instead of NAck for a
+ SubscribeEventgroup with reserved bits set. See :ref:`known_stack_limitations`.
+
+SD Lifecycle Advanced
+^^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.6 — SOMEIP_ETS_088
+ - TC8-SDLC-001
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_088_two_subscribes_same_session``
+ * - §5.1.6 — SOMEIP_ETS_092
+ - TC8-SDLC-002
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_092_ttl_zero_stop_subscribe_no_nack``
+ * - §5.1.6 — SOMEIP_ETS_098
+ - TC8-SDLC-003
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_098_subscribe_accepted_without_prior_rpc``
+ * - §5.1.6 — SOMEIP_ETS_107
+ - TC8-SDLC-004
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_107_find_service_and_subscribe_processed_independently``
+ * - §5.1.6 — SOMEIP_ETS_120
+ - TC8-SDLC-005
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_120_subscribe_endpoint_ip_matches_tester``
+ * - §5.1.6 — SOMEIP_ETS_122
+ - TC8-SDLC-006
+ - ``sd_offer_format``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_122_sd_interface_version_is_one``
+ * - §5.1.6 — SOMEIP_ETS_155
+ - TC8-SDLC-007
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_155_resubscribe_after_stop``
+ * - §5.1.6 — SOMEIP_ETS_091
+ - TC8-SDLC-008
+ - ``sd_offer_format``
+ - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_091_session_id_increments``
+ * - §5.1.6 — SOMEIP_ETS_099
+ - TC8-SDLC-009
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_099_initial_event_sent_after_subscribe``
+ * - §5.1.6 — SOMEIP_ETS_100
+ - TC8-SDLC-010
+ - ``sd_offer_format``
+ - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_100_no_findservice_emitted_by_server``
+ * - §5.1.6 — SOMEIP_ETS_101
+ - TC8-SDLC-011
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_101_stop_offer_ceases_client_events``
+ * - §5.1.6 — SOMEIP_ETS_128
+ - TC8-SDLC-012
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_128_multicast_findservice_version_wildcard``
+ * - §5.1.6 — SOMEIP_ETS_130
+ - TC8-SDLC-013
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_130_multicast_findservice_unicast_flag_clear``
+ * - §5.1.6 — SOMEIP_ETS_084
+ - TC8-SDLC-014
+ - ``sd_sub_lifecycle``
+ - ``test_sd_client::TestSDClientStopSubscribe::test_ets_084_stop_subscribe_ceases_events``
+ * - §5.1.6 — SOMEIP_ETS_081
+ - TC8-SDLC-015
+ - ``sd_reboot``
+ - ``test_sd_client::TestSDClientReboot::test_ets_081_reboot_flag_set_after_first_restart``
+ * - §5.1.6 — SOMEIP_ETS_082
+ - TC8-SDLC-016
+ - ``sd_reboot``
+ - ``test_sd_client::TestSDClientReboot::test_ets_082_reboot_flag_set_after_second_restart``
+ * - §5.1.6 — SOMEIP_ETS_093
+ - TC8-SDLC-017
+ - ``sd_reboot``
+ - ``test_sd_reboot::TestSDReboot::test_ets_093_reboot_on_unicast_channel``
+ * - §5.1.6 — SOMEIP_ETS_094
+ - TC8-SDLC-018
+ - ``sd_reboot``
+ - ``test_sd_reboot::TestSDReboot::test_ets_094_server_reboot_session_id_resets``
+ * - §5.1.6 — SOMEIP_ETS_095
+ - TC8-SDLC-019
+ - ``sd_ttl_expiry``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_095_subscribe_ttl_expires_no_events``
+ * - §5.1.6 — SOMEIP_ETS_105
+ - TC8-SDLC-020
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_105_initial_event_udp_unicast``
+ * - §5.1.6 — SOMEIP_ETS_106
+ - TC8-SDLC-021
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_106_subscribe_eventgroup_ack_received``
+ * - §5.1.6 — SOMEIP_ETS_121
+ - TC8-SDLC-022
+ - ``fld_initial_value``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_121_initial_field_event_after_subscribe``
+ * - §5.1.6 — SOMEIP_ETS_173
+ - TC8-SDLC-023
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_173_unicast_subscribe_receives_ack``
+ * - §5.1.6 — SOMEIP_ETS_104
+ - TC8-SDLC-024
+ - ``sd_sub_lifecycle``
+ - ``test_service_discovery::TestSDSubscribeLifecycleAdvanced::test_ets_104_last_value_udp_multicast``
+ * - §5.1.6 — SOMEIP_ETS_127
+ - TC8-SDLC-025
+ - ``sd_find_response``
+ - ``test_service_discovery::TestSDFindServiceAdvanced::test_ets_127_multicast_findservice_response``
+
+.. note::
+
+ TC8-SDLC-011 (SOMEIP_ETS_101) is implemented as ``pytest.skip`` because
+ stopping the DUT's own OfferService from an external tester requires a
+ dedicated reverse-direction SD client target; the current target does not
+ include that capability.
+
+SD Robustness
+^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.6 — SOMEIP_ETS_111
+ - TC8-SDROBUST-001
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_111_empty_entries_array``
+ * - §5.1.6 — SOMEIP_ETS_112/113
+ - TC8-SDROBUST-002
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_112_empty_option_zero_length``
+ * - §5.1.6 — SOMEIP_ETS_114
+ - TC8-SDROBUST-003
+ - ``sd_robustness``
+ - | ``test_sd_robustness::TestSDMalformedEntries::test_ets_114_entries_length_zero``
+ | ``test_sd_robustness::TestSDMalformedEntries::test_ets_114_entries_length_mismatched``
+ * - §5.1.6 — SOMEIP_ETS_115
+ - TC8-SDROBUST-004
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_115_entry_refs_more_options_than_exist``
+ * - §5.1.6 — SOMEIP_ETS_116
+ - TC8-SDROBUST-005
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_116_entry_unknown_option_type``
+ * - §5.1.6 — SOMEIP_ETS_117
+ - TC8-SDROBUST-006
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_117_two_entries_same_option``
+ * - §5.1.6 — SOMEIP_ETS_118
+ - TC8-SDROBUST-007
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_118_find_service_with_endpoint_option``
+ * - §5.1.6 — SOMEIP_ETS_123/124
+ - TC8-SDROBUST-008
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_123_entries_length_wildly_too_large``
+ * - §5.1.6 — SOMEIP_ETS_125
+ - TC8-SDROBUST-009
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_125_truncated_entry``
+ * - §5.1.6 — SOMEIP_ETS_134
+ - TC8-SDROBUST-010
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_134_option_length_much_too_large``
+ * - §5.1.6 — SOMEIP_ETS_135
+ - TC8-SDROBUST-011
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_135_option_length_one_too_large``
+ * - §5.1.6 — SOMEIP_ETS_136
+ - TC8-SDROBUST-012
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_136_option_length_too_short``
+ * - §5.1.6 — SOMEIP_ETS_137
+ - TC8-SDROBUST-013
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_137_option_length_unaligned``
+ * - §5.1.6 — SOMEIP_ETS_138
+ - TC8-SDROBUST-014
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_138_options_array_length_too_large``
+ * - §5.1.6 — SOMEIP_ETS_139
+ - TC8-SDROBUST-015
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_139_options_array_length_too_short``
+ * - §5.1.6 — SOMEIP_ETS_174
+ - TC8-SDROBUST-016
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_174_unknown_option_type_0x77``
+ * - §5.1.6 — SOMEIP_ETS_109
+ - TC8-SDROBUST-017
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_109_subscribe_no_endpoint_option``
+ * - §5.1.6 — SOMEIP_ETS_110
+ - TC8-SDROBUST-018
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_110_subscribe_endpoint_ip_zero``
+ * - §5.1.6 — SOMEIP_ETS_119
+ - TC8-SDROBUST-019
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_119_subscribe_unknown_l4proto``
+ * - §5.1.6 — SOMEIP_ETS_140
+ - TC8-SDROBUST-020
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_140_subscribe_unknown_service_id``
+ * - §5.1.6 — SOMEIP_ETS_141
+ - TC8-SDROBUST-021
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_141_subscribe_unknown_instance_id``
+ * - §5.1.6 — SOMEIP_ETS_142
+ - TC8-SDROBUST-022
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_142_subscribe_unknown_eventgroup_id``
+ * - §5.1.6 — SOMEIP_ETS_143
+ - TC8-SDROBUST-023
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_143_subscribe_all_ids_unknown``
+ * - §5.1.6 — SOMEIP_ETS_144
+ - TC8-SDROBUST-024
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_144_subscribe_reserved_option_type``
+ * - §5.1.6 — SOMEIP_ETS_152
+ - TC8-SDROBUST-025
+ - ``sd_robustness``
+ - | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_152_high_session_id_0xfffe``
+ | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_152_session_id_0xffff``
+ | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_152_session_id_one``
+ * - §5.1.6 — SOMEIP_ETS_153
+ - TC8-SDROBUST-026
+ - ``sd_robustness``
+ - | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_153_someip_length_too_small``
+ | ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_153_someip_length_too_large``
+ * - §5.1.6 — SOMEIP_ETS_178
+ - TC8-SDROBUST-027
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_178_wrong_someip_service_id``
+ * - §5.1.6 — SOMEIP_ETS_058
+ - TC8-SDROBUST-028
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMessageFramingErrors::test_ets_058_someip_length_way_too_long``
+ * - §5.1.6 — SOMEIP_ETS_113
+ - TC8-SDROBUST-029
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_113_empty_options_array_with_subscribe``
+ * - §5.1.6 — SOMEIP_ETS_124
+ - TC8-SDROBUST-030
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedEntries::test_ets_124_entries_length_too_long_by_small_margin``
+ * - §5.1.6 — SOMEIP_ETS_154
+ - TC8-SDROBUST-031
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_154_subscribe_nonroutable_endpoint_ip``
+ * - §5.1.6 — SOMEIP_ETS_162
+ - TC8-SDROBUST-032
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_162_subscribe_endpoint_ip_not_subscriber``
+ * - §5.1.6 — SOMEIP_ETS_163
+ - TC8-SDROBUST-033
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDSubscribeEdgeCases::test_ets_163_subscribe_endpoint_dut_address``
+ * - §5.1.6 — SOMEIP_ETS_175
+ - TC8-SDROBUST-034
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_175_unreferenced_option_ignored``
+ * - §5.1.6 — SOMEIP_ETS_176
+ - TC8-SDROBUST-035
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_176_trailing_data_after_options_array``
+ * - §5.1.6 — SOMEIP_ETS_177
+ - TC8-SDROBUST-036
+ - ``sd_robustness``
+ - ``test_sd_robustness::TestSDMalformedOptions::test_ets_177_trailing_data_wrong_options_length``
+
+SOME/IP Message Protocol Compliance
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 12 18 45
+
+ * - OA Spec Reference (Ch. 5)
+ - Internal ID
+ - Requirement
+ - Test Function(s)
+ * - §5.1.5.5 — SOMEIPSRV_BASIC_01
+ - TC8-MSG-009
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_01_correct_service_id_gets_response``
+ * - §5.1.5.5 — SOMEIPSRV_BASIC_02
+ - TC8-MSG-010
+ - ``msg_error_codes``
+ - ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_02_unknown_service_id_no_response_or_error``
+ * - §5.1.5.5 — SOMEIPSRV_BASIC_03
+ - TC8-MSG-011
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_03_event_method_id_no_response``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_01
+ - TC8-MSG-012
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_01_response_source_address``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_02
+ - TC8-MSG-013
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_02_method_id_msb_zero_in_response``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_04
+ - TC8-MSG-014
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_04_request_id_reuse``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_06
+ - TC8-MSG-015
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_06_interface_version_echoed``
+ * - §5.1.5.6 — SOMEIPSRV_ONWIRE_11
+ - TC8-MSG-016
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_onwire_11_normal_response_return_code_ok``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_18
+ - TC8-MSG-017
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_rpc_18_message_id_echoed``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_20
+ - TC8-MSG-018
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_rpc_20_interface_version_copied_from_request``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_05
+ - TC8-MSG-019
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_05_fire_and_forget_no_error``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_06
+ - TC8-MSG-020
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_06_return_code_upper_bits_zero``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_07
+ - TC8-MSG-021
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_07_request_with_return_code_bits_set``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_08
+ - TC8-MSG-022
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_08_request_with_error_return_code_no_reply``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_09
+ - TC8-MSG-023
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_09_error_response_no_payload``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_10
+ - TC8-MSG-024
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_10_fire_and_forget_reserved_type_no_error``
+ * - §5.1.6 — SOMEIP_ETS_004
+ - TC8-MSG-025
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_004_burst_10_sequential_requests``
+ * - §5.1.6 — SOMEIP_ETS_054
+ - TC8-MSG-026
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_054_empty_payload_request``
+ * - §5.1.6 — SOMEIP_ETS_059
+ - TC8-MSG-027
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_059_fire_and_forget_wrong_service_no_error``
+ * - §5.1.6 — SOMEIP_ETS_061
+ - TC8-MSG-028
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_061_two_sequential_requests``
+ * - §5.1.6 — SOMEIP_ETS_075
+ - TC8-MSG-029
+ - ``msg_malformed``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_075_notification_as_request_ignored``
+ * - §5.1.6 — SOMEIP_ETS_005
+ - TC8-MSG-030
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_ets_005_response_uses_big_endian_byte_order``
+ * - §5.1.6 — SOMEIP_ETS_058
+ - TC8-MSG-031
+ - ``msg_malformed``
+ - ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_ets_058_oversized_length_field_no_crash``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_19
+ - TC8-MSG-032
+ - ``msg_resp_header``
+ - ``test_someip_message_format::TestSomeipResponseFields::test_rpc_19_session_id_echoed_in_error``
+
+.. note::
+
+ TC8-MSG-022 (SOMEIPSRV_RPC_08) is expected to **FAIL** against vsomeip
+ 3.6.1: the stack replies to a REQUEST with non-zero return_code when the
+ spec requires no reply. See :ref:`known_stack_limitations`.
+
+ TC8-MSG-010 (SOMEIPSRV_BASIC_02) is expected to **FAIL** against vsomeip
+ 3.6.1: the stack silently drops unknown-service requests rather than
+ responding with E_UNKNOWN_SERVICE.
+
+Coverage Summary
+----------------
+
+All 189 internal TC8 test IDs are implemented and mapped to OA spec references.
+Some IDs are covered by multiple pytest functions (~215 total).
+Three tests are expected to fail against vsomeip 3.6.1 — see
+:ref:`known_stack_limitations`.
+
+For the full OA TC8 v3.0 Chapter 5 scope analysis (including blocked tests
+requiring the ETS application), see `TC8 Specification Alignment Analysis`_
+below.
+
+TC8 Specification Alignment Analysis
+-------------------------------------
+
+This section maps the 230 test cases in Chapter 5 of the
+`OPEN Alliance TC8 ECU Test Specification Layer 3-7 v3.0 (October 2019)
+`_
+to the current implementation status. It answers three questions for every
+TC8 group:
+
+1. **What is already tested and passing?**
+2. **What can be tested today without any new software?**
+3. **What requires new software before the tests can run?**
+
+For the full test case catalog see
+``tests/tc8_conformance/tc8_ecu_test_chapter5_someip_v3.0_oct2019.md``.
+
+The specification organizes Chapter 5 into two top-level groups:
+
+- **SOME/IP Server Tests** (``SOMEIPSRV_*``, 93 items, Section 5.1.5) —
+ wire-level protocol checks. Only ``someipd`` and a raw socket are needed.
+ No application code is required.
+- **Enhanced Testability Service Tests** (``SOMEIP_ETS_*``, 137 items,
+ Section 5.1.6) — behavior tests that range from wire-level SD tests
+ (needing only ``someipd``) to full-pipeline serialization tests that
+ require a C++ test application.
+
+Coverage at a Glance
+^^^^^^^^^^^^^^^^^^^^^
+
+The table below shows the top-level status for all five TC8 test groups.
+
+.. list-table::
+ :header-rows: 1
+ :widths: 32 8 9 10 41
+
+ * - TC8 Group
+ - Total
+ - ✅ Tested
+ - ⚠ Can add
+ - Infrastructure needed
+ * - SOMEIPSRV Protocol (§5.1.5)
+ - 93
+ - 93
+ - 0
+ - **N/A** — all wire-level tests complete
+ * - ETS SD Protocol (§5.1.6 SD)
+ - 74
+ - 60
+ - 0
+ - **14 tests blocked — ETS application required**
+ (ETS_089/096/097/103/146–151/164/166–168 require ETS C++ application)
+ * - ETS Robustness (§5.1.6 robustness)
+ - 14
+ - 14
+ - 0
+ - **N/A** — all tests complete
+ * - ETS Serialization / Echo (§5.1.6 echo)
+ - 44
+ - 0
+ - 0
+ - **ETS application + gatewayd** — see `ETS Application Gap`_
+ * - ETS Client / Control (§5.1.6 client)
+ - 5
+ - 3
+ - 0
+ - 2 of 5 require ETS control methods — see `ETS Application Gap`_
+
+**Key points:**
+
+- The first three groups (181 specification items total) need only ``someipd``
+ and the existing pytest framework. 167 of these items have
+ passing tests; 14 ETS SD items remain blocked pending the ETS C++ application
+ (see `ETS Application Gap`_).
+- The last two groups (49 tests total) are **blocked**. They cannot be
+ written until a C++ ETS test application is implemented. See
+ `ETS Application Gap`_ for what is needed.
+
+SOME/IP Server Tests Coverage
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+These 93 tests check the SOME/IP wire protocol at the byte level. The DUT is
+``someipd`` in standalone mode. Each test sends a raw UDP or TCP packet and
+checks the DUT's response. **No C++ application code or gatewayd is needed.**
+
+The table below uses these status labels:
+
+- **Complete** — every specification item in this category has a passing test.
+
+All 93 SOMEIPSRV specification items have passing tests across the seven
+subsections (SD Message Format, SD Options, SD Message Entries, SD Communication
+Behavior, Basic Service Identifiers, On-Wire Format, Remote Procedure Call).
+
+ETS Tests Coverage
+^^^^^^^^^^^^^^^^^^
+
+The ETS test cases split into two tracks based on what infrastructure they
+need.
+
+.. rubric:: Track A — Wire-level tests (88 items, pytest)
+
+These tests check the SOME/IP wire protocol directly. They use ``someipd``
+in standalone mode and send raw packets — exactly the same setup as the
+SOMEIPSRV tests above. All wire-level ETS tests are now implemented; 14
+tests in the ETS SD Protocol group remain blocked pending the ETS C++
+application.
+
+**ETS SD Protocol (74 items)**
+
+This group covers Service Discovery at the wire level: FindService,
+SubscribeEventgroup with various option types, NAck conditions, session ID
+behavior, TTL expiry, reboot detection, and multicast/unicast interactions.
+
+*Status: 60 of 74 implemented (14 blocked — require ETS application).*
+
+All wire-level ETS SD tests that can run without the ETS C++ application
+are now implemented. The 60 implemented tests cover session ID behavior,
+FindService responses, subscribe edge cases, malformed SD entries and
+options, TTL expiry, reboot detection, and multicast/unicast interactions.
+
+.. rubric:: ETS SD Protocol — Blocked Tests (14 items, require ETS application)
+
+The following 14 ETS SD test cases cannot be implemented without the ETS
+C++ application:
+
+- ``SOMEIP_ETS_089`` — ``suspendInterface`` control method required
+- ``SOMEIP_ETS_096`` — TCP connection prerequisite for subscription (needs
+ ETS app for TCP server)
+- ``SOMEIP_ETS_097`` — TCP reconnection recovery (needs ETS app for TCP
+ server)
+- ``SOMEIP_ETS_103`` — ``SD_ClientServiceGetLastValueOfEventTCP`` (TCP
+ event delivery, needs ETS app)
+- ``SOMEIP_ETS_146`` — ``resetInterface`` control method required
+- ``SOMEIP_ETS_147–151`` — ``triggerEvent*`` methods required (event push
+ triggers)
+- ``SOMEIP_ETS_164`` — ``suspendInterface`` control method required
+- ``SOMEIP_ETS_166–168`` — ``TestField*`` methods required (field
+ read/write via ETS app)
+
+These are tracked in `ETS Application Gap`_ and will be unblocked when the
+ETS C++ application is implemented.
+
+**ETS Robustness (14 items)**
+
+These tests send wrong protocol versions, wrong message types, wrong IDs,
+truncated messages, oversized messages, and unaligned packets.
+
+*Status: 14 of 14 implemented.*
+
+.. rubric:: Track B — Tests requiring an ETS application (49 items)
+
+.. _ETS Application Gap:
+
+These tests **cannot run yet** because they require a C++ test application
+that does not exist. The tests cannot be written until that application is
+built. This is the only infrastructure gap for ETS tests.
+
+**What is the ETS application?**
+
+It is a small C++ program (a ``score::mw::com`` Skeleton) that implements
+the TC8 service interface defined in Section 5.1.4 of the specification.
+The planned location is ``tests/tc8_conformance/application/`` (the
+directory structure and README are already in place, but no code exists yet).
+It must expose:
+
+- *Echo methods* — receive a value and return it unchanged
+ (``echoUINT8``, ``echoUINT8Array``, ``echoUTF8DYNAMIC``, ``echoUNION``,
+ and ~40 others). These let the tester verify that the full pipeline
+ (mw::com Skeleton → gatewayd → someipd → network) serializes every
+ SOME/IP data type correctly.
+- *Event triggers* — fire an event on demand
+ (``triggerEventUINT8``, ``triggerEventUINT8Reliable``, etc.)
+- *Field accessors* — getter, setter, and notifier for TC8 test fields
+- *Control methods* — ``resetInterface``, ``suspendInterface``,
+ ``clientServiceActivate`` / ``clientServiceDeactivate``
+
+**ETS Serialization / Echo (44 items)** ``SOMEIP_ETS_001–053, 063–073``
+
+The tester sends an echo request with a specific data value. The DUT must
+return the same value through the full pipeline. This validates the
+**Payload Transformation** component inside ``gatewayd``.
+
+*Status: 0 of 44 implemented.* These tests cannot be written until both the
+ETS application and the Payload Transformation component in gatewayd exist
+and are working correctly.
+
+Data types covered by echo tests: UINT8, INT8, INT64, FLOAT64, arrays
+(static and dynamic, 1D and 2D), strings (UTF-8 and UTF-16, fixed and
+dynamic length), unions, enums, bitfields, E2E-protected messages, and
+common data type combinations.
+
+**ETS Client / Control (5 items)**
+
+Three of these (``SOMEIP_ETS_081/082/084``) are already implemented in
+``test_sd_client.py`` because they only need wire-level SD messages. The
+remaining two (``SOMEIP_ETS_089/164``) use ``resetInterface`` and
+``suspendInterface`` control methods, which require the ETS application.
+
+*Status: 3 of 5 implemented; 2 blocked on ETS application.*
+
+.. _known_stack_limitations:
+
+Known SOME/IP Stack Limitations
+---------------------------------
+
+The following table records the known limitations of **vsomeip 3.6.1**
+against the OA TC8 v3.0 specification. This table must be reviewed and
+updated whenever the SOME/IP stack version changes.
+
+Each test listed here is decorated with ``@pytest.mark.xfail(strict=True)``
+so that CI passes despite the known non-conformance. ``strict=True`` ensures
+that if the limitation is fixed in a future stack version, the unexpected pass
+(XPASS) will cause CI to fail, prompting removal of the marker.
+
+.. list-table::
+ :header-rows: 1
+ :widths: 25 35 30 10
+
+ * - OA Spec Reference
+ - Specification Requirement
+ - vsomeip 3.6.1 Actual Behaviour
+ - Test Result
+ * - §5.1.5.3 — SOMEIPSRV_SD_MESSAGE_19
+ - SubscribeEventgroup with reserved bits set in the entry MUST be
+ responded to with a NAck (SubscribeEventgroupAck with TTL = 0).
+ - Sends a positive SubscribeEventgroupAck (TTL > 0) regardless of
+ reserved bits.
+ - **XFAIL** —
+ ``test_service_discovery::TestSDSubscribeNAck::test_sd_message_19_reserved_field_set``
+ * - §5.1.5.5 — SOMEIPSRV_BASIC_03
+ - When the DUT receives a message with method_id bit 15 = 1 (event
+ notification ID), it MUST NOT send a RESPONSE (message_type 0x80).
+ - Sends a RESPONSE (message_type 0x80) for event-ID messages even
+ though the spec prohibits it.
+ - **XFAIL** —
+ ``test_someip_message_format::TestSomeipBasicIdentifiers::test_basic_03_event_method_id_no_response``
+ * - §5.1.5.7 — SOMEIPSRV_RPC_08
+ - The DUT MUST NOT send a reply to a REQUEST message that already
+ carries a non-zero return code.
+ - Processes the REQUEST normally and sends a RESPONSE, ignoring the
+ return code field.
+ - **XFAIL** —
+ ``test_someip_message_format::TestSomeipFireAndForgetAndErrors::test_rpc_08_request_with_error_return_code_no_reply``
diff --git a/score/gateway_ipc_binding/README.md b/score/gateway_ipc_binding/README.md
index 7d37a27c1..0cc474510 100644
--- a/score/gateway_ipc_binding/README.md
+++ b/score/gateway_ipc_binding/README.md
@@ -1,17 +1,15 @@
-
+ SPDX-License-Identifier: Apache-2.0
+----------------------------------------------------------------------------- -->
# Gateway IPC Binding
diff --git a/score/gateway_ipc_binding/benchmark/README.md b/score/gateway_ipc_binding/benchmark/README.md
index 9705a7e3f..f4a03e61e 100644
--- a/score/gateway_ipc_binding/benchmark/README.md
+++ b/score/gateway_ipc_binding/benchmark/README.md
@@ -1,17 +1,15 @@
-
+ SPDX-License-Identifier: Apache-2.0
+----------------------------------------------------------------------------- -->
# Profiling Guide for gateway_ipc_binding
diff --git a/tests/benchmarks/benchmarks_analysis.md b/tests/benchmarks/benchmarks_analysis.md
index 666af2680..20673453d 100644
--- a/tests/benchmarks/benchmarks_analysis.md
+++ b/tests/benchmarks/benchmarks_analysis.md
@@ -1,17 +1,15 @@
-
+ SPDX-License-Identifier: Apache-2.0
+----------------------------------------------------------------------------- -->
# S-Core SOME/IP Gateway IPC Performance Analysis
diff --git a/tests/common/BUILD b/tests/common/BUILD
new file mode 100644
index 000000000..ec7247e6a
--- /dev/null
+++ b/tests/common/BUILD
@@ -0,0 +1,52 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+
+load("@rules_python//python:defs.bzl", "py_library")
+load("@score_tooling//:defs.bzl", "score_py_pytest")
+
+# ---------------------------------------------------------------------------
+# Shared test utilities — consumed by both tests/integration_test and
+# tests/tc8_conformance. imports=["."] adds tests/common/ to sys.path so
+# downstream code can do:
+# from capture import tcpdump_capture
+# ---------------------------------------------------------------------------
+
+filegroup(
+ name = "all_files",
+ srcs = glob(
+ ["**"],
+ exclude = [
+ "**/__pycache__/**",
+ "**/*.pyc",
+ ],
+ ),
+ visibility = ["//visibility:public"],
+)
+
+py_library(
+ name = "capture",
+ srcs = ["capture.py"],
+ imports = ["."],
+ target_compatible_with = ["@platforms//os:linux"],
+ visibility = ["//tests:__subpackages__"],
+)
+
+score_py_pytest(
+ name = "test_capture",
+ size = "small",
+ srcs = ["test_capture.py"],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ ":capture",
+ ],
+)
diff --git a/tests/common/capture.py b/tests/common/capture.py
new file mode 100644
index 000000000..c36a7bdd8
--- /dev/null
+++ b/tests/common/capture.py
@@ -0,0 +1,204 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+
+"""Host-side packet-capture helpers shared across test suites."""
+
+import io
+import os
+import pwd
+import shutil
+import signal
+import subprocess
+import time
+from typing import Any
+
+
+def as_text(output: Any) -> str:
+ """Decode bytes to str; pass str through unchanged."""
+ if isinstance(output, bytes):
+ return output.decode(errors="replace")
+ return str(output)
+
+
+def _get_content_of_file_object(file_object: io.BufferedReader | None) -> str:
+ """Read available bytes from *file_object* in non-blocking mode; empty string on None."""
+ if file_object is None:
+ return ""
+
+ # Non-blocking so read() returns immediately instead of waiting for more data.
+ os.set_blocking(file_object.fileno(), False)
+
+ data = file_object.read()
+ if data is None:
+ return ""
+ return data.decode(errors="replace")
+
+
+def get_output(process: subprocess.Popen[bytes]) -> str:
+ """Return combined stdout + stderr from *process* as a single string."""
+ return _get_content_of_file_object(process.stdout) + "\n, stderr: " + _get_content_of_file_object(process.stderr)
+
+
+def wait_until_process_exits(process: subprocess.Popen[bytes], timeout: float = 10.0) -> str:
+ """Poll *process* until it exits or *timeout* seconds elapse.
+
+ Returns the combined stdout+stderr output on success.
+ Raises ``TimeoutError`` if the process has not exited within *timeout*
+ seconds.
+ """
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ if process.poll() is not None:
+ return get_output(process)
+ time.sleep(0.5)
+ raise TimeoutError(f"Process did not exit within {timeout} seconds. Last output: {get_output(process)}")
+
+
+def stop_capture(
+ proc: subprocess.Popen[bytes],
+ timeout: float = 5.0,
+) -> bool:
+ """Send SIGINT to *proc* (tcpdump flushes the pcap cleanly on SIGINT), wait,
+ fall back to SIGKILL + pkill sweep on timeout.
+
+ After SIGKILL, ``pkill -9 -x tcpdump`` mops up any privilege-separation
+ child processes forked by tcpdump's -Z handling that survive a parent kill.
+
+ Returns True if the process exited cleanly (or was already gone), False if
+ SIGKILL was required.
+ """
+ if proc.poll() is not None:
+ return True # already exited
+
+ try:
+ proc.send_signal(signal.SIGINT)
+ except ProcessLookupError:
+ return True # process exited between poll() and send_signal()
+ except PermissionError:
+ return False # EPERM from -Z sandbox barrier; process likely still alive
+
+ try:
+ proc.wait(timeout=timeout)
+ return True
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ subprocess.run(["pkill", "-9", "-x", "tcpdump"], check=False)
+ return False
+
+
+class CaptureProcess:
+ """Context manager wrapper around a tcpdump Popen.
+
+ On context exit, sends SIGINT to flush the pcap cleanly, then falls back
+ to SIGKILL if the process does not terminate within the grace period.
+ If the pcap was written to a temporary /tmp path, it is moved to the
+ caller's requested destination after capture stops.
+ Direct attribute access (poll, kill, wait, returncode, …) is delegated to
+ the wrapped Popen so callers that store the object directly continue to work.
+ """
+
+ def __init__(
+ self,
+ proc: subprocess.Popen[bytes],
+ tmp_pcap: str | None = None,
+ final_pcap: str | None = None,
+ ) -> None:
+ self._proc = proc
+ self._tmp_pcap = tmp_pcap
+ self._final_pcap = final_pcap
+
+ def __enter__(self) -> subprocess.Popen[bytes]:
+ return self._proc
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc: BaseException | None,
+ tb: Any,
+ ) -> None:
+ stop_capture(self._proc)
+ if self._tmp_pcap and self._final_pcap and self._tmp_pcap != self._final_pcap:
+ try:
+ shutil.move(self._tmp_pcap, self._final_pcap)
+ except OSError:
+ pass # best-effort; destination may be in a restricted sandbox path
+
+ def __getattr__(self, name: str) -> Any: # noqa: ANN401
+ return getattr(self._proc, name)
+
+
+def tcpdump_capture(
+ filter_expression: str,
+ packet_count: int | None = None,
+ output_file: str | None = None,
+) -> CaptureProcess:
+ """Start tcpdump on the host and return a CaptureProcess context manager.
+
+ Args:
+ filter_expression: BPF filter string.
+ packet_count: Exit after this many packets; omit when using stop_capture.
+ output_file: Move the completed pcap to this path after capture stops;
+ None streams text to stdout.
+
+ Raises:
+ RuntimeError: tcpdump exited immediately (missing binary or CAP_NET_RAW).
+ """
+ # Pass -Z so tcpdump drops to the process's own uid rather
+ # than its compiled-in default user, which fails in CI without CAP_SETUID.
+ try:
+ _z_user = pwd.getpwuid(os.getuid()).pw_name
+ except KeyError:
+ # uid has no /etc/passwd entry (minimal container); use numeric fallback
+ _z_user = "root" if os.getuid() == 0 else str(os.getuid())
+
+ args = [
+ "/usr/bin/tcpdump",
+ "-n",
+ "-i",
+ "any",
+ "-Z",
+ _z_user,
+ ]
+
+ # Write pcap to /tmp, which is writable in all sandbox configurations.
+ # CaptureProcess.__exit__ moves it to output_file after capture stops.
+ tmp_pcap: str | None = None
+ if output_file is not None:
+ tmp_pcap = f"/tmp/tcpdump_{os.urandom(8).hex()}.pcap"
+ args.extend(["-U", "-w", tmp_pcap]) # -U: packet-buffered
+ else:
+ args.append("-l") # line-buffered output for text mode
+
+ if packet_count is not None:
+ args.extend(["-c", str(packet_count)])
+ if filter_expression:
+ args.append(filter_expression)
+
+ proc = subprocess.Popen(
+ args,
+ stdout=subprocess.PIPE if output_file is None else subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ )
+
+ # tcpdump exits within milliseconds if CAP_NET_RAW is missing or binary absent.
+ time.sleep(0.2)
+ if proc.poll() is not None and proc.returncode != 0:
+ stderr_text = _get_content_of_file_object(proc.stderr)
+ raise RuntimeError(
+ f"tcpdump failed to start (exit code {proc.returncode}). "
+ f"Check that /usr/bin/tcpdump exists and the process has "
+ f"CAP_NET_RAW capability. stderr: {stderr_text}"
+ )
+
+ return CaptureProcess(proc, tmp_pcap=tmp_pcap, final_pcap=output_file)
diff --git a/tests/common/test_capture.py b/tests/common/test_capture.py
new file mode 100644
index 000000000..7e780621d
--- /dev/null
+++ b/tests/common/test_capture.py
@@ -0,0 +1,500 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+
+"""Unit tests for tests/common/capture.py.
+
+Covers as_text, _get_content_of_file_object, get_output,
+wait_until_process_exits, stop_capture (ProcessLookupError branch, SIGINT
+path, SIGKILL fallback + pkill sweep), and the CAP_NET_RAW startup-failure path.
+All tests are host-only (no QEMU, no network, no CAP_NET_RAW required).
+"""
+
+import io
+import os
+import subprocess
+
+import pytest
+
+import signal
+
+from capture import (
+ as_text,
+ _get_content_of_file_object,
+ get_output,
+ stop_capture,
+ tcpdump_capture,
+ wait_until_process_exits,
+)
+
+
+# ---------------------------------------------------------------------------
+# as_text
+# ---------------------------------------------------------------------------
+
+
+def test_as_text_decodes_bytes() -> None:
+ """Bytes are decoded to str with UTF-8 (replacement on error)."""
+ assert as_text(b"hello") == "hello"
+
+
+def test_as_text_passes_str_through() -> None:
+ """Plain str is returned unchanged."""
+ assert as_text("world") == "world"
+
+
+def test_as_text_converts_other_types() -> None:
+ """Non-str, non-bytes values are converted via str()."""
+ assert as_text(42) == "42"
+
+
+def test_as_text_handles_invalid_utf8() -> None:
+ """Invalid UTF-8 bytes are replaced rather than raising."""
+ result = as_text(b"\xff\xfe")
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# _get_content_of_file_object
+# ---------------------------------------------------------------------------
+
+
+def test_get_content_of_file_object_none_returns_empty() -> None:
+ """None input returns an empty string without raising."""
+ assert _get_content_of_file_object(None) == ""
+
+
+def test_get_content_of_file_object_reads_pipe() -> None:
+ """Content written to a pipe is read back as a str."""
+ proc = subprocess.Popen(
+ ["echo", "-n", "capture_test"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ proc.wait()
+ result = _get_content_of_file_object(proc.stdout)
+ assert "capture_test" in result
+
+
+# ---------------------------------------------------------------------------
+# get_output
+# ---------------------------------------------------------------------------
+
+
+def test_get_output_includes_stdout_and_stderr() -> None:
+ """get_output returns a string containing both stdout and stderr content."""
+ proc = subprocess.Popen(
+ ["sh", "-c", "echo out; echo err >&2"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ proc.wait()
+ output = get_output(proc)
+ assert "out" in output
+ assert "err" in output
+
+
+def test_get_output_empty_process() -> None:
+ """A process that writes nothing returns a string (may be empty or whitespace)."""
+ proc = subprocess.Popen(
+ ["true"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ proc.wait()
+ result = get_output(proc)
+ assert isinstance(result, str)
+
+
+# ---------------------------------------------------------------------------
+# wait_until_process_exits
+# ---------------------------------------------------------------------------
+
+
+def test_wait_until_process_exits_returns_output() -> None:
+ """A process that exits immediately returns its output as a str."""
+ proc = subprocess.Popen(
+ ["echo", "done"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ output = wait_until_process_exits(proc, timeout=5.0)
+ assert isinstance(output, str)
+ assert proc.poll() is not None
+
+
+def test_wait_until_process_exits_raises_on_timeout() -> None:
+ """TimeoutError is raised when the process does not exit in time."""
+ proc = subprocess.Popen(
+ ["sleep", "60"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ try:
+ with pytest.raises(TimeoutError):
+ wait_until_process_exits(proc, timeout=0.5)
+ finally:
+ proc.kill()
+ proc.wait()
+
+
+# ---------------------------------------------------------------------------
+# tcpdump_capture — argument validation and flag verification
+# ---------------------------------------------------------------------------
+
+
+def test_tcpdump_capture_output_file_without_packet_count_does_not_raise(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """No error when output_file is set and packet_count is omitted; stop_capture handles teardown."""
+ import capture as capture_module # noqa: PLC0415
+
+ original_popen = capture_module.subprocess.Popen
+
+ def _popen_sleep(args: list, **kwargs): # type: ignore[override]
+ return original_popen(["sleep", "5"], **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_sleep)
+
+ proc = tcpdump_capture("icmp", output_file="/tmp/test.pcap")
+ proc.kill()
+ proc.wait()
+
+
+def test_tcpdump_capture_pcap_mode_includes_dash_u_flag(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """-U (packet-buffered) is included when output_file is set."""
+ import capture as capture_module # noqa: PLC0415
+
+ original_popen = capture_module.subprocess.Popen
+ captured_args: list[list[str]] = []
+
+ def _popen_record(args: list, **kwargs): # type: ignore[override]
+ captured_args.append(list(args))
+ return original_popen(["sleep", "5"], **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record)
+
+ proc = tcpdump_capture("icmp", output_file="/tmp/test.pcap")
+ proc.kill()
+ proc.wait()
+
+ assert captured_args, "Popen was not called"
+ assert "-U" in captured_args[0], f"-U flag missing from tcpdump args: {captured_args[0]}"
+
+
+def test_tcpdump_capture_text_mode_excludes_dash_u_flag(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """-U flag is NOT added in text mode (no output_file).
+
+ In text mode stdout=PIPE is used; -U is a pcap-specific flag.
+ """
+ import capture as capture_module # noqa: PLC0415
+
+ original_popen = capture_module.subprocess.Popen
+ captured_args: list[list[str]] = []
+
+ def _popen_record(args: list, **kwargs): # type: ignore[override]
+ captured_args.append(list(args))
+ return original_popen(["sleep", "5"], **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record)
+
+ proc = tcpdump_capture("icmp")
+ proc.kill()
+ proc.wait()
+
+ assert captured_args, "Popen was not called"
+ assert "-U" not in captured_args[0], f"-U should not be in text-mode args: {captured_args[0]}"
+
+
+def test_tcpdump_capture_includes_z_flag_with_current_user(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """-Z is passed with the current username so tcpdump does not attempt to drop to its compiled-in default user (fails without CAP_SETUID in CI)."""
+ import os
+ import pwd
+ import capture as capture_module # noqa: PLC0415
+
+ original_popen = capture_module.subprocess.Popen
+ captured_args: list[list[str]] = []
+
+ def _popen_record(args: list, **kwargs): # type: ignore[override]
+ captured_args.append(list(args))
+ return original_popen(["sleep", "5"], **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record)
+
+ proc = tcpdump_capture("icmp")
+ proc.kill()
+ proc.wait()
+
+ assert captured_args, "Popen was not called"
+ args = captured_args[0]
+ assert "-Z" in args, f"-Z flag missing from tcpdump args: {args}"
+
+ z_index = args.index("-Z")
+ assert z_index + 1 < len(args), "-Z flag has no argument"
+ z_user = args[z_index + 1]
+
+ # The -Z argument must be the current user's name (prevents privilege drop
+ # to the compiled-in tcpdump user).
+ try:
+ expected_user = pwd.getpwuid(os.getuid()).pw_name
+ except KeyError:
+ expected_user = "root" if os.getuid() == 0 else str(os.getuid())
+
+ assert z_user == expected_user, f"-Z argument is '{z_user}', expected '{expected_user}'"
+
+
+def test_tcpdump_capture_pcap_written_under_tmp(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """When output_file is set, tcpdump is told to write to /tmp (always writable).
+
+ Writing directly to TEST_UNDECLARED_OUTPUTS_DIR fails in CI because the
+ bind-mounted testlogs path becomes unwritable after tcpdump's -Z credential drop.
+ /tmp is writable in all sandbox configurations (Linux + QNX8).
+ """
+ import capture as capture_module # noqa: PLC0415
+
+ original_popen = capture_module.subprocess.Popen
+ captured_args: list[list[str]] = []
+
+ def _popen_record(args: list, **kwargs): # type: ignore[override]
+ captured_args.append(list(args))
+ return original_popen(["sleep", "5"], **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_record)
+
+ proc = tcpdump_capture("icmp", output_file="/some/output/dir/test.pcap")
+ proc.kill()
+ proc.wait()
+
+ assert captured_args, "Popen was not called"
+ args = captured_args[0]
+ w_index = args.index("-w")
+ pcap_path = args[w_index + 1]
+ assert pcap_path.startswith("/tmp/"), f"tcpdump pcap path should be under /tmp, got: {pcap_path}"
+
+
+# ---------------------------------------------------------------------------
+# stop_capture
+# ---------------------------------------------------------------------------
+
+
+def test_stop_capture_returns_true_for_already_exited_process() -> None:
+ """stop_capture returns True immediately when the process has already exited."""
+ proc = subprocess.Popen(
+ ["true"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ proc.wait()
+ result = stop_capture(proc, timeout=2.0)
+ assert result is True
+
+
+def test_stop_capture_handles_permission_error() -> None:
+ """PermissionError from proc.send_signal (EPERM in sandbox) returns False.
+
+ EPERM means the -Z privilege-drop created a signaling barrier; the process
+ is likely still alive (unlike ProcessLookupError, which returns True).
+ """
+ proc = subprocess.Popen(
+ ["sleep", "60"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ original_send_signal = proc.send_signal
+
+ def _raise_eperm(sig: int) -> None:
+ raise PermissionError("Operation not permitted (simulated)")
+
+ proc.send_signal = _raise_eperm # type: ignore[method-assign]
+
+ result = stop_capture(proc, timeout=1.0)
+ assert result is False
+
+ # Process is still alive (signal was faked); restore and kill for real.
+ proc.send_signal = original_send_signal # type: ignore[method-assign]
+ proc.kill()
+ proc.wait()
+
+
+def test_stop_capture_handles_process_lookup_error() -> None:
+ """ProcessLookupError from proc.send_signal is treated as 'process already gone'.
+
+ Covers the race where the process exits between poll() returning None
+ and the send_signal() call. The correct response is True (not an exception).
+ """
+ proc = subprocess.Popen(
+ ["sleep", "60"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+
+ original_send_signal = proc.send_signal
+
+ def _raise_esrch(sig: int) -> None:
+ raise ProcessLookupError("No such process (simulated)")
+
+ proc.send_signal = _raise_esrch # type: ignore[method-assign]
+
+ result = stop_capture(proc, timeout=1.0)
+ assert result is True
+
+ # Process is still alive (signal was faked); restore and kill for real.
+ proc.send_signal = original_send_signal # type: ignore[method-assign]
+ proc.kill()
+ proc.wait()
+
+
+def test_stop_capture_graceful_sigint_returns_true() -> None:
+ """stop_capture sends SIGINT; the process exits cleanly, returns True."""
+ proc = subprocess.Popen(
+ ["sleep", "60"],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ result = stop_capture(proc, timeout=2.0)
+ assert result is True
+ assert proc.poll() is not None, "process should have exited after stop_capture"
+
+
+def test_stop_capture_kills_on_timeout_returns_false() -> None:
+ """stop_capture falls back to SIGKILL when SIGINT is ignored; returns False.
+
+ A pipe gates SIGINT until SIG_IGN is installed, avoiding a startup race.
+ start_new_session=True isolates the child so SIGKILL does not reach the
+ test runner's process group.
+ """
+ r_fd, w_fd = os.pipe()
+ proc = subprocess.Popen(
+ [
+ "python3",
+ "-c",
+ (
+ "import signal, time, os; "
+ "signal.signal(signal.SIGINT, signal.SIG_IGN); "
+ f"os.write({w_fd}, b'r'); "
+ "time.sleep(60)"
+ ),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ pass_fds=(w_fd,),
+ start_new_session=True,
+ )
+ os.close(w_fd)
+ os.read(r_fd, 1) # Block until child has installed SIG_IGN.
+ os.close(r_fd)
+
+ result = stop_capture(proc, timeout=0.3)
+ assert result is False
+ assert proc.poll() is not None, "process should be dead after SIGKILL fallback"
+
+
+def test_stop_capture_sweeps_orphans_with_pkill(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """stop_capture calls pkill -9 -x tcpdump after SIGKILL to sweep orphan children.
+
+ tcpdump's -Z privilege-separation may fork a child that survives when only
+ the parent PID is killed. pkill by name cleans up such orphans without
+ requiring process-group targeting (which raises EPERM in the CI sandbox).
+ """
+ import capture as capture_module # noqa: PLC0415
+
+ pkill_calls: list[list[str]] = []
+ original_run = capture_module.subprocess.run
+
+ def _record_run(cmd: list, **kwargs): # type: ignore[override]
+ pkill_calls.append(list(cmd))
+ return original_run(cmd, **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "run", _record_run)
+
+ r_fd, w_fd = os.pipe()
+ proc = subprocess.Popen(
+ [
+ "python3",
+ "-c",
+ (
+ "import signal, time, os; "
+ "signal.signal(signal.SIGINT, signal.SIG_IGN); "
+ f"os.write({w_fd}, b'r'); "
+ "time.sleep(60)"
+ ),
+ ],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ pass_fds=(w_fd,),
+ start_new_session=True,
+ )
+ os.close(w_fd)
+ os.read(r_fd, 1)
+ os.close(r_fd)
+
+ result = stop_capture(proc, timeout=0.3)
+ assert result is False
+
+ pkill_invoked = any(
+ len(cmd) >= 4 and cmd[0] == "pkill" and "-9" in cmd and "-x" in cmd and "tcpdump" in cmd for cmd in pkill_calls
+ )
+ assert pkill_invoked, f"pkill -9 -x tcpdump not called after SIGKILL; subprocess.run calls: {pkill_calls}"
+
+
+# ---------------------------------------------------------------------------
+# tcpdump_capture — CAP_NET_RAW startup-failure detection
+# ---------------------------------------------------------------------------
+
+
+def test_tcpdump_capture_raises_on_immediate_exit(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """RuntimeError is raised when tcpdump exits immediately (CAP_NET_RAW denied or binary absent)."""
+ import capture as capture_module # noqa: PLC0415
+
+ original_popen = capture_module.subprocess.Popen
+
+ def _popen_false(args: list, **kwargs): # type: ignore[override]
+ return original_popen(["/usr/bin/false"], **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_false)
+
+ with pytest.raises(RuntimeError, match="tcpdump failed to start"):
+ tcpdump_capture("icmp")
+
+
+def test_tcpdump_capture_does_not_raise_on_immediate_clean_exit(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """No RuntimeError when tcpdump exits immediately with code 0 (e.g. packet_count=1 captured instantly).
+
+ A clean zero-exit within the 0.2 s window is a valid completion (packet_count
+ satisfied), NOT a startup failure. Only non-zero exits indicate errors.
+ """
+ import capture as capture_module # noqa: PLC0415
+
+ original_popen = capture_module.subprocess.Popen
+
+ def _popen_true(args: list, **kwargs): # type: ignore[override]
+ return original_popen(["true"], **kwargs)
+
+ monkeypatch.setattr(capture_module.subprocess, "Popen", _popen_true)
+
+ proc = tcpdump_capture("icmp")
+ proc.wait()
diff --git a/tests/integration_test/README.md b/tests/integration_test/README.md
index fd87b48ef..eac5c1505 100644
--- a/tests/integration_test/README.md
+++ b/tests/integration_test/README.md
@@ -1,17 +1,15 @@
-
+ SPDX-License-Identifier: Apache-2.0
+----------------------------------------------------------------------------- -->
# Integration Tests
diff --git a/tests/integration_test/conftest.py b/tests/integration_test/conftest.py
index b243e9c07..3aff900ef 100644
--- a/tests/integration_test/conftest.py
+++ b/tests/integration_test/conftest.py
@@ -16,6 +16,7 @@
Pytest configuration and fixtures for integration tests.
"""
+import logging
import os
import pytest
from typing import Generator
@@ -40,15 +41,17 @@ def clean_state(target: Target) -> Generator[Target, None, None]:
def gatewayd_with_someipd(clean_state: Target) -> Generator[Target, None, None]:
"""Start someipd and gatewayd before tests and stop them after."""
- # TODO tcpdump cannot be killed when the test is done. Need to figure out why
-
- # Store test traffic in file for later analysis of failures
+ # Store test traffic in file for later analysis of failures.
+ # tcpdump is kept alive for the entire fixture scope; the sandbox tears it
+ # down on exit so we do not call stop_capture here.
pcap_dir = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR", ".")
pcap_file = os.path.join(pcap_dir, "test_traffic.pcap")
- # no with statement is used by intention to avoid permission denied error when killing tcpdump
- tcpdump_host = tcpdump_capture("", output_file=pcap_file)
- tcpdump_process = tcpdump_host.__enter__()
+ try:
+ tcpdump_host = tcpdump_capture("", output_file=pcap_file)
+ tcpdump_host.__enter__()
+ except RuntimeError:
+ logging.warning("tcpdump could not start; pcap capture skipped")
with ShellProcess(
clean_state,
diff --git a/tests/tc8_conformance/BUILD.bazel b/tests/tc8_conformance/BUILD.bazel
new file mode 100644
index 000000000..df90f37da
--- /dev/null
+++ b/tests/tc8_conformance/BUILD.bazel
@@ -0,0 +1,312 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+
+load("@bazel_skylib//rules:native_binary.bzl", "native_binary")
+load("@rules_pkg//pkg:mappings.bzl", "pkg_attributes", "pkg_files")
+load("@rules_python//python:defs.bzl", "py_library")
+load("@score_someip_gateway//bazel/tools:someip_config.bzl", "generate_someip_config_bin")
+load("//bazel/tools:json_schema_validator.bzl", "validate_json_schema_test")
+load("//quality/integration_testing:integration_testing.bzl", "integration_test")
+
+filegroup(
+ name = "all_files",
+ srcs = glob(
+ ["**"],
+ exclude = [
+ "**/__pycache__/**",
+ "**/*.pyc",
+ ],
+ ),
+ visibility = ["//visibility:public"],
+)
+
+# Linux only: QNX has no Python toolchain and is excluded from the QNX build sweep.
+py_library(
+ name = "tc8-helpers-lib",
+ srcs = glob(["helpers/*.py"]),
+ imports = ["."],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ "@score_someip_gateway_pip//someip",
+ ],
+)
+
+pkg_files(
+ name = "tc8-vsomeip-runtime-pkg",
+ srcs = [
+ "@vsomeip",
+ ],
+ attributes = pkg_attributes(
+ mode = "0777",
+ ),
+ prefix = "/usr",
+)
+
+generate_someip_config_bin(
+ name = "tc8_someipd_config",
+ json = "config/tc8_someipd_config.json",
+ output = "tc8_someipd_config.bin",
+)
+
+native_binary(
+ name = "someipd_tc8",
+ src = "//score/someipd",
+ out = "someipd",
+)
+
+native_binary(
+ name = "gatewayd_tc8",
+ src = "//score/gatewayd",
+ out = "gatewayd",
+)
+
+pkg_files(
+ name = "tc8-itf-pkg",
+ srcs = [
+ "config/tc8_gatewayd_mw_com_config.json",
+ "config/tc8_logging.json",
+ "config/tc8_someipd_multi.json",
+ "config/tc8_someipd_sd.json",
+ "config/tc8_someipd_service.json",
+ ":gatewayd_tc8",
+ ":someipd_tc8",
+ ":tc8-vsomeip-runtime-pkg",
+ ":tc8_someipd_config",
+ "//score/serializer:null_serializer",
+ "//tests/tc8_conformance/application/ets_stub:config/tc8_ets_stub_mw_com_config.json",
+ "//tests/tc8_conformance/application/ets_stub:tc8_ets_stub",
+ ],
+ attributes = pkg_attributes(
+ mode = "0777",
+ ),
+ prefix = "/",
+ renames = {
+ "//tests/tc8_conformance/application/ets_stub:config/tc8_ets_stub_mw_com_config.json": "tc8_ets_stub_mw_com_config.json",
+ "config/tc8_gatewayd_mw_com_config.json": "tc8_gatewayd_mw_com_config.json",
+ "config/tc8_someipd_multi.json": "tc8_multi.json.tmpl",
+ "config/tc8_someipd_sd.json": "tc8_sd.json.tmpl",
+ "config/tc8_logging.json": "tc8_logging.json",
+ "config/tc8_someipd_service.json": "tc8_service.json.tmpl",
+ "tc8_someipd_config.bin": "tc8_someipd_config.bin",
+ "//score/serializer:null_serializer": "usr/lib/score_com_serializer.so",
+ },
+)
+
+# Linux only: QNX has no Python toolchain and is excluded from the QNX build sweep.
+py_library(
+ name = "tc8-itf-lib",
+ srcs = ["tc8_itf_conftest.py"],
+ imports = ["."],
+ target_compatible_with = ["@platforms//os:linux"],
+ deps = [
+ ":tc8-helpers-lib",
+ "@score_someip_gateway_pip//someip",
+ ],
+)
+
+_ITF_ARGS = [
+ "--color=yes",
+ "-p",
+ "tc8_itf_conftest",
+]
+
+_ITF_DEPS = [
+ ":tc8-itf-lib",
+ "//tests/common:capture",
+ "@score_tooling//python_basics/score_pytest:attribute_plugin",
+]
+
+integration_test(
+ name = "test_tc8_service_discovery",
+ timeout = "long",
+ srcs = [
+ "test_service_discovery.py",
+ ],
+ args = _ITF_ARGS,
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+# exclusive: timing sensitive capture
+integration_test(
+ name = "test_tc8_sd_phases_timing",
+ timeout = "long",
+ srcs = [
+ "test_sd_phases_timing.py",
+ ],
+ args = _ITF_ARGS,
+ data = glob(["config/*.json"]),
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "exclusive",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+# exclusive: lifecycle sensitive reboot detection
+integration_test(
+ name = "test_tc8_sd_reboot",
+ timeout = "long",
+ srcs = [
+ "test_sd_reboot.py",
+ ],
+ args = _ITF_ARGS,
+ data = glob(["config/*.json"]),
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "exclusive",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+integration_test(
+ name = "test_tc8_sd_format_compliance",
+ timeout = "long",
+ srcs = [
+ "test_sd_format_compliance.py",
+ ],
+ args = _ITF_ARGS,
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+integration_test(
+ name = "test_tc8_sd_robustness",
+ timeout = "long",
+ srcs = [
+ "test_sd_robustness.py",
+ ],
+ args = _ITF_ARGS,
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+# exclusive: manages DUT lifecycle directly
+integration_test(
+ name = "test_tc8_sd_client",
+ timeout = "long",
+ srcs = [
+ "test_sd_client.py",
+ ],
+ args = _ITF_ARGS,
+ data = glob(["config/*.json"]),
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "exclusive",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+integration_test(
+ name = "test_tc8_someip_message_format",
+ timeout = "long",
+ srcs = [
+ "test_someip_message_format.py",
+ ],
+ args = _ITF_ARGS,
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+integration_test(
+ name = "test_tc8_event_notification",
+ timeout = "long",
+ srcs = [
+ "test_event_notification.py",
+ ],
+ args = _ITF_ARGS,
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+integration_test(
+ name = "test_tc8_field_conformance",
+ timeout = "long",
+ srcs = [
+ "test_field_conformance.py",
+ ],
+ args = _ITF_ARGS,
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+integration_test(
+ name = "test_tc8_multi_service",
+ timeout = "long",
+ srcs = [
+ "test_multi_service.py",
+ ],
+ args = _ITF_ARGS,
+ filesystem = ":tc8-itf-pkg",
+ tags = [
+ "conformance",
+ "tc8",
+ ],
+ deps = _ITF_DEPS,
+)
+
+[validate_json_schema_test(
+ name = cfg.replace(".json", "_schema_valid"),
+ size = "small",
+ json = "config/" + cfg,
+ schema = "config/tc8_vsomeip_config.schema.json",
+ tags = [
+ "lint",
+ "tc8",
+ ],
+) for cfg in [
+ "tc8_someipd_sd.json",
+ "tc8_someipd_service.json",
+ "tc8_someipd_multi.json",
+]]
+
+validate_json_schema_test(
+ name = "tc8_someipd_config_schema_valid",
+ size = "small",
+ json = "config/tc8_someipd_config.json",
+ schema = "//score/config:config_schema",
+ tags = [
+ "lint",
+ "tc8",
+ ],
+)
diff --git a/tests/tc8_conformance/README.md b/tests/tc8_conformance/README.md
new file mode 100644
index 000000000..75f82225b
--- /dev/null
+++ b/tests/tc8_conformance/README.md
@@ -0,0 +1,203 @@
+
+
+# TC8 SOME/IP Conformance Tests
+
+Tests for the S-CORE SOME/IP Gateway based on
+[OPEN Alliance TC8](https://www.opensig.org/about/specifications/).
+
+For architecture diagrams and design rationale, see
+`docs/architecture/tc8_conformance_testing.rst`.
+
+## Test Scopes
+
+| Scope | Description | DUT | Status |
+|---|---|---|---|
+| **Protocol Conformance** | Wire-level SOME/IP (SD, messages, events, fields) | `someipd` standalone | Implemented (SD + MSG + EVT + FLD) |
+| **Application-Level Tests** | End-to-end via mw::com through the gateway | gatewayd + someipd + C++ apps | [Planned](application/README.md) |
+
+Protocol conformance tests send/receive raw UDP packets using the Python `someip` library.
+Application-level tests use C++ `mw::com` applications and work with any SOME/IP binding.
+
+For design rationale and UML dependency diagrams see
+`docs/architecture/tc8_conformance_testing.rst`. For specification alignment
+analysis, coverage status, and known stack limitations see
+`docs/tc8_conformance/traceability.rst`.
+
+## Quick Start
+
+```bash
+# Run all TC8 conformance tests (Linux)
+bazel test --config=tc8-itf //tests/tc8_conformance/...
+
+# Run a specific target
+bazel test --config=tc8-itf //tests/tc8_conformance:test_tc8_service_discovery
+
+# Run on QNX x86_64
+bazel test --config=tc8-itf-qnx //tests/tc8_conformance/...
+```
+
+## Network Setup
+
+Default port values and IP addresses are defined in `tc8_itf_conftest.py`.
+
+| Parameter | Default value | Source |
+|---|---|---|
+| SD multicast port (`TC8_SD_PORT`) | 30490 | `tc8_itf_conftest.py` |
+| Service UDP port (`TC8_SVC_PORT`) | 30509 | `tc8_itf_conftest.py` |
+| Service TCP port (`TC8_SVC_TCP_PORT`) | 30510 | `tc8_itf_conftest.py` |
+| Host TAP IP (`TC8_TESTER_IP`) | auto-configured | `tc8_itf_conftest.py` |
+| QEMU guest IP (`TC8_DUT_IP`) | auto-configured | `tc8_itf_conftest.py` |
+
+## Configuration Templates
+
+Each TC8 test area uses a SOME/IP stack config template. The DUT fixture
+calls `render_someip_config()` to replace `__TC8_HOST_IP__` and `__TC8_SD_PORT__`
+placeholders with the test host IP and a dynamically allocated SD port before
+starting `someipd`.
+
+| Template | Used by | Key differences |
+|---|---|---|
+| `config/tc8_someipd_sd.json` | SD, SD-phases | Event `0x0777` (`is_field: true`, 2 s cycle), eventgroup `0x4455`, `cyclic_offer_delay=2000ms`, initial delay 10-100 ms, repetitions max 3; no TCP reliable port |
+| `config/tc8_someipd_service.json` | MSG, EVT, FLD, TCP | Both events (0x0777 field + 0x0778 TCP-reliable), all 3 eventgroups (UDP 0x4455, multicast 0x4465, TCP 0x4475), TCP reliable port 30510, `cyclic_offer_delay=500ms` |
+| `config/tc8_someipd_multi.json` | Multi-service | Two service entries for multi-service/instance config validation |
+| `config/tc8_someipd_config.schema.json` | (all configs) | JSON Schema for validating TC8 vsomeip config templates |
+
+### Common Parameters
+
+| Parameter | Value | Purpose |
+|---|---|---|
+| Service ID | `0x1234` | DUT test service |
+| Instance ID | `0x5678` | DUT test instance |
+| SD multicast | `224.244.224.245` | SD capture endpoint |
+| SD port | Dynamic (session-scoped) | Allocated at session start; enables parallel execution |
+| Service UDP port | `30509` | SOME/IP data endpoint |
+| Service TCP port | `30510` | TCP transport endpoint |
+| `initial_delay_min/max` | 10-100 ms | SD phase tests |
+| `ttl` | 30 s | Prevents expiry mid-test |
+
+### Creating a New Config
+
+Copy `tc8_someipd_sd.json` and change only the parameters that differ.
+Keep `__TC8_HOST_IP__` and `__TC8_SD_PORT__` as placeholders so the fixture
+can render them at test time.
+
+## Helper Modules
+
+| Module | Purpose |
+|---|---|
+| `helpers/sd_helpers.py` | SD multicast capture and `SOMEIPSDEntry` parsing |
+| `helpers/sd_sender.py` | SD packet building (Find, Subscribe), unicast capture, auto-incrementing session IDs |
+| `helpers/someip_assertions.py` | Field-level assertions for SD entries and SOME/IP response headers |
+| `helpers/timing.py` | Timestamped offer capture for phase/cyclic analysis |
+| `helpers/message_builder.py` | SOME/IP REQUEST/REQUEST_NO_RETURN packet construction and malformed packets |
+| `helpers/event_helpers.py` | Event subscription (subscribe + wait Ack) and NOTIFICATION capture |
+| `helpers/field_helpers.py` | Field GET/SET request helpers over UDP |
+| `helpers/sd_malformed.py` | Malformed SD packet builders for robustness tests |
+| `helpers/tcp_helpers.py` | TCP transport helpers (reliable binding, stream framing) |
+| `helpers/udp_helpers.py` | UDP transport helpers (unreliable binding, length-field framing) |
+| `helpers/dut_lifecycle.py` | DUT launch and teardown helpers for ITF and standalone modes |
+
+## Adding a New Test
+
+Every new test follows this pattern:
+
+1. **Create a config template** — copy `config/tc8_someipd_sd.json`, keep the
+ `__TC8_HOST_IP__` placeholder, and adjust service/event/timing parameters.
+
+2. **Add or extend helpers** — put reusable socket operations, packet builders,
+ and assertions in `helpers/`. Reuse existing helpers; do not duplicate.
+
+3. **Write the test module** — name it `test_.py`. Set `SOMEIP_CONFIG`
+ so the `someipd_dut` fixture picks up the right config:
+
+ ```python
+ SOMEIP_CONFIG = "tc8_someipd_service.json"
+ ```
+
+ Decorate each test with `@add_test_properties` from `score_pytest` for
+ ISO 26262 traceability:
+
+ ```python
+ from attribute_plugin import add_test_properties
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_xxx(self, ...) -> None:
+ """Docstring is mandatory (enforced by the plugin)."""
+ ```
+
+4. **Register a Bazel target** — add an `integration_test()` entry in `BUILD.bazel`
+ following the pattern of existing entries, with tags `["tc8", "conformance"]`.
+
+5. **Add a requirement** — create a `comp_req` in
+ `docs/tc8_conformance/requirements.rst` referencing the TC8 clause.
+
+### Helper Conventions
+
+- One concern per module.
+- Public functions only — no classes unless a `contextmanager` or `dataclass` is genuinely simpler.
+- Callers close returned sockets (or use a `with`/`contextmanager` wrapper).
+- Default timeouts: 5 s for single-message capture, 20 s for multi-message timing.
+- Use `someip.header` for parsing and building. See `sd_sender.py` for the canonical pattern.
+
+## Directory Structure
+
+```
+tests/tc8_conformance/
+├── BUILD.bazel # integration_test() targets (one per TC8 test file)
+├── README.md # This file
+├── tc8_itf_conftest.py # ITF fixtures: someipd_dut, host_ip, dut_ip, tester_ip
+├── test_service_discovery.py # TC8-SD-001 ... 008, 011, 013, 014
+├── test_sd_phases_timing.py # TC8-SD-009 / 010
+├── test_sd_reboot.py # TC8-SD-012
+├── test_sd_format_compliance.py # TC8-FORMAT_01 ... OPTIONS_14 (SD format & options)
+├── test_sd_robustness.py # TC8 Group 4 — malformed SD message handling
+├── test_sd_client.py # TC8-ETS_081/082/084 — SD client lifecycle
+├── test_someip_message_format.py # TC8-MSG-001 ... 008
+├── test_event_notification.py # TC8-EVT-001 ... 006
+├── test_field_conformance.py # TC8-FLD-001 ... 004
+├── test_multi_service.py # SOMEIPSRV_RPC_13 — multi-service config
+├── config/
+│ ├── tc8_someipd_sd.json # SD config template (slow 2 s cycle)
+│ ├── tc8_someipd_service.json # Service config: MSG + EVT + FLD + TCP
+│ ├── tc8_someipd_multi.json # Multi-service vsomeip config template
+│ └── tc8_someipd_config.schema.json # JSON Schema for TC8 config validation
+├── helpers/
+│ ├── __init__.py
+│ ├── constants.py # Shared port/address constants
+│ ├── dut_lifecycle.py # DUT launch and teardown (ITF + standalone)
+│ ├── sd_helpers.py # SD capture + parsing
+│ ├── sd_sender.py # SD packet building + unicast capture
+│ ├── sd_malformed.py # Malformed SD packet builders (robustness)
+│ ├── someip_assertions.py # Assertion helpers (SD + MSG)
+│ ├── timing.py # Timestamped capture
+│ ├── message_builder.py # SOME/IP message construction
+│ ├── event_helpers.py # Event subscription + capture
+│ ├── field_helpers.py # Field GET/SET helpers
+│ ├── tcp_helpers.py # TCP transport (reliable binding)
+│ └── udp_helpers.py # UDP transport (unreliable binding)
+└── application/ # Enhanced testability (planned)
+ ├── README.md
+ ├── apps/ # C++ test apps (planned)
+ │ ├── tc8_service/ # mw::com skeleton
+ │ ├── tc8_client/ # mw::com proxy
+ │ └── config/ # mw_com + SOME/IP configs
+ └── helpers/
+ └── process_orchestrator.py # Multi-process lifecycle (planned)
+```
diff --git a/tests/tc8_conformance/application/README.md b/tests/tc8_conformance/application/README.md
index f50e090af..e4c3a41c2 100644
--- a/tests/tc8_conformance/application/README.md
+++ b/tests/tc8_conformance/application/README.md
@@ -27,4 +27,5 @@ application/
## See Also
+- [Architecture](../../../docs/architecture/tc8_conformance_testing.rst)
- Protocol conformance tests in the parent directory [`tests/tc8_conformance/`](../)
diff --git a/tests/tc8_conformance/config/tc8_gatewayd_mw_com_config.json b/tests/tc8_conformance/config/tc8_gatewayd_mw_com_config.json
new file mode 100644
index 000000000..ccb94e440
--- /dev/null
+++ b/tests/tc8_conformance/config/tc8_gatewayd_mw_com_config.json
@@ -0,0 +1,104 @@
+{
+ "serviceTypes": [
+ {
+ "serviceTypeName": "tc8_service",
+ "version": {
+ "major": 1,
+ "minor": 0
+ },
+ "bindings": [
+ {
+ "binding": "SHM",
+ "serviceId": 4660,
+ "events": [
+ {
+ "eventName": "TestEventUINT8",
+ "eventId": 1
+ },
+ {
+ "eventName": "TestEventUINT8Array",
+ "eventId": 2
+ },
+ {
+ "eventName": "TestEventUINT8Reliable",
+ "eventId": 3
+ },
+ {
+ "eventName": "TestEventUINT8E2E",
+ "eventId": 4
+ },
+ {
+ "eventName": "InterfaceVersion",
+ "eventId": 5
+ },
+ {
+ "eventName": "TestFieldUINT8",
+ "eventId": 6
+ },
+ {
+ "eventName": "TestFieldUINT8Array",
+ "eventId": 7
+ },
+ {
+ "eventName": "TestFieldUINT8Reliable",
+ "eventId": 8
+ },
+ {
+ "eventName": "TestEventUINT8Multicast",
+ "eventId": 9
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "serviceInstances": [
+ {
+ "instanceSpecifier": "tc8/tc8_service",
+ "serviceTypeName": "tc8_service",
+ "version": {
+ "major": 1,
+ "minor": 0
+ },
+ "instances": [
+ {
+ "instanceId": 22136,
+ "asil-level": "QM",
+ "binding": "SHM",
+ "events": [
+ {
+ "eventName": "TestEventUINT8"
+ },
+ {
+ "eventName": "TestEventUINT8Array"
+ },
+ {
+ "eventName": "TestEventUINT8Reliable"
+ },
+ {
+ "eventName": "TestEventUINT8E2E"
+ },
+ {
+ "eventName": "InterfaceVersion"
+ },
+ {
+ "eventName": "TestFieldUINT8"
+ },
+ {
+ "eventName": "TestFieldUINT8Array"
+ },
+ {
+ "eventName": "TestFieldUINT8Reliable"
+ },
+ {
+ "eventName": "TestEventUINT8Multicast"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "global": {
+ "asil-level": "B"
+ }
+}
diff --git a/tests/tc8_conformance/config/tc8_logging.json b/tests/tc8_conformance/config/tc8_logging.json
new file mode 100644
index 000000000..554a41ece
--- /dev/null
+++ b/tests/tc8_conformance/config/tc8_logging.json
@@ -0,0 +1,7 @@
+{
+ "appId": "TC8D",
+ "appDesc": "TC8 DUT",
+ "logLevel": "kInfo",
+ "logLevelThresholdConsole": "kInfo",
+ "logMode": "kConsole"
+}
diff --git a/tests/tc8_conformance/config/tc8_someipd_config.json b/tests/tc8_conformance/config/tc8_someipd_config.json
new file mode 100644
index 000000000..99dd1e178
--- /dev/null
+++ b/tests/tc8_conformance/config/tc8_someipd_config.json
@@ -0,0 +1,99 @@
+{
+ "service_types": [
+ {
+ "service_type_name": "tc8_service",
+ "service_id": 4660,
+ "service_version_major": 1,
+ "service_version_minor": 0,
+ "events": [
+ {
+ "event_name": "TestEventUINT8",
+ "event_id": 32769,
+ "eventgroup_ids": [2],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "TestEventUINT8Array",
+ "event_id": 32770,
+ "eventgroup_ids": [2],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "TestEventUINT8Reliable",
+ "event_id": 32771,
+ "eventgroup_ids": [2, 5],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "TestEventUINT8E2E",
+ "event_id": 32772,
+ "eventgroup_ids": [2],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "InterfaceVersion",
+ "event_id": 32773,
+ "eventgroup_ids": [2],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "TestFieldUINT8",
+ "event_id": 32774,
+ "eventgroup_ids": [2],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "TestFieldUINT8Array",
+ "event_id": 32775,
+ "eventgroup_ids": [2],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "TestFieldUINT8Reliable",
+ "event_id": 32776,
+ "eventgroup_ids": [2],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ },
+ {
+ "event_name": "TestEventUINT8Multicast",
+ "event_id": 32779,
+ "eventgroup_ids": [2, 6],
+ "serialization_config_type": "NullSerializerConfig",
+ "serialization_config": {
+ "max_message_size": 1
+ }
+ }
+ ],
+ "local_service_instances": [
+ {
+ "instance_specifier": "tc8/tc8_service",
+ "instance_id": 22136
+ }
+ ]
+ }
+ ]
+}
diff --git a/tests/tc8_conformance/config/tc8_someipd_multi.json b/tests/tc8_conformance/config/tc8_someipd_multi.json
new file mode 100644
index 000000000..e04337368
--- /dev/null
+++ b/tests/tc8_conformance/config/tc8_someipd_multi.json
@@ -0,0 +1,75 @@
+{
+ "unicast": "__TC8_HOST_IP__",
+ "netmask": "255.255.0.0",
+ "logging": {
+ "level": "info",
+ "console": "true",
+ "file": {
+ "enable": "false",
+ "path": "__TC8_LOG_DIR__/vsomeip-tc8.log"
+ },
+ "dlt": "false"
+ },
+ "applications": [
+ {
+ "name": "someipd",
+ "id": "0x1277"
+ }
+ ],
+ "services": [
+ {
+ "service": "0x1234",
+ "instance": "0x5678",
+ "unreliable": "__TC8_SVC_PORT__",
+ "events": [
+ {
+ "event": "0x0777",
+ "is_field": "true",
+ "update-cycle": 2000
+ }
+ ],
+ "eventgroups": [
+ {
+ "eventgroup": "0x4455",
+ "events": [
+ "0x777"
+ ]
+ }
+ ]
+ },
+ {
+ "service": "0x5678",
+ "instance": "0x0001",
+ "unreliable": "__TC8_SVC_TCP_PORT__",
+ "events": [
+ {
+ "event": "0x0888",
+ "is_field": "true",
+ "update-cycle": 2000
+ }
+ ],
+ "eventgroups": [
+ {
+ "eventgroup": "0x4456",
+ "events": [
+ "0x888"
+ ]
+ }
+ ]
+ }
+ ],
+ "routing": "someipd",
+ "service-discovery": {
+ "enable": "true",
+ "multicast": "224.244.224.245",
+ "port": "__TC8_SD_PORT__",
+ "protocol": "udp",
+ "initial_delay_min": "10",
+ "initial_delay_max": "100",
+ "repetitions_base_delay": "200",
+ "repetitions_max": "3",
+ "ttl": "30",
+ "cyclic_offer_delay": "2000",
+ "request_response_delay": "500"
+ }
+}
diff --git a/tests/tc8_conformance/config/tc8_someipd_sd.json b/tests/tc8_conformance/config/tc8_someipd_sd.json
new file mode 100644
index 000000000..43226435e
--- /dev/null
+++ b/tests/tc8_conformance/config/tc8_someipd_sd.json
@@ -0,0 +1,67 @@
+{
+ "unicast": "__TC8_HOST_IP__",
+ "netmask": "255.255.0.0",
+ "logging": {
+ "level": "info",
+ "console": "true",
+ "file": {
+ "enable": "false",
+ "path": "__TC8_LOG_DIR__/vsomeip-tc8.log"
+ },
+ "dlt": "false"
+ },
+ "applications": [
+ {
+ "name": "someipd",
+ "id": "0x1277"
+ }
+ ],
+ "services": [
+ {
+ "service": "__TC8_SERVICE_ID__",
+ "instance": "__TC8_INSTANCE_ID__",
+ "major_version": "0x01",
+ "unreliable": "__TC8_SVC_PORT__",
+ "events": [
+ {
+ "event": "0x8001",
+ "is_field": "true",
+ "update-cycle": 500
+ }
+ ],
+ "eventgroups": [
+ {
+ "eventgroup": "0x0002",
+ "events": [
+ "0x8001"
+ ]
+ },
+ {
+ "eventgroup": "0x0006",
+ "events": [
+ "0x8001"
+ ],
+ "multicast": {
+ "address": "239.0.0.1",
+ "port": "40490"
+ },
+ "threshold": "1"
+ }
+ ]
+ }
+ ],
+ "routing": "someipd",
+ "service-discovery": {
+ "enable": "true",
+ "multicast": "224.244.224.245",
+ "port": "__TC8_SD_PORT__",
+ "protocol": "udp",
+ "initial_delay_min": "10",
+ "initial_delay_max": "100",
+ "repetitions_base_delay": "200",
+ "repetitions_max": "3",
+ "ttl": "30",
+ "cyclic_offer_delay": "2000",
+ "request_response_delay": "500"
+ }
+}
diff --git a/tests/tc8_conformance/config/tc8_someipd_service.json b/tests/tc8_conformance/config/tc8_someipd_service.json
new file mode 100644
index 000000000..7f475e7e1
--- /dev/null
+++ b/tests/tc8_conformance/config/tc8_someipd_service.json
@@ -0,0 +1,129 @@
+{
+ "unicast": "__TC8_HOST_IP__",
+ "netmask": "255.255.0.0",
+ "logging": {
+ "level": "info",
+ "console": "true",
+ "file": {
+ "enable": "false",
+ "path": "__TC8_LOG_DIR__/vsomeip-tc8.log"
+ },
+ "dlt": "false"
+ },
+ "applications": [
+ {
+ "name": "someipd",
+ "id": "0x1277"
+ }
+ ],
+ "services": [
+ {
+ "service": "__TC8_SERVICE_ID__",
+ "instance": "__TC8_INSTANCE_ID__",
+ "unreliable": "__TC8_SVC_PORT__",
+ "reliable": "__TC8_SVC_TCP_PORT__",
+ "events": [
+ {
+ "event": "0x8001",
+ "is_field": "true",
+ "is_reliable": "false",
+ "update-cycle": "500"
+ },
+ {
+ "event": "0x8002",
+ "is_field": "false",
+ "is_reliable": "false",
+ "update-cycle": "500"
+ },
+ {
+ "event": "0x8003",
+ "is_field": "false",
+ "is_reliable": "true",
+ "update-cycle": "500"
+ },
+ {
+ "event": "0x8004",
+ "is_field": "false",
+ "is_reliable": "false",
+ "update-cycle": "500"
+ },
+ {
+ "event": "0x8005",
+ "is_field": "true",
+ "is_reliable": "false",
+ "update-cycle": "0"
+ },
+ {
+ "event": "0x8006",
+ "is_field": "true",
+ "is_reliable": "false",
+ "update-cycle": "0"
+ },
+ {
+ "event": "0x8007",
+ "is_field": "true",
+ "is_reliable": "false",
+ "update-cycle": "0"
+ },
+ {
+ "event": "0x8008",
+ "is_field": "true",
+ "is_reliable": "true",
+ "update-cycle": "0"
+ },
+ {
+ "event": "0x800B",
+ "is_field": "false",
+ "is_reliable": "false",
+ "update-cycle": "500"
+ }
+ ],
+ "eventgroups": [
+ {
+ "eventgroup": "0x0002",
+ "events": [
+ "0x8001",
+ "0x8002",
+ "0x8003",
+ "0x8004",
+ "0x8005",
+ "0x8006",
+ "0x8007",
+ "0x8008",
+ "0x800B"
+ ]
+ },
+ {
+ "eventgroup": "0x0005",
+ "events": [
+ "0x8003"
+ ]
+ },
+ {
+ "eventgroup": "0x0006",
+ "events": [
+ "0x800B"
+ ],
+ "multicast": {
+ "address": "239.0.0.1",
+ "port": "40490"
+ }
+ }
+ ]
+ }
+ ],
+ "routing": "someipd",
+ "service-discovery": {
+ "enable": "true",
+ "multicast": "224.244.224.245",
+ "port": "__TC8_SD_PORT__",
+ "protocol": "udp",
+ "initial_delay_min": "10",
+ "initial_delay_max": "100",
+ "repetitions_base_delay": "200",
+ "repetitions_max": "3",
+ "ttl": "30",
+ "cyclic_offer_delay": "500",
+ "request_response_delay": "500"
+ }
+}
diff --git a/tests/tc8_conformance/config/tc8_vsomeip_config.schema.json b/tests/tc8_conformance/config/tc8_vsomeip_config.schema.json
new file mode 100644
index 000000000..6364510c1
--- /dev/null
+++ b/tests/tc8_conformance/config/tc8_vsomeip_config.schema.json
@@ -0,0 +1,234 @@
+{
+ "$schema": "https://json-schema.org/draft/2019-09/schema",
+ "description": "JSON Schema for vsomeip configuration files used by TC8 conformance tests.",
+ "type": "object",
+ "required": [
+ "unicast",
+ "applications",
+ "routing",
+ "services",
+ "service-discovery",
+ "logging"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "unicast": {
+ "type": "string",
+ "description": "Unicast IP address of the host running someipd. May be a placeholder string such as __TC8_HOST_IP__."
+ },
+ "netmask": {
+ "type": "string",
+ "description": "Subnet mask used by vsomeip to validate that subscriber endpoint IPs are on the same subnet as the host. Default is 255.255.255.0 (/24). Set to 255.255.0.0 (/16) when the tester IP and DUT IP are in different /24 subnets (e.g. QEMU TAP: DUT 169.254.158.190, tester 169.254.21.88)."
+ },
+ "applications": {
+ "type": "array",
+ "description": "List of vsomeip application registrations.",
+ "items": {
+ "type": "object",
+ "required": [
+ "name",
+ "id"
+ ],
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Application name used by vsomeip for routing."
+ },
+ "id": {
+ "type": "string",
+ "description": "Hex-encoded application ID (e.g. 0x1277)."
+ }
+ },
+ "additionalProperties": true
+ }
+ },
+ "routing": {
+ "type": "string",
+ "description": "Name of the vsomeip routing manager application."
+ },
+ "services": {
+ "type": "array",
+ "description": "List of SOME/IP service definitions offered by this application.",
+ "items": {
+ "type": "object",
+ "required": [
+ "service",
+ "instance"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "service": {
+ "type": "string",
+ "description": "Hex-encoded SOME/IP service ID (e.g. 0x1234)."
+ },
+ "instance": {
+ "type": "string",
+ "description": "Hex-encoded SOME/IP instance ID (e.g. 0x5678)."
+ },
+ "major_version": {
+ "type": "string",
+ "description": "Major interface version as a hex string (e.g. 0x01). Used by vsomeip SD for version matching in SubscribeEventgroup."
+ },
+ "unreliable": {
+ "type": "string",
+ "description": "UDP port number as a string (e.g. 30509)."
+ },
+ "reliable": {
+ "type": "string",
+ "description": "TCP port number as a string (e.g. 30510)."
+ },
+ "events": {
+ "type": "array",
+ "description": "Event definitions for this service.",
+ "items": {
+ "type": "object",
+ "required": [
+ "event",
+ "is_field",
+ "update-cycle"
+ ],
+ "additionalProperties": true,
+ "properties": {
+ "event": {
+ "type": "string",
+ "description": "Hex-encoded event ID (e.g. 0x0777)."
+ },
+ "is_field": {
+ "type": "string",
+ "description": "Whether this event is a field (getter/setter/notifier). Encoded as string 'true' or 'false'."
+ },
+ "is_reliable": {
+ "type": "string",
+ "description": "Whether this event uses reliable (TCP) transport. Encoded as string 'true' or 'false'."
+ },
+ "update-cycle": {
+ "description": "Notification cycle in milliseconds. May be a string or integer depending on vsomeip version.",
+ "type": [
+ "string",
+ "integer"
+ ]
+ }
+ }
+ }
+ },
+ "eventgroups": {
+ "type": "array",
+ "description": "Eventgroup definitions grouping one or more events.",
+ "items": {
+ "type": "object",
+ "required": [
+ "eventgroup",
+ "events"
+ ],
+ "additionalProperties": true,
+ "properties": {
+ "eventgroup": {
+ "type": "string",
+ "description": "Hex-encoded eventgroup ID (e.g. 0x4455)."
+ },
+ "events": {
+ "type": "array",
+ "description": "List of hex-encoded event IDs belonging to this eventgroup.",
+ "items": {
+ "type": "string"
+ }
+ },
+ "multicast": {
+ "type": "object",
+ "description": "Optional multicast configuration for this eventgroup.",
+ "required": [
+ "address",
+ "port"
+ ],
+ "properties": {
+ "address": {
+ "type": "string",
+ "description": "Multicast group IP address."
+ },
+ "port": {
+ "type": "string",
+ "description": "Multicast port as a string."
+ }
+ },
+ "additionalProperties": true
+ },
+ "threshold": {
+ "type": "string",
+ "description": "Threshold for switching between unicast and multicast delivery."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "service-discovery": {
+ "type": "object",
+ "description": "vsomeip service discovery (SD) configuration. All numeric values are encoded as strings.",
+ "required": [
+ "enable",
+ "multicast",
+ "port",
+ "protocol",
+ "initial_delay_min",
+ "initial_delay_max",
+ "repetitions_base_delay",
+ "repetitions_max",
+ "ttl",
+ "cyclic_offer_delay",
+ "request_response_delay"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "enable": {
+ "type": "string",
+ "description": "Enable service discovery. String 'true' or 'false'."
+ },
+ "multicast": {
+ "type": "string",
+ "description": "SD multicast group address."
+ },
+ "port": {
+ "type": "string",
+ "description": "SD port as a string (e.g. '30490'). Must equal the source port used by all SD senders; the SOME/IP-SD stack drops messages from other source ports."
+ },
+ "protocol": {
+ "type": "string",
+ "description": "SD transport protocol ('udp' or 'tcp')."
+ },
+ "initial_delay_min": {
+ "type": "string",
+ "description": "Minimum initial offer delay in milliseconds."
+ },
+ "initial_delay_max": {
+ "type": "string",
+ "description": "Maximum initial offer delay in milliseconds."
+ },
+ "repetitions_base_delay": {
+ "type": "string",
+ "description": "Base delay for offer repetitions in milliseconds."
+ },
+ "repetitions_max": {
+ "type": "string",
+ "description": "Maximum number of offer repetitions."
+ },
+ "ttl": {
+ "type": "string",
+ "description": "SD entry time-to-live in seconds."
+ },
+ "cyclic_offer_delay": {
+ "type": "string",
+ "description": "Cyclic main-phase offer delay in milliseconds."
+ },
+ "request_response_delay": {
+ "type": "string",
+ "description": "Request/response delay in milliseconds."
+ }
+ }
+ },
+ "logging": {
+ "type": "object",
+ "description": "vsomeip logging configuration. Permissive — additional keys are allowed."
+ }
+ }
+}
diff --git a/tests/tc8_conformance/helpers/__init__.py b/tests/tc8_conformance/helpers/__init__.py
new file mode 100644
index 000000000..86ac71d4b
--- /dev/null
+++ b/tests/tc8_conformance/helpers/__init__.py
@@ -0,0 +1,13 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 conformance test helper modules."""
diff --git a/tests/tc8_conformance/helpers/constants.py b/tests/tc8_conformance/helpers/constants.py
new file mode 100644
index 000000000..44fbd5bb5
--- /dev/null
+++ b/tests/tc8_conformance/helpers/constants.py
@@ -0,0 +1,136 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""Shared network constants for TC8 conformance tests.
+
+Single source of truth for port numbers and multicast addresses used
+across all TC8 test modules and helpers. Import from here instead of
+hardcoding literals in individual files.
+
+Port isolation for parallel Bazel execution
+-------------------------------------------
+Each Bazel TC8 target runs in its own OS process and receives unique port
+values via the Bazel ``env`` attribute. The three port constants below read
+from environment variables at **module import time**, which means every
+helper that ``from helpers.constants import SD_PORT`` gets the correct
+per-process value with no function-signature changes.
+
+Defaults reproduce the historical static values so that local developer
+runs (without Bazel, no env vars set) continue to work unchanged.
+"""
+
+import os
+
+#: SOME/IP Service Discovery port (UDP). Both DUT and tester must bind
+#: to this port. The SOME/IP-SD stack drops SD packets arriving from any
+#: source port other than the configured SD port. Read from ``TC8_SD_PORT``
+#: env var; defaults to 30490 (the well-known SOME/IP-SD port) for local
+#: development.
+SD_PORT: int = int(os.environ.get("TC8_SD_PORT", "30490"))
+
+#: SOME/IP-SD multicast group address (all SOME/IP nodes join this group).
+SD_MULTICAST_ADDR: str = "224.244.224.245"
+
+#: DUT unreliable (UDP) service port — matches the ``unreliable`` port in
+#: the DUT's ``tc8_someipd_*.json`` configuration templates. Read from
+#: ``TC8_SVC_PORT`` env var; defaults to 30509 for local development.
+DUT_UNRELIABLE_PORT: int = int(os.environ.get("TC8_SVC_PORT", "30509"))
+
+#: DUT reliable (TCP) service port — matches the ``reliable`` port in
+#: the DUT's ``tc8_someipd_*.json`` configuration templates. Read from
+#: ``TC8_SVC_TCP_PORT`` env var; defaults to 30510 for local development.
+DUT_RELIABLE_PORT: int = int(os.environ.get("TC8_SVC_TCP_PORT", "30510"))
+
+# ---------------------------------------------------------------------------
+# ETS service identity
+# ---------------------------------------------------------------------------
+SERVICE_ID: int = int(os.environ.get("TC8_SERVICE_ID", "0x1234"), 16)
+INSTANCE_ID: int = int(os.environ.get("TC8_INSTANCE_ID", "0x5678"), 16)
+MAJOR_VERSION: int = 1
+MINOR_VERSION: int = 0
+
+# ---------------------------------------------------------------------------
+# ETS Sec. 6.1.4.2 event and field notification IDs
+# ---------------------------------------------------------------------------
+EVENT_TEST_UINT8: int = 0x8001
+EVENT_TEST_UINT8_ARRAY: int = 0x8002
+EVENT_TEST_UINT8_RELIABLE: int = 0x8003
+EVENT_TEST_UINT8_E2E: int = 0x8004
+EVENT_INTERFACE_VERSION: int = 0x8005
+EVENT_FIELD_UINT8: int = 0x8006
+EVENT_FIELD_UINT8_ARRAY: int = 0x8007
+EVENT_FIELD_UINT8_RELIABLE: int = 0x8008
+EVENT_TEST_UINT8_MULTICAST: int = 0x800B
+
+# ---------------------------------------------------------------------------
+# ETS Sec. 6.1.4.1 method IDs
+# ---------------------------------------------------------------------------
+
+# Fire-and-forget trigger / control methods
+METHOD_RESET_INTERFACE: int = 0x01
+METHOD_SUSPEND_INTERFACE: int = 0x02
+METHOD_TRIGGER_EVENT_UINT8: int = 0x03
+METHOD_TRIGGER_EVENT_UINT8_ARRAY: int = 0x04
+METHOD_TRIGGER_EVENT_UINT8_RELIABLE: int = 0x05
+METHOD_TRIGGER_EVENT_UINT8_E2E: int = 0x06
+METHOD_CLIENT_SERVICE_ACTIVATE: int = 0x2F
+METHOD_CLIENT_SERVICE_DEACTIVATE: int = 0x30
+METHOD_CLIENT_SERVICE_SUBSCRIBE_EVENTGROUP: int = 0x32
+METHOD_TRIGGER_EVENT_UINT8_MULTICAST: int = 0x3A
+
+# Echo / RPC methods
+METHOD_ECHO_UINT8: int = 0x08
+METHOD_ECHO_UINT8_ARRAY: int = 0x09
+METHOD_ECHO_UINT8_RELIABLE: int = 0x0A
+METHOD_ECHO_UINT8_E2E: int = 0x0B
+METHOD_ECHO_INT8: int = 0x0E
+METHOD_ECHO_FLOAT64: int = 0x12
+METHOD_ECHO_UTF8_FIXED: int = 0x13
+METHOD_ECHO_UTF16_FIXED: int = 0x14
+METHOD_ECHO_UTF8_DYNAMIC: int = 0x15
+METHOD_ECHO_UTF16_DYNAMIC: int = 0x16
+METHOD_ECHO_ENUM: int = 0x17
+METHOD_ECHO_UNION: int = 0x19
+METHOD_CHECK_BYTE_ORDER: int = 0x1F
+METHOD_ECHO_COMMON_DATATYPES: int = 0x23
+METHOD_ECHO_INT64: int = 0x34
+METHOD_ECHO_UINT8_ARRAY_2DIM: int = 0x35
+METHOD_ECHO_STATIC_UINT8_ARRAY: int = 0x36
+METHOD_ECHO_UINT8_ARRAY_MIN_SIZE: int = 0x37
+METHOD_CLIENT_SERVICE_GET_LAST_TCP: int = 0x3B
+METHOD_CLIENT_SERVICE_GET_LAST_UDP_UNICAST: int = 0x3C
+METHOD_CLIENT_SERVICE_GET_LAST_UDP_MULTICAST: int = 0x3D
+METHOD_ECHO_UINT8_ARRAY_8BIT_LENGTH: int = 0x3E
+METHOD_ECHO_UINT8_ARRAY_16BIT_LENGTH: int = 0x3F
+METHOD_ECHO_BITFIELDS: int = 0x41
+
+# Field getter / setter methods
+METHOD_INTERFACE_VERSION_GET: int = 0x25
+METHOD_FIELD_UINT8_GET: int = 0x26
+METHOD_FIELD_UINT8_SET: int = 0x27
+METHOD_FIELD_UINT8_ARRAY_GET: int = 0x28
+METHOD_FIELD_UINT8_ARRAY_SET: int = 0x29
+METHOD_FIELD_UINT8_RELIABLE_GET: int = 0x2A
+METHOD_FIELD_UINT8_RELIABLE_SET: int = 0x2B
+
+# ---------------------------------------------------------------------------
+# ETS Sec. 6.1.4.2 eventgroup IDs
+# ---------------------------------------------------------------------------
+EVENTGROUP_UDP_UNICAST: int = 0x0002
+EVENTGROUP_TCP_RELIABLE: int = 0x0005 # mixed: UDP events + TestEventUINT8Reliable (TCP)
+EVENTGROUP_UDP_MULTICAST: int = 0x0006
+
+# ---------------------------------------------------------------------------
+# Multicast transport
+# ---------------------------------------------------------------------------
+MULTICAST_ADDR: str = "239.0.0.1"
+MULTICAST_EVENT_PORT: int = 40490
diff --git a/tests/tc8_conformance/helpers/dut_lifecycle.py b/tests/tc8_conformance/helpers/dut_lifecycle.py
new file mode 100644
index 000000000..82e5fd039
--- /dev/null
+++ b/tests/tc8_conformance/helpers/dut_lifecycle.py
@@ -0,0 +1,340 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""DUT lifecycle helpers shared by TC8 tests that manage the DUT stack directly.
+
+Used by ``test_sd_client.py``, ``test_sd_reboot.py``, and
+``test_sd_phases_timing.py``. All functions operate in ITF (QEMU) mode:
+*target_init* is a ``QemuTarget`` provided by the ITF framework and
+commands execute on the QEMU guest via SSH.
+"""
+
+import glob
+import logging
+import os
+import socket
+import struct
+import time
+from pathlib import Path
+from typing import Optional, Union
+
+_logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Config name → rendered guest path mapping
+# ---------------------------------------------------------------------------
+
+#: Maps the template filename used in BUILD.bazel ``env`` to the rendered path
+#: on the QEMU guest (written by ``tc8_itf_config_setup`` via sed).
+_GUEST_CONFIG_MAP: dict[str, str] = {
+ "tc8_someipd_sd.json": "tc8_sd.json",
+ "tc8_someipd_service.json": "tc8_service.json",
+ "tc8_someipd_multi.json": "tc8_multi.json",
+}
+
+
+# ---------------------------------------------------------------------------
+# Popen-compatible adapter for ITF AsyncProcess
+# ---------------------------------------------------------------------------
+
+
+class _TargetProcess:
+ """Wraps an ITF ``AsyncProcess`` with a ``subprocess.Popen``-compatible API.
+
+ TC8 lifecycle helpers call ``proc.terminate()`` and ``proc.wait()`` on the
+ returned process object.
+ """
+
+ def __init__(
+ self,
+ proc: object,
+ target_init: object = None,
+ secondary_proc: object = None,
+ stub_proc: object = None,
+ ) -> None:
+ self._proc = proc
+ self._target_init = target_init
+ self._secondary_proc = secondary_proc
+ self._stub_proc = stub_proc
+
+ def poll(self) -> Optional[int]:
+ """Return ``None`` while running, ``0`` after the process has stopped."""
+ return None if self._proc.is_running() else 0 # type: ignore[attr-defined]
+
+ def terminate(self) -> None:
+ """Stop the remote processes (someipd and gatewayd).
+
+ Force-kills both binaries on the QEMU guest via ``pkill -9`` before
+ calling ``proc.stop()``. ``pkill`` is portable: procps on Linux,
+ ``slay`` symlink on QNX8 (pgrep absent from the QNX IFS).
+
+ Failures are logged as warnings and do not re-raise so teardown
+ does not fail the test.
+ """
+ if self._target_init is not None:
+ try:
+ self._target_init.execute( # type: ignore[attr-defined]
+ "pkill -9 someipd 2>/dev/null || true; "
+ "pkill -9 gatewayd 2>/dev/null || true; "
+ "pkill -9 tc8_ets_stub 2>/dev/null || true"
+ )
+ except Exception: # noqa: BLE001
+ _logger.warning("force-kill of someipd/gatewayd on QEMU guest failed; continuing teardown")
+ try:
+ self._proc.stop() # type: ignore[attr-defined]
+ except RuntimeError as exc:
+ _logger.warning("AsyncProcess.stop() raised during teardown (ignored): %s", exc)
+ if self._secondary_proc is not None:
+ try:
+ self._secondary_proc.stop() # type: ignore[attr-defined]
+ except RuntimeError as exc:
+ _logger.warning(
+ "AsyncProcess.stop() for secondary proc raised during teardown (ignored): %s",
+ exc,
+ )
+ if self._stub_proc is not None:
+ try:
+ self._stub_proc.stop() # type: ignore[attr-defined]
+ except RuntimeError as exc:
+ _logger.warning(
+ "AsyncProcess.stop() for stub proc raised during teardown (ignored): %s",
+ exc,
+ )
+
+ def kill(self) -> None:
+ """Alias for terminate — no separate SIGKILL equivalent in ITF."""
+ self.terminate()
+
+ def wait(self, timeout: Optional[float] = None) -> int: # noqa: ARG002
+ """No-op: ``stop()`` is already synchronous."""
+ return 0
+
+ @property
+ def stdout(self) -> None:
+ return None
+
+ @property
+ def stderr(self) -> None:
+ return None
+
+ @property
+ def returncode(self) -> Optional[int]:
+ return 0 if not self._proc.is_running() else None # type: ignore[attr-defined]
+
+
+# ---------------------------------------------------------------------------
+# Config helpers
+# ---------------------------------------------------------------------------
+
+
+def render_someip_config(
+ config_name: str,
+ host_ip: str,
+ dest_dir: Path,
+ service_id: str = "",
+ instance_id: str = "",
+) -> Path:
+ """Replace ``__TC8_HOST_IP__``, ``__TC8_SERVICE_ID__``,
+ ``__TC8_INSTANCE_ID__``, ``__TC8_SD_PORT__``, ``__TC8_SVC_PORT__``,
+ ``__TC8_SVC_TCP_PORT__``, and ``__TC8_LOG_DIR__`` in a config template.
+
+ Writes the rendered config to *dest_dir* and returns the path.
+
+ In ITF mode this function is called but the resulting file is not used
+ (the QEMU guest already has its configs rendered by ``tc8_itf_config_setup``
+ via sed). The call is kept so that the ``sd_client_config`` fixture
+ signature remains unchanged.
+ """
+ sd_port = os.environ.get("TC8_SD_PORT", "30490")
+ svc_port = os.environ.get("TC8_SVC_PORT", "30509")
+ svc_tcp_port = os.environ.get("TC8_SVC_TCP_PORT", "30510")
+ if not service_id:
+ service_id = os.environ.get("TC8_SERVICE_ID", "0x1234")
+ if not instance_id:
+ instance_id = os.environ.get("TC8_INSTANCE_ID", "0x5678")
+ template_path = Path(__file__).parent.parent / "config" / config_name
+ rendered = (
+ template_path.read_text(encoding="utf-8")
+ .replace("__TC8_HOST_IP__", host_ip)
+ .replace("__TC8_SERVICE_ID__", service_id)
+ .replace("__TC8_INSTANCE_ID__", instance_id)
+ .replace("__TC8_SD_PORT__", sd_port)
+ .replace("__TC8_SVC_PORT__", svc_port)
+ .replace("__TC8_SVC_TCP_PORT__", svc_tcp_port)
+ .replace("__TC8_LOG_DIR__", str(dest_dir))
+ )
+ config_path = dest_dir / config_name
+ config_path.write_text(rendered, encoding="utf-8")
+ return config_path
+
+
+# ---------------------------------------------------------------------------
+# DUT launch / teardown
+# ---------------------------------------------------------------------------
+
+
+def launch_dut(
+ config_path: Union[Path, str],
+ target_init: object = None,
+) -> object:
+ """Start the full DUT stack (someipd, tc8_ets_stub, gatewayd) on the QEMU guest.
+
+ *target_init* is a ``QemuTarget`` provided by the ITF framework. The
+ *config_path* filename selects the pre-rendered guest vsomeip config
+ (written to ``/tmp`` by ``tc8_itf_config_setup`` via sed, session-scoped).
+
+ someipd is started first so it becomes the vsomeip routing manager.
+ gatewayd is started immediately after; it retries the IPC handshake
+ internally until someipd is ready.
+
+ Returns a ``_TargetProcess`` adapter whose ``.terminate()`` / ``.wait()``
+ interface is compatible with :func:`terminate_dut`. Calling
+ ``.terminate()`` kills all three binaries and stops their async process handles.
+ """
+ if target_init is not None:
+ # ITF path: production stack runs on the QEMU guest. Configs are pre-rendered.
+ name = Path(config_path).name if isinstance(config_path, Path) else str(config_path)
+ guest_config = _GUEST_CONFIG_MAP.get(name, "tc8_sd.json")
+
+ # 1. Start someipd — it becomes the vsomeip routing manager.
+ someipd_proc = target_init.execute_async( # type: ignore[attr-defined]
+ f"LD_LIBRARY_PATH=/ "
+ f"VSOMEIP_CONFIGURATION=/tmp/{guest_config} "
+ f"MW_LOG_CONFIG_FILE=/tc8_logging.json "
+ f"/someipd -c /tc8_someipd_config.bin"
+ )
+
+ # 2. Start the ETS stub — provides the mw::com skeleton so gatewayd's
+ # StartFindService callback fires and gatewayd calls offer_event() in vsomeip.
+ stub_proc = target_init.execute_async( # type: ignore[attr-defined]
+ f"MW_LOG_CONFIG_FILE=/tc8_logging.json /tc8_ets_stub -s /tc8_ets_stub_mw_com_config.json"
+ )
+
+ # 3. Start gatewayd — connects to someipd IPC server, discovers the stub,
+ # and calls offer_event() so vsomeip advertises the service.
+ gatewayd_proc = target_init.execute_async( # type: ignore[attr-defined]
+ f"MW_LOG_CONFIG_FILE=/tc8_logging.json "
+ f"/gatewayd -c /tc8_someipd_config.bin -s /tc8_gatewayd_mw_com_config.json"
+ )
+
+ return _TargetProcess(
+ someipd_proc,
+ target_init=target_init,
+ secondary_proc=gatewayd_proc,
+ stub_proc=stub_proc,
+ )
+
+ raise RuntimeError("launch_dut: target_init must be provided (ITF mode only)")
+
+
+def terminate_dut(proc: object) -> None:
+ """Terminate the DUT stack and close its pipes."""
+ proc.terminate() # type: ignore[attr-defined]
+ proc.wait(timeout=5) # type: ignore[attr-defined]
+ if proc.stdout: # type: ignore[attr-defined]
+ proc.stdout.close() # type: ignore[attr-defined]
+ if proc.stderr: # type: ignore[attr-defined]
+ proc.stderr.close() # type: ignore[attr-defined]
+
+
+# ---------------------------------------------------------------------------
+# vsomeip socket cleanup
+# ---------------------------------------------------------------------------
+
+
+def cleanup_vsomeip_sockets(
+ base_path: str = "/tmp",
+ target_init: object = None,
+) -> None:
+ """Remove stale vsomeip routing-manager sockets.
+
+ In **legacy mode** (*target_init* is ``None``) removes
+ ``/vsomeip-*`` socket files on the host.
+
+ In **ITF mode** (*target_init* is a ``QemuTarget``) runs
+ ``rm -f /tmp/vsomeip-*`` on the QEMU guest via SSH.
+ """
+ if target_init is not None:
+ # Remove stale sockets from both paths:
+ # Linux QEMU : /tmp/vsomeip-*
+ # QNX8 QEMU : /var/run/vsomeip-* (vsomeip-qnx8.patch sets VSOMEIP_BASE_PATH="/var/run")
+ target_init.execute("rm -f /tmp/vsomeip-* /var/run/vsomeip-*") # type: ignore[attr-defined]
+ return
+ for stale in glob.glob(f"{base_path}/vsomeip-*"):
+ try:
+ os.unlink(stale)
+ except OSError:
+ pass
+
+
+# ---------------------------------------------------------------------------
+# SD readiness gate (host-side — works in both legacy and ITF modes)
+# ---------------------------------------------------------------------------
+
+
+def wait_for_sd_readiness(
+ host_ip: str,
+ timeout_secs: float = 10.0,
+) -> bool:
+ """Wait until the DUT sends at least one multicast OfferService.
+
+ Opens a short-lived multicast socket on *host_ip* (host TAP interface),
+ joins the SD multicast group, and returns ``True`` as soon as a SOME/IP-SD
+ OfferService entry is received. Returns ``False`` on timeout.
+
+ Port and multicast address are read from ``helpers.constants`` so they
+ stay in sync with the vsomeip config templates.
+ """
+ from helpers.constants import SD_MULTICAST_ADDR, SD_PORT # noqa: PLC0415
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+ except AttributeError:
+ pass
+ sock.bind(("", SD_PORT))
+ group_bytes = socket.inet_aton(SD_MULTICAST_ADDR)
+ iface_bytes = socket.inet_aton(host_ip)
+ mreq = struct.pack("4s4s", group_bytes, iface_bytes)
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+
+ deadline = time.monotonic() + timeout_secs
+ try:
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ if len(data) < 20:
+ continue
+ service_id = int.from_bytes(data[0:2], "big")
+ if service_id != 0xFFFF:
+ continue
+ sd_offset = 16
+ if len(data) < sd_offset + 12:
+ continue
+ entries_len = int.from_bytes(data[sd_offset + 4 : sd_offset + 8], "big")
+ entry_start = sd_offset + 8
+ pos = entry_start
+ while pos + 16 <= entry_start + entries_len and pos + 16 <= len(data):
+ entry_type = data[pos]
+ if entry_type == 0x01: # OfferService
+ return True
+ pos += 16
+ return False
+ finally:
+ sock.close()
diff --git a/tests/tc8_conformance/helpers/event_helpers.py b/tests/tc8_conformance/helpers/event_helpers.py
new file mode 100644
index 000000000..62691d6a0
--- /dev/null
+++ b/tests/tc8_conformance/helpers/event_helpers.py
@@ -0,0 +1,225 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""Event subscription and notification capture for TC8 conformance tests.
+
+Subscribes to eventgroups via SD and captures NOTIFICATION messages.
+"""
+
+import socket
+import time
+from typing import List
+
+from someip.header import SOMEIPHeader, SOMEIPMessageType, L4Protocols
+
+from helpers.sd_sender import (
+ open_sender_socket,
+ send_subscribe_eventgroup,
+ capture_unicast_sd_entries,
+ SOMEIPSDEntryType,
+)
+
+
+def subscribe_and_wait_ack(
+ tester_ip: str,
+ host_ip: str,
+ sd_port: int,
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ major_version: int,
+ notif_port: int,
+ timeout_secs: float = 5.0,
+ ttl: int = 3,
+) -> socket.socket:
+ """Subscribe to an eventgroup and wait for the Ack.
+
+ Returns the SD socket (still open). Caller must close it.
+ Raises AssertionError if no Ack is received.
+
+ *ttl* controls the SD SubscribeEventgroup entry TTL (seconds). Use a
+ larger value (e.g. 30) when the test collects notifications over an
+ interval longer than the default 3-second window.
+ """
+ sd_sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send_sub() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (host_ip, sd_port),
+ service_id,
+ instance_id,
+ eventgroup_id,
+ major_version,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ ttl=ttl,
+ )
+
+ _send_sub()
+ entries = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=timeout_secs,
+ resend=_send_sub,
+ max_results=1, # Return as soon as first ACK arrives; preserves subscription TTL.
+ )
+ acks = [e for e in entries if e.eventgroup_id == eventgroup_id and e.ttl > 0]
+ assert acks, f"No SubscribeEventgroupAck received for eventgroup 0x{eventgroup_id:04x}"
+ except Exception:
+ sd_sock.close()
+ raise
+ return sd_sock
+
+
+def _parse_datagram(data: bytes) -> List[SOMEIPHeader]:
+ """Return all SOME/IP messages packed into a single UDP datagram.
+
+ vsomeip bundles multiple SOME/IP NOTIFICATIONs in one UDP datagram.
+ SOMEIPHeader.parse() returns (message, remaining_bytes); looping over
+ the remainder ensures every message in the datagram is inspected.
+ """
+ messages: List[SOMEIPHeader] = []
+ buf = data
+ while buf:
+ try:
+ msg, buf = SOMEIPHeader.parse(buf)
+ messages.append(msg)
+ except Exception:
+ break
+ return messages
+
+
+def capture_notifications(
+ sock: socket.socket,
+ event_id: int,
+ service_id: int,
+ count: int = 1,
+ timeout_secs: float = 5.0,
+) -> List[SOMEIPHeader]:
+ """Capture NOTIFICATION messages for a specific event on *sock*.
+
+ Returns up to *count* matching notifications within *timeout_secs*.
+ Each UDP datagram may carry multiple bundled SOME/IP messages;
+ all of them are inspected.
+ """
+ collected: List[SOMEIPHeader] = []
+ deadline = time.monotonic() + timeout_secs
+
+ while time.monotonic() < deadline and len(collected) < count:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+
+ for msg in _parse_datagram(data):
+ if msg.service_id == service_id and msg.method_id == event_id:
+ collected.append(msg)
+ if len(collected) >= count:
+ break
+
+ return collected
+
+
+def capture_any_notifications(
+ sock: socket.socket,
+ service_id: int,
+ timeout_secs: float = 5.0,
+) -> List[SOMEIPHeader]:
+ """Capture any SOME/IP messages for *service_id* on *sock*.
+
+ Each UDP datagram may carry multiple bundled SOME/IP messages;
+ all of them are inspected.
+ """
+ collected: List[SOMEIPHeader] = []
+ deadline = time.monotonic() + timeout_secs
+
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+
+ for msg in _parse_datagram(data):
+ if msg.service_id == service_id:
+ collected.append(msg)
+
+ return collected
+
+
+def assert_notification_header(msg: SOMEIPHeader, expected_event_id: int) -> None:
+ """Assert a SOME/IP message is a valid NOTIFICATION with the expected event_id."""
+ assert msg.message_type == SOMEIPMessageType.NOTIFICATION, (
+ f"TC8-EVT: message_type mismatch: got 0x{msg.message_type:02x}, "
+ f"expected NOTIFICATION (0x{SOMEIPMessageType.NOTIFICATION:02x})"
+ )
+ assert msg.method_id == expected_event_id, (
+ f"TC8-EVT: event_id mismatch: got 0x{msg.method_id:04x}, expected 0x{expected_event_id:04x}"
+ )
+
+
+def subscribe_and_wait_ack_tcp(
+ tester_ip: str,
+ host_ip: str,
+ sd_port: int,
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ major_version: int,
+ notif_port: int,
+ timeout_secs: float = 5.0,
+) -> socket.socket:
+ """Subscribe to an eventgroup with a TCP endpoint and wait for the Ack.
+
+ Like subscribe_and_wait_ack() but the subscription advertises a TCP
+ endpoint (L4Proto=TCP) so the DUT delivers notifications over TCP.
+ Returns the SD socket (still open). Caller must close it.
+ """
+ sd_sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send_sub() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (host_ip, sd_port),
+ service_id,
+ instance_id,
+ eventgroup_id,
+ major_version,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ l4proto=L4Protocols.TCP,
+ )
+
+ _send_sub()
+ entries = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=timeout_secs,
+ resend=_send_sub,
+ max_results=1,
+ )
+ acks = [e for e in entries if e.eventgroup_id == eventgroup_id and e.ttl > 0]
+ assert acks, f"No SubscribeEventgroupAck received for TCP eventgroup 0x{eventgroup_id:04x}"
+ except Exception:
+ sd_sock.close()
+ raise
+ return sd_sock
diff --git a/tests/tc8_conformance/helpers/field_helpers.py b/tests/tc8_conformance/helpers/field_helpers.py
new file mode 100644
index 000000000..33d2905c4
--- /dev/null
+++ b/tests/tc8_conformance/helpers/field_helpers.py
@@ -0,0 +1,147 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""Field GET/SET request helpers for TC8-FLD conformance tests.
+
+Provides thin wrappers around the message_builder and raw socket primitives
+already established in test_someip_message_format.py, tailored for field
+getter/setter interactions (TC8-FLD-003 and TC8-FLD-004).
+"""
+
+from someip.header import SOMEIPHeader
+
+from helpers.message_builder import build_request
+from helpers.sd_helpers import create_udp_socket
+from helpers.tcp_helpers import tcp_connect, tcp_receive_response, tcp_send_request
+
+
+def send_get_field(
+ host_ip: str,
+ service_id: int,
+ get_method_id: int,
+ dut_port: int,
+ client_id: int = 0x0020,
+ session_id: int = 0x0001,
+ timeout_secs: float = 3.0,
+) -> SOMEIPHeader:
+ """Send a GET field request and return the RESPONSE.
+
+ Raises ``socket.timeout`` if no response arrives within *timeout_secs*.
+ """
+ request_bytes = build_request(
+ service_id,
+ get_method_id,
+ client_id=client_id,
+ session_id=session_id,
+ )
+ sock = create_udp_socket(port=0)
+ try:
+ sock.sendto(request_bytes, (host_ip, dut_port))
+ sock.settimeout(timeout_secs)
+ data, _ = sock.recvfrom(65535)
+ resp, _ = SOMEIPHeader.parse(data)
+ return resp
+ finally:
+ sock.close()
+
+
+def send_set_field(
+ host_ip: str,
+ service_id: int,
+ set_method_id: int,
+ new_value: bytes,
+ dut_port: int,
+ client_id: int = 0x0020,
+ session_id: int = 0x0002,
+ timeout_secs: float = 3.0,
+) -> SOMEIPHeader:
+ """Send a SET field request with *new_value* as payload and return the RESPONSE.
+
+ Raises ``socket.timeout`` if no response arrives within *timeout_secs*.
+ """
+ request_bytes = build_request(
+ service_id,
+ set_method_id,
+ client_id=client_id,
+ session_id=session_id,
+ payload=new_value,
+ )
+ sock = create_udp_socket(port=0)
+ try:
+ sock.sendto(request_bytes, (host_ip, dut_port))
+ sock.settimeout(timeout_secs)
+ data, _ = sock.recvfrom(65535)
+ resp, _ = SOMEIPHeader.parse(data)
+ return resp
+ finally:
+ sock.close()
+
+
+# ---------------------------------------------------------------------------
+# TCP variants — SOMEIPSRV_RPC_17 (reliable transport)
+# ---------------------------------------------------------------------------
+
+
+def send_get_field_tcp(
+ host_ip: str,
+ service_id: int,
+ get_method_id: int,
+ dut_port: int,
+ client_id: int = 0x0040,
+ session_id: int = 0x0010,
+ timeout_secs: float = 3.0,
+) -> SOMEIPHeader:
+ """Send a GET field request over TCP and return the RESPONSE.
+
+ TCP variant of send_get_field() for SOMEIPSRV_RPC_17 testing.
+ """
+ request_bytes = build_request(
+ service_id,
+ get_method_id,
+ client_id=client_id,
+ session_id=session_id,
+ )
+ sock = tcp_connect(host_ip, dut_port, timeout_secs=timeout_secs)
+ try:
+ tcp_send_request(sock, request_bytes)
+ return tcp_receive_response(sock, timeout_secs=timeout_secs)
+ finally:
+ sock.close()
+
+
+def send_set_field_tcp(
+ host_ip: str,
+ service_id: int,
+ set_method_id: int,
+ new_value: bytes,
+ dut_port: int,
+ client_id: int = 0x0040,
+ session_id: int = 0x0011,
+ timeout_secs: float = 3.0,
+) -> SOMEIPHeader:
+ """Send a SET field request over TCP with *new_value* and return the RESPONSE.
+
+ TCP variant of send_set_field() for SOMEIPSRV_RPC_17 testing.
+ """
+ request_bytes = build_request(
+ service_id,
+ set_method_id,
+ client_id=client_id,
+ session_id=session_id,
+ payload=new_value,
+ )
+ sock = tcp_connect(host_ip, dut_port, timeout_secs=timeout_secs)
+ try:
+ tcp_send_request(sock, request_bytes)
+ return tcp_receive_response(sock, timeout_secs=timeout_secs)
+ finally:
+ sock.close()
diff --git a/tests/tc8_conformance/helpers/message_builder.py b/tests/tc8_conformance/helpers/message_builder.py
new file mode 100644
index 000000000..bd854d2bb
--- /dev/null
+++ b/tests/tc8_conformance/helpers/message_builder.py
@@ -0,0 +1,184 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""SOME/IP message construction helpers for TC8 conformance tests.
+
+Builds REQUEST, REQUEST_NO_RETURN, and intentionally malformed packets.
+"""
+
+from someip.header import (
+ SOMEIPHeader,
+ SOMEIPMessageType,
+ SOMEIPReturnCode,
+)
+from helpers.constants import MAJOR_VERSION
+
+
+def build_request(
+ service_id: int,
+ method_id: int,
+ client_id: int = 0x0001,
+ session_id: int = 0x0001,
+ interface_version: int = MAJOR_VERSION,
+ payload: bytes = b"",
+) -> bytes:
+ """Build a SOME/IP REQUEST message."""
+ return SOMEIPHeader(
+ service_id=service_id,
+ method_id=method_id,
+ client_id=client_id,
+ session_id=session_id,
+ interface_version=interface_version,
+ message_type=SOMEIPMessageType.REQUEST,
+ return_code=SOMEIPReturnCode.E_OK,
+ payload=payload,
+ ).build()
+
+
+def build_request_no_return(
+ service_id: int,
+ method_id: int,
+ client_id: int = 0x0001,
+ session_id: int = 0x0001,
+ interface_version: int = MAJOR_VERSION,
+ payload: bytes = b"",
+) -> bytes:
+ """Build a SOME/IP REQUEST_NO_RETURN (fire-and-forget) message."""
+ return SOMEIPHeader(
+ service_id=service_id,
+ method_id=method_id,
+ client_id=client_id,
+ session_id=session_id,
+ interface_version=interface_version,
+ message_type=SOMEIPMessageType.REQUEST_NO_RETURN,
+ return_code=SOMEIPReturnCode.E_OK,
+ payload=payload,
+ ).build()
+
+
+# ---------------------------------------------------------------------------
+# Malformed message builders — TC8-MSG-007
+# ---------------------------------------------------------------------------
+
+
+def build_truncated_message() -> bytes:
+ """Return 7 raw bytes — one byte shorter than the minimum 8-byte SOME/IP header.
+
+ The DUT must not crash when it receives this (TC8-MSG-007).
+ """
+ # service_id=0x1234, method_id=0x0421, length=0x000000 (3 bytes, truncated)
+ return b"\x12\x34\x04\x21\x00\x00\x00"
+
+
+def build_wrong_protocol_version_request(
+ service_id: int,
+ method_id: int,
+ client_id: int = 0x0001,
+ session_id: int = 0x0001,
+ interface_version: int = MAJOR_VERSION,
+) -> bytes:
+ """Build a valid REQUEST but with protocol_version patched to 0xFF.
+
+ SOME/IP wire layout: byte 12 is protocol_version.
+ The DUT should reject or drop this message (TC8-MSG-007).
+ """
+ raw = build_request(
+ service_id,
+ method_id,
+ client_id=client_id,
+ session_id=session_id,
+ interface_version=interface_version,
+ )
+ # Byte 12 = protocol_version; patch it to an invalid value.
+ return raw[:12] + b"\xff" + raw[13:]
+
+
+def build_oversized_message(
+ service_id: int,
+ method_id: int,
+ client_id: int = 0x0001,
+ session_id: int = 0x0001,
+ interface_version: int = MAJOR_VERSION,
+) -> bytes:
+ """Build a 16-byte packet whose length field claims 0x7FF3 bytes of payload.
+
+ The actual UDP payload is only 16 bytes so the DUT will receive a
+ packet far shorter than advertised. The DUT must not crash (TC8-MSG-007).
+ """
+ raw = build_request(
+ service_id,
+ method_id,
+ client_id=client_id,
+ session_id=session_id,
+ interface_version=interface_version,
+ )
+ # SOME/IP length field = bytes 4–7; it counts bytes from byte 8 onward.
+ # A claim of 0x7FF3 means the message body should be 32755 bytes but is only 8.
+ return raw[:4] + b"\x00\x00\x7f\xf3" + raw[8:]
+
+
+# ---------------------------------------------------------------------------
+# Group 3 message builders — protocol behaviour tests
+# ---------------------------------------------------------------------------
+
+
+def build_notification_as_request(
+ service_id: int,
+ method_id: int,
+ client_id: int = 0x0001,
+ session_id: int = 0x0001,
+ interface_version: int = MAJOR_VERSION,
+ payload: bytes = b"",
+) -> bytes:
+ """Build a SOME/IP packet with message_type=NOTIFICATION (0x02).
+
+ A NOTIFICATION sent in the client→server direction is invalid per the
+ SOME/IP spec. Used by ETS_075: the DUT must not send a RESPONSE.
+ """
+ return SOMEIPHeader(
+ service_id=service_id,
+ method_id=method_id,
+ client_id=client_id,
+ session_id=session_id,
+ interface_version=interface_version,
+ message_type=SOMEIPMessageType.NOTIFICATION,
+ return_code=SOMEIPReturnCode.E_OK,
+ payload=payload,
+ ).build()
+
+
+def build_request_with_return_code(
+ service_id: int,
+ method_id: int,
+ return_code: int,
+ client_id: int = 0x0001,
+ session_id: int = 0x0001,
+ interface_version: int = MAJOR_VERSION,
+ payload: bytes = b"",
+) -> bytes:
+ """Build a REQUEST with an explicit return_code byte value.
+
+ Per SOME/IP spec the return_code in a REQUEST must be E_OK (0x00).
+ Setting it to a non-zero value tests DUT robustness (RPC_06/07/08).
+ The return_code byte is byte 15 in the SOME/IP header wire layout.
+ """
+ raw = build_request(
+ service_id,
+ method_id,
+ client_id=client_id,
+ session_id=session_id,
+ interface_version=interface_version,
+ payload=payload,
+ )
+ # Byte 15 = return_code; patch directly since SOMEIPReturnCode enum
+ # does not accept arbitrary integer values.
+ return raw[:15] + bytes([return_code & 0xFF]) + raw[16:]
diff --git a/tests/tc8_conformance/helpers/sd_helpers.py b/tests/tc8_conformance/helpers/sd_helpers.py
new file mode 100644
index 000000000..18bf6d03f
--- /dev/null
+++ b/tests/tc8_conformance/helpers/sd_helpers.py
@@ -0,0 +1,138 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""
+SD multicast capture and OFFER parsing for TC8 tests.
+
+Single source of truth for SD parsing and multicast sockets.
+Other helpers (``timing.py``, ``sd_sender.py``) import from here.
+
+Uses blocking sockets (no asyncio).
+
+Note: Loopback multicast needs ``bazel test --config=tc8`` (auto-configures
+a private network namespace). Alternatively, set ``TC8_HOST_IP`` to a
+non-loopback interface address to avoid this.
+"""
+
+import socket
+import struct
+import time
+from typing import List
+
+from helpers.constants import SD_MULTICAST_ADDR, SD_PORT
+from someip.header import (
+ SOMEIPHeader,
+ SOMEIPSDEntry,
+ SOMEIPSDEntryType,
+ SOMEIPSDHeader,
+)
+
+# SOME/IP SD messages are identified by service ID 0xFFFF.
+SD_SERVICE_ID: int = 0xFFFF
+
+
+def create_udp_socket(bind_addr: str = "", port: int = 0) -> socket.socket:
+ """Create a UDP socket, optionally bound to *bind_addr*:*port*."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ if bind_addr or port:
+ sock.bind((bind_addr, port))
+ return sock
+
+
+def open_multicast_socket(
+ host_ip: str,
+ multicast_group: str = SD_MULTICAST_ADDR,
+ port: int = SD_PORT,
+) -> socket.socket:
+ """Open a UDP socket and join *multicast_group*. Caller must close it."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+ except AttributeError:
+ pass
+ sock.bind(("", port))
+ group_bytes = socket.inet_aton(multicast_group)
+ iface_bytes = socket.inet_aton(host_ip)
+ mreq = struct.pack("4s4s", group_bytes, iface_bytes)
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+ return sock
+
+
+def parse_sd_offers(data: bytes) -> List[SOMEIPSDEntry]:
+ """Parse a UDP payload and return all OfferService entries.
+
+ Returns ``[]`` if *data* is not a valid SOME/IP-SD message.
+ """
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ except Exception:
+ return []
+
+ if someip_msg.service_id != SD_SERVICE_ID:
+ return []
+
+ try:
+ sd_header, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ except Exception:
+ return []
+
+ sd_header = sd_header.resolve_options()
+
+ return [entry for entry in sd_header.entries if entry.sd_type == SOMEIPSDEntryType.OfferService]
+
+
+def capture_sd_offers(
+ host_ip: str,
+ multicast_group: str = SD_MULTICAST_ADDR,
+ port: int = SD_PORT,
+ min_count: int = 1,
+ timeout_secs: float = 5.0,
+) -> List[SOMEIPSDEntry]:
+ """Join the SD multicast group and collect OfferService entries.
+
+ Returns as soon as *min_count* entries are captured.
+ Raises ``TimeoutError`` if not enough entries arrive within *timeout_secs*.
+ Raises ``OSError`` if the multicast socket setup fails.
+ """
+ sock = open_multicast_socket(host_ip, multicast_group, port)
+ try:
+ deadline = time.monotonic() + timeout_secs
+ collected: List[SOMEIPSDEntry] = []
+
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+
+ offers = parse_sd_offers(data)
+ collected.extend(offers)
+
+ if len(collected) >= min_count:
+ return collected
+
+ finally:
+ sock.close()
+
+ if len(collected) < min_count:
+ raise TimeoutError(
+ f"Captured only {len(collected)} SD OFFER entries within "
+ f"{timeout_secs:.1f}s (expected at least {min_count})"
+ )
+
+ return collected # pragma: no cover — reached only if min_count == 0
diff --git a/tests/tc8_conformance/helpers/sd_malformed.py b/tests/tc8_conformance/helpers/sd_malformed.py
new file mode 100644
index 000000000..1d6992310
--- /dev/null
+++ b/tests/tc8_conformance/helpers/sd_malformed.py
@@ -0,0 +1,820 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""Malformed SD packet builders for TC8 Group 4 robustness tests.
+
+Each public function builds or sends a SOME/IP-SD UDP datagram with a
+deliberate protocol violation. The DUT must survive receipt of any of
+these packets without crashing, hanging, or entering an incorrect state.
+
+Wire format reminder
+--------------------
+SOME/IP header (16 bytes):
+ [0-1] service_id = 0xFFFF
+ [2-3] method_id = 0x8100
+ [4-7] length (big-endian, bytes from byte 8 onward)
+ [8-9] client_id = 0x0001
+ [10-11] session_id (big-endian)
+ [12] protocol_version = 0x01
+ [13] interface_version = 0x01
+ [14] message_type = 0x02 (NOTIFICATION)
+ [15] return_code = 0x00
+
+SD payload (starts at byte 16):
+ [0] flags (0xC0 = reboot | unicast)
+ [1-3] reserved (0x000000)
+ [4-7] entries_array_length (big-endian)
+ [8+] entry bytes (entries_array_length bytes)
+ [...] options_array_length (4 bytes big-endian)
+ [...] option bytes
+
+SOME/IP length = 8 + len(SD payload).
+"""
+
+import ipaddress
+import itertools
+import socket
+import struct
+from typing import Optional, Tuple
+
+from helpers.constants import SD_PORT
+from someip.header import (
+ IPv4EndpointOption,
+ L4Protocols,
+ SOMEIPSDEntry,
+ SOMEIPSDEntryType,
+)
+
+# ---------------------------------------------------------------------------
+# Module-level session counter (independent of sd_sender's counter)
+# ---------------------------------------------------------------------------
+
+_malformed_session: itertools.count = itertools.count(start=200)
+
+
+def _next_session() -> int:
+ """Return next session ID in range 1-65535 (skip 0)."""
+ val = next(_malformed_session) & 0xFFFF
+ return val or 200 # skip 0
+
+
+# ---------------------------------------------------------------------------
+# Core raw-packet builder
+# ---------------------------------------------------------------------------
+
+_SOMEIP_SD_HEADER_FMT = ">HHIHHBBBB" # 16 bytes total
+_SOMEIP_SD_SERVICE_ID = 0xFFFF
+_SOMEIP_SD_METHOD_ID = 0x8100
+_SD_FLAGS_REBOOT_UNICAST = 0xC0
+_SD_FLAGS_NONE = 0x00
+
+
+def _build_someip_header(session_id: int, payload_len: int) -> bytes:
+ """Build the 16-byte SOME/IP header for an SD notification."""
+ length = 8 + payload_len
+ return struct.pack(
+ _SOMEIP_SD_HEADER_FMT,
+ _SOMEIP_SD_SERVICE_ID, # service_id
+ _SOMEIP_SD_METHOD_ID, # method_id
+ length, # length
+ 0x0001, # client_id
+ session_id, # session_id
+ 0x01, # protocol_version
+ 0x01, # interface_version (SD uses 0x01)
+ 0x02, # message_type = NOTIFICATION
+ 0x00, # return_code = E_OK
+ )
+
+
+def _build_sd_payload(
+ flags: int,
+ entries_bytes: bytes,
+ options_bytes: bytes,
+ entries_length_override: Optional[int] = None,
+ options_length_override: Optional[int] = None,
+) -> bytes:
+ """Build the SD payload section (flags + array lengths + entries + options)."""
+ entries_len = entries_length_override if entries_length_override is not None else len(entries_bytes)
+ options_len = options_length_override if options_length_override is not None else len(options_bytes)
+ return (
+ struct.pack(">B3xI", flags, entries_len) # flags(1)+reserved(3)+entries_len(4)
+ + entries_bytes
+ + struct.pack(">I", options_len)
+ + options_bytes
+ )
+
+
+def build_raw_sd_packet(
+ flags: int = _SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes: bytes = b"",
+ options_bytes: bytes = b"",
+ entries_length_override: Optional[int] = None,
+ options_length_override: Optional[int] = None,
+ session_id: int = 0,
+ someip_length_override: Optional[int] = None,
+ someip_service_id_override: Optional[int] = None,
+ someip_method_id_override: Optional[int] = None,
+) -> bytes:
+ """Build a complete SOME/IP+SD packet with optional field overrides.
+
+ Use *_override* parameters to inject specific protocol violations.
+ """
+ if session_id == 0:
+ session_id = _next_session()
+ sd_payload = _build_sd_payload(
+ flags,
+ entries_bytes,
+ options_bytes,
+ entries_length_override,
+ options_length_override,
+ )
+ raw = bytearray(_build_someip_header(session_id, len(sd_payload)) + sd_payload)
+ if someip_length_override is not None:
+ struct.pack_into(">I", raw, 4, someip_length_override)
+ if someip_service_id_override is not None:
+ struct.pack_into(">H", raw, 0, someip_service_id_override)
+ if someip_method_id_override is not None:
+ struct.pack_into(">H", raw, 2, someip_method_id_override)
+ return bytes(raw)
+
+
+# ---------------------------------------------------------------------------
+# SD entry byte builders
+# ---------------------------------------------------------------------------
+
+
+def _find_service_entry_bytes(
+ service_id: int,
+ instance_id: int = 0xFFFF,
+ major_version: int = 0xFF,
+ ttl: int = 3,
+ minor_version: int = 0xFFFFFFFF,
+) -> bytes:
+ """Build a 16-byte FindService (Type 0) SD entry."""
+ ttl_3b = struct.pack(">I", ttl)[1:] # 3 bytes big-endian
+ return (
+ bytes([0x00, 0x00, 0x00, 0x00])
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([major_version])
+ + ttl_3b
+ + struct.pack(">I", minor_version)
+ )
+
+
+def _subscribe_entry_bytes(
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ major_version: int = 0x00,
+ ttl: int = 3,
+ num_opts: int = 1,
+ idx_opt: int = 0,
+ reserved: bytes = b"\x00\x00",
+) -> bytes:
+ """Build a 16-byte SubscribeEventgroup (Type 0x06) SD entry.
+
+ Wire layout (big-endian):
+ byte 0: type = 0x06
+ byte 1: index_first_option_run
+ byte 2: (num_options_1 << 4) | num_options_2
+ byte 3: 0 (reserved/service_type)
+ bytes 4-5: service_id
+ bytes 6-7: instance_id
+ byte 8: major_version
+ bytes 9-11: TTL (3 bytes)
+ bytes 12-13: reserved (should be 0)
+ bytes 14-15: eventgroup_id
+ """
+ ttl_3b = struct.pack(">I", ttl)[1:]
+ return (
+ bytes([0x06, idx_opt, (num_opts << 4), 0x00])
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([major_version])
+ + ttl_3b
+ + reserved
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+
+
+def _endpoint_option_bytes(
+ ip: str,
+ port: int,
+ l4proto: int = 0x11, # 0x11 = UDP, 0x06 = TCP
+ length_override: Optional[int] = None,
+) -> bytes:
+ """Build a 12-byte IPv4 Endpoint Option.
+
+ Wire layout:
+ [0-1] length = 0x0009
+ [2] type = 0x04
+ [3] reserved = 0x00
+ [4-7] IPv4 address
+ [8] reserved = 0x00
+ [9] l4proto
+ [10-11] port
+ """
+ length_field = length_override if length_override is not None else 0x0009
+ addr_bytes = socket.inet_aton(ip)
+ return struct.pack(">HBB", length_field, 0x04, 0x00) + addr_bytes + struct.pack(">BBH", 0x00, l4proto, port)
+
+
+def _unknown_option_bytes(option_type: int = 0x77, content_len: int = 4) -> bytes:
+ """Build an SD option with an unknown type byte."""
+ length_field = content_len + 1 # type byte counts in length
+ padding = bytes(content_len)
+ return struct.pack(">HBB", length_field, option_type, 0x00) + padding
+
+
+# ---------------------------------------------------------------------------
+# Public malformed-packet senders
+# ---------------------------------------------------------------------------
+
+
+def send_sd_empty_entries(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+) -> None:
+ """ETS_111: SD packet with entries_array_length=0 (no entries, no options).
+
+ DUT must not crash or send a spurious OfferService.
+ """
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=b"",
+ options_bytes=b"",
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_find_with_options(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_118: FindService entry with an endpoint option attached.
+
+ SOME/IP-SD spec says options on FindService entries shall be ignored.
+ DUT must still respond to FindService normally.
+ """
+ # Build FindService entry with num_options_1=1 so the DUT sees an option reference.
+ ttl_3b = struct.pack(">I", 3)[1:]
+ entry_bytes = (
+ bytes([0x00, 0x00, 0x10, 0x00]) # type=Find, idx=0, num_1=1, num_2=0, service_type=0
+ + struct.pack(">HH", service_id, 0xFFFF)
+ + bytes([0xFF])
+ + ttl_3b
+ + struct.pack(">I", 0xFFFFFFFF)
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_entries_length_wrong(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ entries_length_override: int,
+) -> None:
+ """ETS_114/123/124/125: SD packet where entries_array_length mismatches actual entry bytes.
+
+ DUT must discard and remain alive.
+ """
+ entry_bytes = _find_service_entry_bytes(service_id=service_id)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=b"",
+ entries_length_override=entries_length_override,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_entry_refs_more_options(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_115: SubscribeEventgroup entry num_options_1=3 but options array has only 1.
+
+ DUT must discard the subscribe (and may send NAck) but must not crash.
+ """
+ # Build entry with num_options_1=3 (bits [7:4] of byte 2)
+ ttl_3b = struct.pack(">I", 3)[1:]
+ entry_bytes = (
+ bytes([0x06, 0x00, 0x30, 0x00]) # type=Subscribe, idx=0, num_1=3, num_2=0
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([0x00])
+ + ttl_3b
+ + b"\x00\x00"
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_entry_unknown_option_type(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+) -> None:
+ """ETS_116/174: SubscribeEventgroup with an option of unknown type 0x77.
+
+ DUT may send NAck or silently discard — must not crash.
+ """
+ ttl_3b = struct.pack(">I", 3)[1:]
+ entry_bytes = (
+ bytes([0x06, 0x00, 0x10, 0x00]) # num_1=1
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([0x00])
+ + ttl_3b
+ + b"\x00\x00"
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+ opt_bytes = _unknown_option_bytes(option_type=0x77, content_len=4)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_entry_same_option_twice(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_117: Two entries pointing to the same endpoint option via option index overlap.
+
+ Builds two SubscribeEventgroup entries both referencing option index 0.
+ DUT must not crash.
+ """
+ ttl_3b = struct.pack(">I", 3)[1:]
+ entry1 = (
+ bytes([0x06, 0x00, 0x10, 0x00])
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([0x00])
+ + ttl_3b
+ + b"\x00\x00"
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+ entry2 = (
+ bytes([0x06, 0x00, 0x10, 0x00]) # also references option index 0
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([0x00])
+ + ttl_3b
+ + b"\x00\x00"
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry1 + entry2,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_option_length_too_long(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+ option_length_override: int,
+) -> None:
+ """ETS_134/135: IPv4EndpointOption with oversize length field.
+
+ The option length field claims more bytes than the options array contains.
+ DUT must discard and remain alive.
+ """
+ ttl_3b = struct.pack(">I", 3)[1:]
+ entry_bytes = (
+ bytes([0x06, 0x00, 0x10, 0x00])
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([0x00])
+ + ttl_3b
+ + b"\x00\x00"
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port, length_override=option_length_override)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_option_length_too_short(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_136: IPv4EndpointOption with length field = 1 (too short for actual content).
+
+ DUT must discard and remain alive.
+ """
+ send_sd_option_length_too_long(
+ sock,
+ dest,
+ service_id,
+ instance_id,
+ eventgroup_id,
+ host_ip,
+ subscriber_port,
+ option_length_override=0x0001,
+ )
+
+
+def send_sd_option_length_unaligned(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_137: IPv4EndpointOption with odd length that doesn't align to option boundary.
+
+ Uses length=0x000A (10) instead of 9; points one byte past the type+reserved into
+ the next field. DUT must discard and remain alive.
+ """
+ send_sd_option_length_too_long(
+ sock,
+ dest,
+ service_id,
+ instance_id,
+ eventgroup_id,
+ host_ip,
+ subscriber_port,
+ option_length_override=0x000A,
+ )
+
+
+def send_sd_options_array_length_too_long(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_138: options_array_length claims more bytes than actually present.
+
+ DUT must discard and remain alive.
+ """
+ ttl_3b = struct.pack(">I", 3)[1:]
+ entry_bytes = (
+ bytes([0x06, 0x00, 0x10, 0x00])
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([0x00])
+ + ttl_3b
+ + b"\x00\x00"
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port)
+ # Override options_array_length to claim 100 bytes, but actual opt_bytes is 12
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ options_length_override=100,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_options_array_length_too_short(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_139: options_array_length claims fewer bytes than actually present.
+
+ DUT must discard and remain alive.
+ """
+ ttl_3b = struct.pack(">I", 3)[1:]
+ entry_bytes = (
+ bytes([0x06, 0x00, 0x10, 0x00])
+ + struct.pack(">HH", service_id, instance_id)
+ + bytes([0x00])
+ + ttl_3b
+ + b"\x00\x00"
+ + struct.pack(">H", eventgroup_id & 0xFFFF)
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port)
+ # Override options_array_length to 2 (far fewer bytes than 12 actual)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ options_length_override=2,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_subscribe_no_endpoint(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+) -> None:
+ """ETS_109: SubscribeEventgroup with num_options_1=0 (no endpoint option).
+
+ DUT must send NAck (SubscribeAck with TTL=0) or silently discard.
+ Must not crash.
+ """
+ entry_bytes = _subscribe_entry_bytes(
+ service_id=service_id,
+ instance_id=instance_id,
+ eventgroup_id=eventgroup_id,
+ num_opts=0,
+ )
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=b"",
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_subscribe_zero_ip(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ subscriber_port: int,
+) -> None:
+ """ETS_110: SubscribeEventgroup with endpoint IP = 0.0.0.0 (unspecified).
+
+ DUT must send NAck or silently discard. Must not crash.
+ """
+ entry_bytes = _subscribe_entry_bytes(
+ service_id=service_id,
+ instance_id=instance_id,
+ eventgroup_id=eventgroup_id,
+ num_opts=1,
+ )
+ opt_bytes = _endpoint_option_bytes("0.0.0.0", subscriber_port)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_subscribe_wrong_l4proto(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+ l4proto: int = 0x00,
+) -> None:
+ """ETS_119: SubscribeEventgroup with unknown L4 protocol byte in endpoint option.
+
+ Uses l4proto=0x00 (neither UDP=0x11 nor TCP=0x06).
+ DUT must send NAck or silently discard. Must not crash.
+ """
+ entry_bytes = _subscribe_entry_bytes(
+ service_id=service_id,
+ instance_id=instance_id,
+ eventgroup_id=eventgroup_id,
+ num_opts=1,
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port, l4proto=l4proto)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_subscribe_reserved_option(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_144: SubscribeEventgroup with a reserved option type (0x20).
+
+ DUT must send NAck or silently discard. Must not crash.
+ """
+ entry_bytes = _subscribe_entry_bytes(
+ service_id=service_id,
+ instance_id=instance_id,
+ eventgroup_id=eventgroup_id,
+ num_opts=1,
+ )
+ opt_bytes = _unknown_option_bytes(option_type=0x20, content_len=8)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_wrong_someip_length(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ length_override: int,
+) -> None:
+ """ETS_153: SOME/IP SD packet where the SOME/IP length field is incorrect.
+
+ DUT must discard and remain alive.
+ """
+ entry_bytes = _find_service_entry_bytes(service_id=service_id)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=b"",
+ someip_length_override=length_override,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_high_session_id(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ session_id: int,
+) -> None:
+ """ETS_152: SD FindService with a high/wrapped session ID (e.g., 0xFFFE, 0xFFFF).
+
+ DUT must not reject or misinterpret packets based on tester's session ID.
+ """
+ entry_bytes = _find_service_entry_bytes(service_id=service_id)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=b"",
+ session_id=session_id,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_wrong_someip_message_id(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id_override: int = 0x1234,
+) -> None:
+ """ETS_178: SD packet with wrong SOME/IP service_id (not 0xFFFF).
+
+ DUT must silently discard (not SD traffic) and remain alive.
+ """
+ entry_bytes = _find_service_entry_bytes(service_id=0x1234)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=b"",
+ someip_service_id_override=service_id_override,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_truncated_entry(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+) -> None:
+ """ETS_125: SD packet with entries_array_length=16 but only 8 bytes of entry data.
+
+ The entry is incomplete (truncated). DUT must discard and remain alive.
+ """
+ entry_bytes = _find_service_entry_bytes(service_id=service_id)[:8] # truncate to 8 bytes
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=b"",
+ entries_length_override=16, # claims 16 bytes but only 8 provided
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_oversized_entries_length(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+) -> None:
+ """ETS_123/124: entries_array_length far exceeds packet size.
+
+ DUT must discard and remain alive.
+ """
+ entry_bytes = _find_service_entry_bytes(service_id=service_id)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=b"",
+ entries_length_override=0xFFFF, # wildly too large
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_empty_option(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+) -> None:
+ """ETS_112/113: SubscribeEventgroup with an option whose length field is 0 or 1.
+
+ An option with length < 2 is malformed (type byte cannot fit).
+ DUT must discard and remain alive.
+ """
+ entry_bytes = _subscribe_entry_bytes(
+ service_id=service_id,
+ instance_id=instance_id,
+ eventgroup_id=eventgroup_id,
+ num_opts=1,
+ )
+ # Build an option with length=0x0001 (only 1 content byte after length field — invalid)
+ opt_bytes = struct.pack(">HBB", 0x0001, 0x04, 0x00) + b"\x00" * 8
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
+
+
+def send_sd_subscribe_nonexistent_service(
+ sock: socket.socket,
+ dest: Tuple[str, int],
+ unknown_service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ host_ip: str,
+ subscriber_port: int,
+) -> None:
+ """ETS_140-143: SubscribeEventgroup for a service_id not offered by DUT.
+
+ DUT must send no SubscribeAck or a NAck. Must not crash.
+ """
+ entry_bytes = _subscribe_entry_bytes(
+ service_id=unknown_service_id,
+ instance_id=instance_id,
+ eventgroup_id=eventgroup_id,
+ num_opts=1,
+ )
+ opt_bytes = _endpoint_option_bytes(host_ip, subscriber_port)
+ pkt = build_raw_sd_packet(
+ flags=_SD_FLAGS_REBOOT_UNICAST,
+ entries_bytes=entry_bytes,
+ options_bytes=opt_bytes,
+ )
+ sock.sendto(pkt, dest)
diff --git a/tests/tc8_conformance/helpers/sd_sender.py b/tests/tc8_conformance/helpers/sd_sender.py
new file mode 100644
index 000000000..74b127c4e
--- /dev/null
+++ b/tests/tc8_conformance/helpers/sd_sender.py
@@ -0,0 +1,376 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""SD packet building, sending, and unicast capture for TC8 tests.
+
+Helpers to send FindService / SubscribeEventgroup messages and
+capture unicast SD responses and SOME/IP notifications.
+"""
+
+import ipaddress
+import itertools
+import socket
+import struct
+import time
+from typing import Callable, List, Optional, Tuple
+
+from helpers.constants import SD_PORT
+from someip.header import (
+ IPv4EndpointOption,
+ L4Protocols,
+ SD_INTERFACE_VERSION,
+ SD_METHOD,
+ SD_SERVICE,
+ SOMEIPHeader,
+ SOMEIPMessageType,
+ SOMEIPReturnCode,
+ SOMEIPSDEntry,
+ SOMEIPSDEntryType,
+ SOMEIPSDHeader,
+ SOMEIPSDOption,
+)
+
+
+# ---------------------------------------------------------------------------
+# Socket management
+# ---------------------------------------------------------------------------
+
+
+def open_sender_socket(local_ip: str) -> socket.socket:
+ """Open a UDP socket at ``(local_ip, SD_PORT)`` for SD send/receive.
+
+ Binds to ``SD_PORT`` because the SOME/IP stack drops SD messages from other ports.
+ ``local_ip`` must differ from the DUT address (use the ``tester_ip`` fixture).
+ Multicast loopback is enabled so the local DUT receives our packets.
+ Caller must close the socket.
+ """
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+ except AttributeError:
+ pass # SO_REUSEPORT not available on all platforms
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(local_ip))
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1)
+ sock.bind((local_ip, SD_PORT))
+ return sock
+
+
+# ---------------------------------------------------------------------------
+# SD packet serialisation helpers
+# ---------------------------------------------------------------------------
+
+
+# SD session counter — incremented per message to avoid DUT duplicate-detection.
+# PRS_SOMEIPSD_00154 requires session_id to start at 0x0001 (0x0000 is reserved)
+# and to increment with each SD message.
+# NOTE: Assumes serial test execution (Bazel "exclusive" tag). Not thread-safe.
+_session_counter = itertools.count(start=1)
+
+
+def _next_session_id() -> int:
+ """Return the next SD session ID (wraps at 16-bit)."""
+ return next(_session_counter) & 0xFFFF or 1 # skip 0 (reserved)
+
+
+def _build_sd_packet(entry: SOMEIPSDEntry, session_id: int = 0) -> bytes:
+ """Wrap a single SD entry into a SOME/IP-SD UDP datagram.
+
+ When *session_id* is 0 (default), an auto-incrementing counter is used.
+ """
+ if session_id == 0:
+ session_id = _next_session_id()
+ options: List[SOMEIPSDOption] = []
+ indexed = entry.assign_option_index(options)
+ sd_hdr = SOMEIPSDHeader(entries=(indexed,), options=tuple(options))
+ return SOMEIPHeader(
+ service_id=SD_SERVICE,
+ method_id=SD_METHOD,
+ client_id=0x0001,
+ session_id=session_id,
+ interface_version=SD_INTERFACE_VERSION,
+ message_type=SOMEIPMessageType.NOTIFICATION,
+ return_code=SOMEIPReturnCode.E_OK,
+ payload=sd_hdr.build(),
+ ).build()
+
+
+# ---------------------------------------------------------------------------
+# Send helpers
+# ---------------------------------------------------------------------------
+
+
+def send_find_service(
+ sock: socket.socket,
+ sd_dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int = 0xFFFF,
+ major_version: int = 0xFF,
+ minor_version: int = 0xFFFFFFFF,
+ session_id: int = 0,
+) -> None:
+ """Send a FindService SD message to *sd_dest*."""
+ entry = SOMEIPSDEntry(
+ sd_type=SOMEIPSDEntryType.FindService,
+ service_id=service_id,
+ instance_id=instance_id,
+ major_version=major_version,
+ ttl=3,
+ minver_or_counter=minor_version,
+ )
+ sock.sendto(_build_sd_packet(entry, session_id), sd_dest)
+
+
+def send_subscribe_eventgroup(
+ sock: socket.socket,
+ sd_dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ major_version: int,
+ subscriber_ip: str,
+ subscriber_port: int = 0,
+ ttl: int = 3,
+ session_id: int = 0,
+ l4proto: L4Protocols = L4Protocols.UDP,
+ tcp_port: Optional[int] = None,
+ udp_port: Optional[int] = None,
+) -> None:
+ """Send a SubscribeEventgroup (or StopSubscribe when ``ttl=0``).
+
+ Endpoint Configuration:
+ - Standard (Single Protocol): Use `subscriber_port` and `l4proto` to subscribe
+ to an eventgroup that contains only UDP or only TCP events.
+ - Mixed (Multi-Protocol): Some eventgroups are configured to contain both TCP
+ and UDP events. Strict SOME/IP implementations require BOTH endpoints to be
+ provided in the subscription request. Pass `tcp_port` and/or `udp_port` to
+ explicitly attach multiple IPv4 Endpoint Options to the SD entry.
+ """
+ endpoint_opt = []
+
+ if tcp_port is None and udp_port is None:
+ # Standard behavior: Attach a single IPv4 Endpoint Option.
+ # This relies on the fallback 'l4proto' and 'subscriber_port'
+ endpoint_opt.append(
+ IPv4EndpointOption(
+ address=ipaddress.IPv4Address(subscriber_ip),
+ l4proto=l4proto,
+ port=subscriber_port,
+ )
+ )
+ else:
+ # Mixed behavior: The caller explicitly defined protocol-specific ports.
+ # We ignore 'subscriber_port' and 'l4proto', and instead attach up to two
+ # separate IPv4 Endpoint Options. This informs the provider exactly where
+ # to route UDP events and where to route TCP events for this eventgroup.
+ if udp_port is not None:
+ endpoint_opt.append(
+ IPv4EndpointOption(
+ address=ipaddress.IPv4Address(subscriber_ip),
+ l4proto=L4Protocols.UDP,
+ port=udp_port,
+ )
+ )
+ if tcp_port is not None:
+ endpoint_opt.append(
+ IPv4EndpointOption(
+ address=ipaddress.IPv4Address(subscriber_ip),
+ l4proto=L4Protocols.TCP,
+ port=tcp_port,
+ )
+ )
+
+ # Build the SOME/IP-SD Entry using the configured endpoint options
+ entry = SOMEIPSDEntry(
+ sd_type=SOMEIPSDEntryType.Subscribe,
+ service_id=service_id,
+ instance_id=instance_id,
+ major_version=major_version,
+ ttl=ttl,
+ minver_or_counter=eventgroup_id & 0xFFFF,
+ options_1=tuple(endpoint_opt),
+ )
+ sock.sendto(_build_sd_packet(entry, session_id), sd_dest)
+
+
+def send_subscribe_eventgroup_reserved_set(
+ sock: socket.socket,
+ sd_dest: Tuple[str, int],
+ service_id: int,
+ instance_id: int,
+ eventgroup_id: int,
+ major_version: int,
+ subscriber_ip: str,
+ subscriber_port: int,
+ ttl: int = 3,
+ reserved_value: int = 0x0F,
+) -> None:
+ """Send a SubscribeEventgroup with the reserved counter bits in the entry set non-zero.
+
+ The SubscribeEventgroup SD entry encodes a 4-bit counter nibble and a 12-bit
+ reserved field in the upper 16 bits of the ``minver_or_counter`` word (wire bytes
+ 12-15 of the entry). This function sets the 12 reserved bits to ``reserved_value``
+ to exercise SOMEIPSRV_SD_MESSAGE_19: the DUT shall send a NAck or silently ignore
+ the subscribe.
+
+ The packet is built by:
+ 1. Constructing a normal subscribe via ``_build_sd_packet()`` (library path).
+ 2. Locating the entry's ``minver_or_counter`` bytes in the serialised buffer.
+ 3. OR-ing the reserved bits (bits [31:20] of the 32-bit counter word) with
+ ``reserved_value << 20``.
+
+ Offset derivation (all big-endian):
+ - SOME/IP header: 16 bytes (bytes 0-15)
+ - SD flags + 3 reserved: 4 bytes (bytes 16-19)
+ - Entries-array length: 4 bytes (bytes 20-23)
+ - Entry starts at byte 24; ``minver_or_counter`` is the last 4 bytes of the
+ 16-byte entry, at entry offset 12 → absolute offset 36.
+ """
+ session_id = _next_session_id()
+ endpoint_opt = IPv4EndpointOption(
+ address=ipaddress.IPv4Address(subscriber_ip),
+ l4proto=L4Protocols.UDP,
+ port=subscriber_port,
+ )
+ entry = SOMEIPSDEntry(
+ sd_type=SOMEIPSDEntryType.Subscribe,
+ service_id=service_id,
+ instance_id=instance_id,
+ major_version=major_version,
+ ttl=ttl,
+ minver_or_counter=eventgroup_id & 0xFFFF,
+ options_1=(endpoint_opt,),
+ )
+ raw = bytearray(_build_sd_packet(entry, session_id))
+
+ # Patch bytes 36-39: the 4-byte ``minver_or_counter`` in the first SD entry.
+ # Structure at those bytes is: [reserved(12 bits) | counter(4 bits) | eventgroup_id(16 bits)].
+ # Set the 12 reserved bits (bits 31-20) to a non-zero pattern.
+ _MINVER_OFFSET = 36
+ original_word: int = struct.unpack_from("!I", raw, _MINVER_OFFSET)[0]
+ patched_word: int = original_word | ((reserved_value & 0x0FFF) << 20)
+ struct.pack_into("!I", raw, _MINVER_OFFSET, patched_word)
+
+ sock.sendto(bytes(raw), sd_dest)
+
+
+# ---------------------------------------------------------------------------
+# Capture helpers
+# ---------------------------------------------------------------------------
+
+
+def capture_unicast_sd_entries(
+ sock: socket.socket,
+ filter_types: Optional[Tuple[SOMEIPSDEntryType, ...]] = None,
+ timeout_secs: float = 5.0,
+ resend: Optional[Callable[[], None]] = None,
+ resend_interval_secs: float = 1.5,
+ max_results: Optional[int] = None,
+) -> List[SOMEIPSDEntry]:
+ """Receive SD entries on *sock* within *timeout_secs*.
+
+ If *filter_types* is set, only matching entry types are returned.
+
+ When *resend* is provided it is called every *resend_interval_secs*
+ while no matching entries have been captured. This mirrors real-world
+ SD client behaviour where FindService / Subscribe messages are sent
+ periodically.
+
+ When *max_results* is set, the function returns as soon as that many
+ matching entries have been collected (early-exit). This avoids
+ consuming the full *timeout_secs* when the desired entries arrive quickly,
+ which matters for tests where the subscription TTL must stay alive.
+ """
+ collected: List[SOMEIPSDEntry] = []
+ deadline = time.monotonic() + timeout_secs
+ next_resend = (time.monotonic() + resend_interval_secs) if resend else None
+
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+
+ # Early-exit once max_results matching entries are collected.
+ if max_results is not None and len(collected) >= max_results:
+ break
+
+ # Resend if no matching entries yet and the interval has elapsed.
+ if resend and next_resend and not collected and time.monotonic() >= next_resend:
+ resend()
+ next_resend = time.monotonic() + resend_interval_secs
+
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+
+ for entry in _parse_sd_entries(data):
+ if filter_types is None or entry.sd_type in filter_types:
+ collected.append(entry)
+
+ return collected
+
+
+def capture_some_ip_messages(
+ sock: socket.socket,
+ service_id: int,
+ timeout_secs: float,
+) -> List[SOMEIPHeader]:
+ """Receive SOME/IP messages for *service_id* on *sock* within *timeout_secs*."""
+ collected: List[SOMEIPHeader] = []
+ deadline = time.monotonic() + timeout_secs
+
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+
+ try:
+ msg, _ = SOMEIPHeader.parse(data)
+ except Exception:
+ continue
+
+ if msg.service_id == service_id:
+ collected.append(msg)
+
+ return collected
+
+
+# ---------------------------------------------------------------------------
+# Internal parsing
+# ---------------------------------------------------------------------------
+
+
+def _parse_sd_entries(data: bytes) -> List[SOMEIPSDEntry]:
+ """Parse a UDP payload and return all SD entries (any type)."""
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ except Exception:
+ return []
+ if someip_msg.service_id != SD_SERVICE:
+ return []
+ try:
+ sd_header, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ except Exception:
+ return []
+ # resolve_options() links each entry's options_1/options_2 tuples to the
+ # actual SOMEIPSDOption objects from sd_header.options. Without this call
+ # options_1 and options_2 on every SOMEIPSDEntry remain empty tuples even
+ # when the wire packet carries options (e.g. the multicast IPv4Endpoint
+ # option in a SubscribeEventgroupAck for TC8-SD-013).
+ return list(sd_header.resolve_options().entries)
diff --git a/tests/tc8_conformance/helpers/someip_assertions.py b/tests/tc8_conformance/helpers/someip_assertions.py
new file mode 100644
index 000000000..74a5d22a1
--- /dev/null
+++ b/tests/tc8_conformance/helpers/someip_assertions.py
@@ -0,0 +1,153 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""
+SOME/IP assertion helpers for TC8 conformance tests.
+
+Reusable assertion functions for SD entry fields and SOME/IP message headers.
+"""
+
+from someip.header import (
+ IPv4EndpointOption,
+ L4Protocols,
+ SOMEIPHeader,
+ SOMEIPMessageType,
+ SOMEIPReturnCode,
+ SOMEIPSDEntry,
+ SOMEIPSDEntryType,
+)
+
+
+def assert_sd_offer_entry(
+ entry: SOMEIPSDEntry,
+ expected_service_id: int,
+ expected_instance_id: int,
+ expected_major_version: int = 0x00,
+ expected_minor_version: int = 0x00000000,
+) -> None:
+ """Assert that an SD OFFER entry has the expected fields (TC8-SD-002)."""
+ # TC8-SD-002: entry type must be OfferService
+ assert entry.sd_type == SOMEIPSDEntryType.OfferService, (
+ f"TC8-SD-002: expected OfferService entry, got {entry.sd_type.name}"
+ )
+
+ # TC8-SD-002: service ID must match configuration
+ assert entry.service_id == expected_service_id, (
+ f"TC8-SD-002: service_id mismatch: got 0x{entry.service_id:04x}, expected 0x{expected_service_id:04x}"
+ )
+
+ # TC8-SD-002: instance ID must match configuration
+ assert entry.instance_id == expected_instance_id, (
+ f"TC8-SD-002: instance_id mismatch: got 0x{entry.instance_id:04x}, expected 0x{expected_instance_id:04x}"
+ )
+
+ # TC8-SD-002: major version must match service definition
+ assert entry.major_version == expected_major_version, (
+ f"TC8-SD-002: major_version mismatch: got 0x{entry.major_version:02x}, expected 0x{expected_major_version:02x}"
+ )
+
+ # TC8-SD-002: minor version must match service definition
+ assert entry.service_minor_version == expected_minor_version, (
+ f"TC8-SD-002: minor_version mismatch: "
+ f"got 0x{entry.service_minor_version:08x}, "
+ f"expected 0x{expected_minor_version:08x}"
+ )
+
+ # TC8-SD-002: TTL must be > 0 (TTL=0 means StopOffer)
+ assert entry.ttl > 0, f"TC8-SD-002: OFFER TTL must be > 0; got {entry.ttl}"
+
+
+def assert_valid_response(resp: SOMEIPHeader, req_service_id: int, req_method_id: int) -> None:
+ """Assert a SOME/IP RESPONSE has correct header fields."""
+ assert resp.protocol_version == 1, f"TC8-MSG: protocol_version mismatch: got {resp.protocol_version}, expected 1"
+ assert resp.message_type == SOMEIPMessageType.RESPONSE, (
+ f"TC8-MSG: message_type mismatch: got {resp.message_type}, "
+ f"expected RESPONSE (0x{SOMEIPMessageType.RESPONSE:02x})"
+ )
+ assert resp.service_id == req_service_id, (
+ f"TC8-MSG: service_id mismatch: got 0x{resp.service_id:04x}, expected 0x{req_service_id:04x}"
+ )
+ assert resp.method_id == req_method_id, (
+ f"TC8-MSG: method_id mismatch: got 0x{resp.method_id:04x}, expected 0x{req_method_id:04x}"
+ )
+
+
+def assert_return_code(resp: SOMEIPHeader, expected: SOMEIPReturnCode) -> None:
+ """Assert a SOME/IP message has the expected return code."""
+ assert resp.return_code == expected, (
+ f"TC8-MSG: return_code mismatch: got 0x{resp.return_code:02x}, "
+ f"expected {expected.name} (0x{expected.value:02x})"
+ )
+
+
+def assert_session_echo(resp: SOMEIPHeader, expected_session_id: int) -> None:
+ """Assert RESPONSE session_id matches the REQUEST session_id."""
+ assert resp.session_id == expected_session_id, (
+ f"TC8-MSG: session_id mismatch: got 0x{resp.session_id:04x}, expected 0x{expected_session_id:04x}"
+ )
+
+
+def assert_client_echo(resp: SOMEIPHeader, expected_client_id: int) -> None:
+ """Assert RESPONSE client_id matches the REQUEST client_id."""
+ assert resp.client_id == expected_client_id, (
+ f"TC8-MSG: client_id mismatch: got 0x{resp.client_id:04x}, expected 0x{expected_client_id:04x}"
+ )
+
+
+def assert_offer_has_ipv4_endpoint_option(
+ entry: SOMEIPSDEntry,
+ expected_ip: str,
+ expected_port: int,
+) -> None:
+ """TC8-SD-011: OfferService entry must include an IPv4EndpointOption (UDP).
+
+ Checks that the SD OFFER carries the correct unicast endpoint so a client
+ can reach the service. The address and port must match the DUT configuration.
+ """
+ options = list(getattr(entry, "options_1", ())) + list(getattr(entry, "options_2", ()))
+ ipv4_opts = [o for o in options if isinstance(o, IPv4EndpointOption)]
+ assert ipv4_opts, (
+ "TC8-SD-011: No IPv4EndpointOption found in OfferService entry options. "
+ f"Entry has {len(options)} option(s): {options}"
+ )
+ opt = ipv4_opts[0]
+ assert str(opt.address) == expected_ip, (
+ f"TC8-SD-011: endpoint address mismatch: got {opt.address}, expected {expected_ip}"
+ )
+ assert opt.port == expected_port, f"TC8-SD-011: endpoint port mismatch: got {opt.port}, expected {expected_port}"
+ assert opt.l4proto == L4Protocols.UDP, f"TC8-SD-011: endpoint protocol mismatch: got {opt.l4proto}, expected UDP"
+
+
+def assert_offer_has_tcp_endpoint_option(
+ entry: SOMEIPSDEntry,
+ expected_ip: str,
+ expected_port: int,
+) -> None:
+ """Assert OfferService includes an IPv4EndpointOption with L4Proto=TCP.
+
+ SOMEIPSRV_OPTIONS_15: the SD OfferService entry must advertise a TCP
+ endpoint option when the service is configured with a reliable port.
+ """
+ options = list(getattr(entry, "options_1", ())) + list(getattr(entry, "options_2", ()))
+ ipv4_opts = [o for o in options if isinstance(o, IPv4EndpointOption)]
+ tcp_opts = [o for o in ipv4_opts if o.l4proto == L4Protocols.TCP]
+ assert tcp_opts, (
+ "SOMEIPSRV_OPTIONS_15: No IPv4EndpointOption with L4Proto=TCP found in "
+ f"OfferService entry. Options present: {options}"
+ )
+ opt = tcp_opts[0]
+ assert str(opt.address) == expected_ip, (
+ f"SOMEIPSRV_OPTIONS_15: TCP endpoint address mismatch: got {opt.address}, expected {expected_ip}"
+ )
+ assert opt.port == expected_port, (
+ f"SOMEIPSRV_OPTIONS_15: TCP endpoint port mismatch: got {opt.port}, expected {expected_port}"
+ )
diff --git a/tests/tc8_conformance/helpers/tcp_helpers.py b/tests/tc8_conformance/helpers/tcp_helpers.py
new file mode 100644
index 000000000..6606f69db
--- /dev/null
+++ b/tests/tc8_conformance/helpers/tcp_helpers.py
@@ -0,0 +1,176 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TCP transport helpers for SOME/IP over TCP (reliable binding).
+
+SOME/IP over TCP uses stream framing: the 4-byte length field in the header
+(bytes 4–7) indicates how many bytes follow after byte 7. The receiver reads
+8 bytes (service_id + method_id + length), then reads `length` more bytes.
+"""
+
+import socket
+import struct
+import time
+
+from someip.header import SOMEIPHeader
+
+_MAX_SOMEIP_MSG_SIZE: int = 65536 # 64 KB safety bound
+
+
+def _recv_exact(sock: socket.socket, nbytes: int, deadline: float) -> bytes:
+ """Read exactly *nbytes* from *sock*, looping on partial reads.
+
+ Raises socket.timeout if deadline passes before all bytes are received.
+ Raises ConnectionError if the peer closes the connection.
+ """
+ buf = bytearray()
+ while len(buf) < nbytes:
+ remaining_time = deadline - time.monotonic()
+ if remaining_time <= 0:
+ raise socket.timeout("tcp_receive_response: deadline exceeded during recv")
+ sock.settimeout(remaining_time)
+ chunk = sock.recv(nbytes - len(buf))
+ if not chunk:
+ raise ConnectionError("TCP peer closed connection before all bytes received")
+ buf.extend(chunk)
+ return bytes(buf)
+
+
+def tcp_connect(host_ip: str, port: int, timeout_secs: float = 5.0) -> socket.socket:
+ """Establish a TCP connection to the DUT.
+
+ Returns the connected socket. Caller must close it.
+ Raises ConnectionRefusedError or socket.timeout on failure.
+ """
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(timeout_secs)
+ sock.connect((host_ip, port))
+ return sock
+
+
+def tcp_send_request(sock: socket.socket, request_bytes: bytes) -> None:
+ """Send a complete SOME/IP message over a TCP connection.
+
+ Uses sendall() to ensure all bytes are transmitted.
+ """
+ sock.sendall(request_bytes)
+
+
+def tcp_send_concatenated(sock: socket.socket, messages: list[bytes]) -> None:
+ """Send multiple SOME/IP messages concatenated into a single TCP write.
+
+ SOME/IP PRS_SOMEIP_00142 requires TCP receivers to handle multiple
+ SOME/IP messages arriving in a single TCP segment (unaligned packing).
+ This helper concatenates all *messages* and delivers them as one
+ ``sendall()`` call so the DUT receives them in one segment.
+
+ Used by: SOMEIP_ETS_068.
+ """
+ sock.sendall(b"".join(messages))
+
+
+def tcp_receive_n_responses(
+ sock: socket.socket,
+ count: int,
+ timeout_secs: float = 5.0,
+) -> list[SOMEIPHeader]:
+ """Receive exactly *count* SOME/IP responses from a TCP stream.
+
+ Uses a single shared deadline across all *count* receive calls so the
+ total wait never exceeds *timeout_secs*. Raises ``socket.timeout`` if
+ not all responses arrive in time.
+
+ Used by: SOMEIP_ETS_068.
+ """
+ deadline = time.monotonic() + timeout_secs
+ responses: list[SOMEIPHeader] = []
+ while len(responses) < count:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise socket.timeout(f"tcp_receive_n_responses: deadline exceeded after {len(responses)}/{count} responses")
+ responses.append(tcp_receive_response(sock, timeout_secs=remaining))
+ return responses
+
+
+def tcp_receive_response(sock: socket.socket, timeout_secs: float = 3.0) -> SOMEIPHeader:
+ """Receive and frame one complete SOME/IP message from a TCP stream.
+
+ Framing:
+ 1. Read 8 bytes (service_id[2] + method_id[2] + length[4]).
+ 2. Extract length from bytes 4–7 (big-endian).
+ 3. Read exactly `length` more bytes.
+ 4. Parse via SOMEIPHeader.parse(header + body).
+
+ Raises socket.timeout if the complete message does not arrive in time.
+ Raises ValueError if length exceeds the 64 KB safety bound.
+ """
+ deadline = time.monotonic() + timeout_secs
+ header_prefix = _recv_exact(sock, 8, deadline)
+ length = struct.unpack("!I", header_prefix[4:8])[0]
+ if length > _MAX_SOMEIP_MSG_SIZE:
+ raise ValueError(f"SOME/IP length field {length} exceeds safety bound {_MAX_SOMEIP_MSG_SIZE}")
+ body = _recv_exact(sock, length, deadline)
+ resp, _ = SOMEIPHeader.parse(header_prefix + body)
+ return resp
+
+
+def tcp_listen(host_ip: str, port: int = 0, backlog: int = 1) -> socket.socket:
+ """Create a TCP server socket and start listening.
+
+ If *port* is 0, the OS assigns an ephemeral port. Use
+ ``sock.getsockname()[1]`` to retrieve the assigned port.
+
+ Returns the listening socket. Caller must close it.
+ """
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind((host_ip, port))
+ srv.listen(backlog)
+ return srv
+
+
+def tcp_accept_and_receive_notification(
+ srv_sock: socket.socket,
+ event_id: int,
+ service_id: int,
+ timeout_secs: float = 8.0,
+) -> SOMEIPHeader:
+ """Accept a TCP connection and receive one SOME/IP notification.
+
+ The DUT connects to our listening socket to deliver event notifications.
+ Uses the same SOME/IP TCP framing as tcp_receive_response().
+
+ Returns the first notification matching *service_id* and *event_id*.
+ Raises socket.timeout or AssertionError if no matching notification arrives.
+ """
+ srv_sock.settimeout(timeout_secs)
+ conn, _ = srv_sock.accept()
+ try:
+ deadline = time.monotonic() + timeout_secs
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ header_prefix = _recv_exact(conn, 8, deadline)
+ length = struct.unpack("!I", header_prefix[4:8])[0]
+ if length > _MAX_SOMEIP_MSG_SIZE:
+ raise ValueError(f"SOME/IP length field {length} exceeds safety bound {_MAX_SOMEIP_MSG_SIZE}")
+ body = _recv_exact(conn, length, deadline)
+ msg, _ = SOMEIPHeader.parse(header_prefix + body)
+ if msg.service_id == service_id and msg.method_id == event_id:
+ return msg
+ raise socket.timeout(
+ f"No SOME/IP notification for service 0x{service_id:04x} "
+ f"event 0x{event_id:04x} received via TCP within {timeout_secs}s"
+ )
+ finally:
+ conn.close()
diff --git a/tests/tc8_conformance/helpers/timing.py b/tests/tc8_conformance/helpers/timing.py
new file mode 100644
index 000000000..ccbbbef19
--- /dev/null
+++ b/tests/tc8_conformance/helpers/timing.py
@@ -0,0 +1,79 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""Timestamped SD capture for TC8 phase timing tests.
+
+Captures OfferService entries with monotonic timestamps to verify
+SD phase timing (initial wait, repetition, main phase).
+
+Delegates parsing and socket setup to :mod:`helpers.sd_helpers`.
+"""
+
+import socket
+import time
+from typing import List, Tuple
+
+from someip.header import SOMEIPSDEntry
+
+from helpers.constants import SD_MULTICAST_ADDR, SD_PORT
+from helpers.sd_helpers import open_multicast_socket, parse_sd_offers
+
+
+def collect_sd_offers_from_socket(
+ sock: socket.socket,
+ count: int,
+ timeout_secs: float,
+) -> List[Tuple[float, SOMEIPSDEntry]]:
+ """Collect *count* OfferService entries with timestamps from *sock*.
+
+ Raises ``TimeoutError`` if fewer than *count* entries arrive in time.
+ """
+ deadline = time.monotonic() + timeout_secs
+ collected: List[Tuple[float, SOMEIPSDEntry]] = []
+
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+
+ recv_ts = time.monotonic()
+ for entry in parse_sd_offers(data):
+ collected.append((recv_ts, entry))
+
+ if len(collected) >= count:
+ return collected
+
+ if len(collected) < count:
+ raise TimeoutError(
+ f"Captured only {len(collected)} SD OFFER entries within {timeout_secs:.1f}s (expected at least {count})"
+ )
+ return collected # pragma: no cover
+
+
+def capture_sd_offers_with_timestamps(
+ host_ip: str,
+ multicast_group: str = SD_MULTICAST_ADDR,
+ port: int = SD_PORT,
+ count: int = 3,
+ timeout_secs: float = 20.0,
+) -> List[Tuple[float, SOMEIPSDEntry]]:
+ """Open a multicast socket and capture *count* OfferService entries with timestamps."""
+ sock = open_multicast_socket(host_ip, multicast_group, port)
+ try:
+ return collect_sd_offers_from_socket(sock, count, timeout_secs)
+ finally:
+ sock.close()
diff --git a/tests/tc8_conformance/helpers/udp_helpers.py b/tests/tc8_conformance/helpers/udp_helpers.py
new file mode 100644
index 000000000..1cd988f5d
--- /dev/null
+++ b/tests/tc8_conformance/helpers/udp_helpers.py
@@ -0,0 +1,68 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""UDP transport helpers for SOME/IP over UDP (unreliable binding).
+
+SOME/IP PRS_SOMEIP_00142 and PRS_SOMEIP_00569 require the receiver to
+parse each SOME/IP message within a UDP datagram sequentially using the
+length field as the sole framing indicator.
+"""
+
+import socket
+import time
+from someip.header import SOMEIPHeader
+
+
+def udp_send_concatenated(
+ sock: socket.socket,
+ addr: tuple[str, int],
+ messages: list[bytes],
+) -> None:
+ """Send multiple SOME/IP messages concatenated into ONE UDP datagram.
+
+ SOME/IP PRS_SOMEIP_00142 and PRS_SOMEIP_00569 require the DUT to parse
+ multiple SOME/IP messages packed into a single UDP datagram. This helper
+ concatenates all *messages* and delivers them as one ``sendto()`` call
+ so the DUT receives them in a single datagram.
+
+ Used by: SOMEIP_ETS_069.
+ """
+ sock.sendto(b"".join(messages), addr)
+
+
+def udp_receive_responses(
+ sock: socket.socket,
+ count: int,
+ timeout_secs: float = 5.0,
+) -> list[SOMEIPHeader]:
+ """Receive exactly *count* SOME/IP responses from a UDP socket.
+
+ Uses a single shared deadline across all *count* receive calls so the
+ total wait never exceeds *timeout_secs*. Each ``recvfrom()`` call returns
+ one complete SOME/IP message (the DUT sends one response datagram per
+ request processed from the concatenated datagram).
+
+ Raises ``socket.timeout`` if not all responses arrive in time.
+
+ Used by: SOMEIP_ETS_069.
+ """
+ deadline = time.monotonic() + timeout_secs
+ responses: list[SOMEIPHeader] = []
+ while len(responses) < count:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise socket.timeout(f"udp_receive_responses: deadline exceeded after {len(responses)}/{count} responses")
+ sock.settimeout(remaining)
+ data, _ = sock.recvfrom(65535)
+ msg, _ = SOMEIPHeader.parse(data)
+ responses.append(msg)
+ return responses
diff --git a/tests/tc8_conformance/tc8_itf_conftest.py b/tests/tc8_conformance/tc8_itf_conftest.py
new file mode 100644
index 000000000..d4f9b09c8
--- /dev/null
+++ b/tests/tc8_conformance/tc8_itf_conftest.py
@@ -0,0 +1,288 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""ITF conftest for TC8 conformance tests running inside QEMU via score_itf.
+
+Loaded as a pytest plugin by the integration_test() Bazel target; provides
+session-scoped fixtures for DUT IP addressing, DUT stack lifecycle, and vsomeip
+config rendering on the QEMU guest.
+"""
+
+import logging
+import os
+import socket
+import struct
+import subprocess
+import time
+from typing import Generator
+
+import pytest
+
+from capture import stop_capture, tcpdump_capture
+from helpers.dut_lifecycle import _TargetProcess, launch_dut
+
+_logger = logging.getLogger(__name__)
+
+
+def pytest_configure(config: pytest.Config) -> None:
+ """Register TC8 markers (mirrors conftest.py)."""
+ config.addinivalue_line("markers", "tc8: mark test as a TC8 conformance test")
+ config.addinivalue_line("markers", "conformance: mark test as a protocol conformance test")
+ config.addinivalue_line("markers", "network: mark test as requiring a non-loopback network interface")
+
+
+def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
+ """Auto-mark all collected items as tc8 and conformance."""
+ for item in items:
+ item.add_marker(pytest.mark.tc8)
+ item.add_marker(pytest.mark.conformance)
+
+
+def _wait_for_sd_readiness(
+ tester_ip: str,
+ timeout_secs: float = 10.0,
+) -> bool:
+ """Block until the DUT sends at least one multicast OfferService, or the
+ timeout expires. Returns True on success, False on timeout.
+ """
+ from helpers.constants import SD_MULTICAST_ADDR, SD_PORT # noqa: PLC0415
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
+ except AttributeError:
+ pass
+ sock.bind(("", SD_PORT))
+ group_bytes = socket.inet_aton(SD_MULTICAST_ADDR)
+ iface_bytes = socket.inet_aton(tester_ip)
+ mreq = struct.pack("4s4s", group_bytes, iface_bytes)
+ sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
+
+ deadline = time.monotonic() + timeout_secs
+ try:
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ if len(data) < 20:
+ continue
+ service_id = int.from_bytes(data[0:2], "big")
+ if service_id != 0xFFFF:
+ continue
+ sd_offset = 16
+ if len(data) < sd_offset + 12:
+ continue
+ entries_len = int.from_bytes(data[sd_offset + 4 : sd_offset + 8], "big")
+ entry_start = sd_offset + 8
+ pos = entry_start
+ while pos + 16 <= entry_start + entries_len and pos + 16 <= len(data):
+ entry_type = data[pos]
+ if entry_type == 0x01: # OfferService
+ return True
+ pos += 16
+ return False
+ finally:
+ sock.close()
+
+
+def _cleanup_vsomeip_sockets_on_target(target_init: object) -> None:
+ """Remove stale vsomeip routing-manager sockets on the QEMU guest. Must be
+ called before each DUT restart, or the new vsomeip instance will fail to
+ become routing manager and send no SD messages.
+ """
+ for vsomeip_socket_glob in ("/tmp/vsomeip-*", "/var/run/vsomeip-*"):
+ exit_code, output = target_init.execute(f"rm -f {vsomeip_socket_glob}")
+ if exit_code != 0:
+ _logger.warning(
+ "vsomeip socket cleanup at %s returned %d: %s",
+ vsomeip_socket_glob,
+ exit_code,
+ output.decode(errors="replace"),
+ )
+
+
+@pytest.fixture(scope="session", autouse=True)
+def someip_pcap_capture() -> Generator[None, None, None]:
+ """Capture SOME/IP traffic on the host TAP interface for the whole session.
+ A missing or permission-denied tcpdump becomes a warning, not a failure.
+ """
+ _tc8_dut_ip_key = "TC8_DUT_IP"
+ _tc8_dut_ip_default = "169.254.158.190"
+ if _tc8_dut_ip_key not in os.environ:
+ _logger.info(
+ "someip_pcap_capture: TC8_DUT_IP env var is not set; "
+ "defaulting to %s (the QEMU TAP bridge IP). "
+ "Override via: export TC8_DUT_IP=",
+ _tc8_dut_ip_default,
+ )
+ dut_ip = os.environ.get(_tc8_dut_ip_key, _tc8_dut_ip_default)
+ sd_port = os.environ.get("TC8_SD_PORT", "30490")
+ svc_tcp_port = os.environ.get("TC8_SVC_TCP_PORT", "30510")
+
+ # Multicast event group port (fixed in tc8_someipd_sd.json eventgroup 0x4465).
+ _multicast_event_port = "40490"
+
+ # BPF filter breakdown:
+ # udp port : SOME/IP-SD (multicast and unicast)
+ # udp port 40490: multicast event data (eventgroup 0x4465)
+ # host udp: all UDP to and from DUT (service and dynamic ports)
+ # host tcp: SOME/IP TCP only, excludes SSH port 22
+ bpf = (
+ f"(udp port {sd_port})"
+ f" or (udp port {_multicast_event_port})"
+ f" or (host {dut_ip} and (udp or (tcp and port {svc_tcp_port})))"
+ )
+
+ output_dir = os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR", ".")
+ output_file = os.path.join(output_dir, "someip_capture.pcap")
+
+ proc: subprocess.Popen[bytes] | None = None
+ try:
+ proc = tcpdump_capture(bpf, output_file=output_file)
+ _logger.info(
+ "someip_pcap_capture: tcpdump started (filter=%r output=%s)",
+ bpf,
+ output_file,
+ )
+ except (RuntimeError, OSError) as exc:
+ _logger.warning(
+ "someip_pcap_capture: tcpdump unavailable - continuing without pcap. %s",
+ exc,
+ )
+
+ try:
+ yield
+ finally:
+ if proc is not None:
+ clean = stop_capture(proc, timeout=5.0)
+ proc.__exit__(None, None, None)
+ if clean:
+ _logger.info(
+ "someip_pcap_capture: tcpdump stopped cleanly, pcap complete. output=%s",
+ output_file,
+ )
+ else:
+ _logger.warning(
+ "someip_pcap_capture: tcpdump did not respond to SIGINT within "
+ "5 s; SIGKILL used. All captured packets are present (written "
+ "per-packet via -U) but pcap trailer may be absent. output=%s",
+ output_file,
+ )
+
+
+@pytest.fixture(scope="session")
+def dut_ip() -> str:
+ """QEMU guest IP used as the unicast destination for all SD and data sends.
+ Overrideable via TC8_DUT_IP.
+ """
+ return os.environ.get("TC8_DUT_IP", "169.254.158.190")
+
+
+@pytest.fixture(scope="session")
+def host_ip() -> str:
+ """Host TAP interface IP for multicast group joins; pass to multicast
+ socket helpers, not to unicast send helpers. Overrideable via
+ TC8_TESTER_IP.
+ """
+ return os.environ.get("TC8_TESTER_IP", "169.254.21.88")
+
+
+@pytest.fixture(scope="session")
+def tester_ip(host_ip: str) -> str:
+ """Tester-side socket bind IP; equals host_ip under QEMU."""
+ return host_ip
+
+
+@pytest.fixture(scope="session")
+def tc8_itf_config_setup(target_init: object, dut_ip: str) -> None:
+ """Render vsomeip config templates on the QEMU guest using the DUT IP and
+ port env vars. Runs once per session; all per-class DUT restarts share the
+ rendered configs.
+ """
+ sd_port = os.environ.get("TC8_SD_PORT", "30490")
+ svc_port = os.environ.get("TC8_SVC_PORT", "30509")
+ svc_tcp_port = os.environ.get("TC8_SVC_TCP_PORT", "30510")
+ service_id = os.environ.get("TC8_SERVICE_ID", "0x1234")
+ instance_id = os.environ.get("TC8_INSTANCE_ID", "0x5678")
+
+ _logger.info(
+ "Rendering TC8 vsomeip configs on QEMU guest: DUT=%s SD=%s SVC=%s TCP=%s",
+ dut_ip,
+ sd_port,
+ svc_port,
+ svc_tcp_port,
+ )
+
+ for tmpl_name, out_name in [
+ ("tc8_sd.json.tmpl", "tc8_sd.json"),
+ ("tc8_service.json.tmpl", "tc8_service.json"),
+ ("tc8_multi.json.tmpl", "tc8_multi.json"),
+ ]:
+ # Use | as sed delimiter to avoid conflicts with / in path substitutions.
+ # Output goes to /tmp/ because the QNX8 IFS root is read-only at runtime.
+ cmd = (
+ f"sed"
+ f" -e 's/__TC8_HOST_IP__/{dut_ip}/g'"
+ f" -e 's/__TC8_SERVICE_ID__/{service_id}/g'"
+ f" -e 's/__TC8_INSTANCE_ID__/{instance_id}/g'"
+ f" -e 's/__TC8_SD_PORT__/{sd_port}/g'"
+ f" -e 's/__TC8_SVC_PORT__/{svc_port}/g'"
+ f" -e 's/__TC8_SVC_TCP_PORT__/{svc_tcp_port}/g'"
+ f" -e 's|__TC8_LOG_DIR__|/tmp|g'"
+ f" /{tmpl_name} > /tmp/{out_name}"
+ )
+ exit_code, output = target_init.execute(cmd)
+ if exit_code != 0:
+ pytest.fail(
+ f"Failed to render {tmpl_name} on QEMU guest (exit {exit_code}): {output.decode(errors='replace')}"
+ )
+ _logger.info("Rendered /%s -> /tmp/%s on QEMU guest", tmpl_name, out_name)
+
+
+@pytest.fixture(scope="class")
+def dut(
+ target_init: object,
+ tc8_itf_config_setup: None, # noqa: ARG001 (ensures configs are rendered first)
+ request: pytest.FixtureRequest,
+ host_ip: str,
+) -> Generator[_TargetProcess, None, None]:
+ """Launch the full DUT stack on the QEMU guest and yield a .poll() adapter.
+
+ Delegates process launch to :func:`helpers.dut_lifecycle.launch_dut`. The
+ fixture skips the test class if the DUT does not send an OfferService within 10 s.
+ """
+ config_name: str = getattr(request.module, "SOMEIP_CONFIG", "tc8_someipd_sd.json")
+
+ _cleanup_vsomeip_sockets_on_target(target_init)
+
+ proc: _TargetProcess = launch_dut(config_name, target_init=target_init) # type: ignore[assignment]
+
+ if not _wait_for_sd_readiness(host_ip):
+ proc.terminate()
+ pytest.skip(
+ "DUT did not reach SD main phase within 10 s (QEMU/ITF). "
+ "Check TAP bridge, multicast route on guest, and vsomeip config."
+ )
+
+ _logger.info("DUT reached SD main phase on QEMU guest")
+
+ try:
+ yield proc
+ finally:
+ proc.terminate()
+ _cleanup_vsomeip_sockets_on_target(target_init)
diff --git a/tests/tc8_conformance/test_event_notification.py b/tests/tc8_conformance/test_event_notification.py
new file mode 100644
index 000000000..1eaa14e87
--- /dev/null
+++ b/tests/tc8_conformance/test_event_notification.py
@@ -0,0 +1,638 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 Event Notification tests: TC8-EVT-001 through TC8-EVT-006.
+
+See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture.
+"""
+
+import socket
+import subprocess
+import time
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.event_helpers import (
+ assert_notification_header,
+ capture_any_notifications,
+ capture_notifications,
+ subscribe_and_wait_ack,
+)
+from helpers.sd_helpers import capture_sd_offers, open_multicast_socket
+from helpers.sd_sender import (
+ L4Protocols,
+ SOMEIPSDEntryType,
+ capture_unicast_sd_entries,
+ open_sender_socket,
+ send_subscribe_eventgroup,
+)
+from helpers.constants import (
+ DUT_RELIABLE_PORT,
+ EVENT_FIELD_UINT8,
+ EVENT_TEST_UINT8,
+ EVENT_TEST_UINT8_RELIABLE,
+ EVENTGROUP_TCP_RELIABLE,
+ EVENTGROUP_UDP_MULTICAST,
+ EVENTGROUP_UDP_UNICAST,
+ INSTANCE_ID,
+ MAJOR_VERSION,
+ MULTICAST_ADDR,
+ MULTICAST_EVENT_PORT,
+ SD_PORT,
+ SERVICE_ID,
+)
+from helpers.tcp_helpers import tcp_receive_response
+from someip.header import SOMEIPMessageType
+
+pytestmark = pytest.mark.skip(reason="Production stack does not forward events without mw::com ETS app (2026-08-11)")
+
+SOMEIP_CONFIG: str = "tc8_someipd_service.json"
+
+
+class TestEventNotificationFormat:
+ """TC8-EVT-001/002, SOMEIPSRV_RPC_15/16: Notification format and delivery strategy."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_evt_001_notification_message_type(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-EVT-001: Event notification has message_type = NOTIFICATION (0x02)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ try:
+ capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+ except (TimeoutError, OSError):
+ pytest.skip("DUT did not offer service within timeout")
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ )
+ # DUT notifies every 500 ms (tc8_someipd_service.json update cycle); wait for at least one
+ notifs = capture_notifications(
+ notif_sock,
+ EVENT_TEST_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=5.0,
+ )
+ assert notifs, "TC8-EVT-001: No NOTIFICATION received after subscription"
+ assert notifs[0].message_type == SOMEIPMessageType.NOTIFICATION, (
+ f"TC8-EVT-001: message_type = 0x{notifs[0].message_type:02x}, "
+ f"expected NOTIFICATION (0x{SOMEIPMessageType.NOTIFICATION:02x})"
+ )
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_evt_002_correct_event_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-EVT-002: Notification carries the correct event_id in the method_id field."""
+ assert dut.poll() is None, "DUT is not running"
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ )
+ notifs = capture_notifications(
+ notif_sock,
+ EVENT_TEST_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=5.0,
+ )
+ assert notifs, "TC8-EVT-002: No NOTIFICATION received after subscription"
+ assert_notification_header(notifs[0], EVENT_TEST_UINT8)
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_15_cyclic_notification_rate(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_15: Cyclic event notifications arrive at the configured cycle period."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _CYCLE_MS = 500
+ _TOLERANCE_FACTOR = 3.0
+ _MIN_INTERVAL = (_CYCLE_MS / 1000.0) / _TOLERANCE_FACTOR
+ _MAX_INTERVAL = (_CYCLE_MS / 1000.0) * _TOLERANCE_FACTOR
+
+ try:
+ capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+ except (TimeoutError, OSError):
+ pytest.skip("DUT did not offer service within timeout")
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ ttl=30, # 30 s > 10 s observation window; keeps subscription alive.
+ )
+
+ timestamps: list = []
+ deadline = time.monotonic() + 10.0
+ while time.monotonic() < deadline and len(timestamps) < 4:
+ remaining = deadline - time.monotonic()
+ notif_sock.settimeout(min(remaining, 2.0))
+ try:
+ data, _ = notif_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ from someip.header import SOMEIPHeader as _HDR
+
+ msg, _ = _HDR.parse(data)
+ if msg.service_id == SERVICE_ID and msg.method_id == EVENT_TEST_UINT8:
+ timestamps.append(time.monotonic())
+ except Exception:
+ continue
+
+ assert len(timestamps) >= 4, (
+ f"SOMEIPSRV_RPC_15: received only {len(timestamps)} notification(s) "
+ f"within 10 s; expected at least 4 at ~{_CYCLE_MS} ms cycle"
+ )
+
+ # Check intervals between consecutive notifications (skip first gap
+ # which may be shorter due to initial-event delivery).
+ intervals = [timestamps[i + 1] - timestamps[i] for i in range(1, len(timestamps) - 1)]
+ for idx, interval in enumerate(intervals):
+ assert _MIN_INTERVAL <= interval <= _MAX_INTERVAL, (
+ f"SOMEIPSRV_RPC_15: notification interval {idx + 1} = "
+ f"{interval * 1000:.0f} ms; expected [{_MIN_INTERVAL * 1000:.0f} ms, "
+ f"{_MAX_INTERVAL * 1000:.0f} ms] "
+ f"(configured cycle: {_CYCLE_MS} ms)"
+ )
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_16_field_notifies_only_on_change(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_16: Field event sends one initial notification then stays silent."""
+ assert dut.poll() is None, "DUT is not running"
+
+ try:
+ from helpers.sd_helpers import capture_sd_offers as _capture_sd_offers
+
+ _capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+ except (TimeoutError, OSError):
+ pytest.skip("DUT did not offer service within timeout")
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ )
+
+ initial = capture_notifications(
+ notif_sock,
+ EVENT_FIELD_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=5.0,
+ )
+ assert initial, (
+ "SOMEIPSRV_RPC_16: No initial-value notification received after "
+ f"subscribing to eventgroup 0x{EVENTGROUP_UDP_UNICAST:04x}"
+ )
+
+ # Wait 3 seconds. No further 0x8006 notifications should arrive because
+ # TestFieldUINT8 is not sent periodically (cycle_ms=0) and the field value
+ # does not change during the test. Filter to EVENT_FIELD_UINT8 only: other
+ # members of eventgroup 0x0002 (e.g. 0x8001) send cyclic events which are
+ # irrelevant to this field-change semantics check.
+ subsequent = capture_notifications(
+ notif_sock,
+ EVENT_FIELD_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=3.0,
+ )
+ assert not subsequent, (
+ f"SOMEIPSRV_RPC_16: {len(subsequent)} unexpected notification(s) of "
+ f"TestFieldUINT8 (0x{EVENT_FIELD_UINT8:04x}) received within 3 s; "
+ "cycle_ms=0 and the field value has not changed — no further "
+ "notifications expected"
+ )
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+
+class TestEventSubscriptionGating:
+ """TC8-EVT-003/004: Notifications only to subscribed endpoints."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_evt_003_notification_only_to_subscriber(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-EVT-003: Subscribed endpoint receives notifications; unsubscribed does not."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sub_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sub_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sub_sock.bind((tester_ip, 0))
+ sub_port = sub_sock.getsockname()[1]
+
+ unsub_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ unsub_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ unsub_sock.bind((tester_ip, 0))
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=sub_port,
+ )
+
+ notifs = capture_notifications(
+ sub_sock,
+ EVENT_TEST_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=5.0,
+ )
+ assert notifs, "TC8-EVT-003: No notification on subscribed socket"
+
+ stray = capture_any_notifications(unsub_sock, SERVICE_ID, timeout_secs=2.0)
+ assert not stray, f"TC8-EVT-003: {len(stray)} notification(s) on unsubscribed socket"
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ sub_sock.close()
+ unsub_sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_evt_004_no_notification_before_subscribe(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-EVT-004: No notifications arrive before subscribing."""
+ assert dut.poll() is None, "DUT is not running"
+
+ # Listen without subscribing. DUT notifies every 2000 ms, so 3 s window is sufficient.
+ listen_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ listen_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ listen_sock.bind((tester_ip, 0))
+
+ try:
+ stray = capture_any_notifications(listen_sock, SERVICE_ID, timeout_secs=3.0)
+ assert not stray, f"TC8-EVT-004: {len(stray)} notification(s) received without subscription"
+ finally:
+ listen_sock.close()
+
+
+class TestEventStopSubscribe:
+ """TC8-EVT-006: StopSubscribeEventgroup ceases notifications."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_evt_006_stop_subscribe_ceases_notifications(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-EVT-006: Notifications stop after StopSubscribeEventgroup (TTL=0)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ )
+ notifs = capture_notifications(
+ notif_sock,
+ EVENT_TEST_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=5.0,
+ )
+ assert notifs, "TC8-EVT-006: pre-condition failed — no notifications before StopSubscribe"
+
+ # StopSubscribe (TTL=0)
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ ttl=0,
+ )
+
+ # DUT sends every 500 ms (tc8_someipd_service.json update cycle), so a 4 s window catches any leaks
+ post = capture_any_notifications(notif_sock, SERVICE_ID, timeout_secs=4.0)
+ assert not post, f"TC8-EVT-006: {len(post)} notification(s) after StopSubscribeEventgroup"
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+
+class TestMulticastEventDelivery:
+ """TC8-EVT-005: Notifications for a multicast eventgroup arrive on the multicast address."""
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__evt_subscription"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_evt_005_multicast_notification_delivery(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-EVT-005: Notifications arrive on the multicast group after subscribing to 0x4465."""
+ assert dut.poll() is None, "DUT is not running"
+
+ try:
+ capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+ except (TimeoutError, OSError):
+ pytest.skip("DUT did not offer service within timeout")
+
+ # Open multicast notification socket BEFORE subscribing so we don't miss
+ # the first notification the DUT sends to the multicast group.
+ try:
+ mcast_sock = open_multicast_socket(
+ host_ip,
+ multicast_group=MULTICAST_ADDR,
+ port=MULTICAST_EVENT_PORT,
+ )
+ except OSError as exc:
+ pytest.skip(f"Cannot join multicast group {MULTICAST_ADDR}:{MULTICAST_EVENT_PORT} on {host_ip}: {exc}")
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_MULTICAST,
+ MAJOR_VERSION,
+ notif_port=mcast_sock.getsockname()[1],
+ )
+ # DUT sends notifications every 500 ms (tc8_someipd_service.json update cycle).
+ # Allow up to 5 s for the first notification to arrive on the multicast socket.
+ notifs = capture_any_notifications(mcast_sock, SERVICE_ID, timeout_secs=5.0)
+ assert notifs, (
+ f"TC8-EVT-005: No SOME/IP notification received on multicast "
+ f"{MULTICAST_ADDR}:{MULTICAST_EVENT_PORT} within 5 s "
+ f"after subscribing to eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ assert notifs[0].message_type == SOMEIPMessageType.NOTIFICATION, (
+ f"TC8-EVT-005: message_type mismatch on multicast socket: "
+ f"got 0x{notifs[0].message_type:02x}, expected NOTIFICATION (0x02)"
+ )
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ mcast_sock.close()
+
+
+class TestEventTcpNotification:
+ """SOMEIPSRV_RPC_17: Event notification delivery via TCP (reliable transport).
+
+ When a subscriber specifies a TCP endpoint in SubscribeEventgroup,
+ the DUT must deliver event notifications over a TCP connection to
+ the subscriber's TCP port.
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_rpc_17_tcp_event_notification_delivery(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_17: Notification arrives on TCP after subscribing with TCP endpoint."""
+ assert dut.poll() is None, "DUT is not running"
+
+ try:
+ capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+ except (TimeoutError, OSError):
+ pytest.skip("DUT did not offer service within timeout")
+
+ # Connect TCP to DUT's reliable port. PRS_SOMEIPSD_00362 requires a
+ # client-initiated TCP connection before the DUT will accept a reliable
+ # subscription. Bind to tester_ip so the source address matches the
+ # subscriber_ip in the SubscribeEventgroup entry (DUT validates the exact
+ # subscriber endpoint).
+ tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ tcp_sock.settimeout(5.0)
+ tcp_sock.bind((tester_ip, 0))
+ tcp_sock.connect((dut_ip, DUT_RELIABLE_PORT))
+ local_tcp_port = tcp_sock.getsockname()[1]
+ time.sleep(0.5) # give the DUT time to register the accepted TCP endpoint
+
+ # Dummy UDP socket setup to satisfy vsomeip's mixed-group requirement
+ udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ udp_sock.bind((tester_ip, 0))
+ local_udp_port = udp_sock.getsockname()[1]
+
+ sd_sock = open_sender_socket(tester_ip)
+ try:
+ # Subscribe to the TCP-only eventgroup using the TCP connection's
+ # source port so the DUT's TCP-connected check passes.
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_TCP_RELIABLE,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ tcp_port=local_tcp_port,
+ udp_port=local_udp_port,
+ )
+
+ entries = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=10.0,
+ resend=lambda: send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_TCP_RELIABLE,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ tcp_port=local_tcp_port,
+ udp_port=local_udp_port,
+ ),
+ max_results=1,
+ )
+ acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_TCP_RELIABLE and e.ttl > 0]
+ assert acks, f"No SubscribeEventgroupAck for TCP eventgroup 0x{EVENTGROUP_TCP_RELIABLE:04x}"
+
+ # Eventgroup 0x0005 contains TestEventUINT8Reliable (0x8003, TCP-only).
+ # Drain until we see the expected event ID.
+ deadline = time.monotonic() + 8.0
+ matched = None
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ msg = tcp_receive_response(tcp_sock, timeout_secs=remaining)
+ if msg.message_type == SOMEIPMessageType.NOTIFICATION and msg.method_id == EVENT_TEST_UINT8_RELIABLE:
+ matched = msg
+ break
+ assert matched is not None, (
+ f"SOMEIPSRV_RPC_17: No NOTIFICATION with event_id=0x{EVENT_TEST_UINT8_RELIABLE:04x} "
+ f"received over TCP within 8 s"
+ )
+ finally:
+ sd_sock.close()
+ tcp_sock.close()
diff --git a/tests/tc8_conformance/test_field_conformance.py b/tests/tc8_conformance/test_field_conformance.py
new file mode 100644
index 000000000..87e610639
--- /dev/null
+++ b/tests/tc8_conformance/test_field_conformance.py
@@ -0,0 +1,376 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 Field Conformance tests: TC8-FLD-001 through TC8-FLD-004.
+
+Fields extend events with three properties:
+ 1. Initial value: the DUT sends the last known value to a new subscriber immediately.
+ 2. Getter: a REQUEST to method 0x26 returns the current field value.
+ 3. Setter: a REQUEST to method 0x27 updates the field, notifies subscribers, and
+ returns a RESPONSE.
+
+The production DUT is ``someipd``, which embeds the SOME/IP and SOME/IP-SD
+protocol stack (vsomeip). It is configured via ``tc8_someipd_config.bin``
+(FlatBuffer binary generated from ``tc8_someipd_config.json`` at build time).
+TestFieldUINT8 uses notify ID 0x8006, getter 0x26, setter 0x27, eventgroup
+0x0002 (EVENTGROUP_UDP_UNICAST). The DUT caches the field value and delivers
+it as an initial-event notification to each new subscriber (is_field=true).
+TestFieldUINT8 is not sent periodically (cycle_ms=0).
+
+See ``docs/tc8_conformance/requirements.rst`` for requirement traceability.
+"""
+
+import socket
+import subprocess
+import time
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.event_helpers import (
+ capture_notifications,
+ subscribe_and_wait_ack,
+)
+from helpers.field_helpers import (
+ send_get_field,
+ send_get_field_tcp,
+ send_set_field,
+ send_set_field_tcp,
+)
+from helpers.constants import (
+ DUT_RELIABLE_PORT,
+ DUT_UNRELIABLE_PORT,
+ EVENT_FIELD_UINT8,
+ EVENTGROUP_UDP_UNICAST,
+ INSTANCE_ID,
+ MAJOR_VERSION,
+ METHOD_FIELD_UINT8_GET,
+ METHOD_FIELD_UINT8_SET,
+ SD_PORT,
+ SERVICE_ID,
+)
+from helpers.sd_helpers import capture_sd_offers
+from someip.header import SOMEIPReturnCode
+
+pytestmark = pytest.mark.skip(
+ reason="Production stack does not deliver initial field values or handle getter/setter methods without mw::com ETS app (2026-08-11)"
+)
+
+#: SOME/IP stack config template (is_field=true, eventgroup 0x0002 UDP unicast).
+SOMEIP_CONFIG: str = "tc8_someipd_service.json"
+
+
+class TestFieldInitialValue:
+ """TC8-FLD-001/002: DUT sends the cached field value to a new subscriber."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__fld_initial_value"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_fld_001_initial_notification_on_subscribe(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-FLD-001: Subscribing to a field eventgroup triggers an immediate NOTIFICATION.
+
+ The DUT caches the last notified value for ``is_field: true`` events and
+ re-sends it to each new subscriber without waiting for the next notify cycle.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ # Wait until the DUT has sent at least one SD offer.
+ try:
+ capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+ except (TimeoutError, OSError):
+ pytest.skip("DUT did not offer service within timeout")
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ )
+ # Wait actively for the DUT to send the initial-event notification.
+ notifs = capture_notifications(
+ notif_sock,
+ EVENT_FIELD_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=1.5,
+ )
+ assert notifs, (
+ "TC8-FLD-001: No initial NOTIFICATION received after subscribing to "
+ "a field eventgroup. Verify tc8_dut is running with is_field=true "
+ "for TestFieldUINT8 (0x8006)."
+ )
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__fld_initial_value"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_fld_002_is_field_sends_initial_value_within_one_second(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-FLD-002: Initial notification for a field arrives within 1 s of subscribe ACK.
+
+ Contrast with ``is_field: false`` events which only deliver notifications
+ on the next regular notify cycle. A field delivers the cached value
+ immediately, so the first notification should arrive well within 1 second.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ )
+ subscribe_time = time.monotonic()
+ # A field (is_field=true) delivers the cached value immediately after ACK.
+ notifs = capture_notifications(
+ notif_sock,
+ EVENT_FIELD_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=1.5,
+ )
+ elapsed_ms = (time.monotonic() - subscribe_time) * 1000.0
+
+ assert notifs, (
+ "TC8-FLD-002: No initial NOTIFICATION received within 1.5 s of subscribe. "
+ f"Elapsed: {elapsed_ms:.0f} ms. "
+ "A field (is_field=true) must deliver the cached value immediately."
+ )
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+
+class TestFieldGetSet:
+ """TC8-FLD-003/004: Field getter returns current value; setter updates and notifies."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__fld_get_set"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_fld_003_getter_returns_current_value(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-FLD-003: GET request (method 0x0001) returns a RESPONSE with the current value."""
+ assert dut.poll() is None, "DUT is not running"
+
+ resp = send_get_field(
+ dut_ip,
+ SERVICE_ID,
+ METHOD_FIELD_UINT8_GET,
+ DUT_UNRELIABLE_PORT,
+ client_id=0x0030,
+ session_id=0x0001,
+ )
+
+ assert resp.return_code == SOMEIPReturnCode.E_OK, (
+ f"TC8-FLD-003: GET returned code 0x{resp.return_code.value:02x} ({resp.return_code.name}), expected E_OK"
+ )
+ assert resp.payload, "TC8-FLD-003: GET response has empty payload — expected current field value"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__fld_get_set"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_fld_004_setter_updates_value_and_notifies(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-FLD-004: SET request (method 0x0002) updates the field and notifies subscribers."""
+ assert dut.poll() is None, "DUT is not running"
+
+ new_value = b"\xca\xfe"
+
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port = notif_sock.getsockname()[1]
+
+ sd_sock = None
+ try:
+ sd_sock = subscribe_and_wait_ack(
+ tester_ip,
+ dut_ip,
+ SD_PORT,
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ notif_port=notif_port,
+ )
+
+ # Drain any initial value notification that arrived due to is_field=true.
+ capture_notifications(notif_sock, EVENT_FIELD_UINT8, SERVICE_ID, count=1, timeout_secs=1.5)
+
+ set_resp = send_set_field(
+ dut_ip,
+ SERVICE_ID,
+ METHOD_FIELD_UINT8_SET,
+ new_value,
+ DUT_UNRELIABLE_PORT,
+ client_id=0x0030,
+ session_id=0x0002,
+ )
+ assert set_resp.return_code == SOMEIPReturnCode.E_OK, (
+ f"TC8-FLD-004: SET returned code 0x{set_resp.return_code.value:02x} "
+ f"({set_resp.return_code.name}), expected E_OK"
+ )
+
+ notifs = capture_notifications(
+ notif_sock,
+ EVENT_FIELD_UINT8,
+ SERVICE_ID,
+ count=1,
+ timeout_secs=3.0,
+ )
+ assert notifs, (
+ "TC8-FLD-004: No NOTIFICATION received after SET — "
+ "DUT must notify all subscribers when a field value changes"
+ )
+ received_payload = bytes(notifs[0].payload) if notifs[0].payload else b""
+ assert received_payload == new_value, (
+ f"TC8-FLD-004: Notification payload mismatch: got {received_payload!r}, expected {new_value!r}"
+ )
+ finally:
+ if sd_sock:
+ sd_sock.close()
+ notif_sock.close()
+
+
+class TestFieldTcpTransport:
+ """Field GET/SET over TCP (SOMEIPSRV_RPC_17).
+
+ These tests verify that someipd correctly handles field GET and SET
+ requests over TCP (reliable transport binding). The DUT is configured
+ with both unreliable (UDP ``DUT_UNRELIABLE_PORT``) and reliable (TCP ``DUT_RELIABLE_PORT``) ports.
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_rpc_17_tcp_field_getter(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_17: GET field request over TCP returns the current value."""
+ assert dut.poll() is None, "DUT is not running"
+
+ resp = send_get_field_tcp(
+ dut_ip,
+ SERVICE_ID,
+ METHOD_FIELD_UINT8_GET,
+ DUT_RELIABLE_PORT,
+ client_id=0x0040,
+ session_id=0x0010,
+ )
+
+ assert resp.return_code == SOMEIPReturnCode.E_OK, (
+ f"SOMEIPSRV_RPC_17: TCP GET returned code 0x{resp.return_code.value:02x} "
+ f"({resp.return_code.name}), expected E_OK"
+ )
+ assert resp.payload, "SOMEIPSRV_RPC_17: TCP GET response has empty payload — expected current field value"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_rpc_17_tcp_field_setter(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_17: SET field request over TCP updates the value."""
+ assert dut.poll() is None, "DUT is not running"
+
+ new_value = b"\xbe\xef"
+ set_resp = send_set_field_tcp(
+ dut_ip,
+ SERVICE_ID,
+ METHOD_FIELD_UINT8_SET,
+ new_value,
+ DUT_RELIABLE_PORT,
+ client_id=0x0040,
+ session_id=0x0011,
+ )
+
+ assert set_resp.return_code == SOMEIPReturnCode.E_OK, (
+ f"SOMEIPSRV_RPC_17: TCP SET returned code 0x{set_resp.return_code.value:02x} "
+ f"({set_resp.return_code.name}), expected E_OK"
+ )
+
+ get_resp = send_get_field_tcp(
+ dut_ip,
+ SERVICE_ID,
+ METHOD_FIELD_UINT8_GET,
+ DUT_RELIABLE_PORT,
+ client_id=0x0040,
+ session_id=0x0012,
+ )
+ received_payload = bytes(get_resp.payload) if get_resp.payload else b""
+ assert received_payload == new_value, (
+ f"SOMEIPSRV_RPC_17: TCP GET after SET payload mismatch: got {received_payload!r}, expected {new_value!r}"
+ )
diff --git a/tests/tc8_conformance/test_multi_service.py b/tests/tc8_conformance/test_multi_service.py
new file mode 100644
index 000000000..46c566852
--- /dev/null
+++ b/tests/tc8_conformance/test_multi_service.py
@@ -0,0 +1,256 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 Multi-service and multi-instance config tests.
+
+SOMEIPSRV_RPC_13: Multi-service config validity:
+ Verify that the DUT's configuration supports multiple service entries and
+ that the DUT correctly offers its configured service with a stable SD stream.
+ The ``tc8_someipd_multi.json`` config declares two services; the DUT validates
+ both at startup. The DUT (``--tc8-standalone``) offers service 0x1234/0x5678.
+
+SOMEIPSRV_RPC_14: Instance port isolation:
+ Verify that each service instance in the config is assigned a distinct UDP
+ port and that the offered service's SD endpoint option matches its configured
+ port, confirming port routing correctness at the SD layer.
+
+See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture.
+"""
+
+import socket
+import subprocess
+import time
+from typing import Dict, Optional, Set
+
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.constants import DUT_UNRELIABLE_PORT, SD_PORT
+from helpers.sd_helpers import open_multicast_socket, parse_sd_offers
+from someip.header import IPv4EndpointOption, L4Protocols, SOMEIPSDEntry
+
+#: Use the multi-service DUT config with two service entries.
+#: Only service A is offered by --tc8-standalone; the config itself must load
+#: successfully with both service definitions present (tests RPC_13 config path).
+SOMEIP_CONFIG: str = "tc8_someipd_multi.json"
+
+#: Service A: the service offered by --tc8-standalone mode.
+_SERVICE_A_ID: int = 0x1234
+_SERVICE_A_INSTANCE: int = 0x5678
+
+#: Service B: declared in config but not offered by current standalone mode.
+_SERVICE_B_ID: int = 0x5678
+_SERVICE_B_INSTANCE: int = 0x0001
+
+_DUT_READY_TIMEOUT_SECS: float = 10.0
+
+_SD_OFFER_COLLECTION_WINDOW_SECS: float = 6.0
+
+
+def _collect_offers_for_service(
+ host_ip: str,
+ service_id: int,
+ timeout_secs: float = _DUT_READY_TIMEOUT_SECS,
+) -> list[SOMEIPSDEntry]:
+ """Collect all OfferService entries for *service_id* within *timeout_secs*.
+
+ Returns as soon as the first entry for the target service is found, or an
+ empty list if the timeout elapses. Skips the calling test if the multicast
+ socket cannot be opened.
+ """
+ try:
+ sock = open_multicast_socket(host_ip, port=SD_PORT)
+ except OSError as exc:
+ pytest.skip(
+ f"Multicast socket setup failed on {host_ip}: {exc}. "
+ "Set TC8_HOST_IP to a non-loopback interface IP or add a multicast "
+ "route: sudo ip route add 224.0.0.0/4 dev lo"
+ )
+
+ results = []
+ deadline = time.monotonic() + timeout_secs
+ try:
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ for entry in parse_sd_offers(data):
+ if entry.service_id == service_id:
+ results.append(entry)
+ return results
+ finally:
+ sock.close()
+
+ return results
+
+
+def _collect_all_offers(
+ host_ip: str,
+ window_secs: float = _SD_OFFER_COLLECTION_WINDOW_SECS,
+) -> Dict[int, SOMEIPSDEntry]:
+ """Collect all OfferService entries within *window_secs*.
+
+ Returns a dict mapping service_id to the most-recently-seen SOMEIPSDEntry.
+ Skips the calling test if the multicast socket cannot be opened.
+ """
+ try:
+ sock = open_multicast_socket(host_ip, port=SD_PORT)
+ except OSError as exc:
+ pytest.skip(
+ f"Multicast socket setup failed on {host_ip}: {exc}. "
+ "Set TC8_HOST_IP to a non-loopback interface IP or add a multicast "
+ "route: sudo ip route add 224.0.0.0/4 dev lo"
+ )
+
+ seen: Dict[int, SOMEIPSDEntry] = {}
+ deadline = time.monotonic() + window_secs
+ try:
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ for entry in parse_sd_offers(data):
+ seen[entry.service_id] = entry
+ finally:
+ sock.close()
+ return seen
+
+
+def _get_udp_endpoint_port(entry: SOMEIPSDEntry) -> Optional[int]:
+ """Return the UDP endpoint port in an SD OfferService entry, or None."""
+ options = list(getattr(entry, "options_1", ())) + list(getattr(entry, "options_2", ()))
+ for opt in options:
+ if isinstance(opt, IPv4EndpointOption) and opt.l4proto == L4Protocols.UDP:
+ return int(opt.port)
+ return None
+
+
+class TestMultiServiceInstanceRouting:
+ """SOMEIPSRV_RPC_13/14: Multi-service config and instance port routing."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__multi_service"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_13_multi_service_config_loads_and_primary_service_offered(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """RPC_13: DUT loads a multi-service config and offers service A.
+
+ When tc8_someipd_multi.json declares two services, the DUT must parse
+ both service entries at startup without error. The DUT then offers the
+ primary service (0x1234/0x5678) via SD; this confirms the multi-service
+ config was accepted by the DUT.
+ """
+ assert dut.poll() is None, "RPC_13: DUT crashed — multi-service config may have caused an initialisation error"
+
+ entries = _collect_offers_for_service(host_ip, _SERVICE_A_ID)
+ assert entries, (
+ f"RPC_13: Service A (0x{_SERVICE_A_ID:04X}/0x{_SERVICE_A_INSTANCE:04X}) "
+ f"not offered within {_DUT_READY_TIMEOUT_SECS}s. "
+ "DUT may have rejected the multi-service config."
+ )
+
+ offered = entries[0]
+ assert offered.service_id == _SERVICE_A_ID, f"RPC_13: Unexpected service_id: 0x{offered.service_id:04X}"
+ assert offered.instance_id == _SERVICE_A_INSTANCE, (
+ f"RPC_13: Unexpected instance_id: 0x{offered.instance_id:04X}"
+ )
+ assert offered.ttl > 0, f"RPC_13: OfferService TTL is 0 (StopOffer) — service not active"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__multi_service"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_14_service_a_advertises_configured_udp_port(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """RPC_14: Service A's OfferService endpoint option matches configured port.
+
+ Per SOMEIPSRV_RPC_14, each service instance must be reachable on its
+ configured UDP port. The SD OfferService IPv4EndpointOption must carry
+ the DUT's configured unreliable port (DUT_UNRELIABLE_PORT /
+ TC8_SVC_PORT = 30512 for the tc8_multi_service Bazel target).
+
+ This verifies that the DUT correctly maps the multi-service config entry
+ to a UDP server endpoint on the right port.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ entries = _collect_offers_for_service(host_ip, _SERVICE_A_ID)
+ assert entries, (
+ f"RPC_14: No OfferService for service A (0x{_SERVICE_A_ID:04X}) received within {_DUT_READY_TIMEOUT_SECS}s."
+ )
+
+ entry = entries[0]
+ port = _get_udp_endpoint_port(entry)
+ assert port is not None, (
+ "RPC_14: Service A OfferService carries no IPv4 UDP endpoint option. "
+ "DUT may not have bound the unreliable port from the multi-service config."
+ )
+ assert port == DUT_UNRELIABLE_PORT, (
+ f"RPC_14: Service A UDP endpoint port mismatch: got {port}, "
+ f"expected {DUT_UNRELIABLE_PORT} (TC8_SVC_PORT from tc8_someipd_multi.json)."
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__multi_service"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_14_no_unexpected_service_ids_in_offers(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """RPC_14: Only configured services appear in SD OfferService messages.
+
+ Collect all SD OfferService entries across a time window and verify
+ that any offered service ID is one of the two configured IDs (0x1234
+ or 0x5678). No phantom service IDs should appear.
+
+ This verifies that the multi-service config does not cause the DUT to
+ offer unexpected services or corrupt the SD entry list.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ seen = _collect_all_offers(host_ip)
+
+ assert _SERVICE_A_ID in seen, (
+ f"RPC_14: No OfferService for service A (0x{_SERVICE_A_ID:04X}) "
+ f"observed in {_SD_OFFER_COLLECTION_WINDOW_SECS}s window."
+ )
+
+ allowed_ids: Set[int] = {_SERVICE_A_ID, _SERVICE_B_ID}
+ unexpected = set(seen.keys()) - allowed_ids
+ assert not unexpected, (
+ f"RPC_14: Unexpected service IDs in SD offers: "
+ f"{[hex(s) for s in unexpected]}. "
+ f"Only {[hex(s) for s in allowed_ids]} are configured."
+ )
diff --git a/tests/tc8_conformance/test_sd_client.py b/tests/tc8_conformance/test_sd_client.py
new file mode 100644
index 000000000..9f6b36f2b
--- /dev/null
+++ b/tests/tc8_conformance/test_sd_client.py
@@ -0,0 +1,397 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 SD Client lifecycle tests: ETS_081/082/084 and skipped ETS_096/097.
+
+This module manages its own someipd lifecycle (launch/terminate per test)
+and must NOT share the module-scoped ``dut`` fixture from conftest.py.
+Running a private DUT avoids routing-manager conflicts with other
+TC8 targets that bind the same SD port.
+
+Port assignment (from BUILD.bazel env):
+ TC8_SD_PORT = 30498 (SD traffic)
+ TC8_SVC_PORT = 30511 (service UDP traffic)
+
+See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture.
+"""
+
+import socket
+import time
+from pathlib import Path
+from typing import List, Tuple
+
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.dut_lifecycle import (
+ cleanup_vsomeip_sockets,
+ launch_dut,
+ render_someip_config,
+ terminate_dut,
+ wait_for_sd_readiness,
+)
+from helpers.constants import (
+ EVENTGROUP_UDP_UNICAST,
+ INSTANCE_ID,
+ MAJOR_VERSION,
+ SD_PORT,
+ SERVICE_ID,
+)
+from helpers.sd_helpers import open_multicast_socket
+from helpers.sd_sender import (
+ SOMEIPSDEntryType,
+ capture_some_ip_messages,
+ capture_unicast_sd_entries,
+ open_sender_socket,
+ send_subscribe_eventgroup,
+)
+from someip.header import SOMEIPHeader, SOMEIPSDHeader
+
+#: Uses the SD config (service 0x1234/0x5678, eventgroup 0x4455 UDP).
+SOMEIP_CONFIG: str = "tc8_someipd_sd.json"
+
+#: Maximum wait time for SD messages, derived from TC8 spec Sec. 4 IUT parameters:
+#: Listen Time (10 s) + Tolerance Time (1 s) + Process Time (2 s) = 13 s,
+#: plus 2 s buffer for container/CI overhead.
+_SD_CAPTURE_TIMEOUT_SECS: float = 15.0
+
+
+def _collect_sd_messages(
+ capture_sock: socket.socket,
+ count: int,
+ timeout_secs: float,
+) -> List[Tuple[SOMEIPHeader, SOMEIPSDHeader]]:
+ """Receive up to *count* SOME/IP-SD messages within *timeout_secs*."""
+ collected: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = []
+ deadline = time.monotonic() + timeout_secs
+ while time.monotonic() < deadline and len(collected) < count:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ capture_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = capture_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ outer, _ = SOMEIPHeader.parse(data)
+ if outer.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload)
+ collected.append((outer, sd_hdr))
+ except Exception: # noqa: BLE001
+ continue
+ return collected
+
+
+def _wait_port_free(port: int, retries: int = 20, delay: float = 0.1) -> None:
+ """Spin until *port* can be bound (i.e. previous someipd has released it)."""
+ for _ in range(retries):
+ try:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ probe.bind(("", port))
+ return
+ except OSError:
+ time.sleep(delay)
+
+
+@pytest.fixture(scope="module")
+def sd_client_config(
+ tmp_path_factory: pytest.TempPathFactory,
+ host_ip: str,
+ request: pytest.FixtureRequest,
+) -> Path:
+ """Render the DUT config template and return the path.
+
+ Triggers ``tc8_itf_config_setup`` (session-scoped) to render vsomeip JSON
+ templates on the QEMU guest before any ``launch_dut`` call. Without
+ this the guest ``/tc8_sd.json`` is absent or has literal ``__TC8_*__``
+ placeholders, causing vsomeip to bind loopback and SD multicast to never
+ egress the TAP interface.
+
+ ``launch_dut`` uses the path's filename to select the correct
+ pre-rendered guest config; the host-side file is harmless.
+ """
+ request.getfixturevalue("tc8_itf_config_setup")
+ tmp_dir = tmp_path_factory.mktemp("tc8_sd_client_config")
+ return render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir)
+
+
+@add_test_properties(
+ partially_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+)
+def test_ets_096_tcp_connection_before_subscribe() -> None:
+ """ETS_096: TCP connection established before SubscribeEventgroup for TCP eventgroup."""
+ pytest.skip(
+ "ETS_096 requires a TCP eventgroup config (tc8_someipd_service.json) and a "
+ "dedicated TC8_SVC_TCP_PORT. The tc8_sd_client target allocates only "
+ "TC8_SD_PORT=30498 and TC8_SVC_PORT=30511 (no TCP port). Implement once "
+ "a dedicated tc8_sd_client_tcp target with TC8_SVC_TCP_PORT is added."
+ )
+
+
+@add_test_properties(
+ partially_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+)
+def test_ets_097_tcp_reconnect() -> None:
+ """ETS_097: TCP reconnection after disconnect yields a new SubscribeAck."""
+ pytest.skip(
+ "ETS_097 requires a TCP eventgroup config (tc8_someipd_service.json) and a "
+ "dedicated TC8_SVC_TCP_PORT. The tc8_sd_client target allocates only "
+ "TC8_SD_PORT=30498 and TC8_SVC_PORT=30511 (no TCP port). Implement once "
+ "a dedicated tc8_sd_client_tcp target with TC8_SVC_TCP_PORT is added."
+ )
+
+
+class TestSDClientStopSubscribe:
+ """ETS_084: Client-initiated StopSubscribeEventgroup stops event delivery."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_084_stop_subscribe_ceases_events(
+ self,
+ sd_client_config: Path,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ request: pytest.FixtureRequest,
+ ) -> None:
+ """ETS_084: After StopSubscribeEventgroup (TTL=0) the DUT stops sending events.
+
+ This test has its own DUT lifecycle so that the StopSubscribe is verified
+ on a fresh subscription with a known notification history.
+ """
+ target_init = request.getfixturevalue("target_init")
+
+ proc = launch_dut(sd_client_config, target_init=target_init)
+ try:
+ # Wait for DUT to reach SD main phase (first OfferService multicast).
+ if not wait_for_sd_readiness(host_ip, timeout_secs=15.0):
+ terminate_dut(proc)
+ pytest.skip("someipd DUT did not reach SD main phase within timeout")
+
+ sd_sock = open_sender_socket(tester_ip)
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port: int = notif_sock.getsockname()[1]
+
+ try:
+
+ def _subscribe() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ )
+
+ _subscribe()
+ acks = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_subscribe,
+ )
+ assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), (
+ "ETS_084: Prerequisite failed — no SubscribeAck received"
+ )
+
+ pre_notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0)
+ assert pre_notifs, "ETS_084: No notifications received after subscribe (prerequisite)"
+
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ ttl=0,
+ )
+
+ post_notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0)
+ assert not post_notifs, (
+ f"ETS_084: {len(post_notifs)} notification(s) received after "
+ "StopSubscribeEventgroup (TTL=0). DUT must cease sending events."
+ )
+ finally:
+ sd_sock.close()
+ notif_sock.close()
+ finally:
+ terminate_dut(proc)
+
+
+class TestSDClientReboot:
+ """ETS_081/082: DUT reboot flag and session reset across restarts."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_reboot"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_081_reboot_flag_set_after_first_restart(
+ self,
+ sd_client_config: Path,
+ host_ip: str,
+ request: pytest.FixtureRequest,
+ ) -> None:
+ """ETS_081: After restart the first SD message has the reboot flag (bit 7) set."""
+ target_init = request.getfixturevalue("target_init")
+
+ try:
+ pre_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip(f"Multicast socket unavailable on {host_ip}")
+
+ try:
+ proc1 = launch_dut(sd_client_config, target_init=target_init)
+ except Exception:
+ pre_sock.close()
+ raise
+
+ try:
+ _collect_sd_messages(pre_sock, count=3, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS)
+ finally:
+ pre_sock.close()
+ terminate_dut(proc1)
+
+ cleanup_vsomeip_sockets(target_init=target_init)
+
+ try:
+ post_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip("Multicast socket unavailable for post-reboot capture")
+
+ try:
+ proc2 = launch_dut(sd_client_config, target_init=target_init)
+ except Exception:
+ post_sock.close()
+ raise
+
+ try:
+ post_messages = _collect_sd_messages(post_sock, count=2, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS)
+ finally:
+ post_sock.close()
+ terminate_dut(proc2)
+
+ assert post_messages, "ETS_081: No SD messages captured after restart"
+
+ outer, sd_hdr = post_messages[0]
+
+ reboot_flag = getattr(sd_hdr, "flag_reboot", None)
+ if reboot_flag is None:
+ raw_flags = getattr(sd_hdr, "flags", 0)
+ reboot_flag = bool(raw_flags & 0x80)
+
+ assert reboot_flag, (
+ "ETS_081: Reboot flag not set in first SD message after restart. "
+ "The DUT must reset the reboot flag when restarted (PRS_SOMEIPSD_00385)."
+ )
+ assert outer.session_id <= 2, (
+ f"ETS_081: session_id after restart = {outer.session_id}; "
+ "expected ≤ 2 (session counter must reset on reboot)"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_reboot"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_082_reboot_flag_set_after_second_restart(
+ self,
+ sd_client_config: Path,
+ host_ip: str,
+ request: pytest.FixtureRequest,
+ ) -> None:
+ """ETS_082: Reboot flag and session reset hold across a second consecutive restart.
+
+ This verifies that the DUT correctly resets SD state on every cold start,
+ not just the first one.
+ """
+ target_init = request.getfixturevalue("target_init")
+
+ def _drain_and_stop(pre_sock: socket.socket) -> None:
+ """Launch DUT, drain 3 messages, terminate, clean up sockets."""
+ try:
+ proc = launch_dut(sd_client_config, target_init=target_init)
+ except Exception:
+ pre_sock.close()
+ raise
+ try:
+ _collect_sd_messages(pre_sock, count=3, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS)
+ finally:
+ pre_sock.close()
+ terminate_dut(proc)
+ cleanup_vsomeip_sockets(target_init=target_init)
+
+ try:
+ sock1 = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip(f"Multicast socket unavailable on {host_ip}")
+ _drain_and_stop(sock1)
+
+ try:
+ sock2 = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip("Multicast socket unavailable for second run")
+ _drain_and_stop(sock2)
+
+ try:
+ post_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip("Multicast socket unavailable for post-reboot capture")
+
+ try:
+ proc3 = launch_dut(sd_client_config, target_init=target_init)
+ except Exception:
+ post_sock.close()
+ raise
+
+ try:
+ post_messages = _collect_sd_messages(post_sock, count=2, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS)
+ finally:
+ post_sock.close()
+ terminate_dut(proc3)
+
+ assert post_messages, "ETS_082: No SD messages captured after second restart"
+
+ outer, sd_hdr = post_messages[0]
+
+ reboot_flag = getattr(sd_hdr, "flag_reboot", None)
+ if reboot_flag is None:
+ raw_flags = getattr(sd_hdr, "flags", 0)
+ reboot_flag = bool(raw_flags & 0x80)
+
+ assert reboot_flag, (
+ "ETS_082: Reboot flag not set after second consecutive restart. "
+ "The DUT must reset the reboot flag on every cold start (PRS_SOMEIPSD_00385)."
+ )
+ assert outer.session_id <= 2, (
+ f"ETS_082: session_id after second restart = {outer.session_id}; "
+ "expected ≤ 2 (session counter must reset on every reboot)"
+ )
diff --git a/tests/tc8_conformance/test_sd_format_compliance.py b/tests/tc8_conformance/test_sd_format_compliance.py
new file mode 100644
index 000000000..c5c74ef62
--- /dev/null
+++ b/tests/tc8_conformance/test_sd_format_compliance.py
@@ -0,0 +1,1450 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 SD Format & Options Field Assertions: FORMAT_01 through OPTIONS_14.
+
+Verifies byte-level field values in SD OfferService messages and
+SubscribeEventgroup Ack responses from someipd. No DUT behaviour is
+triggered beyond normal SD operation; tests only observe what the DUT
+already sends.
+
+Test classes:
+ TestSdHeaderFieldsOfferService -- FORMAT_01/02/04/05/06/09/10
+ TestSdOfferEntryFields -- FORMAT_11/12/13/15/16/18
+ TestSdHeaderFieldsSubscribeAck -- FORMAT_19/20/21/23/24/25/26/27/28
+ TestSdOptionsEndpoint -- OPTIONS_01/02/03/05/06
+ TestSdOptionsMulticast -- OPTIONS_08/09/10/11/12/13/14
+"""
+
+import ipaddress
+import socket
+import subprocess
+import time
+from typing import Optional, Tuple
+
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.constants import (
+ DUT_UNRELIABLE_PORT,
+ EVENTGROUP_UDP_MULTICAST,
+ EVENTGROUP_UDP_UNICAST,
+ INSTANCE_ID,
+ MAJOR_VERSION,
+ MINOR_VERSION,
+ MULTICAST_ADDR,
+ MULTICAST_EVENT_PORT,
+ SD_PORT,
+ SERVICE_ID,
+)
+from helpers.sd_helpers import open_multicast_socket
+from helpers.sd_sender import (
+ capture_unicast_sd_entries,
+ open_sender_socket,
+ send_subscribe_eventgroup,
+)
+from someip.header import (
+ IPv4EndpointOption,
+ IPv4MulticastOption,
+ L4Protocols,
+ SD_INTERFACE_VERSION,
+ SD_METHOD,
+ SD_SERVICE,
+ SOMEIPHeader,
+ SOMEIPMessageType,
+ SOMEIPReturnCode,
+ SOMEIPSDEntry,
+ SOMEIPSDEntryType,
+ SOMEIPSDHeader,
+)
+
+SOMEIP_CONFIG: str = "tc8_someipd_sd.json"
+
+
+def _capture_raw_sd_offer(
+ host_ip: str,
+ timeout_secs: float = 5.0,
+) -> Tuple[SOMEIPHeader, SOMEIPSDHeader, SOMEIPSDEntry, SOMEIPSDEntry]:
+ """Capture the first raw OfferService for ``SERVICE_ID`` on the multicast group.
+
+ Returns a 4-tuple:
+ - raw SOME/IP header
+ - resolved SD header (options populated in entries)
+ - resolved offer entry (``options_1`` / ``options_2`` populated)
+ - unresolved offer entry (``num_options_1`` / ``option_index_1`` populated)
+
+ Callers that only need the resolved entry may use ``result[2]``; callers
+ that need raw option-count fields should use ``result[3]``.
+
+ Raises ``TimeoutError`` when nothing arrives within *timeout_secs*.
+ """
+ sock = open_multicast_socket(host_ip)
+ try:
+ deadline = time.monotonic() + timeout_secs
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != SD_SERVICE:
+ continue
+ sd_hdr_raw, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ sd_hdr_resolved = sd_hdr_raw.resolve_options()
+ for raw_entry, resolved_entry in zip(sd_hdr_raw.entries, sd_hdr_resolved.entries):
+ if (
+ resolved_entry.sd_type == SOMEIPSDEntryType.OfferService
+ and resolved_entry.service_id == SERVICE_ID
+ ):
+ return someip_msg, sd_hdr_resolved, resolved_entry, raw_entry
+ except Exception: # noqa: BLE001
+ continue
+ finally:
+ sock.close()
+
+ raise TimeoutError(
+ f"No OfferService for service 0x{SERVICE_ID:04x} received within {timeout_secs:.1f}s on {host_ip}:{SD_PORT}"
+ )
+
+
+def _capture_subscribe_ack(
+ dut_ip: str,
+ tester_ip: str,
+ eventgroup_id: int,
+ timeout_secs: float = 5.0,
+) -> SOMEIPSDEntry:
+ """Subscribe to *eventgroup_id* and capture the SubscribeEventgroupAck entry.
+
+ Raises ``AssertionError`` when no matching ack arrives.
+ """
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port: int = sock.getsockname()[1]
+
+ def _send_sub() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ eventgroup_id,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send_sub()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=timeout_secs,
+ resend=_send_sub,
+ max_results=1,
+ )
+ acks = [e for e in entries if e.eventgroup_id == eventgroup_id and e.ttl > 0]
+ assert acks, (
+ f"No SubscribeEventgroupAck received for eventgroup 0x{eventgroup_id:04x} within {timeout_secs:.1f}s"
+ )
+ return acks[0]
+ finally:
+ sock.close()
+
+
+def _capture_subscribe_ack_with_options(
+ dut_ip: str,
+ tester_ip: str,
+ eventgroup_id: int,
+ timeout_secs: float = 5.0,
+) -> Tuple[SOMEIPSDEntry, SOMEIPSDEntry]:
+ """Subscribe to *eventgroup_id* and return (unresolved_ack, resolved_ack).
+
+ The unresolved entry comes from ``capture_unicast_sd_entries``;
+ the resolved entry is from a second pass that calls ``resolve_options()``.
+ """
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port: int = sock.getsockname()[1]
+
+ def _send_sub() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ eventgroup_id,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send_sub()
+ deadline = time.monotonic() + timeout_secs
+ next_resend = time.monotonic() + 1.5
+
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ if time.monotonic() >= next_resend:
+ _send_sub()
+ next_resend = time.monotonic() + 1.5
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != SD_SERVICE:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ sd_hdr_resolved = sd_hdr.resolve_options()
+ for unresolved_entry, resolved_entry in zip(sd_hdr.entries, sd_hdr_resolved.entries):
+ if (
+ unresolved_entry.sd_type == SOMEIPSDEntryType.SubscribeAck
+ and unresolved_entry.eventgroup_id == eventgroup_id
+ and unresolved_entry.ttl > 0
+ ):
+ return unresolved_entry, resolved_entry
+ except Exception: # noqa: BLE001
+ continue
+ finally:
+ sock.close()
+
+ raise AssertionError(
+ f"No SubscribeEventgroupAck with options received for eventgroup "
+ f"0x{eventgroup_id:04x} within {timeout_secs:.1f}s"
+ )
+
+
+class TestSdHeaderFieldsOfferService:
+ """FORMAT_01/02/04/05/06/09/10: SOME/IP header fields of an OfferService SD message."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_01_client_id_is_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_01: SOME/IP SD header client_id must be 0x0000."""
+ assert dut.poll() is None, "DUT is not running"
+
+ someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip)
+
+ assert someip_msg.client_id == 0x0000, f"FORMAT_01: client_id must be 0x0000; got 0x{someip_msg.client_id:04x}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_02_session_id_is_nonzero_and_in_range(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_02: SD session_id must be non-zero and fit in 16 bits.
+
+ Per PRS_SOMEIPSD_00154 the session_id starts at 0x0001 and
+ increments with each SD message; the value 0x0000 is reserved. Because
+ the module-scoped DUT may have already sent earlier SD messages (during
+ preceding test classes), the captured value may be greater than 1, but
+ it must never be 0 and must not exceed 0xFFFF.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip, timeout_secs=5.0)
+
+ assert someip_msg.session_id != 0x0000, "FORMAT_02: session_id must never be 0x0000 (reserved)"
+ assert someip_msg.session_id <= 0xFFFF, (
+ f"FORMAT_02: session_id must fit in 16 bits; got 0x{someip_msg.session_id:08x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_04_interface_version_is_one(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_04: SOME/IP SD interface_version must be 0x01."""
+ assert dut.poll() is None, "DUT is not running"
+
+ someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip)
+
+ assert someip_msg.interface_version == SD_INTERFACE_VERSION, (
+ f"FORMAT_04: interface_version must be {SD_INTERFACE_VERSION}; got {someip_msg.interface_version}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_05_message_type_is_notification(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_05: SOME/IP SD message_type must be NOTIFICATION (0x02)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip)
+
+ assert someip_msg.message_type == SOMEIPMessageType.NOTIFICATION, (
+ f"FORMAT_05: message_type must be NOTIFICATION "
+ f"(0x{SOMEIPMessageType.NOTIFICATION:02x}); "
+ f"got 0x{someip_msg.message_type:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_06_return_code_is_e_ok(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_06: SOME/IP SD return_code must be E_OK (0x00)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip)
+
+ assert someip_msg.return_code == SOMEIPReturnCode.E_OK, (
+ f"FORMAT_06: return_code must be E_OK (0x{SOMEIPReturnCode.E_OK:02x}); got 0x{someip_msg.return_code:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_09_sd_flags_reserved_bits_are_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_09: Reserved bits (5-0) of the SD Flags byte must be 0."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, sd_hdr, _, _ = _capture_raw_sd_offer(host_ip)
+
+ # flags_unknown captures bits 5-0 (the reserved/undefined bits).
+ assert sd_hdr.flags_unknown == 0, (
+ f"FORMAT_09: SD Flags reserved bits (5-0) must be 0; flags_unknown=0x{sd_hdr.flags_unknown:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_10_sd_entry_reserved_bytes_are_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_10: Reserved bytes in the 16-byte OfferService SD entry must be 0.
+
+ SOME/IP-SD entry layout (16 bytes):
+ [0] type
+ [1] index_first_option_run (0 when no options before type-1 entries)
+ [2] index_second_option_run (reserved in OfferService, must be 0)
+ [3] high nibble = num_options_run1, low nibble = num_options_run2
+ [4-5] service_id
+ [6-7] instance_id
+ [8] major_version
+ [9-11] TTL (3 bytes)
+ [12-15] minor_version (4 bytes)
+
+ Byte [2] (index_second_option_run) is reserved for Type-1 OfferService
+ entries and must be transmitted as 0.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ # Assign option index so the entry can be built.
+ assigned = entry.assign_option_index([])
+ raw = assigned.build()
+
+ reserved_byte = raw[2]
+ assert reserved_byte == 0, (
+ f"FORMAT_10: Reserved byte [2] in OfferService SD entry must be 0; got 0x{reserved_byte:02x}"
+ )
+
+
+class TestSdOfferEntryFields:
+ """FORMAT_11/12/13/15/16/18: OfferService SD entry field assertions."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_11_entry_is_16_bytes(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_11: Each SD entry must be exactly 16 bytes."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assigned = entry.assign_option_index([])
+ entry_bytes = assigned.build()
+
+ assert len(entry_bytes) == 16, f"FORMAT_11: SD entry must be 16 bytes; got {len(entry_bytes)}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_12_first_option_run_index_is_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_12: OfferService first option run index must be 0."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assigned = entry.assign_option_index([])
+ raw = assigned.build()
+
+ option_index_1 = raw[1]
+ assert option_index_1 == 0, f"FORMAT_12: option_index_1 (byte [1]) must be 0; got 0x{option_index_1:02x}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_13_num_options_matches_options_list(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_13: num_options_1 field must equal the number of resolved options."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, raw_entry = _capture_raw_sd_offer(host_ip)
+
+ # After resolve_options() the ``num_options_1`` field is None (the library
+ # replaces the counter with the actual option objects). Use the raw entry's
+ # counter (preserved before resolve) against the resolved entry's options list.
+ assert raw_entry.num_options_1 == len(entry.options_1), (
+ f"FORMAT_13: num_options_1 ({raw_entry.num_options_1}) must equal len(options_1) ({len(entry.options_1)})"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_15_instance_id_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_15: OfferService instance_id must match the configured value."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assert entry.instance_id == INSTANCE_ID, (
+ f"FORMAT_15: instance_id must be 0x{INSTANCE_ID:04x}; got 0x{entry.instance_id:04x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_16_major_version_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_16: OfferService major_version must match the configured value."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assert entry.major_version == MAJOR_VERSION, (
+ f"FORMAT_16: major_version must be 0x{MAJOR_VERSION:02x}; got 0x{entry.major_version:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_18_minor_version_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_18: OfferService minor_version must match the configured value."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assert entry.service_minor_version == MINOR_VERSION, (
+ f"FORMAT_18: minor_version must be 0x{MINOR_VERSION:08x}; got 0x{entry.service_minor_version:08x}"
+ )
+
+
+class TestSdHeaderFieldsSubscribeAck:
+ """FORMAT_19/20/21/23/24/25/26/27/28: SubscribeEventgroupAck entry field assertions."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_19_ack_entry_type_is_subscribe_ack(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_19: SubscribeEventgroupAck SD entry type must be 0x07."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assert ack.sd_type == SOMEIPSDEntryType.SubscribeAck, (
+ f"FORMAT_19: sd_type must be SubscribeAck (0x07); got {ack.sd_type!r}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_20_ack_entry_is_16_bytes(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_20: SubscribeEventgroupAck SD entry must be exactly 16 bytes."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assigned = ack.assign_option_index([])
+ entry_bytes = assigned.build()
+
+ assert len(entry_bytes) == 16, f"FORMAT_20: SubscribeAck SD entry must be 16 bytes; got {len(entry_bytes)}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_21_ack_option_run_index_is_zero_when_no_options(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_21: SubscribeAck option_index_1 matches the attached options."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assigned = ack.assign_option_index([])
+ raw = assigned.build()
+ option_index_1 = raw[1]
+ expected_option_count = ack.num_options_1
+
+ if expected_option_count == 0:
+ assert option_index_1 == 0, (
+ f"FORMAT_21: option_index_1 must be 0 when no options; got 0x{option_index_1:02x}"
+ )
+ else:
+ # Non-zero index is valid only when the SD packet carries options.
+ assert option_index_1 < 16, (
+ f"FORMAT_21: option_index_1 must be a valid index (< 16); got 0x{option_index_1:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_23_ack_service_id_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_23: SubscribeAck service_id must match the subscribed service."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assert ack.service_id == SERVICE_ID, (
+ f"FORMAT_23: service_id must be 0x{SERVICE_ID:04x}; got 0x{ack.service_id:04x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_24_ack_instance_id_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_24: SubscribeAck instance_id must match the subscribed instance."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assert ack.instance_id == INSTANCE_ID, (
+ f"FORMAT_24: instance_id must be 0x{INSTANCE_ID:04x}; got 0x{ack.instance_id:04x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_25_ack_major_version_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_25: SubscribeAck major_version must match the service definition."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assert ack.major_version == MAJOR_VERSION, (
+ f"FORMAT_25: major_version must be 0x{MAJOR_VERSION:02x}; got 0x{ack.major_version:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_26_ack_ttl_is_nonzero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_26: SubscribeEventgroupAck TTL must be > 0 (TTL=0 means Nack)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assert ack.ttl > 0, f"FORMAT_26: SubscribeAck TTL must be > 0; got {ack.ttl}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_27_ack_reserved_field_is_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_27: Reserved bits (high 12 bits of last 4 bytes) of SubscribeAck must be 0.
+
+ In the 16-byte SD entry for SubscribeEventgroupAck:
+ bytes [12-13]: high 4 bits = reserved counter (must be 0)
+ bytes [14-15]: eventgroup_id
+
+ The ``minver_or_counter`` field encodes ``(reserved << 16) | eventgroup_id``.
+ The high 16 bits must be 0.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ reserved_high = (ack.minver_or_counter >> 16) & 0xFFFF
+ assert reserved_high == 0, (
+ f"FORMAT_27: High 16 bits of SubscribeAck minver_or_counter must be 0; "
+ f"got 0x{reserved_high:04x} (minver_or_counter=0x{ack.minver_or_counter:08x})"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_28_ack_eventgroup_id_matches_subscribe(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_28: SubscribeAck eventgroup_id must match the subscribed eventgroup."""
+ assert dut.poll() is None, "DUT is not running"
+
+ ack = _capture_subscribe_ack(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ assert (ack.minver_or_counter & 0xFFFF) == EVENTGROUP_UDP_UNICAST, (
+ f"FORMAT_28: eventgroup_id in SubscribeAck must be "
+ f"0x{EVENTGROUP_UDP_UNICAST:04x}; "
+ f"got 0x{ack.minver_or_counter & 0xFFFF:04x}"
+ )
+
+
+class TestSdOptionsEndpoint:
+ """OPTIONS_01/02/03/05/06: IPv4EndpointOption field assertions from OfferService."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_01_endpoint_option_length_is_nine(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """OPTIONS_01: IPv4EndpointOption length field must be 0x0009."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)]
+ assert ipv4_opts, "OPTIONS_01: No IPv4EndpointOption found in OfferService entry"
+ opt = ipv4_opts[0]
+ raw = opt.build()
+
+ # Wire format: [0-1] length field (big-endian).
+ length_field = int.from_bytes(raw[0:2], "big")
+ assert length_field == 0x0009, (
+ f"OPTIONS_01: IPv4EndpointOption length field must be 0x0009; got 0x{length_field:04x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_02_endpoint_option_type_is_0x04(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """OPTIONS_02: IPv4EndpointOption type byte must be 0x04."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)]
+ assert ipv4_opts, "OPTIONS_02: No IPv4EndpointOption found in OfferService entry"
+ opt = ipv4_opts[0]
+ raw = opt.build()
+
+ # Wire format: [2] type byte.
+ type_byte = raw[2]
+ assert type_byte == 0x04, f"OPTIONS_02: IPv4EndpointOption type byte must be 0x04; got 0x{type_byte:02x}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_03_endpoint_option_reserved_after_type_is_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """OPTIONS_03: Reserved byte at offset [3] of IPv4EndpointOption must be 0x00."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)]
+ assert ipv4_opts, "OPTIONS_03: No IPv4EndpointOption found in OfferService entry"
+ opt = ipv4_opts[0]
+ raw = opt.build()
+
+ # Wire format: [3] reserved byte after type.
+ reserved_byte = raw[3]
+ assert reserved_byte == 0x00, (
+ f"OPTIONS_03: IPv4EndpointOption reserved byte [3] must be 0x00; got 0x{reserved_byte:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_05_endpoint_option_reserved_before_protocol_is_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """OPTIONS_05: Reserved byte at offset [8] of IPv4EndpointOption must be 0x00."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)]
+ assert ipv4_opts, "OPTIONS_05: No IPv4EndpointOption found in OfferService entry"
+ opt = ipv4_opts[0]
+ raw = opt.build()
+
+ # Wire format: [8] reserved byte before protocol byte.
+ reserved_byte = raw[8]
+ assert reserved_byte == 0x00, (
+ f"OPTIONS_05: IPv4EndpointOption reserved byte [8] must be 0x00; got 0x{reserved_byte:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_06_endpoint_option_protocol_is_udp(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """OPTIONS_06: IPv4EndpointOption L4 protocol must be UDP (0x11)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ ipv4_opts = [o for o in entry.options_1 if isinstance(o, IPv4EndpointOption)]
+ assert ipv4_opts, "OPTIONS_06: No IPv4EndpointOption found in OfferService entry"
+ opt = ipv4_opts[0]
+
+ assert opt.l4proto == L4Protocols.UDP, (
+ f"OPTIONS_06: IPv4EndpointOption l4proto must be UDP (0x{L4Protocols.UDP:02x}); got {opt.l4proto!r}"
+ )
+
+
+class TestSdOptionsMulticast:
+ """OPTIONS_08/09/10/11/12/13/14: IPv4MulticastOption field assertions from SubscribeAck."""
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_08_multicast_option_length_is_nine(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """OPTIONS_08: IPv4MulticastOption length field must be 0x0009."""
+ assert dut.poll() is None, "DUT is not running"
+
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "OPTIONS_08: Multicast endpoint option in SubscribeAck requires a non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address."
+ )
+
+ _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST)
+
+ all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2)
+ mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)]
+ assert mcast_opts, (
+ f"OPTIONS_08: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ opt = mcast_opts[0]
+ raw = opt.build()
+
+ length_field = int.from_bytes(raw[0:2], "big")
+ assert length_field == 0x0009, (
+ f"OPTIONS_08: IPv4MulticastOption length field must be 0x0009; got 0x{length_field:04x}"
+ )
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_09_multicast_option_type_is_0x14(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """OPTIONS_09: IPv4MulticastOption type byte must be 0x14 (decimal 20)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "OPTIONS_09: Multicast endpoint option in SubscribeAck requires a non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address."
+ )
+
+ _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST)
+
+ all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2)
+ mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)]
+ assert mcast_opts, (
+ f"OPTIONS_09: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ opt = mcast_opts[0]
+ raw = opt.build()
+
+ type_byte = raw[2]
+ assert type_byte == 0x14, f"OPTIONS_09: IPv4MulticastOption type byte must be 0x14; got 0x{type_byte:02x}"
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_10_multicast_option_reserved_is_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """OPTIONS_10: Reserved byte at offset [3] of IPv4MulticastOption must be 0x00."""
+ assert dut.poll() is None, "DUT is not running"
+
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "OPTIONS_10: Multicast endpoint option in SubscribeAck requires a non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address."
+ )
+
+ _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST)
+
+ all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2)
+ mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)]
+ assert mcast_opts, (
+ f"OPTIONS_10: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ opt = mcast_opts[0]
+ raw = opt.build()
+
+ reserved_byte = raw[3]
+ assert reserved_byte == 0x00, (
+ f"OPTIONS_10: IPv4MulticastOption reserved byte [3] must be 0x00; got 0x{reserved_byte:02x}"
+ )
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_11_multicast_address_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """OPTIONS_11: IPv4MulticastOption address must match the configured multicast address."""
+ assert dut.poll() is None, "DUT is not running"
+
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "OPTIONS_11: Multicast endpoint option in SubscribeAck requires a non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address."
+ )
+
+ _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST)
+
+ all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2)
+ mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)]
+ assert mcast_opts, (
+ f"OPTIONS_11: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ opt = mcast_opts[0]
+
+ expected_addr = ipaddress.IPv4Address(MULTICAST_ADDR)
+ assert opt.address == expected_addr, (
+ f"OPTIONS_11: multicast address must be {MULTICAST_ADDR}; got {opt.address}"
+ )
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_12_multicast_option_reserved_before_port_is_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """OPTIONS_12: Reserved byte at offset [8] of IPv4MulticastOption must be 0x00."""
+ assert dut.poll() is None, "DUT is not running"
+
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "OPTIONS_12: Multicast endpoint option in SubscribeAck requires a non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address."
+ )
+
+ _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST)
+
+ all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2)
+ mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)]
+ assert mcast_opts, (
+ f"OPTIONS_12: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ opt = mcast_opts[0]
+ raw = opt.build()
+
+ reserved_byte = raw[8]
+ assert reserved_byte == 0x00, (
+ f"OPTIONS_12: IPv4MulticastOption reserved byte [8] must be 0x00; got 0x{reserved_byte:02x}"
+ )
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_13_multicast_option_protocol_is_udp(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """OPTIONS_13: IPv4MulticastOption L4 protocol must be UDP (0x11)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "OPTIONS_13: Multicast endpoint option in SubscribeAck requires a non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address."
+ )
+
+ _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST)
+
+ all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2)
+ mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)]
+ assert mcast_opts, (
+ f"OPTIONS_13: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ opt = mcast_opts[0]
+
+ assert opt.l4proto == L4Protocols.UDP, (
+ f"OPTIONS_13: IPv4MulticastOption l4proto must be UDP (0x{L4Protocols.UDP:02x}); got {opt.l4proto!r}"
+ )
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_options_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_options_14_multicast_port_matches_config(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """OPTIONS_14: IPv4MulticastOption port must match the configured multicast port."""
+ assert dut.poll() is None, "DUT is not running"
+
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "OPTIONS_14: Multicast endpoint option in SubscribeAck requires a non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address."
+ )
+
+ _, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_MULTICAST)
+
+ all_opts = list(resolved_ack.options_1) + list(resolved_ack.options_2)
+ mcast_opts = [o for o in all_opts if isinstance(o, IPv4MulticastOption)]
+ assert mcast_opts, (
+ f"OPTIONS_14: No IPv4MulticastOption found in SubscribeAck for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ opt = mcast_opts[0]
+
+ assert opt.port == MULTICAST_EVENT_PORT, (
+ f"OPTIONS_14: multicast port must be {MULTICAST_EVENT_PORT}; got {opt.port}"
+ )
+
+
+class TestSdMissingFormatFields:
+ """FORMAT_03/07/14/17/22: previously unverified SD format fields."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_03_protocol_version_is_one(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_03: SOME/IP protocol_version in the SD message header must be 1.
+
+ The SOME/IP header byte 12 (protocol_version) must be 0x01 per
+ PRS_SOMEIP_00052. This applies to all SD messages including OfferService.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ someip_msg, _, _, _ = _capture_raw_sd_offer(host_ip)
+
+ assert someip_msg.protocol_version == 1, (
+ f"FORMAT_03: protocol_version must be 0x01; got 0x{someip_msg.protocol_version:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_08_unicast_flag_set(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_08: SD Flags byte, Unicast flag (bit 6) must be set in OfferService.
+
+ PRS_SOMEIPSD_00351: The Unicast flag indicates the sender supports unicast
+ communication.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ _, sd_hdr, _, _ = _capture_raw_sd_offer(host_ip)
+
+ assert sd_hdr.flag_unicast, (
+ f"FORMAT_08: SD Flags Unicast flag (bit 6) must be set in OfferService; flag_unicast={sd_hdr.flag_unicast}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_14_entry_type_is_offer(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_14: OfferService entry Type byte (byte[0]) must be 0x01.
+
+ SOME/IP-SD Type 1 entry (OfferService) has type byte 0x01 per
+ PRS_SOMEIPSD_00306. This test reads the raw serialised entry byte
+ directly rather than relying on the parsed enum value.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assigned = entry.assign_option_index([])
+ raw = assigned.build()
+
+ type_byte = raw[0]
+ assert type_byte == 0x01, f"FORMAT_14: OfferService entry Type byte must be 0x01; got 0x{type_byte:02x}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_17_ttl_is_nonzero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """FORMAT_17: OfferService entry TTL (3-byte big-endian, bytes[9–11]) must be > 0.
+
+ TTL = 0 in an OfferService entry is reserved for StopOfferService
+ (PRS_SOMEIPSD_00137). A live service must advertise TTL > 0.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assigned = entry.assign_option_index([])
+ raw = assigned.build()
+
+ # Bytes [9-11] are the 3-byte big-endian TTL field.
+ ttl_value = int.from_bytes(raw[9:12], "big")
+ assert ttl_value > 0, f"FORMAT_17: OfferService entry TTL (bytes[9-11]) must be > 0; got {ttl_value}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_format_22_ack_num_options_1_matches(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """FORMAT_22: SubscribeAck entry num_options_1 must equal len(options_1) list.
+
+ After options are resolved, the original num_options_1 counter in the
+ raw entry must match the number of option objects in the resolved list.
+ This verifies the DUT serialises the options-count nibble correctly.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ unresolved_ack, resolved_ack = _capture_subscribe_ack_with_options(dut_ip, tester_ip, EVENTGROUP_UDP_UNICAST)
+
+ # unresolved_ack.num_options_1 is the raw counter from the wire.
+ # resolved_ack.options_1 is the list populated by resolve_options().
+ assert unresolved_ack.num_options_1 == len(resolved_ack.options_1), (
+ f"FORMAT_22: SubscribeAck num_options_1 ({unresolved_ack.num_options_1}) "
+ f"must equal len(options_1) ({len(resolved_ack.options_1)})"
+ )
+
+
+class TestSdEntryOptionFields:
+ """SD_MESSAGE_07-09, SD_MESSAGE_11, SD_MESSAGE_12: entry option-run fields."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_07_offer_entry_type_byte(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """SD_MESSAGE_07: OfferService entry raw Type byte must be 0x01.
+
+ PRS_SOMEIPSD_00306 assigns Type=0x01 to OfferService.
+ Duplicate of FORMAT_14 at the SD_MESSAGE layer to satisfy the
+ SD_MESSAGE_07 traceability requirement.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assigned = entry.assign_option_index([])
+ raw = assigned.build()
+
+ type_byte = raw[0]
+ assert type_byte == 0x01, f"SD_MESSAGE_07: OfferService entry Type byte must be 0x01; got 0x{type_byte:02x}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_08_offer_entry_option_run2_index_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """SD_MESSAGE_08: OfferService entry option_index_2 must be 0 (single option run).
+
+ A standard OfferService entry uses only the first option run.
+ Byte[2] of the 16-byte entry holds option_index_2 (the index into
+ the Options Array for the second option run) and must be 0 when only
+ one option run is used.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, _ = _capture_raw_sd_offer(host_ip)
+
+ assigned = entry.assign_option_index([])
+ raw = assigned.build()
+
+ # Byte [2] is option_index_2 (second option-run start index).
+ option_index_2 = raw[2]
+ assert option_index_2 == 0, (
+ f"SD_MESSAGE_08: OfferService option_index_2 (byte[2]) must be 0; got 0x{option_index_2:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_09_offer_entry_num_options_2_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """SD_MESSAGE_09: OfferService entry num_options_2 must be 0 (single option run).
+
+ Byte[3] of the 16-byte SD entry encodes num_options_1 in the high nibble
+ and num_options_2 in the low nibble. A standard OfferService with a single
+ option run must have num_options_2 == 0 (low nibble = 0).
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ _, _, entry, raw_entry = _capture_raw_sd_offer(host_ip)
+
+ assigned = entry.assign_option_index([])
+ raw = assigned.build()
+
+ # Byte [3]: high nibble = num_options_1, low nibble = num_options_2.
+ num_options_2 = raw[3] & 0x0F
+ assert num_options_2 == 0, (
+ f"SD_MESSAGE_09: OfferService num_options_2 (low nibble of byte[3]) "
+ f"must be 0; got 0x{num_options_2:01x} "
+ f"(full byte[3]: 0x{raw[3]:02x})"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_format_fields"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_11_subscribe_entry_type_byte(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SD_MESSAGE_11: A SubscribeEventgroup entry must have Type byte 0x06.
+
+ PRS_SOMEIPSD_00306 assigns Type=0x06 to SubscribeEventgroup (Type 2 entry).
+ This test builds a SubscribeEventgroup entry using the same helper used
+ by the SD sender (``send_subscribe_eventgroup``) and verifies that the
+ serialised entry byte[0] == 0x06. This confirms our sender constructs
+ conformant SD messages.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port: int = sock.getsockname()[1]
+ # Build a subscribe entry directly and inspect the raw bytes.
+ endpoint_opt = IPv4EndpointOption(
+ address=ipaddress.IPv4Address(tester_ip),
+ l4proto=L4Protocols.UDP,
+ port=sender_port,
+ )
+ subscribe_entry = SOMEIPSDEntry(
+ sd_type=SOMEIPSDEntryType.Subscribe,
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ major_version=MAJOR_VERSION,
+ ttl=3,
+ minver_or_counter=EVENTGROUP_UDP_UNICAST & 0xFFFF,
+ options_1=(endpoint_opt,),
+ )
+ options: list = []
+ assigned = subscribe_entry.assign_option_index(options)
+ raw = assigned.build()
+ finally:
+ sock.close()
+
+ type_byte = raw[0]
+ assert type_byte == 0x06, (
+ f"SD_MESSAGE_11: SubscribeEventgroup entry Type byte must be 0x06; got 0x{type_byte:02x}"
+ )
+
+
+class TestSdStopSubscribeFormat:
+ """SD_MESSAGE_12: StopSubscribeEventgroup entry format byte-level assertions.
+
+ Per PRS_SOMEIPSD_00386 a StopSubscribeEventgroup entry is a
+ SubscribeEventgroup entry (Type 0x06) with TTL set to 0x000000.
+ This class verifies the byte-level layout of such an entry without
+ any DUT interaction (the entry is constructed in memory and inspected
+ directly).
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_stop_sub_fmt"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_12_stop_subscribe_entry_format(self) -> None:
+ """SD_MESSAGE_12: StopSubscribeEventgroup entry has Type=0x06 and TTL=0.
+
+ SOMEIPSRV_SD_MESSAGE_12 (Sec. 5.1.5.3): A StopSubscribeEventgroup message
+ is a SubscribeEventgroup entry (Type=0x06) with TTL=0x000000.
+
+ This test constructs the 16-byte entry in memory and verifies:
+ - byte[0] == 0x06 (entry type field)
+ - byte[9:12] == b'\\x00\\x00\\x00' (TTL = 0)
+
+ No DUT interaction is required. This is a pure structural assertion on
+ the serialised entry layout.
+ """
+ stop_subscribe_entry = SOMEIPSDEntry(
+ sd_type=SOMEIPSDEntryType.Subscribe,
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ major_version=MAJOR_VERSION,
+ ttl=0,
+ minver_or_counter=EVENTGROUP_UDP_UNICAST & 0xFFFF,
+ options_1=(),
+ )
+ options: list = []
+ assigned = stop_subscribe_entry.assign_option_index(options)
+ entry = assigned.build()
+
+ assert len(entry) == 16, f"SD_MESSAGE_12: StopSubscribeEventgroup entry must be 16 bytes; got {len(entry)}"
+
+ type_byte = entry[0]
+ assert type_byte == 0x06, (
+ f"SD_MESSAGE_12: StopSubscribeEventgroup Type byte must be 0x06 "
+ f"(same as SubscribeEventgroup); got 0x{type_byte:02x}"
+ )
+
+ ttl_bytes = entry[9:12]
+ assert ttl_bytes == b"\x00\x00\x00", (
+ f"SD_MESSAGE_12: StopSubscribeEventgroup TTL bytes[9:12] must be b'\\x00\\x00\\x00'; got {ttl_bytes!r}"
+ )
diff --git a/tests/tc8_conformance/test_sd_phases_timing.py b/tests/tc8_conformance/test_sd_phases_timing.py
new file mode 100644
index 000000000..97a36410e
--- /dev/null
+++ b/tests/tc8_conformance/test_sd_phases_timing.py
@@ -0,0 +1,162 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 SD phase timing tests: TC8-SD-009 and TC8-SD-010.
+
+Verifies that ``someipd`` goes through the Repetition Phase (short intervals)
+before entering the Main Phase (long cyclic_offer_delay intervals).
+
+See ``docs/architecture/tc8_conformance_testing.rst``.
+"""
+
+from typing import Generator, List, Tuple
+
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.dut_lifecycle import (
+ launch_dut,
+ render_someip_config,
+ terminate_dut,
+)
+from helpers.constants import SERVICE_ID
+from helpers.sd_helpers import open_multicast_socket
+from helpers.timing import collect_sd_offers_from_socket
+from someip.header import SOMEIPSDEntry
+
+SOMEIP_CONFIG: str = "tc8_someipd_sd.json"
+
+#: SD configuration values from ``tc8_someipd_sd.json``.
+_CYCLIC_OFFER_DELAY_MS: float = 2000.0
+_REPETITIONS_BASE_DELAY_MS: float = 200.0
+_REPETITIONS_MAX: int = 3
+
+#: Capture timeout accounts for DUT startup, repetition phase, and one cyclic
+#: gap. Theoretical minimum ~3.5 s (100 ms initial + 1.4 s reps + 2 s cyclic),
+#: with generous margin for slow CI/container environments.
+_PHASE_CAPTURE_TIMEOUT_SECS: float = 20.0
+
+
+@pytest.fixture(scope="module")
+def sd_phase_capture(
+ tmp_path_factory: pytest.TempPathFactory,
+ host_ip: str,
+ request: pytest.FixtureRequest,
+) -> Generator[List[Tuple[float, SOMEIPSDEntry]], None, None]:
+ """Start a fresh someipd and capture timestamped SD offers.
+
+ Opens the multicast socket BEFORE launching someipd to guarantee the first
+ offer is captured. Yields the captured data to the tests.
+
+ someipd runs on the QEMU guest; ``host_ip`` is the host TAP interface
+ (169.254.21.88) used for multicast join.
+ """
+ target_init = request.getfixturevalue("target_init")
+
+ # Ensure guest vsomeip configs are rendered (sed on QEMU guest).
+ # Without this the guest /tc8_sd.json is absent or has literal placeholders,
+ # causing vsomeip to bind loopback and SD multicast to never reach the host.
+ request.getfixturevalue("tc8_itf_config_setup")
+
+ tmp_dir = tmp_path_factory.mktemp("tc8_phase_config")
+ # Host-side render is harmless; launch_dut uses the filename to select
+ # the pre-rendered guest config.
+ config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir)
+
+ # Open the socket BEFORE launching someipd so the first offer is captured.
+ try:
+ capture_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip(
+ f"Multicast socket setup failed on {host_ip}. "
+ "Set TC8_HOST_IP to a non-loopback IP or add a multicast route: "
+ "sudo ip route add 224.0.0.0/4 dev lo"
+ )
+
+ proc = launch_dut(config_path, target_init=target_init)
+
+ try:
+ offers = collect_sd_offers_from_socket(
+ capture_sock,
+ count=5, # initial offer + 3 reps + 1 cyclic
+ timeout_secs=_PHASE_CAPTURE_TIMEOUT_SECS,
+ )
+ except TimeoutError:
+ offers = [] # tests will handle empty capture with appropriate assertions
+ finally:
+ capture_sock.close()
+
+ terminate_dut(proc)
+
+ yield offers
+
+
+class TestSDPhasesTiming:
+ """TC8-SD-009/010: Repetition Phase intervals and count."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_phases_timing"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_009_repetition_phase_intervals(
+ self,
+ sd_phase_capture: List[Tuple[float, SOMEIPSDEntry]],
+ ) -> None:
+ """TC8-SD-009: Repetition Phase offer gaps are shorter than cyclic_offer_delay."""
+ service_offers = [(ts, e) for ts, e in sd_phase_capture if e.service_id == SERVICE_ID]
+ assert len(service_offers) >= 2, "TC8-SD-009: Not enough OfferService entries captured for timing analysis"
+
+ # The first gap must be a Repetition Phase gap (< half the cyclic period).
+ gap_ms = (service_offers[1][0] - service_offers[0][0]) * 1000.0
+ cyclic_half_ms = _CYCLIC_OFFER_DELAY_MS * 0.5
+
+ assert gap_ms < cyclic_half_ms, (
+ f"TC8-SD-009: First inter-offer gap {gap_ms:.0f} ms >= {cyclic_half_ms:.0f} ms; "
+ f"expected a Repetition Phase gap (< {cyclic_half_ms:.0f} ms)"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_phases_timing"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_010_repetition_count_before_main_phase(
+ self,
+ sd_phase_capture: List[Tuple[float, SOMEIPSDEntry]],
+ ) -> None:
+ """TC8-SD-010: At least repetitions_max - 1 short-gap offers before Main Phase.
+
+ The first offer may be missed due to a race between socket setup and
+ process startup. Requiring ``repetitions_max - 1 = 2`` short gaps
+ still proves the Repetition Phase happened with doubling intervals.
+ """
+ service_offers = [(ts, e) for ts, e in sd_phase_capture if e.service_id == SERVICE_ID]
+ assert len(service_offers) >= 2, "TC8-SD-010: Not enough OfferService entries captured for phase counting"
+
+ # Count "short" gaps (Repetition Phase) vs "long" gaps (Main Phase).
+ cyclic_half_ms = _CYCLIC_OFFER_DELAY_MS * 0.5
+ short_gap_count = 0
+
+ for i in range(len(service_offers) - 1):
+ gap_ms = (service_offers[i + 1][0] - service_offers[i][0]) * 1000.0
+ if gap_ms < cyclic_half_ms:
+ short_gap_count += 1
+
+ # Allow repetitions_max - 1 to tolerate the first-offer capture race.
+ min_short_gaps = _REPETITIONS_MAX - 1
+ assert short_gap_count >= min_short_gaps, (
+ f"TC8-SD-010: Only {short_gap_count} Repetition Phase gap(s) observed; "
+ f"expected at least {min_short_gaps} "
+ f"(repetitions_max={_REPETITIONS_MAX}, tolerance: -1 for first-offer capture race)"
+ )
diff --git a/tests/tc8_conformance/test_sd_reboot.py b/tests/tc8_conformance/test_sd_reboot.py
new file mode 100644
index 000000000..1c7971de5
--- /dev/null
+++ b/tests/tc8_conformance/test_sd_reboot.py
@@ -0,0 +1,373 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 Service Discovery: Reboot Detection tests (TC8-SD-012).
+
+Isolated in a separate module because these tests manage their own ``someipd``
+lifecycle (start, drain, stop, restart) and must not share the
+module-scoped ``dut`` fixture used by ``test_service_discovery.py``.
+Running both in the same module would cause a routing-manager conflict.
+
+See ``docs/tc8_conformance/requirements.rst`` for requirement traceability.
+"""
+
+import socket
+import time
+from typing import List, Tuple
+
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.dut_lifecycle import (
+ cleanup_vsomeip_sockets,
+ launch_dut,
+ render_someip_config,
+ terminate_dut,
+)
+from helpers.constants import SD_PORT
+from helpers.sd_helpers import open_multicast_socket
+from someip.header import SOMEIPHeader, SOMEIPSDHeader
+
+SOMEIP_CONFIG: str = "tc8_someipd_sd.json"
+
+#: Maximum wait time for SD messages, derived from TC8 spec Sec. 4 IUT parameters:
+#: Listen Time (10 s) + Tolerance Time (1 s) + Process Time (2 s) = 13 s,
+#: plus 2 s buffer for container/CI overhead.
+_SD_CAPTURE_TIMEOUT_SECS: float = 15.0
+
+
+@pytest.fixture(scope="module")
+def sd_reboot_capture(
+ tmp_path_factory: pytest.TempPathFactory,
+ host_ip: str,
+ request: pytest.FixtureRequest,
+) -> List[Tuple[SOMEIPHeader, SOMEIPSDHeader]]:
+ """Start someipd, drain stable SD messages, restart, return post-reboot messages.
+
+ Returns a list of (outer_header, sd_header) tuples captured after the restart.
+ The multicast socket is opened *before* the second launch so the very first
+ post-reboot SD packet is captured.
+
+ someipd runs on the QEMU guest; vsomeip socket cleanup also runs on the guest.
+ """
+ target_init = request.getfixturevalue("target_init")
+
+ # Ensure guest vsomeip configs are rendered (sed on QEMU guest).
+ # Without this the guest /tc8_sd.json is absent or has literal placeholders,
+ # causing vsomeip to bind loopback and SD multicast to never reach the host.
+ request.getfixturevalue("tc8_itf_config_setup")
+
+ tmp_dir = tmp_path_factory.mktemp("tc8_reboot_config")
+ # Host-side render is harmless; launch_dut uses the filename to select
+ # the pre-rendered guest config.
+ config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir)
+
+ def _collect_sd_messages(
+ capture_sock: socket.socket,
+ count: int,
+ timeout_secs: float,
+ ) -> List[Tuple[SOMEIPHeader, SOMEIPSDHeader]]:
+ """Receive up to *count* SOME/IP-SD messages within *timeout_secs*."""
+ collected: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = []
+ deadline = time.monotonic() + timeout_secs
+ while time.monotonic() < deadline and len(collected) < count:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ capture_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = capture_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ outer, _ = SOMEIPHeader.parse(data)
+ if outer.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload)
+ collected.append((outer, sd_hdr))
+ except Exception: # noqa: BLE001
+ continue
+ return collected
+
+ try:
+ pre_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip(f"Multicast socket setup failed on {host_ip}. Set TC8_HOST_IP to a non-loopback IP.")
+
+ try:
+ proc1 = launch_dut(config_path, target_init=target_init)
+ except Exception:
+ pre_sock.close()
+ raise
+
+ try:
+ # Drain 3 SD messages so the DUT has cleared the reboot flag.
+ _collect_sd_messages(pre_sock, count=3, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS)
+ finally:
+ pre_sock.close()
+ terminate_dut(proc1)
+
+ # Remove stale vsomeip routing-manager sockets so the second instance
+ # can become routing manager immediately.
+ cleanup_vsomeip_sockets(target_init=target_init)
+
+ try:
+ post_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip("Multicast socket unavailable for post-reboot capture.")
+
+ try:
+ proc2 = launch_dut(config_path, target_init=target_init)
+ except Exception:
+ post_sock.close()
+ raise
+
+ try:
+ post_messages = _collect_sd_messages(post_sock, count=2, timeout_secs=_SD_CAPTURE_TIMEOUT_SECS)
+ finally:
+ post_sock.close()
+ terminate_dut(proc2)
+
+ return post_messages
+
+
+class TestSDReboot:
+ """TC8-SD-012: DUT resets SD state on restart (reboot flag + session ID)."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_reboot"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_012_reboot_flag_set_after_restart(
+ self,
+ sd_reboot_capture: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]],
+ ) -> None:
+ """TC8-SD-012: First SD message after restart has the reboot flag set (bit 7 = 1)."""
+ assert sd_reboot_capture, "TC8-SD-012: No SD messages captured after restart"
+ _, sd_hdr = sd_reboot_capture[0]
+
+ # SOME/IP-SD specification: flags byte bit 7 (0x80) is the reboot flag.
+ # The someip library exposes this as ``flag_reboot`` on SOMEIPSDHeader.
+ reboot_flag = getattr(sd_hdr, "flag_reboot", None)
+ if reboot_flag is None:
+ # Defensive fallback if the library attribute name changes.
+ raw_flags = getattr(sd_hdr, "flags", 0)
+ reboot_flag = bool(raw_flags & 0x80)
+
+ assert reboot_flag, (
+ "TC8-SD-012: Reboot flag (SD flags bit 7) not set in first SD message "
+ "after restart. The DUT must reset its SD state (session counter and "
+ "reboot flag) when restarted (PRS_SOMEIPSD_00157)."
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_reboot"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_012_session_id_resets_after_restart(
+ self,
+ sd_reboot_capture: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]],
+ ) -> None:
+ """TC8-SD-012: SD session ID resets to ≤ 2 after restart."""
+ assert sd_reboot_capture, "TC8-SD-012: No SD messages captured after restart"
+ outer, _ = sd_reboot_capture[0]
+ assert outer.session_id <= 2, (
+ f"TC8-SD-012: session_id after restart = {outer.session_id}; "
+ "expected ≤ 2 (session counter must reset on reboot)"
+ )
+
+
+class TestSDRebootDetectionETS:
+ """TC8-SDLC-017/018: ETS reboot flag and session ID behaviour after restart.
+
+ Unlike ``TestSDReboot``, these tests manage their own someipd lifecycle
+ inline (no shared fixture) so they can verify unicast and multicast
+ behaviour independently.
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_reboot"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_093_reboot_on_unicast_channel(
+ self,
+ tmp_path_factory: pytest.TempPathFactory,
+ host_ip: str,
+ request: pytest.FixtureRequest,
+ ) -> None:
+ """TC8-SDLC-017: First unicast SD OFFER after DUT restart has reboot flag set and session_id = 1."""
+ target_init = request.getfixturevalue("target_init")
+ request.getfixturevalue("tc8_itf_config_setup")
+
+ tmp_dir = tmp_path_factory.mktemp("tc8_ets093_config")
+ config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir)
+
+ try:
+ pre_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip(f"Multicast socket setup failed on {host_ip}. Set TC8_HOST_IP to a non-loopback IP.")
+
+ proc1 = launch_dut(config_path, target_init=target_init)
+ drained: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = []
+ deadline = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS
+ while time.monotonic() < deadline and len(drained) < 3:
+ remaining = deadline - time.monotonic()
+ pre_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = pre_sock.recvfrom(65535)
+ outer, _ = SOMEIPHeader.parse(data)
+ if outer.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload)
+ drained.append((outer, sd_hdr))
+ except socket.timeout:
+ continue
+ except Exception: # noqa: BLE001
+ continue
+ pre_sock.close()
+ terminate_dut(proc1)
+
+ cleanup_vsomeip_sockets(target_init=target_init)
+
+ try:
+ post_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip("Multicast socket unavailable for post-reboot capture.")
+
+ proc2 = launch_dut(config_path, target_init=target_init)
+ post_messages: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = []
+ deadline2 = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS
+ while time.monotonic() < deadline2 and len(post_messages) < 1:
+ remaining = deadline2 - time.monotonic()
+ post_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = post_sock.recvfrom(65535)
+ outer, _ = SOMEIPHeader.parse(data)
+ if outer.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload)
+ post_messages.append((outer, sd_hdr))
+ except socket.timeout:
+ continue
+ except Exception: # noqa: BLE001
+ continue
+ post_sock.close()
+ terminate_dut(proc2)
+
+ assert post_messages, "TC8-SDLC-017: No SD messages captured after DUT restart"
+ outer_hdr, sd_hdr = post_messages[0]
+
+ reboot_flag = getattr(sd_hdr, "flag_reboot", None)
+ if reboot_flag is None:
+ raw_flags = getattr(sd_hdr, "flags", 0)
+ reboot_flag = bool(raw_flags & 0x80)
+
+ assert reboot_flag, (
+ "TC8-SDLC-017: Reboot flag (SD flags bit 7) not set in first SD OFFER after restart (PRS_SOMEIPSD_00157)."
+ )
+ assert outer_hdr.session_id == 1, (
+ f"TC8-SDLC-017: session_id after restart = {outer_hdr.session_id}; "
+ "expected 1 (session counter must reset to 1 on reboot)"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_reboot"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_094_server_reboot_session_id_resets(
+ self,
+ tmp_path_factory: pytest.TempPathFactory,
+ host_ip: str,
+ request: pytest.FixtureRequest,
+ ) -> None:
+ """TC8-SDLC-018: After restart the SD session_id resets to 1 and the reboot flag is set.
+
+ Verifies PRS_SOMEIPSD_00157 from the multicast channel: the very first
+ OfferService multicast after a clean restart must have session_id = 1
+ and the reboot flag bit set regardless of what session_id was before.
+ """
+ target_init = request.getfixturevalue("target_init")
+ request.getfixturevalue("tc8_itf_config_setup")
+
+ tmp_dir = tmp_path_factory.mktemp("tc8_ets094_config")
+ config_path = render_someip_config(SOMEIP_CONFIG, host_ip, tmp_dir)
+
+ try:
+ pre_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip(f"Multicast socket setup failed on {host_ip}. Set TC8_HOST_IP to a non-loopback IP.")
+
+ proc1 = launch_dut(config_path, target_init=target_init)
+ pre_messages: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = []
+ deadline = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS
+ while time.monotonic() < deadline and len(pre_messages) < 3:
+ remaining = deadline - time.monotonic()
+ pre_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = pre_sock.recvfrom(65535)
+ outer, _ = SOMEIPHeader.parse(data)
+ if outer.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload)
+ pre_messages.append((outer, sd_hdr))
+ except socket.timeout:
+ continue
+ except Exception: # noqa: BLE001
+ continue
+ pre_sock.close()
+ terminate_dut(proc1)
+
+ cleanup_vsomeip_sockets(target_init=target_init)
+
+ try:
+ post_sock = open_multicast_socket(host_ip)
+ except OSError:
+ pytest.skip("Multicast socket unavailable for post-reboot capture.")
+
+ proc2 = launch_dut(config_path, target_init=target_init)
+ post_messages: List[Tuple[SOMEIPHeader, SOMEIPSDHeader]] = []
+ deadline2 = time.monotonic() + _SD_CAPTURE_TIMEOUT_SECS
+ while time.monotonic() < deadline2 and len(post_messages) < 1:
+ remaining = deadline2 - time.monotonic()
+ post_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = post_sock.recvfrom(65535)
+ outer, _ = SOMEIPHeader.parse(data)
+ if outer.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(outer.payload)
+ post_messages.append((outer, sd_hdr))
+ except socket.timeout:
+ continue
+ except Exception: # noqa: BLE001
+ continue
+ post_sock.close()
+ terminate_dut(proc2)
+
+ assert post_messages, "TC8-SDLC-018: No SD messages captured after DUT restart"
+ outer_hdr, sd_hdr = post_messages[0]
+
+ reboot_flag = getattr(sd_hdr, "flag_reboot", None)
+ if reboot_flag is None:
+ raw_flags = getattr(sd_hdr, "flags", 0)
+ reboot_flag = bool(raw_flags & 0x80)
+
+ assert reboot_flag, "TC8-SDLC-018: Reboot flag not set in first SD OFFER after restart (PRS_SOMEIPSD_00157)."
+ assert outer_hdr.session_id == 1, (
+ f"TC8-SDLC-018: session_id after restart = {outer_hdr.session_id}; "
+ "expected 1 (session counter must reset on reboot per PRS_SOMEIPSD_00157)"
+ )
diff --git a/tests/tc8_conformance/test_sd_robustness.py b/tests/tc8_conformance/test_sd_robustness.py
new file mode 100644
index 000000000..e24fb0f53
--- /dev/null
+++ b/tests/tc8_conformance/test_sd_robustness.py
@@ -0,0 +1,830 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 Group 4: SD Robustness: malformed SD message handling.
+
+Verifies that ``someipd`` never crashes, hangs, or enters an incorrect state
+when it receives malformed SOME/IP-SD packets.
+
+All tests follow the pattern: inject one malformed SD packet, then send a valid
+FindService and verify the DUT still replies with OfferService.
+
+The "DUT alive" assertion is the primary safety property: no crash implies the
+DUT continues to answer SD queries.
+
+Test classes: TestSDMalformedEntries (ETS_111-118/123-125),
+TestSDMalformedOptions (ETS_134-139/174), TestSDSubscribeEdgeCases
+(ETS_109/110/119/140-144), TestSDMessageFramingErrors (ETS_152/153/178).
+"""
+
+import socket
+import subprocess
+from typing import List
+
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.constants import (
+ EVENTGROUP_UDP_UNICAST,
+ INSTANCE_ID,
+ SD_PORT,
+ SERVICE_ID,
+)
+from helpers.sd_malformed import (
+ send_sd_empty_entries,
+ send_sd_empty_option,
+ send_sd_entries_length_wrong,
+ send_sd_entry_refs_more_options,
+ send_sd_entry_same_option_twice,
+ send_sd_entry_unknown_option_type,
+ send_sd_find_with_options,
+ send_sd_high_session_id,
+ send_sd_option_length_too_long,
+ send_sd_option_length_too_short,
+ send_sd_option_length_unaligned,
+ send_sd_options_array_length_too_long,
+ send_sd_options_array_length_too_short,
+ send_sd_oversized_entries_length,
+ send_sd_subscribe_no_endpoint,
+ send_sd_subscribe_nonexistent_service,
+ send_sd_subscribe_reserved_option,
+ send_sd_subscribe_wrong_l4proto,
+ send_sd_subscribe_zero_ip,
+ send_sd_truncated_entry,
+ send_sd_wrong_someip_length,
+ send_sd_wrong_someip_message_id,
+)
+from helpers.sd_sender import (
+ SOMEIPSDEntryType,
+ capture_unicast_sd_entries,
+ open_sender_socket,
+ send_find_service,
+)
+
+SOMEIP_CONFIG: str = "tc8_someipd_sd.json"
+
+#: An unknown service/instance not offered by the DUT.
+_UNKNOWNSERVICE_ID: int = 0xDEAD
+_UNKNOWNINSTANCE_ID: int = 0xBEEF
+_UNKNOWNEVENTGROUP_UDP_UNICAST: int = 0xDEAD
+
+#: How long to wait for a DUT OfferService reply after injecting malformed data.
+_DUT_ALIVE_TIMEOUT: float = 5.0
+
+#: Subscriber port used when building endpoint options in malformed packets.
+_SUBSCRIBER_PORT: int = 34567
+
+
+def _verify_dut_alive(sock: socket.socket, dut_ip: str) -> None:
+ """Verify the DUT is still alive by checking it responds to a valid FindService.
+
+ Sends a FindService and waits up to ``_DUT_ALIVE_TIMEOUT`` seconds for an
+ OfferService reply. Fails the calling test if no reply arrives, which
+ indicates the DUT crashed or is unresponsive after malformed packet injection.
+ """
+ sd_dest = (dut_ip, SD_PORT)
+
+ def _resend() -> None:
+ send_find_service(sock, sd_dest, SERVICE_ID)
+
+ _resend()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=_DUT_ALIVE_TIMEOUT,
+ resend=_resend,
+ resend_interval_secs=1.0,
+ max_results=1,
+ )
+ assert len(entries) >= 1, (
+ f"DUT is not alive — no OfferService received within {_DUT_ALIVE_TIMEOUT:.0f}s after malformed SD injection"
+ )
+
+
+@pytest.fixture(scope="module")
+def sender(tester_ip: str) -> socket.socket:
+ """Open a UDP sender socket bound to tester_ip:SD_PORT for the entire module."""
+ sock = open_sender_socket(tester_ip)
+ yield sock
+ sock.close()
+
+
+class TestSDMalformedEntries:
+ """ETS_111/112/113/114/115/116/117/118/123/124/125: malformed entries array."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_111_empty_entries_array(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_111: SD packet with entries_array_length=0; DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_empty_entries(sender, (dut_ip, SD_PORT))
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_112_empty_option_zero_length(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_112/113: SubscribeEventgroup with option length=1 (too short). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_empty_option(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_114_entries_length_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_114: entries_array_length=0 but one entry is present. DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_entries_length_wrong(sender, (dut_ip, SD_PORT), SERVICE_ID, entries_length_override=0)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_114_entries_length_mismatched(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_114: entries_array_length=8 (not a multiple of 16). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_entries_length_wrong(sender, (dut_ip, SD_PORT), SERVICE_ID, entries_length_override=8)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_115_entry_refs_more_options_than_exist(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_115: Entry num_options_1=3 but options array has only 1. DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_entry_refs_more_options(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_116_entry_unknown_option_type(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_116/174: SubscribeEventgroup with unknown option type 0x77. DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_entry_unknown_option_type(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_117_two_entries_same_option(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_117: Two entries sharing option index 0 (index overlap). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_entry_same_option_twice(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_118_find_service_with_endpoint_option(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_118: FindService entry with an unexpected endpoint option attached.
+
+ Per spec the DUT must ignore the option and still respond to FindService.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_find_with_options(sender, (dut_ip, SD_PORT), SERVICE_ID, tester_ip, _SUBSCRIBER_PORT)
+ # The DUT should still respond to this FindService (options are ignored on Find)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_123_entries_length_wildly_too_large(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_123/124: entries_array_length=0xFFFF (far exceeds packet size). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_oversized_entries_length(sender, (dut_ip, SD_PORT), SERVICE_ID)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_125_truncated_entry(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_125: entries_array_length=16 but only 8 bytes of entry data present. DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_truncated_entry(sender, (dut_ip, SD_PORT), SERVICE_ID)
+ _verify_dut_alive(sender, dut_ip)
+
+
+class TestSDMalformedOptions:
+ """ETS_134/135/136/137/138/139/174: malformed options array."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_134_option_length_much_too_large(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_134: IPv4EndpointOption length field = 0x00FF (way larger than 0x0009). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_option_length_too_long(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ option_length_override=0x00FF,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_135_option_length_one_too_large(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_135: IPv4EndpointOption length field = 0x000A (one larger than 0x0009). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_option_length_too_long(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ option_length_override=0x000A,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_136_option_length_too_short(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_136: IPv4EndpointOption length field = 0x0001 (shorter than minimum). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_option_length_too_short(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_137_option_length_unaligned(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_137: IPv4EndpointOption length field = 0x000A (unaligned/odd). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_option_length_unaligned(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_138_options_array_length_too_large(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_138: options_array_length claims 100 bytes but only 12 present. DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_options_array_length_too_long(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_139_options_array_length_too_short(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_139: options_array_length claims 2 bytes but 12 are present. DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_options_array_length_too_short(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_174_unknown_option_type_0x77(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_174: Option type 0x77 (unknown/reserved). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_entry_unknown_option_type(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST)
+ _verify_dut_alive(sender, dut_ip)
+
+
+class TestSDSubscribeEdgeCases:
+ """ETS_109/110/119/140/141/142/143/144: subscribe message edge cases."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_109_subscribe_no_endpoint_option(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_109: SubscribeEventgroup with num_options_1=0 (no endpoint).
+
+ DUT must send NAck or silently discard. Must not crash.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_no_endpoint(sender, (dut_ip, SD_PORT), SERVICE_ID, INSTANCE_ID, EVENTGROUP_UDP_UNICAST)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_110_subscribe_endpoint_ip_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_110: SubscribeEventgroup with endpoint IP = 0.0.0.0.
+
+ DUT must send NAck or silently discard. Must not crash.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_zero_ip(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ subscriber_port=_SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_119_subscribe_unknown_l4proto(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_119: SubscribeEventgroup with L4 protocol byte = 0x00 (unknown).
+
+ DUT must send NAck or silently discard. Must not crash.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_wrong_l4proto(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ l4proto=0x00,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_140_subscribe_unknown_service_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_140: SubscribeEventgroup for an unknown service_id (0xDEAD).
+
+ DUT must not send SubscribeAck for a service it does not offer.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_nonexistent_service(
+ sender,
+ (dut_ip, SD_PORT),
+ _UNKNOWNSERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_141_subscribe_unknown_instance_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_141: SubscribeEventgroup for correct service_id but unknown instance_id.
+
+ DUT must not send SubscribeAck for a non-existent instance.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_nonexistent_service(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ _UNKNOWNINSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_142_subscribe_unknown_eventgroup_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_142: SubscribeEventgroup for correct service/instance but unknown eventgroup.
+
+ DUT must send NAck (SubscribeAck TTL=0) or silently discard.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_nonexistent_service(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ _UNKNOWNEVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_143_subscribe_all_ids_unknown(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_143: SubscribeEventgroup with service, instance, and eventgroup all unknown.
+
+ DUT must not send any SubscribeAck. Must not crash.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_nonexistent_service(
+ sender,
+ (dut_ip, SD_PORT),
+ _UNKNOWNSERVICE_ID,
+ _UNKNOWNINSTANCE_ID,
+ _UNKNOWNEVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_144_subscribe_reserved_option_type(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_144: SubscribeEventgroup with reserved option type 0x20.
+
+ DUT must send NAck or silently discard. Must not crash.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_subscribe_reserved_option(
+ sender,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ tester_ip,
+ _SUBSCRIBER_PORT,
+ )
+ _verify_dut_alive(sender, dut_ip)
+
+
+class TestSDMessageFramingErrors:
+ """ETS_152/153/178: SOME/IP framing and header field errors."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_152_high_session_id_0xfffe(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_152a: FindService with session_id=0xFFFE. DUT must not be confused by near-wrap session ID."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_high_session_id(sender, (dut_ip, SD_PORT), SERVICE_ID, session_id=0xFFFE)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_152_session_id_0xffff(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_152b: FindService with session_id=0xFFFF (maximum). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_high_session_id(sender, (dut_ip, SD_PORT), SERVICE_ID, session_id=0xFFFF)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_152_session_id_one(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_152c: FindService with session_id=0x0001 after high session_id. DUT must accept wrap."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_high_session_id(sender, (dut_ip, SD_PORT), SERVICE_ID, session_id=0x0001)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_153_someip_length_too_small(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_153a: SOME/IP length field smaller than actual payload (length=8). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_wrong_someip_length(sender, (dut_ip, SD_PORT), SERVICE_ID, length_override=8)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_153_someip_length_too_large(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_153b: SOME/IP length field larger than actual payload (length=0x1000). DUT must not crash."""
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_wrong_someip_length(sender, (dut_ip, SD_PORT), SERVICE_ID, length_override=0x1000)
+ _verify_dut_alive(sender, dut_ip)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_robustness"],
+ test_type="robustness",
+ derivation_technique="fault-injection",
+ )
+ def test_ets_178_wrong_someip_service_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ sender: socket.socket,
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_178: SD packet with SOME/IP service_id=0x1234 (not 0xFFFF).
+
+ DUT must silently discard (not recognized as SD) and remain alive.
+ """
+ assert dut.poll() is None, "DUT is not running before injection"
+ send_sd_wrong_someip_message_id(sender, (dut_ip, SD_PORT))
+ _verify_dut_alive(sender, dut_ip)
diff --git a/tests/tc8_conformance/test_service_discovery.py b/tests/tc8_conformance/test_service_discovery.py
new file mode 100644
index 000000000..229136b79
--- /dev/null
+++ b/tests/tc8_conformance/test_service_discovery.py
@@ -0,0 +1,2144 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 Service Discovery tests: TC8-SD-001 through TC8-SD-014.
+
+See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture.
+"""
+
+import ipaddress
+import socket
+import subprocess
+import time
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.sd_helpers import capture_sd_offers, open_multicast_socket
+from helpers.sd_sender import (
+ SOMEIPSDEntryType,
+ capture_some_ip_messages,
+ capture_unicast_sd_entries,
+ open_sender_socket,
+ send_find_service,
+ send_subscribe_eventgroup,
+ send_subscribe_eventgroup_reserved_set,
+)
+from helpers.someip_assertions import (
+ assert_offer_has_ipv4_endpoint_option,
+ assert_sd_offer_entry,
+)
+from helpers.constants import (
+ DUT_UNRELIABLE_PORT,
+ EVENTGROUP_UDP_MULTICAST,
+ EVENTGROUP_UDP_UNICAST,
+ INSTANCE_ID,
+ MAJOR_VERSION,
+ MINOR_VERSION,
+ SD_MULTICAST_ADDR,
+ SD_PORT,
+ SERVICE_ID,
+)
+from helpers.timing import capture_sd_offers_with_timestamps
+from someip.header import SOMEIPHeader, SOMEIPSDHeader
+
+#: SOME/IP stack config template used for all tests in this module.
+SOMEIP_CONFIG: str = "tc8_someipd_sd.json"
+
+#: Service and instance IDs declared in ``tc8_someipd_sd.json``.
+_UNKNOWNEVENTGROUP_UDP_UNICAST: int = 0xBEEF
+_UNKNOWNSERVICE_ID: int = 0xBEEF
+
+#: SD configuration values from ``tc8_someipd_sd.json``.
+_CYCLIC_OFFER_DELAY_MS: float = 2000.0
+_REQUEST_RESPONSE_DELAY_MS: float = 500.0
+
+#: Unknown IDs used in negative tests.
+_UNKNOWNINSTANCE_ID: int = 0xBEEF
+
+
+class TestSDOfferFormat:
+ """TC8-SD-001/002/003: Offer presence, fields, and cyclic timing."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_offer_format"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_001_multicast_offer_on_startup(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """TC8-SD-001: someipd sends at least one SD OfferService on multicast at startup."""
+ assert dut.poll() is None, "DUT is not running"
+
+ offers = capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+
+ assert len(offers) >= 1, (
+ "TC8-SD-001: No SD OfferService entries received within 5 s. "
+ "Verify someipd is running and the SD multicast address/port matches config."
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_offer_format"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_002_offer_entry_format(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """TC8-SD-002: OfferService entry has correct service ID, instance ID, version, and TTL."""
+ assert dut.poll() is None, "DUT is not running"
+
+ offers = capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+
+ service_offers = [e for e in offers if e.service_id == SERVICE_ID]
+ assert service_offers, (
+ f"TC8-SD-002: No OfferService entry found for service 0x{SERVICE_ID:04x} "
+ f"in {len(offers)} captured SD entries."
+ )
+
+ assert_sd_offer_entry(
+ service_offers[0],
+ expected_service_id=SERVICE_ID,
+ expected_instance_id=INSTANCE_ID,
+ expected_major_version=MAJOR_VERSION,
+ expected_minor_version=MINOR_VERSION,
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_cyclic_timing"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_003_cyclic_offer_timing(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """TC8-SD-003: Offers repeat at cyclic_offer_delay ±20% in the main phase."""
+ assert dut.poll() is None, "DUT is not running"
+
+ # Wait for main phase. Repetition phase ends ~1.5 s after first offer
+ # (200+400+800 ms doubling, repetitions_max=3). Add one cyclic gap
+ # (2000 ms) to ensure we capture main-phase offers.
+ time.sleep(3.5)
+
+ # Capture enough offers to derive at least 2 inter-cycle gaps.
+ # The DUT may send multiple SD packets per cycle (service + eventgroup),
+ # so we capture extra and de-duplicate within a 500 ms window.
+ timed = capture_sd_offers_with_timestamps(host_ip, count=5, timeout_secs=15.0)
+
+ service_offers = [(ts, e) for ts, e in timed if e.service_id == SERVICE_ID]
+ assert len(service_offers) >= 2, "TC8-SD-003: Not enough OfferService entries for timing analysis"
+
+ # De-duplicate: collapse offers within 500 ms into one cycle timestamp.
+ cycle_timestamps: list[float] = [service_offers[0][0]]
+ for ts, _ in service_offers[1:]:
+ if (ts - cycle_timestamps[-1]) * 1000.0 > 500:
+ cycle_timestamps.append(ts)
+
+ assert len(cycle_timestamps) >= 3, (
+ f"TC8-SD-003: Only {len(cycle_timestamps)} distinct cycles captured "
+ f"(need at least 3 for 2 gap measurements)"
+ )
+
+ lo_ms = _CYCLIC_OFFER_DELAY_MS * 0.80
+ hi_ms = _CYCLIC_OFFER_DELAY_MS * 1.20
+
+ for i in range(len(cycle_timestamps) - 1):
+ gap_ms = (cycle_timestamps[i + 1] - cycle_timestamps[i]) * 1000.0
+ assert lo_ms <= gap_ms <= hi_ms, (
+ f"TC8-SD-003: inter-offer gap {gap_ms:.0f} ms not in "
+ f"[{lo_ms:.0f}, {hi_ms:.0f}] ms "
+ f"(cyclic_offer_delay={_CYCLIC_OFFER_DELAY_MS:.0f} ms ±20%)"
+ )
+
+
+class TestSDFindResponse:
+ """TC8-SD-004/005: FindService response for known/unknown services."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_004_find_known_service_unicast_offer(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-SD-004: FindService for offered service triggers a unicast OfferService."""
+ assert dut.poll() is None, "DUT is not running"
+
+ # tester_ip differs from host_ip; both need SD_PORT (SD spec requirement).
+ # Send FindService via unicast to the DUT (multicast delivery on loopback
+ # between different 127.x addresses is unreliable in some environments).
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send_find() -> None:
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ )
+
+ _send_find()
+ # Provider responds to the sender's address via unicast.
+ # DUT may defer the response until the next SD cycle (~2 s).
+ # Resend periodically to handle batching/dedup in long-running DUT.
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send_find,
+ )
+ service_offers = [e for e in entries if e.service_id == SERVICE_ID]
+ assert service_offers, (
+ f"TC8-SD-004: No unicast OfferService received for service 0x{SERVICE_ID:04x} within 5 s of FindService"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_005_find_unknown_service_no_response(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-SD-005: FindService for unknown service does not trigger OfferService."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ send_find_service(
+ sock,
+ (SD_MULTICAST_ADDR, SD_PORT),
+ service_id=_UNKNOWNSERVICE_ID,
+ )
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=2.0,
+ )
+ unknown_offers = [e for e in entries if e.service_id == _UNKNOWNSERVICE_ID]
+ assert not unknown_offers, (
+ f"TC8-SD-005: Unexpected OfferService received for unknown service 0x{_UNKNOWNSERVICE_ID:04x}"
+ )
+ finally:
+ sock.close()
+
+
+class TestSDSubscribeLifecycle:
+ """TC8-SD-006/007/008: Subscribe Ack, Nack, and StopSubscribe."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_006_subscribe_valid_eventgroup_ack(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-SD-006: Valid SubscribeEventgroup receives SubscribeEventgroupAck (TTL>0)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ # DUT sends Ack back to the Subscribe source address.
+ # DUT may defer the Ack until the next SD cycle (~2 s).
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1] # = SD_PORT
+
+ def _send_subscribe() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send_subscribe()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send_subscribe,
+ )
+ acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0]
+ assert acks, f"TC8-SD-006: No SubscribeEventgroupAck received for eventgroup 0x{EVENTGROUP_UDP_UNICAST:04x}"
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_007_subscribe_unknown_eventgroup_nack(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-SD-007: SubscribeEventgroup for unknown eventgroup receives Nack (TTL=0)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1] # = SD_PORT
+
+ def _send_subscribe_unknown() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ _UNKNOWNEVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send_subscribe_unknown()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send_subscribe_unknown,
+ )
+ nacks = [e for e in entries if e.eventgroup_id == _UNKNOWNEVENTGROUP_UDP_UNICAST and e.ttl == 0]
+ assert nacks, (
+ f"TC8-SD-007: No SubscribeEventgroupNack received for unknown eventgroup "
+ f"0x{_UNKNOWNEVENTGROUP_UDP_UNICAST:04x} (expected SubscribeAck with TTL=0)"
+ )
+ finally:
+ sock.close()
+
+ @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)")
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_008_stop_subscribe_ceases_notifications(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-SD-008: StopSubscribeEventgroup (TTL=0) ceases event notifications."""
+ assert dut.poll() is None, "DUT is not running"
+
+ # sd_sock: SD sender at tester_ip:SD_PORT.
+ # notif_sock: receives event notifications at tester_ip:.
+ sd_sock = open_sender_socket(tester_ip)
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port: int = notif_sock.getsockname()[1]
+
+ try:
+ # Notifications will arrive on notif_sock.
+ def _send_sub_008() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ )
+
+ _send_sub_008()
+ acks = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send_sub_008,
+ )
+ assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), (
+ "TC8-SD-008: Prerequisite failed — no SubscribeEventgroupAck received"
+ )
+
+ # Expect at least one notification (DUT fires notify() every 2 s).
+ notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0)
+ assert notifs, "TC8-SD-008: No SOME/IP notifications received after subscribe"
+
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ ttl=0,
+ )
+
+ post = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0)
+ assert not post, f"TC8-SD-008: {len(post)} notification(s) received after StopSubscribeEventgroup"
+ finally:
+ sd_sock.close()
+ notif_sock.close()
+
+
+class TestSDOptionFormat:
+ """TC8-SD-011: OfferService entry carries a valid IPv4EndpointOption."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_endpoint_option"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_011_offer_ipv4_endpoint_option(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-SD-011: SD OFFER entry includes IPv4EndpointOption with correct address/port/proto."""
+ assert dut.poll() is None, "DUT is not running"
+
+ # Capture raw SD packets so we can inspect options attached to entries.
+ sock = open_multicast_socket(host_ip)
+ try:
+ deadline = time.monotonic() + 5.0
+ found_entry = None
+ while time.monotonic() < deadline and found_entry is None:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ sd_hdr = sd_hdr.resolve_options() # populate entry.options_1 from sd_hdr.options
+ for entry in sd_hdr.entries:
+ if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID:
+ found_entry = entry
+ break
+ except Exception: # noqa: BLE001
+ continue
+ finally:
+ sock.close()
+
+ assert found_entry is not None, (
+ f"TC8-SD-011: No OfferService entry found for service 0x{SERVICE_ID:04x} within 5 s"
+ )
+ assert_offer_has_ipv4_endpoint_option(
+ found_entry,
+ expected_ip=dut_ip,
+ expected_port=DUT_UNRELIABLE_PORT,
+ )
+
+
+# TC8-SD-012 tests are in tests/tc8_conformance/test_sd_reboot.py.
+# They require their own someipd lifecycle (start/stop/restart) and cannot
+# share the module-scoped dut fixture used by the other SD tests here.
+
+
+class TestSDMulticastEventgroup:
+ """TC8-SD-013: SUBSCRIBE_ACK for multicast eventgroup includes multicast IP option."""
+
+ @pytest.mark.network
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_mcast_eg"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_013_subscribe_ack_has_multicast_option(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-SD-013: Subscribing to multicast eventgroup 0x4465 yields a multicast endpoint option."""
+
+ # On loopback, vsomeip 3.6.1 does not include IPv4MulticastOption in
+ # SUBSCRIBE_ACK. Requires a non-loopback interface.
+ if ipaddress.ip_address(host_ip).is_loopback:
+ pytest.skip(
+ "TC8-SD-013: Multicast endpoint option in SUBSCRIBE_ACK requires a "
+ "non-loopback interface. "
+ "Set TC8_HOST_IP to a non-loopback address (e.g. TC8_HOST_IP=192.168.x.y)."
+ )
+
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send_subscribe_multicast() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_MULTICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send_subscribe_multicast()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send_subscribe_multicast,
+ )
+ acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_MULTICAST and e.ttl > 0]
+ assert acks, (
+ f"TC8-SD-013: No SubscribeEventgroupAck received for multicast "
+ f"eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ )
+ ack = acks[0]
+ # The ACK for a multicast eventgroup must carry an IPv4MulticastOption
+ # (SD option type 0x14). This is distinct from IPv4EndpointOption (0x04)
+ # which is used for unicast endpoints; isinstance(o, IPv4EndpointOption)
+ # would silently exclude all multicast options.
+ from someip.header import (
+ IPv4MulticastOption,
+ ) # local import to keep module-level clean
+
+ options = list(getattr(ack, "options_1", ())) + list(getattr(ack, "options_2", ()))
+ multicast_opts = [
+ o
+ for o in options
+ if isinstance(o, IPv4MulticastOption) and ipaddress.ip_address(str(o.address)).is_multicast
+ ]
+ assert multicast_opts, (
+ f"TC8-SD-013: SUBSCRIBE_ACK for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x} "
+ f"does not carry an IPv4MulticastOption (SD option type 0x14). "
+ f"Options found: {options}"
+ )
+ finally:
+ sock.close()
+
+
+class TestSDTTLExpiry:
+ """TC8-SD-014: Subscription with finite TTL is cleaned up after expiry."""
+
+ @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)")
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_014_ttl_expiry_ceases_notifications(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """TC8-SD-014: No notifications after subscription TTL expires."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sd_sock = open_sender_socket(tester_ip)
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port: int = notif_sock.getsockname()[1]
+
+ try:
+ _TTL_SECS = 3
+
+ def _send_sub_ttl() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ ttl=_TTL_SECS,
+ )
+
+ _send_sub_ttl()
+ acks = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send_sub_ttl,
+ )
+ assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), (
+ "TC8-SD-014: Prerequisite failed — no SubscribeEventgroupAck received"
+ )
+
+ pre_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0)
+ assert pre_expiry, "TC8-SD-014: No notifications received before TTL expiry"
+
+ # Wait for TTL to expire (TTL + 2 s margin).
+ time.sleep(_TTL_SECS + 2)
+
+ post_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=3.0)
+ assert not post_expiry, (
+ f"TC8-SD-014: {len(post_expiry)} notification(s) received after TTL expiry ({_TTL_SECS} s + 2 s margin)"
+ )
+ finally:
+ sd_sock.close()
+ notif_sock.close()
+
+
+class TestSDVersionMatching:
+ """SOMEIPSRV_SD_MESSAGE_01-06: FindService instance/version wildcard and specific matching."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_01_instance_wildcard(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_01: FindService with instance_id=0xFFFF returns specific instance."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send() -> None:
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=0xFFFF,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ matching = [e for e in entries if e.service_id == SERVICE_ID]
+ assert matching, (
+ "SOMEIPSRV_SD_MESSAGE_01: No OfferService received for instance_id=0xFFFF (wildcard) FindService"
+ )
+ assert matching[0].instance_id == INSTANCE_ID, (
+ f"SOMEIPSRV_SD_MESSAGE_01: OfferService instance_id "
+ f"0x{matching[0].instance_id:04x} != 0x{INSTANCE_ID:04x}"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_02_instance_specific(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_02: FindService with exact instance_id returns that instance."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send() -> None:
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ matching = [e for e in entries if e.service_id == SERVICE_ID and e.instance_id == INSTANCE_ID]
+ assert matching, (
+ f"SOMEIPSRV_SD_MESSAGE_02: No OfferService received for specific instance_id=0x{INSTANCE_ID:04x}"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_03_major_version_wildcard(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_03: FindService with major_version=0xFF (any) returns service."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send() -> None:
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ major_version=0xFF,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ matching = [e for e in entries if e.service_id == SERVICE_ID]
+ assert matching, (
+ "SOMEIPSRV_SD_MESSAGE_03: No OfferService received for major_version=0xFF (wildcard) FindService"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_04_major_version_specific(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_04: FindService with exact major_version returns service."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send() -> None:
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ major_version=MAJOR_VERSION,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ matching = [e for e in entries if e.service_id == SERVICE_ID and e.major_version == MAJOR_VERSION]
+ assert matching, (
+ f"SOMEIPSRV_SD_MESSAGE_04: No OfferService received for specific major_version=0x{MAJOR_VERSION:02x}"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_05_minor_version_wildcard(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_05: FindService with minor_version=0xFFFFFFFF (any) returns service."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send() -> None:
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ major_version=MAJOR_VERSION,
+ minor_version=0xFFFFFFFF,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ matching = [e for e in entries if e.service_id == SERVICE_ID]
+ assert matching, (
+ "SOMEIPSRV_SD_MESSAGE_05: No OfferService received for minor_version=0xFFFFFFFF (wildcard) FindService"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_06_minor_version_specific(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_06: FindService with exact minor_version=0x00000000 returns service."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send() -> None:
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ major_version=MAJOR_VERSION,
+ minor_version=MINOR_VERSION,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ matching = [e for e in entries if e.service_id == SERVICE_ID]
+ assert matching, (
+ f"SOMEIPSRV_SD_MESSAGE_06: No OfferService received for specific minor_version=0x{MINOR_VERSION:08x}"
+ )
+ finally:
+ sock.close()
+
+
+class TestSDSubscribeNAck:
+ """SOMEIPSRV_SD_MESSAGE_14-19: SubscribeEventgroup NAck scenarios.
+
+ A SubscribeEventgroup NAck is a SubscribeAck SD entry (type 0x07) with TTL=0.
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_14_wrong_major_version(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_14: Subscribe with wrong major_version receives NAck (TTL=0)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ major_version=0xFF, # DUT expects 0x00
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=3.0,
+ resend=_send,
+ )
+ nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0]
+ assert nacks, (
+ "SOMEIPSRV_SD_MESSAGE_14: No SubscribeEventgroupNAck (TTL=0) received for wrong major_version=0xFF"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_15_wrong_service_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_15: Subscribe to unknown service_id receives NAck (TTL=0).
+
+ Per SOMEIPSRV_SD_MESSAGE_15 the DUT shall respond with a SubscribeEventgroupNAck
+ (SubscribeAck entry with TTL=0). The DUT sends a NAck for unknown
+ service IDs; the response carries the same eventgroup_id as the request.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=_UNKNOWNSERVICE_ID,
+ instance_id=INSTANCE_ID,
+ eventgroup_id=EVENTGROUP_UDP_UNICAST,
+ major_version=MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=3.0,
+ resend=_send,
+ )
+ nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0]
+ assert nacks, (
+ f"SOMEIPSRV_SD_MESSAGE_15: No SubscribeEventgroupNAck (TTL=0) received "
+ f"for unknown service_id=0x{_UNKNOWNSERVICE_ID:04x}"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_16_wrong_instance_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_16: Subscribe to wrong instance_id receives NAck (TTL=0)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ instance_id=_UNKNOWNINSTANCE_ID,
+ eventgroup_id=EVENTGROUP_UDP_UNICAST,
+ major_version=MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=3.0,
+ resend=_send,
+ )
+ nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0]
+ assert nacks, (
+ "SOMEIPSRV_SD_MESSAGE_16: No SubscribeEventgroupNAck (TTL=0) received "
+ f"for wrong instance_id=0x{_UNKNOWNINSTANCE_ID:04x}"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_17_unknown_eventgroup_id(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_17: Subscribe to unknown eventgroup_id receives NAck (TTL=0)."""
+ assert dut.poll() is None, "DUT is not running"
+
+ # This reuses the same scenario as TC8-SD-007 with an explicit SD_MESSAGE_17 trace.
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ eventgroup_id=_UNKNOWNEVENTGROUP_UDP_UNICAST,
+ major_version=MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ nacks = [e for e in entries if e.eventgroup_id == _UNKNOWNEVENTGROUP_UDP_UNICAST and e.ttl == 0]
+ assert nacks, (
+ f"SOMEIPSRV_SD_MESSAGE_17: No SubscribeEventgroupNAck (TTL=0) received "
+ f"for unknown eventgroup_id=0x{_UNKNOWNEVENTGROUP_UDP_UNICAST:04x}"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_message_18_ttl_zero_stop_subscribe(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_18: StopSubscribeEventgroup (TTL=0) produces no SubscribeAck."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+ # Send a StopSubscribe directly (TTL=0) without a prior subscribe.
+ # Per spec, a TTL=0 subscribe is a StopSubscribe and must not generate an Ack.
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ ttl=0,
+ )
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=2.0,
+ )
+ acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0]
+ assert not acks, (
+ f"SOMEIPSRV_SD_MESSAGE_18: Unexpected SubscribeAck(TTL>0) received "
+ f"in response to StopSubscribeEventgroup (TTL=0). Got {len(acks)} entry/ies."
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ @pytest.mark.xfail(
+ strict=True,
+ reason=(
+ "vsomeip 3.6.1 limitation (SOMEIPSRV_SD_MESSAGE_19): DUT sends a positive "
+ "SubscribeEventgroupAck (TTL > 0) even when reserved bits are set in the "
+ "Subscribe entry; spec requires a NAck (TTL = 0). "
+ "See docs/architecture/tc8_conformance_testing.rst "
+ "see: Known SOME/IP Stack Limitations."
+ ),
+ )
+ def test_sd_message_19_reserved_field_set(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_MESSAGE_19: Subscribe with reserved field set receives NAck or is ignored."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send() -> None:
+ send_subscribe_eventgroup_reserved_set(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ reserved_value=0x0F,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=3.0,
+ resend=_send,
+ )
+ # Accept either: a NAck (TTL=0) or no response at all (DUT silently ignores).
+ # A positive Ack (TTL>0) would indicate the DUT accepted the malformed entry.
+ acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0]
+ assert not acks, (
+ "SOMEIPSRV_SD_MESSAGE_19: DUT sent a positive SubscribeAck (TTL>0) for a "
+ "SubscribeEventgroup entry with reserved bits set. Expected NAck or no response."
+ )
+ finally:
+ sock.close()
+
+
+class TestSDFindServiceTiming:
+ """SOMEIPSRV_SD_BEHAVIOR_03-04: FindService response timing constraints."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_cyclic_timing"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_behavior_03_unicast_findservice_timing(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_BEHAVIOR_03: Unicast FindService response arrives within request_response_delay * 1.5.
+
+ The DUT is in its main phase (the module-scoped dut fixture has been
+ running for the full test session). Per spec the DUT must respond within
+ ``request_response_delay`` (500 ms); we allow 1.5x = 750 ms per implementation
+ tolerance. If the cyclic offer fires within that window it also satisfies the
+ test. We resend every 600 ms so the measurement window starts fresh each send.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ # Resend interval < request_response_delay so each send-to-response window
+ # is well-contained. Measure individually: send, wait up to 750 ms,
+ # record elapsed, assert. Repeat up to 3 times to avoid a cyclic offer
+ # coincidence that swamps the measurement.
+ _max_allowed_ms = _REQUEST_RESPONSE_DELAY_MS * 1.5
+
+ for _ in range(3):
+ t0 = time.monotonic()
+ send_find_service(
+ sock,
+ (dut_ip, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ major_version=MAJOR_VERSION,
+ )
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=_max_allowed_ms / 1000.0,
+ max_results=1,
+ )
+ t1 = time.monotonic()
+ matching = [e for e in entries if e.service_id == SERVICE_ID]
+ if matching:
+ elapsed_ms = (t1 - t0) * 1000.0
+ assert elapsed_ms <= _max_allowed_ms, (
+ f"SOMEIPSRV_SD_BEHAVIOR_03: OfferService arrived in {elapsed_ms:.0f} ms, "
+ f"exceeds request_response_delay * 1.5 = {_max_allowed_ms:.0f} ms "
+ f"(configured request_response_delay={_REQUEST_RESPONSE_DELAY_MS:.0f} ms)"
+ )
+ return # test passes on first successful attempt
+ # No response in the tight window; the cyclic offer may have just fired.
+ # Drain any pending offers and retry.
+ capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=_CYCLIC_OFFER_DELAY_MS / 1000.0,
+ max_results=1,
+ )
+
+ pytest.fail(
+ "SOMEIPSRV_SD_BEHAVIOR_03: No OfferService received within "
+ f"request_response_delay * 1.5 = {_max_allowed_ms:.0f} ms "
+ "in 3 consecutive attempts"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_cyclic_timing"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_sd_behavior_04_multicast_findservice_timing(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """SOMEIPSRV_SD_BEHAVIOR_04: Multicast FindService (Unicast flag=0) triggers a multicast OfferService.
+
+ A FindService sent to the SD multicast group shall be answered with a multicast
+ OfferService response. This test captures the response on the SD multicast
+ socket (not the unicast sender socket) and verifies it arrives within
+ ``cyclic_offer_delay * 1.5`` of the FindService transmission.
+
+ On a loopback interface the multicast response arrives on the socket that has
+ joined the SD multicast group (``open_multicast_socket``). Both the sender
+ and listener sockets are opened so the FindService can be injected while the
+ multicast socket is actively listening.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ send_sock = open_sender_socket(tester_ip)
+ mc_sock = open_multicast_socket(host_ip)
+ try:
+ t0 = time.monotonic()
+ send_find_service(
+ send_sock,
+ (SD_MULTICAST_ADDR, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=0xFFFF,
+ major_version=0xFF,
+ )
+
+ # Listen on the multicast socket for the DUT's OfferService response.
+ # The response may be the next scheduled cyclic offer or a triggered offer.
+ # Allow up to cyclic_offer_delay * 1.5 for the first offer to arrive.
+ _max_allowed_secs = (_CYCLIC_OFFER_DELAY_MS * 1.5) / 1000.0
+ deadline = time.monotonic() + _max_allowed_secs
+ matching = []
+ while time.monotonic() < deadline and not matching:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ mc_sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = mc_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ for entry in sd_hdr.entries:
+ if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID:
+ matching.append(entry)
+ except Exception: # noqa: BLE001
+ continue
+ t1 = time.monotonic()
+
+ assert matching, (
+ "SOMEIPSRV_SD_BEHAVIOR_04: No multicast OfferService received after "
+ f"multicast FindService within {_max_allowed_secs:.1f} s "
+ f"(cyclic_offer_delay * 1.5 = {_CYCLIC_OFFER_DELAY_MS * 1.5:.0f} ms)"
+ )
+ elapsed_ms = (t1 - t0) * 1000.0
+ max_allowed_ms = _CYCLIC_OFFER_DELAY_MS * 1.5
+ assert elapsed_ms <= max_allowed_ms, (
+ f"SOMEIPSRV_SD_BEHAVIOR_04: OfferService arrived in {elapsed_ms:.0f} ms, "
+ f"exceeds cyclic_offer_delay * 1.5 = {max_allowed_ms:.0f} ms"
+ )
+ finally:
+ send_sock.close()
+ mc_sock.close()
+
+
+class TestSDSubscribeLifecycleAdvanced:
+ """ETS_088/092/098/107/120/122/155: Multi-subscribe and lifecycle edge cases."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_088_two_subscribes_same_session(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_088: Two SubscribeEventgroup entries (different eventgroups) both receive ACKs.
+
+ The spec requires the DUT to process multiple subscribe entries even when
+ sent in rapid succession. We send two separate SD messages (one per
+ eventgroup) and assert that both receive a SubscribeAck with TTL > 0.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _subscribe_eg1() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ def _subscribe_eg2() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_MULTICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _subscribe_eg1()
+ _subscribe_eg2()
+
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=6.0,
+ resend=lambda: (_subscribe_eg1(), _subscribe_eg2()), # type: ignore[func-returns-value]
+ )
+ acks_eg1 = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0]
+ acks_eg2 = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_MULTICAST and e.ttl > 0]
+ assert acks_eg1, f"ETS_088: No SubscribeAck received for eventgroup 0x{EVENTGROUP_UDP_UNICAST:04x}"
+ assert acks_eg2, f"ETS_088: No SubscribeAck received for eventgroup 0x{EVENTGROUP_UDP_MULTICAST:04x}"
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_092_ttl_zero_stop_subscribe_no_nack(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_092: SubscribeEventgroup with TTL=0 is treated as StopSubscribe; no NAck sent.
+
+ Per PRS_SOMEIPSD_00386 and PRS_SOMEIPSD_00387 a subscribe entry with TTL=0 is
+ a StopSubscribeEventgroup. The DUT must not send a SubscribeAck (positive or
+ negative) in response.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ ttl=0,
+ )
+ # No resend; a StopSubscribe should never produce an ACK.
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=2.0,
+ )
+ # Per spec a NAck (TTL=0 Ack) must NOT be sent for a stop-subscribe.
+ nacks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl == 0]
+ assert not nacks, (
+ f"ETS_092: DUT sent NAck (TTL=0 SubscribeAck) in response to "
+ f"StopSubscribeEventgroup (TTL=0). Got {len(nacks)} NAck(s). "
+ "StopSubscribe must not trigger any acknowledgement."
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_098_subscribe_accepted_without_prior_rpc(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_098: SubscribeEventgroup is accepted without a prior method call.
+
+ A server must not require the client to invoke a method before accepting
+ an eventgroup subscription. Verify a positive ACK (TTL > 0) is received.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0]
+ assert acks, (
+ "ETS_098: No SubscribeAck (TTL>0) received without a prior method call. "
+ "Server must accept subscriptions unconditionally."
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_107_find_service_and_subscribe_processed_independently(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_107: DUT processes SD entries independently of arrival order.
+
+ Send a FindService immediately followed by a SubscribeEventgroup in rapid
+ succession (two separate packets). The DUT must process both entries
+ independently regardless of their order in the stream.
+
+ Verification strategy:
+ - FindService response (OfferService) is captured on the SD multicast
+ socket because the DUT (server) responds to incoming FindService messages on multicast.
+ - SubscribeAck arrives on the unicast sender socket.
+ Both arriving confirms the DUT processed both entries.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sd_sock = open_sender_socket(tester_ip)
+ mc_sock = open_multicast_socket(host_ip)
+ try:
+ sender_port = sd_sock.getsockname()[1]
+
+ def _send_both() -> None:
+ send_find_service(
+ sd_sock,
+ (SD_MULTICAST_ADDR, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=INSTANCE_ID,
+ )
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send_both()
+
+ acks = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=6.0,
+ resend=_send_both,
+ )
+ acks_valid = [e for e in acks if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0]
+
+ # Capture OfferService on the multicast socket (FindService response).
+ _max_secs = (_CYCLIC_OFFER_DELAY_MS * 1.5) / 1000.0
+ deadline = time.monotonic() + _max_secs
+ offers: list = []
+ while time.monotonic() < deadline and not offers:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ mc_sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = mc_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ for entry in sd_hdr.entries:
+ if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID:
+ offers.append(entry)
+ except Exception: # noqa: BLE001
+ continue
+
+ assert offers, (
+ "ETS_107: No OfferService received on multicast after FindService burst. "
+ "DUT must process FindService independently of co-arriving SD entries."
+ )
+ assert acks_valid, (
+ "ETS_107: No SubscribeAck received after SubscribeEventgroup burst. "
+ "DUT must process SubscribeEventgroup independently of co-arriving SD entries."
+ )
+ finally:
+ sd_sock.close()
+ mc_sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_120_subscribe_endpoint_ip_matches_tester(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_120: SubscribeEventgroup with explicit subscriber IP receives OfferService ACK.
+
+ The subscribe endpoint carries tester_ip as the subscriber address.
+ The DUT must send the ACK to that IP. Verifying the ACK arrives on the
+ tester socket confirms the DUT correctly used the subscriber_ip field.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ sender_port = sock.getsockname()[1]
+
+ def _send() -> None:
+ send_subscribe_eventgroup(
+ sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=sender_port,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ acks = [e for e in entries if e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0]
+ assert acks, (
+ f"ETS_120: No SubscribeAck received at tester_ip={tester_ip} for subscribe with explicit subscriber_ip."
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_offer_format"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_122_sd_interface_version_is_one(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """ETS_122: SOME/IP-SD messages carry interface_version = 0x01.
+
+ Per PRS_SOMEIPSD_00357 and PRS_SOMEIPSD_00360 the interface_version field
+ in the SOME/IP outer header of SD messages must be fixed at 0x01.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ mc_sock = open_multicast_socket(host_ip)
+ try:
+ deadline = time.monotonic() + 6.0
+ found: list = []
+ while time.monotonic() < deadline and not found:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ mc_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = mc_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != 0xFFFF:
+ continue
+ found.append(someip_msg)
+ except Exception: # noqa: BLE001
+ continue
+ finally:
+ mc_sock.close()
+
+ assert found, "ETS_122: No SOME/IP-SD messages captured within 6 s"
+ sd_msg = found[0]
+ assert sd_msg.interface_version == 1, (
+ f"ETS_122: SOME/IP-SD interface_version = {sd_msg.interface_version}, "
+ "expected 0x01 per PRS_SOMEIPSD_00357, PRS_SOMEIPSD_00360"
+ )
+
+ @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)")
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_155_resubscribe_after_stop(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_155: Re-subscribe after StopSubscribe receives a new ACK and resumes events.
+
+ Lifecycle: Subscribe, ACK, StopSubscribe (TTL=0), Subscribe, ACK.
+ The DUT must accept the second subscription and resume event delivery.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sd_sock = open_sender_socket(tester_ip)
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port: int = notif_sock.getsockname()[1]
+
+ try:
+
+ def _subscribe() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ )
+
+ _subscribe()
+ acks1 = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_subscribe,
+ )
+ assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks1), (
+ "ETS_155: Prerequisite failed — no initial SubscribeAck received"
+ )
+
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ ttl=0,
+ )
+ time.sleep(0.5) # Allow DUT to process the StopSubscribe.
+
+ _subscribe()
+ acks2 = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_subscribe,
+ )
+ assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks2), (
+ "ETS_155: No SubscribeAck received after re-subscribe following StopSubscribe"
+ )
+ finally:
+ sd_sock.close()
+ notif_sock.close()
+
+ @pytest.mark.skip(reason="Production stack sends no event notifications without mw::com ETS app (2026-08-11)")
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_ttl_expiry"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_095_subscribe_ttl_expires_no_events(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_095: No NOTIFICATION messages are received after subscription TTL expires.
+
+ SOMEIP_ETS_095 (Sec. 5.1.6): After a subscription TTL elapses and is not
+ renewed the server must cease sending event notifications to the expired
+ subscriber.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ # Use TTL=3 (same as TC8-SD-014); TTL=1 is too short to reliably
+ # process the expiry before the next notify cycle.
+ _TTL_SECS = 3
+
+ sd_sock = open_sender_socket(tester_ip)
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port: int = notif_sock.getsockname()[1]
+
+ try:
+
+ def _send_sub_ttl() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ ttl=_TTL_SECS,
+ )
+
+ _send_sub_ttl()
+ acks = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_send_sub_ttl,
+ )
+ assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), (
+ "ETS_095: Prerequisite failed — no SubscribeEventgroupAck received"
+ )
+
+ # Drain notifications sent during the active subscription period
+ # so they don't contaminate the post-expiry observation window.
+ pre_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=4.0)
+ assert pre_expiry, "ETS_095: No notifications received before TTL expiry"
+
+ # Wait for TTL to expire (TTL + 2 s margin); do NOT renew.
+ time.sleep(_TTL_SECS + 2)
+
+ # Verify no notifications arrive after TTL expiry.
+ post_expiry = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=3.0)
+ assert not post_expiry, (
+ f"ETS_095: {len(post_expiry)} NOTIFICATION(s) received after "
+ f"subscription TTL expired (TTL={_TTL_SECS} s + 2 s margin). "
+ "Server must cease sending events once the subscription TTL elapses."
+ )
+ finally:
+ sd_sock.close()
+ notif_sock.close()
+
+
+class TestSDFindServiceAdvanced:
+ """ETS_091/099/100/128/130: FindService and offer lifecycle advanced scenarios."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_offer_format"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_091_session_id_increments(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ ) -> None:
+ """ETS_091: Successive SD messages have monotonically incrementing session_id.
+
+ Capture at least 2 OfferService packets from the DUT and verify that
+ each subsequent packet's session_id is greater than the previous one.
+ The DUT emits cyclic offers every 2000 ms; allow up to 8 s to capture 3.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ mc_sock = open_multicast_socket(host_ip)
+ try:
+ session_ids: list[int] = []
+ deadline = time.monotonic() + 8.0
+ while time.monotonic() < deadline and len(session_ids) < 3:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ mc_sock.settimeout(min(remaining, 1.0))
+ try:
+ data, _ = mc_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ has_offer = any(
+ e.sd_type == SOMEIPSDEntryType.OfferService and e.service_id == SERVICE_ID
+ for e in sd_hdr.entries
+ )
+ if has_offer:
+ session_ids.append(someip_msg.session_id)
+ except Exception: # noqa: BLE001
+ continue
+ finally:
+ mc_sock.close()
+
+ assert len(session_ids) >= 2, (
+ f"ETS_091: Only {len(session_ids)} OfferService packet(s) captured "
+ "(need at least 2 to check session_id monotonicity)"
+ )
+ for i in range(len(session_ids) - 1):
+ current = session_ids[i]
+ nxt = session_ids[i + 1]
+ # Allow wrap-around at 0xFFFF (session_id is 16-bit).
+ is_increment = nxt == (current % 0xFFFF) + 1
+ assert is_increment, f"ETS_091: session_id did not increment monotonically: {current:#06x} -> {nxt:#06x}"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_099_initial_event_sent_after_subscribe(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_099: DUT sends initial field value as event after SubscribeEventgroup ACK.
+
+ The eventgroup 0x4455 carries a field event (update-cycle=2000ms in config).
+ After subscribing, the first notification must arrive within the cycle window.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sd_sock = open_sender_socket(tester_ip)
+ notif_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ notif_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ notif_sock.bind((tester_ip, 0))
+ notif_port: int = notif_sock.getsockname()[1]
+
+ try:
+
+ def _subscribe() -> None:
+ send_subscribe_eventgroup(
+ sd_sock,
+ (dut_ip, SD_PORT),
+ SERVICE_ID,
+ INSTANCE_ID,
+ EVENTGROUP_UDP_UNICAST,
+ MAJOR_VERSION,
+ subscriber_ip=tester_ip,
+ subscriber_port=notif_port,
+ )
+
+ _subscribe()
+ acks = capture_unicast_sd_entries(
+ sd_sock,
+ filter_types=(SOMEIPSDEntryType.SubscribeAck,),
+ timeout_secs=5.0,
+ resend=_subscribe,
+ )
+ assert any(e.eventgroup_id == EVENTGROUP_UDP_UNICAST and e.ttl > 0 for e in acks), (
+ "ETS_099: Prerequisite failed — no SubscribeAck received"
+ )
+
+ # Expect at least one notification (field sends initial value + cyclic updates).
+ notifs = capture_some_ip_messages(notif_sock, SERVICE_ID, timeout_secs=5.0)
+ assert notifs, (
+ "ETS_099: No SOME/IP notifications received after subscribe. "
+ "DUT must send initial field value on subscription."
+ )
+ finally:
+ sd_sock.close()
+ notif_sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_offer_format"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_100_no_findservice_emitted_by_server(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_100: DUT (server) must not emit FindService entries in main phase.
+
+ A SOME/IP server that has offered its service must not transmit
+ FindService SD entries. Capture unicast SD entries on the tester
+ socket (which is bound at SD_PORT) for 5 s and assert none are
+ FindService type from the DUT.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.FindService,),
+ timeout_secs=5.0,
+ )
+ assert not entries, (
+ f"ETS_100: DUT emitted {len(entries)} FindService SD entry/ies. "
+ "A server in main phase must not send FindService."
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ partially_verifies=["comp_req__tc8_conformance__sd_sub_lifecycle"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_101_stop_offer_ceases_client_events(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_101: DUT is server-only; client StopSubscribe reaction to server StopOfferService is not applicable."""
+ pytest.skip("DUT is server-only; client StopSubscribe reaction to server StopOfferService is not applicable.")
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_128_multicast_findservice_version_wildcard(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_128: Multicast FindService with major=0xFF/minor=0xFFFFFFFF triggers OfferService.
+
+ Sending a FindService with wildcard version to the SD multicast address
+ must cause the DUT to respond with an OfferService.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = open_sender_socket(tester_ip)
+ try:
+
+ def _send() -> None:
+ send_find_service(
+ sock,
+ (SD_MULTICAST_ADDR, SD_PORT),
+ service_id=SERVICE_ID,
+ instance_id=0xFFFF,
+ major_version=0xFF,
+ minor_version=0xFFFFFFFF,
+ )
+
+ _send()
+ entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=5.0,
+ resend=_send,
+ )
+ matching = [e for e in entries if e.service_id == SERVICE_ID]
+ assert matching, (
+ f"ETS_128: No OfferService received for multicast FindService "
+ f"with wildcard version (major=0xFF, minor=0xFFFFFFFF)"
+ )
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__sd_find_response"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_130_multicast_findservice_unicast_flag_clear(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ tester_ip: str,
+ ) -> None:
+ """ETS_130: FindService with unicast_flag=0 (flags byte bit 6 clear) is processed.
+
+ Per SOME/IP-SD spec, the unicast flag (bit 6 of the SD flags byte) signals
+ whether the sender supports unicast responses. With the flag clear (0) the
+ DUT may respond on multicast. At minimum it must process the FindService
+ and we capture any resulting offer on either socket.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ from helpers.sd_malformed import build_raw_sd_packet, _find_service_entry_bytes # noqa: PLC0415
+
+ sock = open_sender_socket(tester_ip)
+ mc_sock = open_multicast_socket(host_ip)
+ try:
+ # SD flags: reboot bit (0x80) set, unicast bit (0x40) clear, resulting byte = 0x80.
+ entry_bytes = _find_service_entry_bytes(
+ service_id=SERVICE_ID,
+ instance_id=0xFFFF,
+ major_version=0xFF,
+ minor_version=0xFFFFFFFF,
+ )
+ pkt = build_raw_sd_packet(flags=0x80, entries_bytes=entry_bytes)
+ sock.sendto(pkt, (SD_MULTICAST_ADDR, SD_PORT))
+
+ # Capture on multicast socket (DUT may respond on multicast when flag=0).
+ deadline = time.monotonic() + 5.0
+ found: list = []
+ while time.monotonic() < deadline and not found:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ mc_sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = mc_sock.recvfrom(65535)
+ except socket.timeout:
+ continue
+ try:
+ someip_msg, _ = SOMEIPHeader.parse(data)
+ if someip_msg.service_id != 0xFFFF:
+ continue
+ sd_hdr, _ = SOMEIPSDHeader.parse(someip_msg.payload)
+ for entry in sd_hdr.entries:
+ if entry.sd_type == SOMEIPSDEntryType.OfferService and entry.service_id == SERVICE_ID:
+ found.append(entry)
+ except Exception: # noqa: BLE001
+ continue
+
+ # Also drain any unicast OfferService on the sender socket.
+ if not found:
+ unicast_entries = capture_unicast_sd_entries(
+ sock,
+ filter_types=(SOMEIPSDEntryType.OfferService,),
+ timeout_secs=0.5,
+ )
+ found.extend(e for e in unicast_entries if e.service_id == SERVICE_ID)
+
+ assert found, (
+ "ETS_130: No OfferService received after multicast FindService "
+ "with unicast_flag=0. DUT must still process the FindService entry."
+ )
+ finally:
+ sock.close()
+ mc_sock.close()
diff --git a/tests/tc8_conformance/test_someip_message_format.py b/tests/tc8_conformance/test_someip_message_format.py
new file mode 100644
index 000000000..88e260202
--- /dev/null
+++ b/tests/tc8_conformance/test_someip_message_format.py
@@ -0,0 +1,1483 @@
+# *******************************************************************************
+# Copyright (c) 2026 Contributors to the Eclipse Foundation
+#
+# See the NOTICE file(s) distributed with this work for additional
+# information regarding copyright ownership.
+#
+# This program and the accompanying materials are made available under the
+# terms of the Apache License Version 2.0 which is available at
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# SPDX-License-Identifier: Apache-2.0
+# *******************************************************************************
+"""TC8 SOME/IP Message Format tests: TC8-MSG-001 through TC8-MSG-008.
+
+See ``docs/architecture/tc8_conformance_testing.rst`` for the test architecture.
+"""
+
+import socket
+import subprocess
+import time
+import pytest
+
+from attribute_plugin import add_test_properties
+
+from helpers.message_builder import (
+ build_notification_as_request,
+ build_oversized_message,
+ build_request,
+ build_request_no_return,
+ build_request_with_return_code,
+ build_truncated_message,
+ build_wrong_protocol_version_request,
+)
+from helpers.someip_assertions import (
+ assert_client_echo,
+ assert_offer_has_tcp_endpoint_option,
+ assert_return_code,
+ assert_session_echo,
+ assert_valid_response,
+)
+from helpers.constants import (
+ DUT_RELIABLE_PORT,
+ DUT_UNRELIABLE_PORT,
+ EVENT_TEST_UINT8,
+ INSTANCE_ID,
+ METHOD_ECHO_UINT8,
+ SERVICE_ID,
+)
+from helpers.sd_helpers import capture_sd_offers
+from helpers.tcp_helpers import (
+ tcp_connect,
+ tcp_receive_n_responses,
+ tcp_receive_response,
+ tcp_send_concatenated,
+ tcp_send_request,
+)
+from helpers.udp_helpers import udp_receive_responses, udp_send_concatenated
+from someip.header import SOMEIPHeader, SOMEIPMessageType, SOMEIPReturnCode
+
+pytestmark = pytest.mark.skip(
+ reason="Production stack does not handle SOME/IP methods (gatewayd: to_num_of_methods=0) without mw::com ETS app (2026-08-11)"
+)
+
+SOMEIP_CONFIG: str = "tc8_someipd_service.json"
+
+_UNKNOWNMETHOD_ECHO_UINT8: int = 0xBEEF
+
+
+def _wait_for_dut_offer(host_ip: str, timeout: float = 5.0) -> None:
+ """Block until the DUT sends at least one SD OfferService."""
+ try:
+ capture_sd_offers(host_ip, min_count=1, timeout_secs=timeout)
+ except (TimeoutError, OSError):
+ pytest.skip("DUT did not offer service within timeout — multicast may be unavailable")
+
+
+def _send_request_and_receive(
+ dut_ip: str,
+ request_bytes: bytes,
+ timeout_secs: float = 3.0,
+) -> SOMEIPHeader:
+ """Send a SOME/IP request to the DUT and return the first response."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind(("", 0))
+ sock.sendto(request_bytes, (dut_ip, DUT_UNRELIABLE_PORT))
+ sock.settimeout(timeout_secs)
+ data, _ = sock.recvfrom(65535)
+ resp, _ = SOMEIPHeader.parse(data)
+ return resp
+ finally:
+ sock.close()
+
+
+def _send_request_expect_no_response(
+ dut_ip: str,
+ request_bytes: bytes,
+ timeout_secs: float = 2.0,
+) -> list:
+ """Send a SOME/IP request and verify no response arrives."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ collected = []
+ try:
+ sock.bind(("", 0))
+ sock.sendto(request_bytes, (dut_ip, DUT_UNRELIABLE_PORT))
+ deadline = time.monotonic() + timeout_secs
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ msg, _ = SOMEIPHeader.parse(data)
+ collected.append(msg)
+ except socket.timeout:
+ continue
+ except Exception:
+ continue
+ finally:
+ sock.close()
+ return collected
+
+
+class TestSomeipResponseHeader:
+ """TC8-MSG-001/002/005/008: Response header validation."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_001_protocol_version(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-001: RESPONSE has protocol_version = 0x01."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0001)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert resp.protocol_version == 1, f"TC8-MSG-001: protocol_version = {resp.protocol_version}, expected 1 (0x01)"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_002_message_type_response(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-002: Response to REQUEST has message_type = RESPONSE (0x80)."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0002)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_002_no_response_for_request_no_return(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-002 (fire-and-forget): REQUEST_NO_RETURN must not produce a RESPONSE."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request_no_return(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0009)
+ responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0)
+
+ assert not responses, (
+ f"TC8-MSG-002: {len(responses)} response(s) received for REQUEST_NO_RETURN; "
+ "SOME/IP spec requires no response to fire-and-forget messages"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_005_session_id_echo(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-005: RESPONSE session_id matches REQUEST session_id."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ session_id = 0x1234
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=session_id)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_session_echo(resp, session_id)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_008_client_id_echo(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-008: RESPONSE client_id matches REQUEST client_id."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ client_id = 0x0011
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=client_id, session_id=0x0003)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_client_echo(resp, client_id)
+
+
+class TestSomeipErrorCodes:
+ """TC8-MSG-003/004/006: Error return codes."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_error_codes"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_003_unknown_service(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-003: Request to unknown service gets E_UNKNOWN_SERVICE or no response.
+
+ Note: The DUT may silently drop requests for services it does not offer.
+ TC8 allows either E_UNKNOWN_SERVICE (0x02) or no response at all; this
+ test accepts both behaviors.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ unknown_service = 0xBEEF
+ req = build_request(unknown_service, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0004)
+ responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0)
+
+ if responses:
+ assert_return_code(responses[0], SOMEIPReturnCode.E_UNKNOWN_SERVICE)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_error_codes"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_004_unknown_method(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-004: Request with unknown method_id gets E_UNKNOWN_METHOD."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, _UNKNOWNMETHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0005)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_return_code(resp, SOMEIPReturnCode.E_UNKNOWN_METHOD)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_error_codes"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_006_wrong_interface_version(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-006: Request with wrong interface version must get E_WRONG_INTERFACE_VERSION.
+
+ Per SOME/IP spec PRS_SOMEIP_00191, a server receiving a REQUEST whose
+ interface_version does not match the offered service version must respond
+ with E_WRONG_INTERFACE_VERSION (0x08). vsomeip 3.6.1 returns the conformant
+ E_WRONG_INTERFACE_VERSION for such requests.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0006,
+ interface_version=0xFF,
+ )
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_return_code(resp, SOMEIPReturnCode.E_WRONG_INTERFACE_VERSION)
+
+
+class TestMalformedMessages:
+ """TC8-MSG-007: DUT must survive malformed SOME/IP messages without crashing."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_malformed"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_007_truncated_message_no_crash(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-007: Truncated message (< 8 bytes) does not crash the DUT."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ sock.sendto(build_truncated_message(), (dut_ip, DUT_UNRELIABLE_PORT))
+ finally:
+ sock.close()
+
+ time.sleep(0.3)
+ assert dut.poll() is None, "TC8-MSG-007: DUT crashed after receiving a truncated SOME/IP message"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_malformed"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_007_wrong_protocol_version_no_crash(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-007: Message with protocol_version=0xFF does not crash the DUT."""
+ assert dut.poll() is None, "DUT is not running"
+
+ malformed = build_wrong_protocol_version_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0007,
+ )
+ # DUT may respond with E_MALFORMED_MESSAGE or drop silently; both are valid.
+ _send_request_expect_no_response(dut_ip, malformed, timeout_secs=1.0)
+
+ assert dut.poll() is None, "TC8-MSG-007: DUT crashed after receiving a wrong-protocol-version message"
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_malformed"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_msg_007_oversized_length_field_no_crash(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """TC8-MSG-007: Message whose length field claims 0x7FF3 bytes does not crash the DUT."""
+ assert dut.poll() is None, "DUT is not running"
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ try:
+ sock.sendto(
+ build_oversized_message(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0008),
+ (dut_ip, DUT_UNRELIABLE_PORT),
+ )
+ finally:
+ sock.close()
+
+ time.sleep(0.3)
+ assert dut.poll() is None, "TC8-MSG-007: DUT crashed after receiving a message with oversized length field"
+
+
+class TestSomeipTcpTransport:
+ """TCP transport binding tests (SOMEIPSRV_RPC_01/02, OPTIONS_15).
+
+ These tests verify that someipd correctly handles SOME/IP request/response
+ over TCP (reliable transport binding). The DUT is configured with both
+ unreliable (UDP ``DUT_UNRELIABLE_PORT``) and reliable (TCP ``DUT_RELIABLE_PORT``) ports.
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_rpc_01_tcp_request_response(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_01: REQUEST over TCP receives a valid RESPONSE.
+
+ Verifies that the DUT accepts a TCP connection and correctly responds
+ to a SOME/IP REQUEST with a RESPONSE having message_type=0x80.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT)
+ try:
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0050)
+ tcp_send_request(sock, req)
+ resp = tcp_receive_response(sock)
+
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_rpc_01_tcp_session_id_echo(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_01: TCP RESPONSE echoes the REQUEST session_id."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ session_id = 0x5678
+ sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT)
+ try:
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=session_id)
+ tcp_send_request(sock, req)
+ resp = tcp_receive_response(sock)
+
+ assert_session_echo(resp, session_id)
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_rpc_01_tcp_client_id_echo(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_01: TCP RESPONSE echoes the REQUEST client_id."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ client_id = 0x0015
+ sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT)
+ try:
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=client_id, session_id=0x0051)
+ tcp_send_request(sock, req)
+ resp = tcp_receive_response(sock)
+
+ assert_client_echo(resp, client_id)
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_rpc_02_tcp_multiple_methods_single_connection(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_02: Multiple SOME/IP methods on a single TCP connection.
+
+ Verifies that the DUT handles multiple sequential request/response
+ exchanges on the same TCP connection without requiring reconnection.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT)
+ try:
+ req1 = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0060)
+ tcp_send_request(sock, req1)
+ resp1 = tcp_receive_response(sock)
+ assert_valid_response(resp1, SERVICE_ID, METHOD_ECHO_UINT8)
+ assert_session_echo(resp1, 0x0060)
+
+ # Second request on the SAME connection
+ req2 = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0010, session_id=0x0061)
+ tcp_send_request(sock, req2)
+ resp2 = tcp_receive_response(sock)
+ assert_valid_response(resp2, SERVICE_ID, METHOD_ECHO_UINT8)
+ assert_session_echo(resp2, 0x0061)
+ finally:
+ sock.close()
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_sd_options_15_tcp_endpoint_advertised(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_OPTIONS_15: SD OfferService includes a TCP endpoint option.
+
+ When the DUT is configured with a reliable (TCP) port, the SD
+ OfferService message must include an IPv4EndpointOption with
+ L4Proto=TCP and the correct port.
+ """
+ assert dut.poll() is None, "DUT is not running"
+
+ offers = capture_sd_offers(host_ip, min_count=1, timeout_secs=5.0)
+ assert offers, "No SD OfferService received"
+
+ assert_offer_has_tcp_endpoint_option(offers[0], dut_ip, DUT_RELIABLE_PORT)
+
+
+class TestTcpUnalignedMessages:
+ """SOMEIP_ETS_068: Multiple unaligned SOME/IP messages in one TCP segment.
+
+ PRS_SOMEIP_00142, PRS_SOMEIP_00569: A SOME/IP TCP receiver must parse
+ the byte stream using the length field as the sole framing indicator.
+ No 4-byte alignment between consecutive messages is guaranteed or required.
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__tcp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_ets_068_unaligned_someip_messages_over_tcp(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIP_ETS_068: Three REQUEST messages in one TCP segment; third is unaligned.
+
+ Payload layout in the concatenated TCP write:
+ msg1 (session 0x0071): 0-byte payload -> 16 bytes (offset 0, aligned)
+ msg2 (session 0x0072): 2-byte payload -> 18 bytes (offset 16, aligned)
+ msg3 (session 0x0073): 0-byte payload -> 16 bytes (offset 34, NOT 4-byte aligned)
+
+ All three must receive individual RESPONSE messages from the DUT.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ msg1 = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0071,
+ payload=b"",
+ )
+ msg2 = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0072,
+ payload=b"\xaa\xbb",
+ )
+ msg3 = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0073,
+ payload=b"",
+ )
+
+ sock = tcp_connect(dut_ip, DUT_RELIABLE_PORT)
+ try:
+ tcp_send_concatenated(sock, [msg1, msg2, msg3])
+ responses = tcp_receive_n_responses(sock, count=3, timeout_secs=5.0)
+ finally:
+ sock.close()
+
+ assert len(responses) == 3, f"SOMEIP_ETS_068: expected 3 RESPONSE messages, got {len(responses)}"
+ for resp in responses:
+ assert resp.service_id == SERVICE_ID
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+
+ session_ids = {resp.session_id for resp in responses}
+ assert session_ids == {0x0071, 0x0072, 0x0073}, (
+ f"SOMEIP_ETS_068: unexpected session IDs in responses: "
+ f"{{{', '.join(f'0x{s:04x}' for s in sorted(session_ids))}}}"
+ )
+
+
+class TestUdpUnalignedMessages:
+ """SOMEIP_ETS_069: Multiple unaligned SOME/IP messages in one UDP datagram.
+
+ PRS_SOMEIP_00142, PRS_SOMEIP_00569: A SOME/IP UDP receiver must parse
+ each SOME/IP message within the datagram using the length field as the
+ sole framing indicator. No 4-byte alignment between consecutive messages
+ is guaranteed or required.
+ """
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__udp_transport"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_tc8_ets_069_unaligned_someip_messages_over_udp(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIP_ETS_069: Three REQUEST messages in one UDP datagram; third is unaligned.
+
+ Payload layout in the concatenated UDP write:
+ msg1 (session 0x0081): 0-byte payload -> 16 bytes (offset 0, aligned)
+ msg2 (session 0x0082): 2-byte payload -> 18 bytes (offset 16, aligned)
+ msg3 (session 0x0083): 0-byte payload -> 16 bytes (offset 34, NOT 4-byte aligned)
+
+ All three must receive individual RESPONSE messages from the DUT.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ msg1 = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0081,
+ payload=b"",
+ )
+ msg2 = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0082,
+ payload=b"\xaa\xbb",
+ )
+ msg3 = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0010,
+ session_id=0x0083,
+ payload=b"",
+ )
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind(("", 0))
+ try:
+ udp_send_concatenated(sock, (dut_ip, DUT_UNRELIABLE_PORT), [msg1, msg2, msg3])
+ responses = udp_receive_responses(sock, count=3, timeout_secs=5.0)
+ finally:
+ sock.close()
+
+ assert len(responses) == 3, f"SOMEIP_ETS_069: expected 3 RESPONSE messages, got {len(responses)}"
+ for resp in responses:
+ assert resp.service_id == SERVICE_ID
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+
+ session_ids = {resp.session_id for resp in responses}
+ assert session_ids == {0x0081, 0x0082, 0x0083}, (
+ f"SOMEIP_ETS_069: unexpected session IDs in responses: "
+ f"{{{', '.join(f'0x{s:04x}' for s in sorted(session_ids))}}}"
+ )
+
+
+_UNKNOWNSERVICE_ID: int = 0xBEEF
+
+
+def _send_request_and_receive_with_addr(
+ dut_ip: str,
+ request_bytes: bytes,
+ timeout_secs: float = 3.0,
+) -> tuple[SOMEIPHeader, tuple[str, int]]:
+ """Send a SOME/IP request to the DUT and return (response, (src_ip, src_port))."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind(("", 0))
+ sock.sendto(request_bytes, (dut_ip, DUT_UNRELIABLE_PORT))
+ sock.settimeout(timeout_secs)
+ data, addr = sock.recvfrom(65535)
+ resp, _ = SOMEIPHeader.parse(data)
+ return resp, addr
+ finally:
+ sock.close()
+
+
+class TestSomeipBasicIdentifiers:
+ """SOMEIPSRV_BASIC_01-03: Service identification primitives."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_basic_01_correct_service_id_gets_response(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_BASIC_01: REQUEST to known service/method receives RESPONSE with E_OK."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0020, session_id=0x0001)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+ assert_return_code(resp, SOMEIPReturnCode.E_OK)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_error_codes"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_basic_02_unknown_service_id_no_response_or_error(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_BASIC_02: REQUEST to unknown service_id must return E_UNKNOWN_SERVICE.
+
+ Per SOMEIPSRV_BASIC_02, a DUT that receives a REQUEST for an unknown service_id
+ MUST reply with E_UNKNOWN_SERVICE (return_code 0x02).
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(_UNKNOWNSERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0020, session_id=0x0002)
+ responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0)
+
+ assert responses, (
+ "SOMEIPSRV_BASIC_02: No response received for unknown service_id "
+ f"0x{_UNKNOWNSERVICE_ID:04x}; DUT must reply with E_UNKNOWN_SERVICE"
+ )
+ assert_return_code(responses[0], SOMEIPReturnCode.E_UNKNOWN_SERVICE)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ @pytest.mark.xfail(
+ strict=False,
+ reason=(
+ "vsomeip 3.6.1 sends E_UNKNOWN_METHOD RESPONSE to REQUEST with event "
+ "method_id (bit 15 set); stack limitation"
+ ),
+ )
+ def test_basic_03_event_method_id_no_response(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_BASIC_03: REQUEST with event method_id (bit 15 set) must not produce a RESPONSE.
+
+ Per SOMEIPSRV_BASIC_03, when the DUT receives a message with method_id bit 15 = 1
+ (event notification ID range), it MUST NOT send a RESPONSE (message_type 0x80).
+ ERROR messages are not prohibited by the spec.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, EVENT_TEST_UINT8, client_id=0x0020, session_id=0x0003)
+ responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0)
+
+ response_msgs = [r for r in responses if r.message_type == SOMEIPMessageType.RESPONSE]
+ assert not response_msgs, (
+ f"SOMEIPSRV_BASIC_03: DUT sent {len(response_msgs)} RESPONSE message(s) "
+ "(message_type=0x80) to a REQUEST with event method_id — "
+ "DUT must not send a RESPONSE for event method IDs"
+ )
+
+
+class TestSomeipResponseFields:
+ """SOMEIPSRV_ONWIRE_01/02/04/06/11 + RPC_18/20: Verify RESPONSE field values."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_onwire_01_response_source_address(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ONWIRE_01: RESPONSE originates from the DUT's offering address and port."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0010)
+ resp, addr = _send_request_and_receive_with_addr(dut_ip, req)
+
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+ assert addr[0] == dut_ip, f"SOMEIPSRV_ONWIRE_01: RESPONSE source IP mismatch: got {addr[0]}, expected {dut_ip}"
+ assert addr[1] == DUT_UNRELIABLE_PORT, (
+ f"SOMEIPSRV_ONWIRE_01: RESPONSE source port mismatch: got {addr[1]}, expected {DUT_UNRELIABLE_PORT}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_onwire_02_method_id_msb_zero_in_response(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ONWIRE_02: RESPONSE method_id has bit 15 = 0 (not an event)."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0011)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert (resp.method_id & 0x8000) == 0, (
+ f"SOMEIPSRV_ONWIRE_02: RESPONSE method_id bit 15 is set "
+ f"(0x{resp.method_id:04x}); responses must not carry event method IDs"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_onwire_04_request_id_reuse(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ONWIRE_04: Each RESPONSE echoes the corresponding request_id (client:session).
+
+ Sends two REQUESTs with the same client_id/session_id pair and verifies
+ both RESPONSEs echo the pair correctly.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ client_id = 0x0001
+ session_id = 0x0042
+
+ for _ in range(2):
+ req = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=client_id,
+ session_id=session_id,
+ )
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert resp.client_id == client_id, (
+ f"SOMEIPSRV_ONWIRE_04: client_id mismatch: got 0x{resp.client_id:04x}, expected 0x{client_id:04x}"
+ )
+ assert resp.session_id == session_id, (
+ f"SOMEIPSRV_ONWIRE_04: session_id mismatch: got 0x{resp.session_id:04x}, expected 0x{session_id:04x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_onwire_06_interface_version_echoed(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ONWIRE_06: RESPONSE interface_version matches the REQUEST interface_version.
+
+ The interface_version byte (byte 13) in the RESPONSE must be copied
+ from the inbound REQUEST, not from the local service configuration.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ iface_ver = 0x05
+ req = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0021,
+ session_id=0x0012,
+ interface_version=iface_ver,
+ )
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert resp.interface_version == iface_ver, (
+ f"SOMEIPSRV_ONWIRE_06: interface_version mismatch: "
+ f"got 0x{resp.interface_version:02x}, expected 0x{iface_ver:02x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_onwire_11_normal_response_return_code_ok(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ONWIRE_11: A normal RESPONSE to a valid REQUEST has return_code E_OK."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0013)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_return_code(resp, SOMEIPReturnCode.E_OK)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_18_message_id_echoed(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_18: RESPONSE message_id (service_id:method_id) echoes REQUEST values."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0021, session_id=0x0014)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert resp.service_id == SERVICE_ID, (
+ f"SOMEIPSRV_RPC_18: service_id mismatch in RESPONSE: "
+ f"got 0x{resp.service_id:04x}, expected 0x{SERVICE_ID:04x}"
+ )
+ assert resp.method_id == METHOD_ECHO_UINT8, (
+ f"SOMEIPSRV_RPC_18: method_id mismatch in RESPONSE: "
+ f"got 0x{resp.method_id:04x}, expected 0x{METHOD_ECHO_UINT8:04x}"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_19_session_id_echoed_in_error(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_19: Error response session_id must equal the request session_id.
+
+ When the DUT returns an error response (e.g. E_UNKNOWN_METHOD for an
+ unknown method_id), the session_id in the response header MUST equal
+ the session_id from the request per PRS_SOMEIP_00137 (request_id echo).
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ session_id = 0x0016
+ req = build_request(
+ SERVICE_ID,
+ _UNKNOWNMETHOD_ECHO_UINT8,
+ client_id=0x0021,
+ session_id=session_id,
+ )
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_session_echo(resp, session_id)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_20_interface_version_copied_from_request(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_20: RESPONSE interface_version is copied from the REQUEST (variant).
+
+ Sends with interface_version=0x03 (different from ONWIRE_06's 0x05) to
+ confirm the echo is dynamic and not hardcoded.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ iface_ver = 0x03
+ req = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0021,
+ session_id=0x0015,
+ interface_version=iface_ver,
+ )
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert resp.interface_version == iface_ver, (
+ f"SOMEIPSRV_RPC_20: interface_version mismatch: "
+ f"got 0x{resp.interface_version:02x}, expected 0x{iface_ver:02x}"
+ )
+
+
+class TestSomeipFireAndForgetAndErrors:
+ """SOMEIPSRV_RPC_05-10 + ETS_004/054/059/061/075: Fire-and-forget and robustness."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_05_fire_and_forget_no_error(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_05: REQUEST_NO_RETURN produces neither a RESPONSE nor an ERROR."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request_no_return(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0020)
+ responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0)
+
+ error_msgs = [
+ r
+ for r in responses
+ if r.message_type
+ in (
+ SOMEIPMessageType.ERROR,
+ SOMEIPMessageType.ERROR_ACK,
+ )
+ ]
+ assert not responses, (
+ f"SOMEIPSRV_RPC_05: {len(responses)} message(s) received for "
+ "REQUEST_NO_RETURN; no RESPONSE or ERROR expected"
+ )
+ assert not error_msgs, (
+ f"SOMEIPSRV_RPC_05: {len(error_msgs)} ERROR message(s) received for "
+ "REQUEST_NO_RETURN; SOME/IP spec prohibits error replies to fire-and-forget"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_06_return_code_upper_bits_zero(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_06: RESPONSE return_code has bits 7-5 = 0 (only bits 4-0 are used)."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0021)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ rc_value: int = resp.return_code.value if hasattr(resp.return_code, "value") else int(resp.return_code)
+ assert (rc_value & 0xE0) == 0, (
+ f"SOMEIPSRV_RPC_06: RESPONSE return_code upper bits are not zero: "
+ f"0x{rc_value:02x} (bits 7-5 = 0x{(rc_value >> 5) & 0x07:01x})"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_07_request_with_return_code_bits_set(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_07: DUT processes REQUEST normally even if return_code field is non-zero.
+
+ The SOME/IP spec says servers must ignore return_code in inbound REQUESTs.
+ Sending return_code=0x20 (non-zero) must still yield a valid RESPONSE.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request_with_return_code(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ return_code=0x20,
+ client_id=0x0030,
+ session_id=0x0022,
+ )
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ @pytest.mark.xfail(
+ strict=True,
+ reason=(
+ "vsomeip 3.6.1 limitation (SOMEIPSRV_RPC_08): DUT replies to REQUEST "
+ "messages carrying a non-zero return code, violating the spec. "
+ "See docs/architecture/tc8_conformance_testing.rst "
+ "see: Known SOME/IP Stack Limitations."
+ ),
+ )
+ def test_rpc_08_request_with_error_return_code_no_reply(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_08: DUT does not reply to REQUEST with return_code = E_NOT_OK (0x01).
+
+ Per SOME/IP spec a server must not process a REQUEST whose return_code
+ field is set to an error value by the client.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request_with_return_code(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ return_code=0x01,
+ client_id=0x0030,
+ session_id=0x0023,
+ )
+ responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0)
+
+ assert not responses, (
+ f"SOMEIPSRV_RPC_08: {len(responses)} response(s) received; "
+ "DUT must not reply to REQUEST with non-zero return_code"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_error_codes"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_09_error_response_no_payload(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_09: ERROR response to unknown method has length = 8 (no payload).
+
+ The SOME/IP length field counts from byte 8 onward. An error RESPONSE
+ with no payload has length = 8 (the fixed-header tail: client_id, session_id,
+ protocol_version, interface_version, message_type, return_code).
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, _UNKNOWNMETHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0024)
+ resp = _send_request_and_receive(dut_ip, req)
+
+ raw_resp = resp.build()
+ length_field = int.from_bytes(raw_resp[4:8], "big")
+ assert length_field == 8, (
+ f"SOMEIPSRV_RPC_09: ERROR response length field = {length_field}, "
+ "expected 8 (no payload for error responses)"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_malformed"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_rpc_10_fire_and_forget_reserved_type_no_error(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_RPC_10: REQUEST_NO_RETURN with patched reserved message_type byte is dropped.
+
+ Patches byte 14 to 0x04 (reserved message type) then sends as fire-and-forget.
+ The DUT must not send an ERROR in response.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ raw = build_request_no_return(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0025)
+ # Patch byte 14 (message_type) to reserved value 0x04.
+ patched = raw[:14] + b"\x04" + raw[15:]
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ responses = []
+ try:
+ sock.bind(("", 0))
+ sock.sendto(patched, (dut_ip, DUT_UNRELIABLE_PORT))
+ deadline = time.monotonic() + 2.0
+ while time.monotonic() < deadline:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
+ sock.settimeout(min(remaining, 0.5))
+ try:
+ data, _ = sock.recvfrom(65535)
+ msg, _ = SOMEIPHeader.parse(data)
+ responses.append(msg)
+ except socket.timeout:
+ continue
+ except Exception:
+ continue
+ finally:
+ sock.close()
+
+ assert not responses, (
+ f"SOMEIPSRV_RPC_10: {len(responses)} message(s) received after sending "
+ "fire-and-forget with reserved message_type; DUT must not send ERROR"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_004_burst_10_sequential_requests(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ETS_004: 10 sequential REQUESTs each produce a correctly echoed RESPONSE.
+
+ Sends 10 REQUESTs with incrementing session IDs and verifies each
+ RESPONSE carries the matching session_id.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ base_session_id = 0x0100
+ for i in range(10):
+ session_id = base_session_id + i
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=session_id)
+ resp = _send_request_and_receive(dut_ip, req, timeout_secs=3.0)
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+ assert_session_echo(resp, session_id)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_054_empty_payload_request(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ETS_054: REQUEST with empty payload (length=8) gets E_OK RESPONSE."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(
+ SERVICE_ID,
+ METHOD_ECHO_UINT8,
+ client_id=0x0030,
+ session_id=0x0030,
+ payload=b"",
+ )
+ resp = _send_request_and_receive(dut_ip, req)
+
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+ assert_return_code(resp, SOMEIPReturnCode.E_OK)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_059_fire_and_forget_wrong_service_no_error(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ETS_059: REQUEST_NO_RETURN to non-existent service gets no ERROR reply."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request_no_return(_UNKNOWNSERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0031)
+ responses = _send_request_expect_no_response(dut_ip, req, timeout_secs=2.0)
+
+ assert not responses, (
+ f"SOMEIPSRV_ETS_059: {len(responses)} message(s) received after sending "
+ "REQUEST_NO_RETURN to unknown service; DUT must not send ERROR"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_061_two_sequential_requests(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ETS_061: Two sequential REQUESTs each receive RESPONSE with correct session_id."""
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ for session_id in (0x0040, 0x0041):
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=session_id)
+ resp = _send_request_and_receive(dut_ip, req)
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)
+ assert_session_echo(resp, session_id)
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_malformed"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_075_notification_as_request_ignored(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """SOMEIPSRV_ETS_075: DUT ignores a message with message_type=NOTIFICATION (0x02).
+
+ A NOTIFICATION message type in the client-to-server direction is invalid
+ per SOME/IP spec. The server must not send a RESPONSE.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ msg = build_notification_as_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0030, session_id=0x0050)
+ responses = _send_request_expect_no_response(dut_ip, msg, timeout_secs=2.0)
+
+ assert not responses, (
+ f"SOMEIPSRV_ETS_075: {len(responses)} response(s) received for a "
+ "NOTIFICATION message sent to the server; DUT must not reply"
+ )
+
+
+class TestSomeipByteOrder:
+ """ETS_005/058: Big-endian byte order in SOME/IP responses and oversized length robustness."""
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_resp_header"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_005_response_uses_big_endian_byte_order(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_005: SOME/IP RESPONSE is encoded in big-endian byte order.
+
+ Verifies PRS_SOMEIP_00087: all SOME/IP header fields are big-endian.
+ Sends a REQUEST and compares the raw bytes of the RESPONSE with the
+ parsed field values to confirm big-endian encoding.
+ """
+ import struct as _struct
+
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0022, session_id=0x0090)
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ raw_data: bytes = b""
+ parsed: SOMEIPHeader
+ try:
+ sock.bind(("", 0))
+ sock.sendto(req, (dut_ip, DUT_UNRELIABLE_PORT))
+ sock.settimeout(3.0)
+ raw_data, _ = sock.recvfrom(65535)
+ parsed, _ = SOMEIPHeader.parse(raw_data)
+ finally:
+ sock.close()
+
+ assert len(raw_data) >= 16, f"ETS_005: RESPONSE too short to be a valid SOME/IP header ({len(raw_data)} bytes)"
+
+ # service_id is bytes 0-1 (big-endian uint16)
+ expected_service_id_msb = (parsed.service_id >> 8) & 0xFF
+ assert raw_data[0] == expected_service_id_msb, (
+ f"ETS_005: service_id MSB byte mismatch — raw byte[0] = 0x{raw_data[0]:02x}, "
+ f"expected 0x{expected_service_id_msb:02x} (service_id = 0x{parsed.service_id:04x}). "
+ "SOME/IP header must use big-endian byte order (PRS_SOMEIP_00087)."
+ )
+
+ # length field is bytes 4-7 (big-endian uint32)
+ raw_length = _struct.unpack_from(">I", raw_data, 4)[0]
+ assert raw_length == len(raw_data) - 8, (
+ f"ETS_005: length field = {raw_length}, actual payload+header-tail = "
+ f"{len(raw_data) - 8}; length field must be big-endian encoded"
+ )
+
+ @add_test_properties(
+ fully_verifies=["comp_req__tc8_conformance__msg_malformed"],
+ test_type="requirements-based",
+ derivation_technique="requirements-analysis",
+ )
+ def test_ets_058_oversized_length_field_no_crash(
+ self,
+ dut: subprocess.Popen[bytes],
+ host_ip: str,
+ dut_ip: str,
+ ) -> None:
+ """ETS_058: SOME/IP message with oversized length field (0xFFFFFFF0) does not crash DUT.
+
+ Patches bytes 4-7 of a valid REQUEST to claim a payload length of
+ 0xFFFFFFF0 (far exceeding the actual UDP datagram size). The DUT must
+ discard the malformed message and remain operational, confirmed by a
+ successful response to a subsequent valid REQUEST.
+ """
+ assert dut.poll() is None, "DUT is not running"
+ _wait_for_dut_offer(host_ip)
+
+ valid_req = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0022, session_id=0x0091)
+ oversized = bytearray(valid_req)
+ oversized[4] = 0xFF
+ oversized[5] = 0xFF
+ oversized[6] = 0xFF
+ oversized[7] = 0xF0
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ try:
+ sock.bind(("", 0))
+ sock.sendto(bytes(oversized), (dut_ip, DUT_UNRELIABLE_PORT))
+ finally:
+ sock.close()
+
+ time.sleep(0.3)
+ assert dut.poll() is None, (
+ "ETS_058: DUT crashed after receiving a message with oversized length field (0xFFFFFFF0)"
+ )
+
+ follow_up = build_request(SERVICE_ID, METHOD_ECHO_UINT8, client_id=0x0022, session_id=0x0092)
+ resp = _send_request_and_receive(dut_ip, follow_up)
+ assert_valid_response(resp, SERVICE_ID, METHOD_ECHO_UINT8)