From c5b0eb3c3d42d8aca0afdf40fc9d3a996fb2cc37 Mon Sep 17 00:00:00 2001 From: vishk23 <119831996+vishk23@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:37:58 -0400 Subject: [PATCH] ble: stop the config-read probe reporting silence as a firmware verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #103 probe's no-answer verdict printed one sentence — "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" — for three runs that establish three different things, and for one that establishes the opposite. Two timeouts got it. But `BLEManager.send` can return without transmitting at all: there is an early return when `cmdCharacteristic` is missing, and another in the 5/MG allowlist else-branch. So a run in which nothing ever reached the strap rendered a claim about what the strap's firmware serves. The file's own header names answered / rejected as UNSUPPORTED / silent as three distinct outcomes and then the verdict collapsed silence into the same factual claim as a refusal. One refusal got it for both verbs. The `||` meant 128 answering UNSUPPORTED(3) while 121 timed out printed "neither ... is served — rejected as UNSUPPORTED". 121 was never refused; it was never heard from. An undecodable reply got the bare "is not served" sentence. A CRC or envelope failure is affirmative evidence the strap DID transmit, which is the opposite of what that sentence says. The sentence is now per-verb and tied to the evidence that produced it: UNSUPPORTED is "refused by firmware (UNSUPPORTED)", a timeout is "served no reply in Ns — unconfirmed" (the window is now recorded, not just traced), an undecodable reply is "replied but the frame did not decode — unconfirmed", and an untried verb is "not asked". Only UNSUPPORTED is the firmware answering, so the strong "not served by this firmware" wording survives in exactly one case: the firmware refused BOTH verbs itself, where the `||` is now an `&&`. Same defect class as the ECG probe fix on #896: a verdict asserting a conclusion the run's own inputs could not support. Mirrored byte-for-byte in com.noop.protocol.DeviceConfigReadProbe; the golden cross-platform report test is unaffected because it exercises the answered path. swift test 428 passed, 0 failures; gradlew testFullDebugUnitTest 3199 tests, 0 failures, 5 skipped, verified from the JUnit XML (the DeepCaptureMigrationTest failure noted in #897 is fixed on current main and passes here). Refs #103. --- .../WhoopProtocol/DeviceConfigReadProbe.swift | 53 +++++++++-- .../DeviceConfigReadProbeTests.swift | 67 +++++++++++++- .../noop/protocol/DeviceConfigReadProbe.kt | 60 ++++++++++--- .../protocol/DeviceConfigReadProbeTest.kt | 88 ++++++++++++++++++- 4 files changed, 244 insertions(+), 24 deletions(-) diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift index 49ce3cc6fa..a238e2a82c 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/DeviceConfigReadProbe.swift @@ -23,8 +23,16 @@ import Foundation /// (`Resources/whoop_protocol.json`, `CommandNumber`) names 121 `GET_DEVICE_CONFIG_VALUE` and 128 /// `GET_FF_VALUE`, but a name in a table is not a served verb: opcode 96 (`enterHighFreqHistoricalMode`) /// is a standing example of a number the table carries that nothing in the wild sends. So the probe's -/// PRIMARY deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, or -/// **silent** — and "both verbs are unimplemented" is a useful, publishable result, not a failure. +/// PRIMARY deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, +/// **silent**, or **undecodable** — and "both verbs are unimplemented" is a useful, publishable result, +/// not a failure. +/// +/// Those four are not interchangeable, and the verdict never treats them as such. **Only UNSUPPORTED is +/// the firmware's own answer**, so only UNSUPPORTED supports the sentence "this firmware does not serve +/// that verb". A timeout is a fact about one run and not even proof a frame reached the strap — +/// `BLEManager.send` returns without transmitting when there is no `cmdCharacteristic`, and again when +/// the 5/MG allowlist does not carry the opcode — while an undecodable reply is affirmative evidence the +/// strap DID transmit, which is the opposite of "not served". Both are reported as **unconfirmed**. /// /// Only if a verb answers does the probe go on to read values: first the sixteen key names NOOP already /// has (`Whoop5Config.enableR22Sequence` — their VALUES on a real strap have never been read, only @@ -353,6 +361,9 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { public private(set) var steps = 0 /// Set once the walk stopped for a reason worth naming beyond "the plan ran out". public private(set) var stopReason: String? + /// Per-verb reply window, in seconds, recorded by `noteTimeout`. The verdict names the window a verb + /// went silent through instead of converting that silence into a claim about the firmware. + private var silenceWindow: [UInt8: Int] = [:] // MARK: Plan cursors @@ -468,7 +479,12 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { /// Record the strap answering nothing at all within the per-step window. The verb is marked silent, /// which retires it — one no-reply must not cost another twenty timeouts. + /// + /// The window is kept, not just printed, because the verdict has to be able to say how long nothing + /// came back for. Silence is not the firmware refusing; it does not even establish that a frame was + /// transmitted (see the header), so it can never be reported as what the firmware serves. public mutating func noteTimeout(for step: Step, seconds: Int) { + silenceWindow[step.opcode] = seconds setStatus(.silent, for: step.opcode) trace.append("\(DeviceConfigReadProbeReport.opcodeLabel(step.opcode)) key=\"\(step.key)\" → no COMMAND_RESPONSE within \(seconds)s") } @@ -491,18 +507,37 @@ public struct DeviceConfigReadProbeReport: Equatable, Sendable { ? "GET_DEVICE_CONFIG_VALUE(121)" : "GET_FF_VALUE(128)" } + /// How one verb ended, worded to exactly the evidence behind it. See the file header for why the four + /// statuses are not interchangeable; the short version is that only `unsupported` is the firmware + /// answering, so only `unsupported` speaks for the firmware. + private func outcome(_ status: VerbStatus, for opcode: UInt8) -> String { + switch status { + case .untried: return "not asked" + case .answered: return "answered" + case .unsupported: return "refused by firmware (UNSUPPORTED)" + case .silent: + guard let seconds = silenceWindow[opcode] else { return "served no reply — unconfirmed" } + return "served no reply in \(seconds)s — unconfirmed" + case .undecodable: return "replied but the frame did not decode — unconfirmed" + } + } + /// One-line summary of what the probe established. + /// + /// "not served by this firmware" is a claim about the firmware, so it is made in exactly one case: the + /// firmware refused BOTH verbs itself. Every other run in which nothing answered reports each verb + /// against the evidence that verb produced, because two timeouts, one refusal plus one timeout, and a + /// reply that failed to decode are three different findings that used to print as the same sentence. public var verdict: String { let answered = [featureFlagVerb, deviceConfigVerb].filter { $0 == .answered }.count if answered == 0 { - let both = "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" - if featureFlagVerb == .unsupported || deviceConfigVerb == .unsupported { - return "\(both) — rejected as UNSUPPORTED" - } - if featureFlagVerb == .silent && deviceConfigVerb == .silent { - return "\(both) — no reply to either" + if featureFlagVerb == .unsupported && deviceConfigVerb == .unsupported { + return "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED" } - return both + let ff = outcome(featureFlagVerb, for: DeviceConfigReadProbe.getFeatureFlagValueCmd) + let dc = outcome(deviceConfigVerb, for: DeviceConfigReadProbe.getDeviceConfigValueCmd) + return "no read verb answered — GET_FF_VALUE(128) \(ff); GET_DEVICE_CONFIG_VALUE(121) \(dc)" } let named = readings.filter { $0.value != nil }.count if named == 0 { diff --git a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift index b2382e0161..0151818b65 100644 --- a/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift +++ b/Packages/WhoopProtocol/Tests/WhoopProtocolTests/DeviceConfigReadProbeTests.swift @@ -246,10 +246,16 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertEqual(report.steps, 2) XCTAssertEqual(report.featureFlagVerb, .unsupported) XCTAssertEqual(report.deviceConfigVerb, .unsupported) - XCTAssertTrue(report.verdict.contains("rejected as UNSUPPORTED")) + // The one run that supports the strong sentence: the firmware itself refused both verbs. + XCTAssertEqual(report.verdict, + "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED") XCTAssertTrue(report.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)")) } + /// Two timeouts are two timeouts. `BLEManager.send` returns without transmitting when there is no + /// `cmdCharacteristic` and again when the 5/MG allowlist does not carry the opcode, so a run in which + /// nothing reached the strap must not print a claim about what the firmware serves. func testSilentVerbsEndTheProbeAndAreSaidPlainly() { var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) @@ -260,12 +266,69 @@ final class DeviceConfigReadProbeTests: XCTestCase { XCTAssertNil(report.nextStep()) XCTAssertEqual(report.featureFlagVerb, .silent) XCTAssertEqual(report.deviceConfigVerb, .silent) + XCTAssertEqual(report.verdict, + "no read verb answered — GET_FF_VALUE(128) served no reply in 8s — unconfirmed; " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed") let text = report.render() - XCTAssertTrue(text.contains("no reply to either")) + XCTAssertFalse(text.contains("served by this firmware"), + "silence is not the firmware answering — it is not even proof a frame was sent") + XCTAssertFalse(text.contains("UNSUPPORTED"), "nothing was refused; nothing replied at all") XCTAssertTrue(text.contains("no COMMAND_RESPONSE within 8s")) XCTAssertTrue(text.contains("(none — the verb that would carry them did not answer)")) } + /// One verb refused and the other timed out: the refusal belongs to the verb that was refused. The + /// old `||` printed "neither … is served by this firmware — rejected as UNSUPPORTED" over a 121 that + /// was never refused, only never heard from. + func testOneRefusalIsNotGeneralisedToTheVerbThatWasNeverHeardFrom() { + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, + candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) + guard let s128 = report.nextStep() else { return XCTFail("128") } + XCTAssertEqual(s128.opcode, 128) + report.noteReply(.init(resultCode: 3, record: [0x00]), for: s128) + guard let s121 = report.nextStep() else { return XCTFail("121") } + XCTAssertEqual(s121.opcode, 121) + report.noteTimeout(for: s121, seconds: 8) + + XCTAssertNil(report.nextStep()) + XCTAssertEqual(report.featureFlagVerb, .unsupported) + XCTAssertEqual(report.deviceConfigVerb, .silent) + XCTAssertEqual(report.verdict, + "no read verb answered — GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed") + XCTAssertFalse(report.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)"), + "one refusal does not speak for the other verb") + } + + /// An undecodable reply is affirmative evidence the strap DID transmit, so "not served by this + /// firmware" states the opposite of what the run observed. + func testAnUndecodableReplyIsNotReportedAsUnserved() { + var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: []) + guard let s128 = report.nextStep() else { return XCTFail("128") } + report.noteFailure(.crc, for: s128) + guard let s121 = report.nextStep() else { return XCTFail("121") } + report.noteFailure(.envelope, for: s121) + + XCTAssertEqual(report.featureFlagVerb, .undecodable) + XCTAssertEqual(report.deviceConfigVerb, .undecodable) + XCTAssertEqual(report.verdict, + "no read verb answered — " + + "GET_FF_VALUE(128) replied but the frame did not decode — unconfirmed; " + + "GET_DEVICE_CONFIG_VALUE(121) replied but the frame did not decode — unconfirmed") + XCTAssertFalse(report.render().contains("served by this firmware")) + } + + /// A probe that ended before either verb went out says so, rather than reporting an empty run as a + /// finding about the firmware. + func testAProbeThatAskedNothingClaimsNothing() { + let report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: []) + XCTAssertEqual(report.featureFlagVerb, .untried) + XCTAssertEqual(report.deviceConfigVerb, .untried) + XCTAssertEqual(report.verdict, + "no read verb answered — GET_FF_VALUE(128) not asked; " + + "GET_DEVICE_CONFIG_VALUE(121) not asked") + } + func testAnAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { var report = DeviceConfigReadProbeReport(family: .whoop5, knownFlagKeys: flagKeys, candidateKeys: DeviceConfigReadProbe.oxygenCandidateKeys) diff --git a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt index e46ff031a4..14042468ee 100644 --- a/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt +++ b/android/app/src/main/java/com/noop/protocol/DeviceConfigReadProbe.kt @@ -29,8 +29,15 @@ package com.noop.protocol * (`whoop_protocol.json`, [CommandNumber]) names 121 `GET_DEVICE_CONFIG_VALUE` and 128 `GET_FF_VALUE`, * but a name in a table is not a served verb: opcode 96 (`ENTER_HIGH_FREQ_HISTORICAL_MODE`) is a * standing example of a number the table carries that nothing in the wild sends. So the probe's PRIMARY - * deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, or **silent** — - * and "both verbs are unimplemented" is a useful, publishable result, not a failure. + * deliverable is a clean verdict per verb — **answered**, **rejected as UNSUPPORTED**, **silent**, or + * **undecodable** — and "both verbs are unimplemented" is a useful, publishable result, not a failure. + * + * Those four are not interchangeable, and the verdict never treats them as such. **Only UNSUPPORTED is + * the firmware's own answer**, so only UNSUPPORTED supports the sentence "this firmware does not serve + * that verb". A timeout is a fact about one run and not even proof a frame reached the strap — the send + * path returns without transmitting when the command characteristic is missing, and again when the 5/MG + * allowlist does not carry the opcode — while an undecodable reply is affirmative evidence the strap DID + * transmit, which is the opposite of "not served". Both are reported as **unconfirmed**. * * Only if a verb answers does the probe go on to read values: first the sixteen key names NOOP already * has (their VALUES on a real strap have never been read, only written), then a short list of GUESSED @@ -317,6 +324,12 @@ class DeviceConfigReadProbeReport( var stopReason: String? = null private set + /** + * Per-verb reply window, in seconds, recorded by [noteTimeout]. The verdict names the window a verb + * went silent through instead of converting that silence into a claim about the firmware. + */ + private val silenceWindow = mutableMapOf() + private var phase = 0 // 0 discovery, 1 known flags, 2 candidates, 3 done private var cursor = 0 /** `"opcode:key"` pairs already attempted, so discovery's key is not re-read in a later phase. */ @@ -437,8 +450,13 @@ class DeviceConfigReadProbeReport( /** * Record the strap answering nothing at all within the per-step window. The verb is marked silent, * which retires it — one no-reply must not cost another twenty timeouts. + * + * The window is kept, not just printed, because the verdict has to be able to say how long nothing + * came back for. Silence is not the firmware refusing; it does not even establish that a frame was + * transmitted (see the header), so it can never be reported as what the firmware serves. */ fun noteTimeout(step: Step, seconds: Int) { + silenceWindow[step.opcode] = seconds setStatus(VerbStatus.SILENT, step.opcode) _trace.add("${opcodeLabel(step.opcode)} key=\"${step.key}\" → no COMMAND_RESPONSE within ${seconds}s") } @@ -467,20 +485,40 @@ class DeviceConfigReadProbeReport( "GET_FF_VALUE(128)" } - /** One-line summary of what the probe established. */ + /** + * How one verb ended, worded to exactly the evidence behind it. See the file header for why the four + * statuses are not interchangeable; the short version is that only UNSUPPORTED is the firmware + * answering, so only UNSUPPORTED speaks for the firmware. + */ + private fun outcome(status: VerbStatus, opcode: Int): String = when (status) { + VerbStatus.UNTRIED -> "not asked" + VerbStatus.ANSWERED -> "answered" + VerbStatus.UNSUPPORTED -> "refused by firmware (UNSUPPORTED)" + VerbStatus.SILENT -> silenceWindow[opcode] + ?.let { "served no reply in ${it}s — unconfirmed" } + ?: "served no reply — unconfirmed" + VerbStatus.UNDECODABLE -> "replied but the frame did not decode — unconfirmed" + } + + /** + * One-line summary of what the probe established. + * + * "not served by this firmware" is a claim about the firmware, so it is made in exactly one case: the + * firmware refused BOTH verbs itself. Every other run in which nothing answered reports each verb + * against the evidence that verb produced, because two timeouts, one refusal plus one timeout, and a + * reply that failed to decode are three different findings that used to print as the same sentence. + */ val verdict: String get() { val answered = listOf(featureFlagVerb, deviceConfigVerb).count { it == VerbStatus.ANSWERED } if (answered == 0) { - val both = - "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this firmware" - if (featureFlagVerb == VerbStatus.UNSUPPORTED || deviceConfigVerb == VerbStatus.UNSUPPORTED) { - return "$both — rejected as UNSUPPORTED" - } - if (featureFlagVerb == VerbStatus.SILENT && deviceConfigVerb == VerbStatus.SILENT) { - return "$both — no reply to either" + if (featureFlagVerb == VerbStatus.UNSUPPORTED && deviceConfigVerb == VerbStatus.UNSUPPORTED) { + return "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED" } - return both + val ff = outcome(featureFlagVerb, DeviceConfigReadProbe.GET_FEATURE_FLAG_VALUE_CMD) + val dc = outcome(deviceConfigVerb, DeviceConfigReadProbe.GET_DEVICE_CONFIG_VALUE_CMD) + return "no read verb answered — GET_FF_VALUE(128) $ff; GET_DEVICE_CONFIG_VALUE(121) $dc" } val named = _readings.count { it.value != null } if (named == 0) { diff --git a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt index 5c328ed5a6..91e7c20be8 100644 --- a/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt +++ b/android/app/src/test/java/com/noop/protocol/DeviceConfigReadProbeTest.kt @@ -289,10 +289,20 @@ class DeviceConfigReadProbeTest { assertEquals(2, rep.steps) assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.featureFlagVerb) assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.deviceConfigVerb) - assertTrue(rep.verdict.contains("rejected as UNSUPPORTED")) + // The one run that supports the strong sentence: the firmware itself refused both verbs. + assertEquals( + "neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121) is served by this " + + "firmware — rejected as UNSUPPORTED", + rep.verdict, + ) assertTrue(rep.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)")) } + /** + * Two timeouts are two timeouts. The send path returns without transmitting when the command + * characteristic is missing and again when the 5/MG allowlist does not carry the opcode, so a run in + * which nothing reached the strap must not print a claim about what the firmware serves. + */ @Test fun silentVerbsEndTheProbeAndAreSaidPlainly() { val rep = report() @@ -300,12 +310,86 @@ class DeviceConfigReadProbeTest { assertNull(rep.nextStep()) assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, rep.featureFlagVerb) assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, rep.deviceConfigVerb) + assertEquals( + "no read verb answered — GET_FF_VALUE(128) served no reply in 8s — unconfirmed; " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed", + rep.verdict, + ) val text = rep.render() - assertTrue(text.contains("no reply to either")) + assertFalse( + "silence is not the firmware answering — it is not even proof a frame was sent", + text.contains("served by this firmware"), + ) + assertFalse("nothing was refused; nothing replied at all", text.contains("UNSUPPORTED")) assertTrue(text.contains("no COMMAND_RESPONSE within 8s")) assertTrue(text.contains("(none — the verb that would carry them did not answer)")) } + /** + * One verb refused and the other timed out: the refusal belongs to the verb that was refused. The old + * `||` printed "neither … is served by this firmware — rejected as UNSUPPORTED" over a 121 that was + * never refused, only never heard from. + */ + @Test + fun oneRefusalIsNotGeneralisedToTheVerbThatWasNeverHeardFrom() { + val rep = report() + val s128 = rep.nextStep()!! + assertEquals(128, s128.opcode) + rep.noteReply(DeviceConfigReadProbe.ValueResponse(3, byteArrayOf(0)), s128) + val s121 = rep.nextStep()!! + assertEquals(121, s121.opcode) + rep.noteTimeout(s121, 8) + + assertNull(rep.nextStep()) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNSUPPORTED, rep.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.SILENT, rep.deviceConfigVerb) + assertEquals( + "no read verb answered — GET_FF_VALUE(128) refused by firmware (UNSUPPORTED); " + + "GET_DEVICE_CONFIG_VALUE(121) served no reply in 8s — unconfirmed", + rep.verdict, + ) + assertFalse( + "one refusal does not speak for the other verb", + rep.render().contains("neither GET_FF_VALUE(128) nor GET_DEVICE_CONFIG_VALUE(121)"), + ) + } + + /** + * An undecodable reply is affirmative evidence the strap DID transmit, so "not served by this + * firmware" states the opposite of what the run observed. + */ + @Test + fun anUndecodableReplyIsNotReportedAsUnserved() { + val rep = report(candidates = emptyList()) + rep.noteFailure(DeviceConfigReadProbe.ParseFailure.CRC, rep.nextStep()!!) + rep.noteFailure(DeviceConfigReadProbe.ParseFailure.ENVELOPE, rep.nextStep()!!) + + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, rep.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNDECODABLE, rep.deviceConfigVerb) + assertEquals( + "no read verb answered — " + + "GET_FF_VALUE(128) replied but the frame did not decode — unconfirmed; " + + "GET_DEVICE_CONFIG_VALUE(121) replied but the frame did not decode — unconfirmed", + rep.verdict, + ) + assertFalse(rep.render().contains("served by this firmware")) + } + + /** + * A probe that ended before either verb went out says so, rather than reporting an empty run as a + * finding about the firmware. + */ + @Test + fun aProbeThatAskedNothingClaimsNothing() { + val rep = report(candidates = emptyList()) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNTRIED, rep.featureFlagVerb) + assertEquals(DeviceConfigReadProbeReport.VerbStatus.UNTRIED, rep.deviceConfigVerb) + assertEquals( + "no read verb answered — GET_FF_VALUE(128) not asked; GET_DEVICE_CONFIG_VALUE(121) not asked", + rep.verdict, + ) + } + @Test fun anAnsweringFeatureFlagVerbWalksTheFifteenRemainingKnownFlags() { val rep = report()