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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ and ideas that are not ready for scheduling are recorded in

## Active milestone

### Milestone 24.8 — MSI direct-UDP retrieval foundation
### Milestone 24.8 — MSI UDP correlation/completion hardening

The first four Milestone 24.8 slices established lossless bounded MSI XML
modeling/parsing, exact serialized `MSI\r` retrieval for CR-line/replay
Expand All @@ -27,6 +27,15 @@ assembled; numbered `Footer`/`Foot` fragments use the existing bounded
reassembly path; retryable sequence failures resend the exact original `MSI`
wire; and successful completion clears one-shot MSI retry/expectation state.

This sixth narrow hardening slice adds no production protocol behavior. It adds
direct behavioral regression coverage for two invariants already provided by
the shared UDP machinery: an exact one-shot MSI expectation survives unrelated
bare XML roots until matching MSI XML arrives, and successful MSI completion
after an automatic sequence-gap retry removes the one-shot retry authority so
later stray MSI fragment gaps cannot resend a stale command. The tests use only
deterministic fake datagrams and synchronize on a later ordinary response before
asserting that no additional UDP write occurred.

Direct `UdpTransport` and its capture wrapper are covered only with deterministic
fake datagrams. This is software transport/framing evidence, not a physical
scanner or firmware-support claim. Fallback transports remain blocked because
Expand All @@ -35,7 +44,7 @@ has been established. Custom transports that merely advertise an `udp://`
endpoint remain fail-closed unless they are the repository's direct
`UdpTransport` path.

This slice does not add `MSV`/`MSB` execution, unindexed `MNU`, menu
This hardening slice does not add `MSV`/`MSB` execution, unindexed `MNU`, menu
lifecycle/state ownership, renderer behavior, model/firmware applicability, or
physical validation. The serialized `[RSV]` field in `MSV`/`MSB` remains
unresolved, and Quick Search (`QSH`) remains blocked pending exact `FRQ`
Expand Down
13 changes: 12 additions & 1 deletion docs/advanced-protocol-research.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,18 @@ completion clears MSI one-shot retry state. `SDSScanner.get_msi()` is therefore
allowed on the repository's direct `UdpTransport`, including when wrapped by
capture recording.

All UDP evidence in this slice is deterministic fake-datagram software evidence.
The sixth narrow slice adds no production protocol behavior. It hardens the
existing direct-UDP contract with explicit behavioral regressions for root
correlation and one-shot retry cleanup: while `MSI` is expected, unrelated bare
`ScannerInfo`, `GLT`, or `AST` XML remains uncorrelated and does not consume the
MSI expectation; after a retryable MSI fragment gap and a subsequent successful
MSI completion, another stray MSI fragment gap cannot reuse the completed
request's automatic-retry authority. The latter assertion waits for a later
ordinary decoded response before checking the transmitted datagrams, avoiding a
negative-write race.

All UDP evidence in the fifth and sixth slices is deterministic fake-datagram
software evidence.
It does not establish physical SDS200 behavior, firmware availability, or a
broader transport guarantee. Fallback transports remain fail-closed because
their active transport can change and no fallback-wide MSI framing contract is
Expand Down
78 changes: 78 additions & 0 deletions tests/test_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,29 @@ def test_udp_decoder_does_not_expect_nonexact_msi_command(command: str) -> None:
assert decoder.feed(bare_xml) == (bare_xml.decode(),)


@pytest.mark.parametrize(
"other_xml",
[
(
b'<ScannerInfo Mode="Trunk Scan"><Property Sig="4" />'
b"</ScannerInfo>"
),
b'<GLT><FL Index="0" /></GLT>',
b'<AST><System Name="Synthetic" /></AST>',
],
)
def test_udp_decoder_keeps_msi_expectation_across_other_xml_roots(
other_xml: bytes,
) -> None:
decoder = UdpDatagramDecoder()
decoder.expect_command("MSI")
msi = b'<MSI FutureRoot="keep-root"><SyntheticRecord /></MSI>'

assert decoder.feed(other_xml) == (other_xml.decode(),)
assert decoder.feed(msi) == ("MSI,<XML>,", msi.decode())
assert decoder.feed(msi) == (msi.decode(),)


def test_decoder_malformed_bare_glt_does_not_complete_expectation() -> None:
completed: list[str] = []
decoder = UdpDatagramDecoder(completion_handler=completed.append)
Expand Down Expand Up @@ -504,6 +527,61 @@ def test_udp_transport_retries_msi_with_exact_original_wire_command() -> None:
assert transport.statistics["xml_fragments_dropped"] == 1


def test_udp_transport_completed_msi_clears_one_shot_retry_state() -> None:
fake = FakeDatagramSocket()
transport = UdpTransport(
"192.0.2.25",
socket_factory=FakeDatagramSocketFactory(fake),
reconnect=False,
max_xml_retries=2,
)
diagnostics: list[TransportDiagnostic] = []
received: list[str] = []
transport.set_diagnostic_handler(diagnostics.append)
transport.start(received.append)
try:
transport.write_command("MSI")
fake.feed(
b'MSI,<XML>,<MSI><MenuItem Name="One" />'
b'<Footer No="1" EOT="0" /></MSI>'
)
fake.feed(
b'MSI,<XML>,<MSI><MenuItem Name="Three" />'
b'<Footer No="3" EOT="1" /></MSI>'
)
wait_until(lambda: fake.sent == [b"MSI\r", b"MSI\r"])

fake.feed(
b'MSI,<XML>,<MSI><MenuItem Name="Recovered" /></MSI>'
)
wait_until(
lambda: transport.statistics["xml_documents_completed"] == 1
)

fake.feed(
b'MSI,<XML>,<MSI><MenuItem Name="StaleOne" />'
b'<Footer No="1" EOT="0" /></MSI>'
)
fake.feed(
b'MSI,<XML>,<MSI><MenuItem Name="StaleThree" />'
b'<Footer No="3" EOT="1" /></MSI>'
)
fake.feed(b"MDL,SDS200\r")
wait_until(lambda: "MDL,SDS200" in received)
finally:
transport.stop()

assert [diagnostic.kind for diagnostic in diagnostics] == [
"sequence_gap",
"sequence_gap",
]
assert fake.sent == [b"MSI\r", b"MSI\r"]
assert transport.statistics["commands_sent"] == 2
assert transport.statistics["retries_sent"] == 1
assert transport.statistics["xml_fragments_dropped"] == 2
assert transport.statistics["xml_documents_completed"] == 1


def test_radio_network_parses_bare_glt_favorites() -> None:
fake = FakeDatagramSocket()
radio = SDS200.network(
Expand Down
Loading