From ca9debeb700336aa57538961091752776fabe438 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Fri, 9 Oct 2026 08:02:00 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(app):=201.2.0=20=E2=80=94=20"Changed?= =?UTF-8?q?=20by=20the=20Buds",=20serial=20numbers,=20the=20worn=20line,?= =?UTF-8?q?=20two=20tab=20moves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the maintainer asked for release 1.2.0 (ai-sessions/0082, chat 2026-10-09). - ANC (i): "Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)." when a Notify the app did not provoke changes the shown mode (AncModeCause: READ / SET_BY_APP / CHANGED_BY_BUDS; reset at Connect). Fixtures: CAP-045 609/612/1583/1755/1818/1849. - Info tab: the component serial numbers from one GetHardwareInfo per Connect, sent last of the Connect-time requests, never retried, not gated by Safe Mode (DECISIONS.md ADR-058). Field 7 read by position: Case, Right bud, Left bud. Fixtures: CAP-036 1415/1423, CAP-024 801/832 (serials redacted, CRC re-sealed). The serials are never logged; the log line carries a count. - Battery card: a "Worn" line derived from the Settable byte, the in-ear detection setting and the charging flags (ADR-059) — no new wire traffic; "probably worn", never a bud named. Fixtures: CAP-065 5465/6334, CAP-064 10394, CAP-062 3760/4845. - Case sounds moved from Controls to gear → Settings; Conversation detection moved from Sound to Controls. The write paths are unchanged (the ai-sessions/0076 fixtures still verify the bytes). One commit for the five items: they share BudsRepository(Impl), the UI state and MainActivity's wiring, and SettingsMenu.kt carries both the serial lines and the Case sounds card — splitting the hunks would leave intermediate commits that do not compile. Tests: :data +17, :domain +7, :ui +14; mutation checks M1–M10 killed (ai-sessions/0082 RESULT §H). Co-Authored-By: Claude Fable 5.1 --- .../opencontrolpixelbuds/app/MainActivity.kt | 8 + .../data/BudsRepositoryImpl.kt | 142 ++++++++- .../data/codec/CodecRouter.kt | 10 +- .../data/codec/HardwareInfo.kt | 51 ++++ .../data/codec/Maestro.kt | 18 +- .../data/BudsRepositoryImplTest.kt | 287 +++++++++++++++++- .../data/codec/Cap045Fixtures.kt | 48 +++ .../data/codec/HardwareInfoCodecTest.kt | 125 ++++++++ .../data/codec/HardwareInfoFixtures.kt | 59 ++++ .../domain/AncModeCause.kt | 35 +++ .../domain/BudsRepository.kt | 22 +- .../opencontrolpixelbuds/domain/DeviceInfo.kt | 15 +- .../domain/WornReading.kt | 73 +++++ .../domain/WornReadingTest.kt | 88 ++++++ .../opencontrolpixelbuds/ui/AncScreen.kt | 21 +- .../ui/ConnectionScreen.kt | 51 +++- .../opencontrolpixelbuds/ui/ControlsScreen.kt | 64 +--- .../opencontrolpixelbuds/ui/EqScreen.kt | 13 +- .../ui/OpenControlNavHost.kt | 22 +- .../opencontrolpixelbuds/ui/SettingsMenu.kt | 104 ++++++- .../opencontrolpixelbuds/ui/SettingsUi.kt | 28 ++ .../opencontrolpixelbuds/ui/AncScreenTest.kt | 38 +++ .../ui/BatteryCardTest.kt | 46 ++- .../ui/ControlsScreenTest.kt | 42 +-- .../opencontrolpixelbuds/ui/EqScreenTest.kt | 24 +- .../ui/SettingsMenuTest.kt | 123 ++++++++ 26 files changed, 1428 insertions(+), 129 deletions(-) create mode 100644 android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfo.kt create mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap045Fixtures.kt create mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoCodecTest.kt create mode 100644 android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoFixtures.kt create mode 100644 android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/AncModeCause.kt create mode 100644 android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReading.kt create mode 100644 android/domain/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReadingTest.kt diff --git a/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/MainActivity.kt b/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/MainActivity.kt index 6ca0c7b6..5f2bb500 100644 --- a/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/MainActivity.kt +++ b/android/app/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/app/MainActivity.kt @@ -54,6 +54,7 @@ import io.github.tedsluis.opencontrolpixelbuds.R import io.github.tedsluis.opencontrolpixelbuds.data.settings.DarkModeSettingsStore import io.github.tedsluis.opencontrolpixelbuds.data.settings.DebugSettingsStore import io.github.tedsluis.opencontrolpixelbuds.domain.AncAvailability +import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause import io.github.tedsluis.opencontrolpixelbuds.domain.AndroidLink import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryStatus import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError @@ -68,6 +69,7 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.PermissionStatus import io.github.tedsluis.opencontrolpixelbuds.domain.RingNotice import io.github.tedsluis.opencontrolpixelbuds.domain.SafeModeState import io.github.tedsluis.opencontrolpixelbuds.domain.SessionLossCause +import io.github.tedsluis.opencontrolpixelbuds.domain.WornReading import io.github.tedsluis.opencontrolpixelbuds.domain.deriveDeviceStatus import io.github.tedsluis.opencontrolpixelbuds.domain.permissionStatus import io.github.tedsluis.opencontrolpixelbuds.hardware.BleLogger @@ -333,6 +335,7 @@ class MainActivity : ComponentActivity() { val ancAvailability by budsRepository.ancAvailability.collectAsStateWithLifecycle(initialValue = AncAvailability.UNKNOWN) val ancAvailabilityUpdatedAt by budsRepository.ancAvailabilityUpdatedAt.collectAsStateWithLifecycle(initialValue = null as Long?) val ancModeUnconfirmedAt by budsRepository.ancModeUnconfirmedAt.collectAsStateWithLifecycle(initialValue = null as Long?) + val ancModeCause by budsRepository.ancModeCause.collectAsStateWithLifecycle(initialValue = null as AncModeCause?) val eqProfile by budsRepository.eqProfile.collectAsStateWithLifecycle(initialValue = null) val eqProfileUpdatedAt by budsRepository.eqProfileUpdatedAt.collectAsStateWithLifecycle(initialValue = null as Long?) val eqError by budsRepository.eqError.collectAsStateWithLifecycle(initialValue = null as BudsError?) @@ -346,7 +349,9 @@ class MainActivity : ComponentActivity() { val settingsError by budsRepository.settingsError.collectAsStateWithLifecycle(initialValue = null as SettingsFailure?) val batteryRefreshError by budsRepository.batteryRefreshError.collectAsStateWithLifecycle(initialValue = null as BudsError?) val safeMode by budsRepository.safeMode.collectAsStateWithLifecycle(initialValue = null as SafeModeState?) + val wornReading by budsRepository.wornReading.collectAsStateWithLifecycle(initialValue = WornReading.NotRead) val deviceInfo by budsRepository.deviceInfo.collectAsStateWithLifecycle(initialValue = null as DeviceInfo?) + val serialsError by budsRepository.serialsError.collectAsStateWithLifecycle(initialValue = null as BudsError?) val ringing by budsRepository.ringing.collectAsStateWithLifecycle(initialValue = null as RingNotice?) val lastConnectionError by budsRepository.lastConnectionError .collectAsStateWithLifecycle(initialValue = null as BudsError?) @@ -449,10 +454,13 @@ class MainActivity : ComponentActivity() { ancAvailability = ancAvailability, ancAvailabilityUpdatedAt = ancAvailabilityUpdatedAt, ancModeUnconfirmedAt = ancModeUnconfirmedAt, + ancModeCause = ancModeCause, caseBatteryError = caseBatteryError, batteryRefreshError = batteryRefreshError, safeMode = safeMode, + wornReading = wornReading, deviceInfo = deviceInfo, + serialsError = serialsError, ringing = ringing, eqProfile = eqProfile, eqProfileUpdatedAt = eqProfileUpdatedAt, diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt index 8dcbe975..333a9f42 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImpl.kt @@ -47,6 +47,7 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.BudsSettings import io.github.tedsluis.opencontrolpixelbuds.domain.SettingReading import io.github.tedsluis.opencontrolpixelbuds.domain.SettingsFailure import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode +import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause import io.github.tedsluis.opencontrolpixelbuds.domain.AdapterOffReading import io.github.tedsluis.opencontrolpixelbuds.domain.AndroidLink import io.github.tedsluis.opencontrolpixelbuds.domain.LinkReading @@ -58,6 +59,7 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryStatus import io.github.tedsluis.opencontrolpixelbuds.domain.ChargingReading import io.github.tedsluis.opencontrolpixelbuds.domain.ChargingSource import io.github.tedsluis.opencontrolpixelbuds.domain.markedFromEarlierSession +import io.github.tedsluis.opencontrolpixelbuds.domain.ComponentSerial import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceInfo import io.github.tedsluis.opencontrolpixelbuds.domain.AncAvailability import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError @@ -70,6 +72,8 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.RingNotice import io.github.tedsluis.opencontrolpixelbuds.domain.RingTarget import io.github.tedsluis.opencontrolpixelbuds.domain.SafeModeState import io.github.tedsluis.opencontrolpixelbuds.domain.UnidentifiedFrame +import io.github.tedsluis.opencontrolpixelbuds.domain.WornReading +import io.github.tedsluis.opencontrolpixelbuds.domain.wornReading import io.github.tedsluis.opencontrolpixelbuds.hardware.BleLogger import io.github.tedsluis.opencontrolpixelbuds.hardware.BudsSdpUuids import io.github.tedsluis.opencontrolpixelbuds.hardware.BudsTransport @@ -86,6 +90,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first @@ -241,6 +246,21 @@ class BudsRepositoryImpl( private val _ancModeUpdatedAt = MutableStateFlow(null) override val ancModeUpdatedAt: StateFlow = _ancModeUpdatedAt + /** Why the current [_ancMode] is what it is (`ai-sessions/0082` item 1, the maintainer's rule in chat 2026-10-09); `null` until this connection's first report. */ + private val _ancModeCause = MutableStateFlow(null) + override val ancModeCause: StateFlow = _ancModeCause + + /** + * Item 1's bookkeeping, inside the existing claim code and without a timer: set when the app's `Get` is written, cleared by the first `Notify` after it (its + * answer — a reading, [AncModeCause.READ]) or when that wait ends. + */ + @Volatile + private var getPending = false + + /** The mode of the app's `Set` from its write until its wait ends (ACK, NAK, timeout, cut-off): a `Notify` of that mode meanwhile is [AncModeCause.SET_BY_APP]. */ + @Volatile + private var setPendingMode: AncMode? = null + private val _eqProfile = MutableStateFlow(null) override val eqProfile: StateFlow = _eqProfile @@ -294,6 +314,12 @@ class BudsRepositoryImpl( private val _safeMode = MutableStateFlow(null) override val safeMode: StateFlow = _safeMode + /** ADR-059: the pure derivation of `:domain`, over the four flows it reads — nothing is requested for it. */ + override val wornReading: Flow = + combine(_ancAvailability, _ancAvailabilityUpdatedAt, _settings, _batteryStatus) { availability, checkedAt, settings, battery -> + wornReading(availability, checkedAt, settings.inEarDetection, battery.leftCharging, battery.rightCharging) + } + /** The Model ID the Buds sent on the *current* Message Stream claim (`null` until it arrives; reset on every new open). */ private val _modelIdThisClaim = MutableStateFlow(null) @@ -316,9 +342,23 @@ class BudsRepositoryImpl( /** Sibling helpers so every mutation of the value above also stamps its own [System.currentTimeMillis] * timestamp, in one place (`ai-sessions/0043` Phase H) — never a polling timer, only recorded when a * value is actually received. */ - private fun emitAncMode(mode: AncMode) { + private fun emitAncMode(mode: AncMode, cause: AncModeCause? = null) { _ancMode.value = mode _ancModeUpdatedAt.value = clock() + if (cause != null) _ancModeCause.value = cause + } + + /** + * The cause a `Notify` gives the mode it carries (`ai-sessions/0082` item 1, the maintainer's rule): the answer to the app's `Get` is a reading; the requested + * mode while the app's `Set` waits is the app's own change; a mode other than the shown one, with nothing waiting, is the Buds' — a press-and-hold on a bud or + * their own change; the first mode ever shown this run is a reading too (nothing was shown that could have changed); the same mode again changes nothing (`null`). + */ + private fun causeOfNotify(mode: AncMode, answersGet: Boolean): AncModeCause? = when { + answersGet -> AncModeCause.READ + setPendingMode == mode -> AncModeCause.SET_BY_APP + _ancMode.value == null -> AncModeCause.READ + mode != _ancMode.value -> AncModeCause.CHANGED_BY_BUDS + else -> null } private fun updateEqProfile(gains: EqBandGains?) { @@ -456,6 +496,17 @@ class BudsRepositoryImpl( private val _deviceInfo = MutableStateFlow(null) override val deviceInfo: Flow = _deviceInfo + private val _serialsError = MutableStateFlow(null) + override val serialsError: Flow = _serialsError + + /** + * ADR-058: the serials the Buds answered — into [DeviceInfo] with their receive time. The announcement normally precedes them (the request waits for it); + * should it come again on the same connection, the serials of this connection are kept (see the `MaestroHello` branch). + */ + private fun applySerials(serials: List, atMillis: Long) { + _deviceInfo.update { (it ?: DeviceInfo(emptyList())).copy(serials = serials, serialsReadAtMillis = atMillis) } + } + private val _ringing = MutableStateFlow(null) override val ringing: Flow = _ringing @@ -547,6 +598,9 @@ class BudsRepositoryImpl( when (frame) { is RoutedFrame.Anc -> when (val anc = frame.frame) { is AncFrame.Notify -> { + // Item 1 (`ai-sessions/0082`): the first Notify after the app's Get is its answer — a reading, whatever mode it carries. + val answersGet = getPending + getPending = false // I-3 (`ai-sessions/0048`, ADR-024 Update 2026-09-25): Settable 0x00 = the Buds refuse a Set (NAK 0x02); non-zero = allowed. val availability = AncAvailability.fromSettableToggles(anc.settableToggles) updateAncAvailability(availability) @@ -554,7 +608,7 @@ class BudsRepositoryImpl( anc.currentMode?.let { // F-3: the Buds' own report — whatever a cut-off answer would have said, this is their mode now. _ancModeUnconfirmedAt.value = null - emitAncMode(it) + emitAncMode(it, causeOfNotify(it, answersGet)) _ancModeFresh.tryEmit(it) _ancOutcomes.tryEmit(AncOutcome.Notified(it)) } @@ -596,11 +650,18 @@ class BudsRepositoryImpl( BleLogger.logConnectionEvent("Maestro channel announced by the Buds: ${frame.channelId}") _maestroChannelId.value = frame.channelId if (frame.firmware.isNotEmpty()) { - _deviceInfo.value = DeviceInfo(frame.firmware, frame.entries, frame.channelId, clock()) // + entries/channel/time for Info (F-5) + // + entries/channel/time for Info (F-5); the serials already read on this connection stay (ADR-058). + _deviceInfo.update { DeviceInfo(frame.firmware, frame.entries, frame.channelId, clock(), it?.serials ?: emptyList(), it?.serialsReadAtMillis) } refreshObservedSafeMode() } } + // ADR-058 (`ai-sessions/0082`): the answer to the app's one GetHardwareInfo per Connect — field 7's serials, with their receive time. Never logged. + is RoutedFrame.HardwareInfo -> { + applySerials(frame.serials, clock()) + _maestroReplies.tryEmit(MaestroReply.Hardware(frame.serials.size)) + } + is RoutedFrame.RpcResult -> { if (frame.methodId == Maestro.METHOD_WRITE_SETTING && clock() < writeQuarantineUntil) { // A58-APP-04: the late answer to a write that already timed out — nothing waits for it, it is dropped (no payload logged). @@ -684,6 +745,11 @@ class BudsRepositoryImpl( // A68-APP-02 (`ai-sessions/0069`): from here on, whatever the Buds reported earlier is "from the last connection" — the ANC mode is kept and // judged by this time ([isCurrent]); it becomes current again with this connection's first Notify or ACK. _sessionSince.value = clock() + // Item 1 (`ai-sessions/0082`): the kept mode's cause is this connection's to re-establish — its first Notify is a reading. Before the lookup, like + // `_sessionSince`: it belongs to the attempt, whatever the attempt does. The Settable byte likewise (I-3: unknown ⇒ enabled; ADR-059's reading + // then starts at "—" with the settings, which are reset above). + _ancModeCause.value = null + _ancAvailability.value = AncAvailability.UNKNOWN // re-read by this connection's snapshot claim // 0044 APP-8: no bonded device is "not paired", not a missing permission (AGENTS.md §8 — a specific message per cause). val device = bondedDeviceProvider() ?: return@withLock BudsResult.Failure(BudsError.NotPaired) @@ -693,9 +759,9 @@ class BudsRepositoryImpl( _maestroChannelId.value = null // announced afresh by this connection's first Buds packet (ADR-034) _deviceInfo.value = null _caseBatteryError.value = null + _serialsError.value = null _batteryRefreshError.value = null _settingsError.value = null - _ancAvailability.value = AncAvailability.UNKNOWN // re-read by this connection's snapshot claim (I-3: unknown ⇒ enabled) _safeMode.value = null _modelIdThisClaim.value = null modelIdSeen = null @@ -741,6 +807,7 @@ class BudsRepositoryImpl( _messageStreamError.value = null _eqError.value = null _caseBatteryError.value = null + _serialsError.value = null _batteryRefreshError.value = null _settingsError.value = null _deviceInfo.value = null @@ -797,8 +864,14 @@ class BudsRepositoryImpl( private suspend fun ancSetOnClaim(mode: AncMode): BudsResult { writeGate(requireModelIdOfClaim = true)?.let { return BudsResult.Failure(it) } val sentAt = clock() // "the change at HH:MM:SS" of the not-confirmed mark - val wait = sendAndAwaitOnClaim(_ancOutcomes, ACK_WAIT_MS) { - transport.send(Dlci.FAST_PAIR_MESSAGE_STREAM, AncFrameEncoder.encode(AncFrame.Set(mode))) + // Item 1 (`ai-sessions/0082`): while this Set waits, a Notify of the requested mode is the app's own change — not one by the Buds. + setPendingMode = mode + val wait = try { + sendAndAwaitOnClaim(_ancOutcomes, ACK_WAIT_MS) { + transport.send(Dlci.FAST_PAIR_MESSAGE_STREAM, AncFrameEncoder.encode(AncFrame.Set(mode))) + } + } finally { + setPendingMode = null } return when (wait) { is ClaimWait.SendFailed -> BudsResult.Failure(wait.error) @@ -810,7 +883,7 @@ class BudsRepositoryImpl( is ClaimWait.Answered -> when (val outcome = wait.value) { is AncOutcome.Rejected -> BudsResult.Failure(rejected(outcome.reason)) is AncOutcome.Acked -> { - emitAncMode(mode) + emitAncMode(mode, AncModeCause.SET_BY_APP) _ancModeUnconfirmedAt.value = null BudsResult.Success(Unit) } @@ -820,8 +893,14 @@ class BudsRepositoryImpl( } /** The ANC `Get` (`08 11 00 00`, ADR-021) on the claimed Message Stream, then the first emission of [reply] within [timeoutMs] — or the claim's close. */ - private suspend fun sendAncGetAndAwait(reply: SharedFlow, timeoutMs: Long): ClaimWait = - sendAndAwaitOnClaim(reply, timeoutMs) { transport.send(Dlci.FAST_PAIR_MESSAGE_STREAM, AncFrameEncoder.encode(AncFrame.Get)) } + private suspend fun sendAncGetAndAwait(reply: SharedFlow, timeoutMs: Long): ClaimWait { + getPending = true // item 1 (`ai-sessions/0082`): the first Notify after this Get is its answer — a reading, not a change + try { + return sendAndAwaitOnClaim(reply, timeoutMs) { transport.send(Dlci.FAST_PAIR_MESSAGE_STREAM, AncFrameEncoder.encode(AncFrame.Get)) } + } finally { + getPending = false + } + } private fun answerCutOff(what: String, detail: String?): BudsError { BleLogger.logConnectionEvent("$what: the Message Stream claim was closed before the Buds' answer arrived (answer cut off, not retried)") @@ -1296,8 +1375,9 @@ class BudsRepositoryImpl( } /** - * Started by [connect] once the session is `Ready` (ADR-034): the EQ read, the settings reads (ADR-036, `ai-sessions/0052`), then the one - * runtime-info subscription (ADR-043) — all wait for the Buds' channel announcement. `internal` so a test can start it without a `BluetoothDevice`. + * Started by [connect] once the session is `Ready` (ADR-034): the EQ read, the settings reads (ADR-036, `ai-sessions/0052`), the one + * runtime-info subscription (ADR-043), then the one hardware-info read (ADR-058) — all wait for the Buds' channel announcement. `internal` so a test + * can start it without a `BluetoothDevice`. */ internal fun launchInitialEqRead() { eqReadJob?.cancel() @@ -1305,6 +1385,43 @@ class BudsRepositoryImpl( readEq() readSettings() subscribeRuntimeInfo() + readHardwareInfo() + } + } + + /** + * `GetHardwareInfo` (DECISIONS.md ADR-058): one unary request per Connect on the announced channel with its ADR-034 address — byte-identical to the official + * app's `CAP-036` 1415 / `CAP-024` 801 — after the runtime-info subscription; waits ≤ [SETTING_READ_TIMEOUT_MS] for the answer, never retried. A read: not + * gated by Safe Mode (ADR-042 item 3). The answer's serials reach [deviceInfo] through [handleRoutedFrame]; a failure goes to [serialsError]. The log + * carries the count only — a serial is a device identifier (`AGENTS.md` §9). + */ + private suspend fun readHardwareInfo(): Unit = eqMutex.withLock { + val channel = when (val c = awaitMaestroChannel()) { + is BudsResult.Failure -> { + _serialsError.value = c.error + return@withLock + } + is BudsResult.Success -> c.value + } + val wire = Hdlc.encode(channel.requestAddress, PW_HDLC_CONTROL_UI, PwRpc.encode(Maestro.getHardwareInfoRequest(channel.channelId))) + val accept: (MaestroReply) -> Boolean = { + it is MaestroReply.Hardware || (it is MaestroReply.Result && it.result.methodId == Maestro.METHOD_GET_HARDWARE_INFO) + } + awaitWriteQuarantine() + val (sent, reply) = sendAndAwait(_maestroReplies, SETTING_READ_TIMEOUT_MS, accept) { transport.send(Dlci.MAESTRO, wire) } + val error = when { + sent is BudsResult.Failure -> sent.error + reply == null -> BudsError.Timeout + reply is MaestroReply.Result && reply.result.isOk -> BudsError.UnreadableAnswer // an OK answer without the three strings + reply is MaestroReply.Result -> BudsError.MaestroRejected("${PwRpc.typeName(reply.result.type)} ${PwRpc.statusName(reply.result.status)}") + else -> null + } + if (error != null) { + BleLogger.logConnectionEvent("Hardware info read failed: ${eqErrorLogText(error)}") + _serialsError.value = error + } else { + BleLogger.logConnectionEvent("Hardware info read (channel ${channel.channelId}): ${(reply as MaestroReply.Hardware).count} serial numbers") + _serialsError.value = null } } @@ -1490,5 +1607,8 @@ private sealed class MaestroReply { data class Result(val result: RoutedFrame.RpcResult) : MaestroReply() data class Setting(val value: SettingValue) : MaestroReply() + /** The `GetHardwareInfo` answer carried serials (ADR-058) — only their [count] travels here; the strings went straight to `deviceInfo`. */ + data class Hardware(val count: Int) : MaestroReply() + fun isResultFor(methodId: Int): Boolean = this is Result && result.methodId == methodId } diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt index e86118ae..c83a0fb2 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/CodecRouter.kt @@ -53,6 +53,9 @@ sealed class RoutedFrame { /** A DLCI 0x02 `SubscribeRuntimeInfo` stream packet (DECISIONS.md ADR-043 and its 2026-09-25 Update): the Case and each bud's charging state. */ data class RuntimeInfo(val info: io.github.tedsluis.opencontrolpixelbuds.data.codec.RuntimeInfo) : RoutedFrame() + /** The answer to the app's `GetHardwareInfo` (DECISIONS.md ADR-058): field 7's component serials, never empty here (an answer without them is an [RpcResult]). */ + data class HardwareInfo(val serials: List) : RoutedFrame() + /** * The Buds' unsolicited `GetSoftwareInfo` push announcing the pw_rpc channel of this connection (ADR-034); [firmware] = the * distinct firmware strings it carries (`ai-sessions/0042`), empty if the payload had another shape; [entries] = each entry with its field number @@ -302,7 +305,12 @@ internal fun routeMaestro(packet: RpcPacket): RoutedFrame? { ) { RuntimeInfoDecoder.decode(packet.payload)?.let { return RoutedFrame.RuntimeInfo(it) } } - val answersUs = method == Maestro.METHOD_READ_SETTING || method == Maestro.METHOD_WRITE_SETTING + // ADR-058 (`ai-sessions/0082`): the unary answer to the app's GetHardwareInfo — field 7's serials; an OK answer without them falls through to RpcResult. + if (method == Maestro.METHOD_GET_HARDWARE_INFO && packet.type == PwRpc.TYPE_RESPONSE && (packet.status == null || packet.status == 0)) { + val serials = HardwareInfo.serials(packet.payload) + if (serials.isNotEmpty()) return RoutedFrame.HardwareInfo(serials) + } + val answersUs = method == Maestro.METHOD_READ_SETTING || method == Maestro.METHOD_WRITE_SETTING || method == Maestro.METHOD_GET_HARDWARE_INFO val isError = packet.type == PwRpc.TYPE_CLIENT_ERROR || packet.type == PwRpc.TYPE_SERVER_ERROR if (answersUs && (packet.type == PwRpc.TYPE_RESPONSE || isError)) { return RoutedFrame.RpcResult(packet.channelId, method, packet.type, packet.status) diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfo.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfo.kt new file mode 100644 index 00000000..bdcac4cc --- /dev/null +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfo.kt @@ -0,0 +1,51 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.data.codec + +import io.github.tedsluis.opencontrolpixelbuds.domain.ComponentSerial + +/** + * Reads the component serial numbers out of the Buds' `GetHardwareInfo` answer (`maestro_pw.Maestro`, DECISIONS.md ADR-058, `ai-sessions/0082`): the + * payload is `1: 2: 5: 6: 7:{1: 2: 3:} 8:{…}` — `CAP-036` frame 1423 (channel 21), + * `CAP-024` frame 832 (channel 19); 138 answers in 54 logs carry the same three length-14 strings in fields 7.1, 7.2, 7.3. Only field 7 is read; fields 1, 2, + * 5, 6 and 8 are not interpreted (ADR-058 item 3). Which component an index is — 1 = Case, 2 = Right bud, 3 = Left bud — is the official app's reading by + * position (`PROTOCOL.md` §6, 2026-10-09); the labels are `:ui`'s. Never throws; a string that is not printable ASCII of 1–64 characters, a repeated index + * or a payload of another shape is left out — an answer without any readable serial is an empty list (the caller reports `UnreadableAnswer`). + */ +internal object HardwareInfo { + private const val FIELD_SERIALS = 7 + private const val MAX_INDEX = 3 + private const val MAX_LENGTH = 64 + + fun serials(payload: ByteArray): List { + val top = Proto.fields(payload) ?: return emptyList() + val group = top.firstOrNull { it.number == FIELD_SERIALS && it.bytes != null }?.bytes ?: return emptyList() + val fields = Proto.fields(group) ?: return emptyList() + val out = ArrayList(MAX_INDEX) + for (f in fields) { + if (f.number !in 1..MAX_INDEX || out.any { it.index == f.number }) continue + val bytes = f.bytes ?: continue + if (bytes.isEmpty() || bytes.size > MAX_LENGTH) continue + val text = bytes.toString(Charsets.US_ASCII) + if (text.all { it.code in 0x21..0x7E }) out += ComponentSerial(f.number, text) + } + return out.sortedBy { it.index } + } +} diff --git a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt index d1e2946b..b3b5e202 100644 --- a/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt +++ b/android/data/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Maestro.kt @@ -31,9 +31,9 @@ object Maestro { val METHOD_SUBSCRIBE_TO_SETTINGS_CHANGES: Int = PwRpc.nameHash("SubscribeToSettingsChanges") // 0x2821adf5 val METHOD_GET_SOFTWARE_INFO: Int = PwRpc.nameHash("GetSoftwareInfo") // 0x7199fa44 - // The official app's connect-time burst (PROTOCOL.md §6, 🟢 FACT 2026-09-24, `CAP-036` frames 1404–1570). GetHardwareInfo and - // SetWallclock are names only, for the debug log's method column — this app never sends them. SubscribeRuntimeInfo is sent once - // per Connect for the Case battery (DECISIONS.md ADR-043). + // The official app's connect-time burst (PROTOCOL.md §6, 🟢 FACT 2026-09-24, `CAP-036` frames 1404–1570). SetWallclock is a name only, for the + // debug log's method column — this app never sends it. SubscribeRuntimeInfo is sent once per Connect for the Case battery (DECISIONS.md ADR-043); + // GetHardwareInfo once per Connect for the serial numbers (ADR-058, `ai-sessions/0082`). val METHOD_GET_HARDWARE_INFO: Int = PwRpc.nameHash("GetHardwareInfo") // 0x28eca5e3 val METHOD_SUBSCRIBE_RUNTIME_INFO: Int = PwRpc.nameHash("SubscribeRuntimeInfo") // 0xe61e8290 val METHOD_SET_WALLCLOCK: Int = PwRpc.nameHash("SetWallclock") // 0x673bed4e @@ -93,6 +93,18 @@ object Maestro { serviceId = SERVICE_ID, methodId = METHOD_SUBSCRIBE_RUNTIME_INFO, ) + + /** + * `GetHardwareInfo` request (DECISIONS.md ADR-058, `ai-sessions/0082`): no payload, no call id — byte-identical to the official app's `CAP-036` frame 1415 + * (`10 15 1d ea 71 de 7e 25 e3 a5 ec 28` on channel 21) and `CAP-024` frame 801 (`10 13 …` on channel 19). One per Connect, after the runtime-info + * subscription; the unary `RESPONSE` carries the three component serials in field 7 ([HardwareInfo]). + */ + fun getHardwareInfoRequest(channelId: Int): RpcPacket = RpcPacket( + type = PwRpc.TYPE_REQUEST, + channelId = channelId, + serviceId = SERVICE_ID, + methodId = METHOD_GET_HARDWARE_INFO, + ) } /** diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt index d3d072e8..793227fb 100644 --- a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/BudsRepositoryImplTest.kt @@ -21,6 +21,7 @@ package io.github.tedsluis.opencontrolpixelbuds.data +import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap045 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap061 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap062 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap063 @@ -28,6 +29,7 @@ import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap064 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap065 import io.github.tedsluis.opencontrolpixelbuds.data.codec.Cap066Balance import io.github.tedsluis.opencontrolpixelbuds.data.codec.Dlci +import io.github.tedsluis.opencontrolpixelbuds.data.codec.HardwareInfoFixtures import io.github.tedsluis.opencontrolpixelbuds.data.codec.Hdlc import io.github.tedsluis.opencontrolpixelbuds.data.codec.Maestro import io.github.tedsluis.opencontrolpixelbuds.data.codec.PW_HDLC_CONTROL_UI @@ -44,9 +46,11 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.HoldAction import io.github.tedsluis.opencontrolpixelbuds.domain.SettingReading import io.github.tedsluis.opencontrolpixelbuds.domain.AncAvailability import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode +import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeList import io.github.tedsluis.opencontrolpixelbuds.domain.AndroidLink import io.github.tedsluis.opencontrolpixelbuds.domain.SessionLossCause +import io.github.tedsluis.opencontrolpixelbuds.domain.WornReading import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryLevel import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError import io.github.tedsluis.opencontrolpixelbuds.domain.ChargingReading @@ -416,6 +420,8 @@ class BudsRepositoryImplTest { if (rpc.methodId == Maestro.METHOD_READ_SETTING) { Settings036.ANSWERS[rpc.payload[1].toInt()]?.let { transport.emit(Dlci.MAESTRO, hex(it)) } } + // ADR-058: the Buds of CAP-036 answer the one GetHardwareInfo with frame 1423 (serials redacted in the fixture). + if (rpc.methodId == Maestro.METHOD_GET_HARDWARE_INFO) transport.emit(Dlci.MAESTRO, hex(HardwareInfoFixtures.RESPONSE_CH21_CAP036_1423_REDACTED)) } } } @@ -451,7 +457,13 @@ class BudsRepositoryImplTest { val requests = transport.maestroRequests() assertEquals("2010", requests.first().payload.toHex(), "4:16 first") - assertEquals(2 + settingReads.size, requests.size, "the EQ read, the settings reads (ADR-036, ADR-046, ADR-053), then the runtime-info subscription (ADR-043)") + assertEquals( + 3 + settingReads.size, + requests.size, + "the EQ read, the settings reads (ADR-036, ADR-046, ADR-053), the runtime-info subscription (ADR-043), then GetHardwareInfo (ADR-058)", + ) + assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, requests[requests.size - 2].methodId) + assertEquals(Maestro.METHOD_GET_HARDWARE_INFO, requests.last().methodId, "last, after the subscription") assertEquals(0.3f, repo.eqProfile.first()!!.mid, 1e-4f) // CAP-036 frame 1525 } @@ -468,7 +480,8 @@ class BudsRepositoryImplTest { val maestro = transport.sent.filter { it.first == Dlci.MAESTRO }.map { it.second.toHex() } assertEquals(cap036SettingReads, maestro.subList(1, 1 + settingReads.size)) - assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().last().methodId) + assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().let { it[it.size - 2] }.methodId) + assertEquals(Maestro.METHOD_GET_HARDWARE_INFO, transport.maestroRequests().last().methodId, "ADR-058: last") assertEquals( BudsSettings( inEarDetection = SettingReading(true, 4_000), @@ -517,7 +530,8 @@ class BudsRepositoryImplTest { assertNull(repo.settings.value.volumeBalance) assertEquals(true, repo.settings.value.conversationDetection?.value) assertEquals(io.github.tedsluis.opencontrolpixelbuds.domain.SettingsFailure(BudsError.Timeout, write = false), repo.settingsError.first(), "the last reason, a read") - assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().last().methodId) + assertEquals(Maestro.METHOD_SUBSCRIBE_RUNTIME_INFO, transport.maestroRequests().let { it[it.size - 2] }.methodId) + assertEquals(Maestro.METHOD_GET_HARDWARE_INFO, transport.maestroRequests().last().methodId, "ADR-058: the sequence goes on to the hardware-info read") } /** @@ -827,9 +841,10 @@ class BudsRepositoryImplTest { settle() val maestro = transport.sent.filter { it.first == Dlci.MAESTRO }.map { it.second.toHex() } - assertEquals(2 + settingReads.size, maestro.size, "EQ read, the settings reads (field 12 added by ADR-046, 11 by ai-sessions/0074), the subscription") + assertEquals(3 + settingReads.size, maestro.size, "EQ read, the settings reads (field 12 added by ADR-046, 11 by ai-sessions/0074), the subscription, GetHardwareInfo (ADR-058)") assertEquals(1, maestro.count { it == "7e004b0310151dea71de7d5e2590821ee66654bfab7e" }) - assertEquals("7e004b0310151dea71de7d5e2590821ee66654bfab7e", maestro.last()) + assertEquals("7e004b0310151dea71de7d5e2590821ee66654bfab7e", maestro[maestro.size - 2], "the subscription right after the settings reads") + assertEquals(HardwareInfoFixtures.REQUEST_CH21_CAP036_1415, maestro.last(), "ADR-058: GetHardwareInfo last") assertEquals(0.3f, repo.eqProfile.first()!!.mid, 1e-4f) } @@ -978,12 +993,123 @@ class BudsRepositoryImplTest { runCurrent() advanceTimeBy(3_100) // … for the settings reads … runCurrent() - advanceTimeBy(3_100) // … and for the subscription + advanceTimeBy(3_100) // … for the subscription … + runCurrent() + advanceTimeBy(3_100) // … and for the hardware-info read (ADR-058) runCurrent() assertEquals(0, transport.sent.size) assertEquals(BudsError.MaestroChannelUnknown(null), repo.settingsError.first()?.error) assertEquals(BudsError.MaestroChannelUnknown(null), repo.caseBatteryError.first()) + assertEquals(BudsError.MaestroChannelUnknown(null), repo.serialsError.first()) + } + + // ---- ADR-058 (`ai-sessions/0082` item 2): one GetHardwareInfo per Connect, the serial numbers on the Info tab ---- + + /** The Buds of `CAP-036`/`CAP-024`: every ReadSetting answered, the GetHardwareInfo by [answer] (`null` = silence). */ + private fun answerHardwareInfo(transport: FakeBudsTransport, answer: String?) { + transport.onSent = { ch, frame -> + if (ch == Dlci.MAESTRO) { + val rpc = (PwRpc.decode((Hdlc.decode(frame) as BudsResult.Success).value.payload) as BudsResult.Success).value + if (rpc.methodId == Maestro.METHOD_READ_SETTING) Settings036.ANSWERS[rpc.payload[1].toInt()]?.let { transport.emit(Dlci.MAESTRO, hex(it)) } + if (rpc.methodId == Maestro.METHOD_GET_HARDWARE_INFO) answer?.let { transport.emit(Dlci.MAESTRO, hex(it)) } + } + } + } + + @Test + @DisplayName("ADR-058: Connect sends exactly one GetHardwareInfo, byte-identical to CAP-036 1415 (ch 21), after the subscription; 1423's serials reach deviceInfo with their time") + fun `Connect reads the serial numbers once, on channel 21`() = runTest { + BleLogger.clear() + val (repo, transport) = buildRepository(verified = false) + answerHardwareInfo(transport, HardwareInfoFixtures.RESPONSE_CH21_CAP036_1423_REDACTED) + transport.emit(Dlci.MAESTRO, helloWithFirmware(21, 10496, "release_5.203")) + settle() + advanceTimeBy(4_000) + repo.launchInitialEqRead() + settle() + + val hardwareRequests = transport.sent.filter { it.first == Dlci.MAESTRO && it.second.toHex() == HardwareInfoFixtures.REQUEST_CH21_CAP036_1415 } + assertEquals(1, hardwareRequests.size, "exactly one, byte-identical to the official app's") + val info = repo.deviceInfo.first()!! + assertEquals(HardwareInfoFixtures.SERIALS_REDACTED, info.serials.map { it.serial }) + assertEquals(listOf(1, 2, 3), info.serials.map { it.index }) + assertEquals(currentTime, info.serialsReadAtMillis) + assertEquals(listOf("release_5.203"), info.firmware, "the announcement's firmware stays") + assertNull(repo.serialsError.first()) + // AGENTS.md §9 / mutation M4: no serial in any log line — the always-on log says how many, never which. + val log = BleLogger.exportLog() + for (serial in HardwareInfoFixtures.SERIALS_REDACTED) assertEquals(false, serial in log, "serial in the log") + assertEquals(true, "Hardware info read (channel 21): 3 serial numbers" in log) + } + + @Test + @DisplayName("ADR-058: on channel 19 the request is CAP-024 801 and the answer 832 gives the same three serials in the same order") + fun `the serial numbers are read on channel 19 too`() = runTest { + val (repo, transport) = buildRepository(verified = false) + answerHardwareInfo(transport, HardwareInfoFixtures.RESPONSE_CH19_CAP024_832_REDACTED) + transport.emit(Dlci.MAESTRO, helloWithFirmware(19, 10432, "release_5.203")) // response address `80 a3` (channel 19, ADR-034) + settle() + repo.launchInitialEqRead() + settle() + assertEquals(1, transport.sent.count { it.second.toHex() == HardwareInfoFixtures.REQUEST_CH19_CAP024_801 }) + assertEquals(HardwareInfoFixtures.SERIALS_REDACTED, repo.deviceInfo.first()!!.serials.map { it.serial }) + } + + @Test + @DisplayName("ADR-058: no answer within 2 s -> Timeout in serialsError, never retried, the serials stay empty; an OK answer without field 7 -> UnreadableAnswer") + fun `an unanswered or unreadable hardware-info read keeps the serials empty and says why`() = runTest { + val (repo, transport) = buildRepository(verified = false) + answerHardwareInfo(transport, null) + transport.emit(Dlci.MAESTRO, helloWithFirmware(21, 10496, "release_5.203")) + settle() + repo.launchInitialEqRead() + settle() + advanceTimeBy(2_100) + runCurrent() + assertEquals(1, transport.sent.count { it.second.toHex() == HardwareInfoFixtures.REQUEST_CH21_CAP036_1415 }, "sent once, never retried") + assertEquals(BudsError.Timeout, repo.serialsError.first()) + assertEquals(emptyList(), repo.deviceInfo.first()!!.serials) + + // Labelled structural case (no capture holds it): the answer without field 7. + val (repo2, transport2) = buildRepository(verified = false) + val withoutSerials = RpcPacket(PwRpc.TYPE_RESPONSE, 21, Maestro.SERVICE_ID, Maestro.METHOD_GET_HARDWARE_INFO, hex("0806101128093007")) + answerHardwareInfo(transport2, Hdlc.encode(10496, PW_HDLC_CONTROL_UI, PwRpc.encode(withoutSerials)).toHex()) + transport2.emit(Dlci.MAESTRO, helloWithFirmware(21, 10496, "release_5.203")) + settle() + repo2.launchInitialEqRead() + settle() + assertEquals(BudsError.UnreadableAnswer, repo2.serialsError.first()) + assertEquals(emptyList(), repo2.deviceInfo.first()!!.serials) + } + + @Test + @DisplayName("ADR-058 item 2 / ADR-042 item 3: a read is not gated — the request is sent to an unverified firmware too (M10 guards the opposite)") + fun `the hardware-info read is sent on an unverified firmware`() = runTest { + val (repo, transport) = buildRepository(verified = false) + answerHardwareInfo(transport, HardwareInfoFixtures.RESPONSE_CH21_CAP036_1423_REDACTED) + transport.emit(Dlci.MAESTRO, helloWithFirmware(21, 10496, "release_9.999")) + settle() + repo.launchInitialEqRead() + settle() + assertEquals(1, transport.sent.count { it.second.toHex() == HardwareInfoFixtures.REQUEST_CH21_CAP036_1415 }) + assertEquals(HardwareInfoFixtures.SERIALS_REDACTED, repo.deviceInfo.first()!!.serials.map { it.serial }) + assertEquals(listOf("release_9.999"), repo.safeMode.first()?.firmware, "Safe Mode is observed (the Connection card) — it refuses writes, not this read") + } + + @Test + @DisplayName("ADR-058: Disconnect clears deviceInfo (serials included) and the error; nothing is persisted") + fun `Disconnect drops the serials with the rest of deviceInfo`() = runTest { + val (repo, transport) = buildRepository(verified = false) + answerHardwareInfo(transport, HardwareInfoFixtures.RESPONSE_CH21_CAP036_1423_REDACTED) + transport.emit(Dlci.MAESTRO, helloWithFirmware(21, 10496, "release_5.203")) + settle() + repo.launchInitialEqRead() + settle() + assertEquals(3, repo.deviceInfo.first()!!.serials.size) + repo.disconnect() + assertNull(repo.deviceInfo.first()) + assertNull(repo.serialsError.first()) } @Test @@ -2796,6 +2922,155 @@ class BudsRepositoryImplTest { assertEquals(true, status.leftCharging?.fromEarlierSession, end) } } + + // ---- ADR-059 (`ai-sessions/0082` item 3): the worn reading, derived from state the app already holds ---- + + @Test + @DisplayName("ADR-059: not read until a Notify; CAP-065 5465 (00) -> not worn; 6334 (e8) -> probably worn; CAP-062 4845 -> both in the case; field 2 = 0 -> unknown; Connect resets") + fun `the worn reading follows the Notify, field 2 and the charging flags`() = runTest { + val (repo, transport) = buildRepository() + settle() + assertEquals(WornReading.NotRead, repo.wornReading.first()) + + transport.emit(Dlci.MAESTRO, hex(Settings036.READ_2_RESP)) // CAP-036 1447: `4:{2:1}`, in-ear detection on + settle() + assertEquals(WornReading.NotRead, repo.wornReading.first(), "still no Notify on this connection") + + advanceTimeBy(1_000) + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap065.NOTIFY_00_OFF_5465)) // both buds loose on the table + settle() + assertEquals(WornReading.NotWorn(currentTime), repo.wornReading.first()) + + advanceTimeBy(1_000) + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap065.NOTIFY_E8_OFF_6334)) // both worn + settle() + val checkedAt = currentTime + assertEquals(WornReading.ProbablyWorn(checkedAt), repo.wornReading.first()) + + advanceTimeBy(1_000) + transport.emit(Dlci.MAESTRO, hex(Cap062.STREAM_BOTH_4845)) // both buds charging + settle() + assertEquals(WornReading.BothInCase, repo.wornReading.first()) + + transport.emit(Dlci.MAESTRO, hex(Cap062.STREAM_NONE_2782)) // neither charging: back to the byte's reading, with the Notify's own time + settle() + assertEquals(WornReading.ProbablyWorn(checkedAt), repo.wornReading.first()) + + // The app's own write of field 2 = 0 (CAP-064 9922 → OK): the byte says nothing any more. + transport.onSent = { ch, frame -> if (ch == Dlci.MAESTRO) transport.emit(Dlci.MAESTRO, hex(SettingsWrites.ACK_CH21_1731)) } + assertEquals(BudsResult.Success(Unit), repo.setInEarDetection(false)) + settle() + assertEquals(WornReading.InEarDetectionOff, repo.wornReading.first()) + + repo.disconnect() + repo.connect() // NotPaired in this test, but a new connection attempt: the Settable byte and field 2 are this connection's to re-read + settle() + assertEquals(WornReading.NotRead, repo.wornReading.first()) + } + + // ---- `ai-sessions/0082` item 1: why the shown ANC mode is what it is — read, set by this app, or changed by the Buds (the maintainer's rule, chat 2026-10-09) ---- + + @Test + @DisplayName("Item 1: the answer to the app's own Get is a reading (CAP-045 609 -> 612), never \"changed by the Buds\"") + fun `the Get's answer is a reading`() = runTest { + val (repo, transport) = buildRepository() + settle() + assertNull(repo.ancModeCause.first(), "nothing reported yet") + transport.onSent = null // the test answers the Get itself + val job = launch { repo.refreshAncMode() } + runCurrent() + assertEquals(Cap045.GET_609, transport.sent.single().second.toHex()) + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_ADAPTIVE_612)) + job.join() + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.READ, repo.ancModeCause.first()) + } + + @Test + @DisplayName("Item 1: a Notify the app did not provoke, with another mode, is changed by the Buds (CAP-045 1583, 1755, 1818); the same mode again changes nothing") + fun `an unsolicited Notify with another mode is changed by the Buds`() = runTest { + val (repo, transport) = buildRepository() + settle() + transport.onSent = null + val job = launch { repo.refreshAncMode() } + runCurrent() + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_ADAPTIVE_612)) // the Get's answer + job.join() + assertEquals(AncModeCause.READ, repo.ancModeCause.first()) + + advanceTimeBy(24_477) // 06:23:21.948 − 06:22:57.471 + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_TRANSPARENT_1583)) // a press-and-hold on a bud + settle() + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first(), "the line's time is the mode's receive time") + + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_ACTIVE_1755)) + settle() + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_ADAPTIVE_1818)) + settle() + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + + // The Buds repeat the mode already shown (nothing pending): the cause stays, only the time moves. + advanceTimeBy(1_000) + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_ADAPTIVE_1818)) + settle() + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + assertEquals(currentTime, repo.ancModeUpdatedAt.first()) + } + + @Test + @DisplayName("Item 1: the app's own Set (Get -> Notify -> Set -> ACK), then the Buds' Notify of that mode: set by this app, never changed by the Buds") + fun `the app's own acknowledged Set is set by this app`() = runTest { + val (repo, transport) = buildRepository() // autoAck: the Get answered by CAP-065 6334 (OFF, e8), the Set ACKed + settle() + + assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.ADAPTIVE)) + assertEquals(AncMode.ADAPTIVE, repo.ancMode.first()) + assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first()) + + // The Buds' own Notify after the ACK (as CAP-059 2779 -> 2782): the requested mode — still the app's change. + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_ADAPTIVE_612)) + settle() + assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first()) + } + + @Test + @DisplayName("Item 1: the Buds answer the Set with their Notify of the requested mode before any ACK — the app's change, not theirs") + fun `a Notify of the requested mode while the Set waits is set by this app`() = runTest { + val (repo, transport) = buildRepository() + settle() + transport.onSent = { ch, frame -> + when (frame.toHex().take(4)) { + "0811" -> transport.emit(ch, hex(Cap065.NOTIFY_E8_OFF_6334)) + "0812" -> transport.emit(ch, hex(Cap045.NOTIFY_TRANSPARENT_1583)) // the requested mode, as a Notify, no ACK + } + } + assertEquals(BudsResult.Success(Unit), repo.setAncMode(AncMode.TRANSPARENT)) + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first()) + assertEquals(AncModeCause.SET_BY_APP, repo.ancModeCause.first()) + } + + @Test + @DisplayName("Item 1: a new Connect resets the cause; the first Notify of this app run is a reading") + fun `Connect resets the cause and the first mode ever shown is a reading`() = runTest { + val (repo, transport) = buildRepository() + settle() + // No Get pending, no mode shown yet: nothing could have changed. + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_ADAPTIVE_612)) + settle() + assertEquals(AncModeCause.READ, repo.ancModeCause.first()) + transport.emit(Dlci.FAST_PAIR_MESSAGE_STREAM, hex(Cap045.NOTIFY_TRANSPARENT_1583)) + settle() + assertEquals(AncModeCause.CHANGED_BY_BUDS, repo.ancModeCause.first()) + + repo.disconnect() + repo.connect() // no bonded device in this test: NotPaired, but a new connection attempt all the same (A68-APP-02) + settle() + assertEquals(AncMode.TRANSPARENT, repo.ancMode.first(), "the mode is kept (from the last connection)") + assertNull(repo.ancModeCause.first(), "its cause is this connection's to re-establish") + } } private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap045Fixtures.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap045Fixtures.kt new file mode 100644 index 00000000..3e9edd6d --- /dev/null +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/Cap045Fixtures.kt @@ -0,0 +1,48 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.data.codec + +/** + * Real bytes from `CAP-045` (`captures/CAP-045-2026-09-12_08-22-51_08-24-32-Group_AJ/`, the official app, Group AJ: press-and-hold ANC changes on a bud + * while the official client held the Message Stream — `CAP-045-FINDINGS.md` §3, `TOUCH-007`), for `ai-sessions/0082` item 1 ("Changed by the Buds"). + * Every value is the RFCOMM payload of the named HCI frame, unchanged. Command (`ai-sessions/0082` RESULT §A.1; `bluetooth.addr` matches nothing in this + * log, the Buds' handle `0x0002` comes from the Connection Complete event, frame 294): + * `tshark -r CAP-045-btsnoop_hci.log -Y 'btrfcomm.dlci==4 and btrfcomm.len>0' -T fields -e frame.number -e frame.time_utc -e frame.p2p_dir + * -e bthci_acl.chandle -e data.data | grep -E "\s08(11|12|13)"` (exit 0). The whole log holds no `08 12`: every change below is a bare `Notify`. + */ +internal object Cap045 { + /** Frame 609 (06:22:57.420909 UTC), phone → Buds: the official client's connect-time `Get ANC state`. */ + const val GET_609 = "08110000" + + /** Frame 612 (06:22:57.471104), Buds → phone: the answer to 609 — Settable `e8`, mode ADAPTIVE (`40`). */ + const val NOTIFY_ADAPTIVE_612 = "0813000401e8e840" + + /** Frame 1583 (06:23:21.948918), Buds → phone: `Notify` only (no `Get`, no `Set` before it) — a press-and-hold: TRANSPARENT (`80`). */ + const val NOTIFY_TRANSPARENT_1583 = "0813000401e8e880" + + /** Frame 1755 (06:23:32.507031), Buds → phone: `Notify` only — ACTIVE (`08`, Noise cancellation). */ + const val NOTIFY_ACTIVE_1755 = "0813000401e8e808" + + /** Frame 1818 (06:23:46.898794), Buds → phone: `Notify` only — ADAPTIVE (`40`) again. */ + const val NOTIFY_ADAPTIVE_1818 = "0813000401e8e840" + + /** Frame 1849 (06:23:54.405932), Buds → phone: `Notify` only — OFF (`20`) with Settable `00` (a bud removed). */ + const val NOTIFY_OFF_SETTABLE_00_1849 = "0813000401e80020" +} diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoCodecTest.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoCodecTest.kt new file mode 100644 index 00000000..cc3b852a --- /dev/null +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoCodecTest.kt @@ -0,0 +1,125 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.data.codec + +import io.github.tedsluis.opencontrolpixelbuds.domain.BudsResult +import io.github.tedsluis.opencontrolpixelbuds.domain.ComponentSerial +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import kotlin.random.Random + +/** + * `GetHardwareInfo` (DECISIONS.md ADR-058, `ai-sessions/0082` item 2): the request on both channels is byte-identical to the official app's, the answer's + * field 7 decodes to the three component serials by position, an answer without them is an OK result (never a guessed serial), and the reader never throws. + * Fixtures: [HardwareInfoFixtures] (the answers with the serials redacted — device identifiers, `AGENTS.md` §9). + */ +class HardwareInfoCodecTest { + + private fun wire(address: Int, packet: RpcPacket) = Hdlc.encode(address, PW_HDLC_CONTROL_UI, PwRpc.encode(packet)).toHex() + + private fun payloadOf(frameHex: String): ByteArray = + (PwRpc.decode((Hdlc.decode(hex(frameHex)) as BudsResult.Success).value.payload) as BudsResult.Success).value.payload + + @Test + @DisplayName("the request is byte-identical to CAP-036 frame 1415 (channel 21) and CAP-024 frame 801 (channel 19); empty payload, no call id") + fun `request matches the official app's on both channels`() { + assertEquals(HardwareInfoFixtures.REQUEST_CH21_CAP036_1415, wire(4736, Maestro.getHardwareInfoRequest(21))) + assertEquals(HardwareInfoFixtures.REQUEST_CH19_CAP024_801, wire(3712, Maestro.getHardwareInfoRequest(19))) + val packet = Maestro.getHardwareInfoRequest(21) + assertEquals(0, packet.payload.size) + assertEquals(null, packet.callId) + assertEquals(PwRpc.nameHash("GetHardwareInfo"), packet.methodId) // 0x28eca5e3, PROTOCOL.md §6 + } + + @Test + @DisplayName("CAP-036 1423: field 7 -> three serials in field order 1, 2, 3; fields 1, 2, 5, 6, 8 are not read") + fun `the answer's field 7 decodes to the three serials by position`() { + val serials = HardwareInfo.serials(payloadOf(HardwareInfoFixtures.RESPONSE_CH21_CAP036_1423_REDACTED)) + assertEquals(HardwareInfoFixtures.SERIALS_REDACTED.mapIndexed { i, s -> ComponentSerial(i + 1, s) }, serials) + } + + @Test + @DisplayName("CAP-024 832 (channel 19): the same three strings in the same order — the answer does not depend on the hosting bud") + fun `the channel-19 answer carries the same serials in the same order`() { + val serials = HardwareInfo.serials(payloadOf(HardwareInfoFixtures.RESPONSE_CH19_CAP024_832_REDACTED)) + assertEquals(HardwareInfoFixtures.SERIALS_REDACTED, serials.map { it.serial }) + assertEquals(listOf(1, 2, 3), serials.map { it.index }) + } + + @Test + @DisplayName("the router: the whole real frame routes to RoutedFrame.HardwareInfo; an OK answer without field 7 is an RpcResult (UnreadableAnswer upstream)") + fun `the router routes the answer, and an answer without serials as a plain result`() { + val routed = CodecRouter().feed(Dlci.MAESTRO, hex(HardwareInfoFixtures.RESPONSE_CH21_CAP036_1423_REDACTED), timestampMillis = 0) + assertEquals(RoutedFrame.HardwareInfo(HardwareInfoFixtures.SERIALS_REDACTED.mapIndexed { i, s -> ComponentSerial(i + 1, s) }), routed.single()) + + // A labelled supplementary structural case (no capture holds it): the same answer with field 7 absent — fields 1, 2, 5, 6 only. + val withoutSerials = RpcPacket(PwRpc.TYPE_RESPONSE, 21, Maestro.SERVICE_ID, Maestro.METHOD_GET_HARDWARE_INFO, hex("08061011280930 07".replace(" ", ""))) + val plain = CodecRouter().feed(Dlci.MAESTRO, Hdlc.encode(10496, PW_HDLC_CONTROL_UI, PwRpc.encode(withoutSerials)), timestampMillis = 0) + assertEquals(RoutedFrame.RpcResult(21, Maestro.METHOD_GET_HARDWARE_INFO, PwRpc.TYPE_RESPONSE, null), plain.single()) + assertTrue(HardwareInfo.serials(withoutSerials.payload).isEmpty()) + + // An error status for the method is a result too (never a serial). + val rejected = RpcPacket(PwRpc.TYPE_RESPONSE, 21, Maestro.SERVICE_ID, Maestro.METHOD_GET_HARDWARE_INFO, status = 5) + val error = CodecRouter().feed(Dlci.MAESTRO, Hdlc.encode(10496, PW_HDLC_CONTROL_UI, PwRpc.encode(rejected)), timestampMillis = 0) + assertEquals(RoutedFrame.RpcResult(21, Maestro.METHOD_GET_HARDWARE_INFO, PwRpc.TYPE_RESPONSE, 5), error.single()) + } + + @Test + @DisplayName("labelled structural cases: a non-printable, empty, over-long or repeated string is left out; index 4 is ignored — never a guessed serial") + fun `only printable ASCII strings in fields 1 to 3 are serials`() { + fun group(vararg entries: Pair): ByteArray { + val inner = entries.fold(ByteArray(0)) { acc, (n, b) -> acc + Varint.encode((n shl 3) or 2) + Varint.encode(b.size) + b } + return Varint.encode((7 shl 3) or 2) + Varint.encode(inner.size) + inner + } + val ok = "5707XXXXXXXX51".toByteArray() + assertEquals(listOf(ComponentSerial(1, "5707XXXXXXXX51")), HardwareInfo.serials(group(1 to ok, 2 to byteArrayOf(0x01, 0x02), 3 to ByteArray(0), 4 to ok))) + assertEquals(listOf(ComponentSerial(1, "5707XXXXXXXX51")), HardwareInfo.serials(group(1 to ok, 1 to "other".toByteArray()))) + assertTrue(HardwareInfo.serials(group(2 to ByteArray(65) { 'A'.code.toByte() })).isEmpty()) + assertTrue(HardwareInfo.serials(hex("0806")).isEmpty(), "no field 7") + assertTrue(HardwareInfo.serials(ByteArray(0)).isEmpty()) + } + + @Test + @DisplayName("fuzz: random payloads and the real answer with mutated bytes never throw (AGENTS.md §11)") + fun `never throws on arbitrary input`() { + val random = Random(2026_10_09) + repeat(3_000) { + HardwareInfo.serials(ByteArray(random.nextInt(0, 96)) { random.nextInt(256).toByte() }) + } + val real = payloadOf(HardwareInfoFixtures.RESPONSE_CH21_CAP036_1423_REDACTED) + repeat(3_000) { + val mutated = real.copyOf() + repeat(random.nextInt(1, 4)) { mutated[random.nextInt(mutated.size)] = random.nextInt(256).toByte() } + HardwareInfo.serials(mutated) + HardwareInfo.serials(mutated.copyOfRange(0, random.nextInt(mutated.size))) + } + // Through the router, re-sealed with a valid CRC, so the reader is reached (the pattern of OversizedLengthTest). + val router = CodecRouter() + repeat(500) { + val mutated = real.copyOf() + repeat(random.nextInt(1, 4)) { mutated[random.nextInt(mutated.size)] = random.nextInt(256).toByte() } + router.feed(Dlci.MAESTRO, Hdlc.encode(10496, PW_HDLC_CONTROL_UI, mutated), timestampMillis = 0) + } + } + + private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } +} diff --git a/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoFixtures.kt b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoFixtures.kt new file mode 100644 index 00000000..c09ed22e --- /dev/null +++ b/android/data/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/data/codec/HardwareInfoFixtures.kt @@ -0,0 +1,59 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.data.codec + +/** + * The official app's `GetHardwareInfo` exchange (DECISIONS.md ADR-058, `ai-sessions/0082`) on both control channels. The **requests are the real frames** + * (they carry no identifier). The **answers are the real frames with the serial numbers redacted**: serials are device identifiers (`AGENTS.md` §9, §11 — + * "redact any real device identifiers first"), so in each of the three length-14 strings the eight middle characters are replaced by `X` (the first four and + * the last two kept) and the CRC-32 is re-sealed — the envelope, every tag, length and other field are byte-identical to the log. The session that wrote + * these fixtures verified the real frames' CRC-32 against the logs (`ai-sessions/0082` RESULT §A.2, §D). Commands: + * `tshark -r CAP-036-btsnoop_hci.log -Y 'frame.number==1415 || frame.number==1423' -T fields -e frame.number -e frame.time_utc -e frame.p2p_dir + * -e bthci_acl.chandle -e btrfcomm.dlci -e data.data` (handle `0x0005`, Connection Complete 906) and the same for `CAP-024` 801/832 (handle `0x0002`, 383); + * `python3 scripts/pwrpc_decode.py | grep GetHardwareInfo`. + */ +internal object HardwareInfoFixtures { + /** `CAP-036` frame 1415 (04:36:32.628142 UTC), phone → Buds, channel 21, address `00 4b`: the whole pw_hdlc frame of the request (no payload). */ + const val REQUEST_CH21_CAP036_1415 = "7e004b0310151dea71de7d5e25e3a5ec28f96761b57e" + + /** `CAP-024` frame 801 (06:31:37.346823 UTC), phone → Buds, channel 19, address `00 3b`: the second pw_hdlc frame of that RFCOMM frame (the first is `ReadSetting 4:13`). */ + const val REQUEST_CH19_CAP024_801 = "7e003b0310131dea71de7d5e25e3a5ec28ff1ebbb77e" + + /** + * `CAP-036` frame 1423 (04:36:32.711117 UTC, 83 ms after the request), Buds → phone, channel 21, address `00 a5`: the first pw_hdlc frame of that RFCOMM + * frame — `RESPONSE` `1:6 2:17 5:9 6:7 7:{1:"5707…51" 2:"5708…09" 3:"5707…47"} 8:{…}` — **serials redacted**, CRC re-sealed (the real frame: CRC `36 db 71 c0`, + * verified OK). + */ + const val RESPONSE_CH21_CAP036_1423_REDACTED = + "7e00a5032a4408061011280930073a300a0e3537303758585858585858583531120e35373038585858585858585830391a0e353730375858585858585858343742083030313332" + + "303933080110151dea71de7d5e25e3a5ec28276dfa917e" + + /** + * `CAP-024` frame 832 (06:31:37.429407 UTC, 83 ms after the request), Buds → phone, channel 19, address `80 a3`: the second pw_hdlc frame of that RFCOMM + * frame — the same three strings in the same order (field 8 differs: `…94` instead of `…93`) — **serials redacted**, CRC re-sealed (the real frame: CRC + * `a4 a6 c5 17`, verified OK). + */ + const val RESPONSE_CH19_CAP024_832_REDACTED = + "7e80a3032a4408061011280930073a300a0e3537303758585858585858583531120e35373038585858585858585830391a0e353730375858585858585858343742083030313332" + + "303934080110131dea71de7d5e25e3a5ec28b5104e467e" + + /** The three strings as the redacted answers carry them, in field order 1, 2, 3 (Case, Right bud, Left bud per the official app's reading). */ + val SERIALS_REDACTED: List = listOf("5707XXXXXXXX51", "5708XXXXXXXX09", "5707XXXXXXXX47") +} diff --git a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/AncModeCause.kt b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/AncModeCause.kt new file mode 100644 index 00000000..f26c1172 --- /dev/null +++ b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/AncModeCause.kt @@ -0,0 +1,35 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.domain + +/** + * Why the shown [AncMode] is what it is (`ai-sessions/0082`, item 1 — `CAP-069-FINDINGS.md` §12 item 8; the maintainer's choice in chat 2026-10-09). The Buds + * push `Notify ANC state` for every change, whatever caused it (PROTOCOL.md §4.1): the app's own `Set`, a press-and-hold on a bud (`CAP-045`), or the Buds' + * own change (`CAP-067` §2). The cause is derived from what the app itself had just sent — never from a timer: + * + * - [READ] — the `Notify` answered the app's own `Get` (the Connect snapshot, Refresh, the `Get` before every tap), or it is the first mode this app run + * ever showed: a reading, not a change. + * - [SET_BY_APP] — the Buds' ACK of the app's `Set`, or their `Notify` of the requested mode while that `Set` was still waiting for its answer. + * - [CHANGED_BY_BUDS] — any other `Notify` whose mode differs from the shown one: a press-and-hold on a bud, or the Buds' own change — the app cannot + * tell which, and says so. + * + * A `Notify` with the mode already shown, while nothing is waiting, leaves the cause as it is (only the mode's time moves). Reset at every Connect. + */ +enum class AncModeCause { READ, SET_BY_APP, CHANGED_BY_BUDS } diff --git a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt index 900ec561..c7b80c8a 100644 --- a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt +++ b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/BudsRepository.kt @@ -88,6 +88,13 @@ interface BudsRepository { * polling). */ val ancModeUpdatedAt: Flow + /** + * Why the current [ancMode] is what it is ([AncModeCause], `ai-sessions/0082` item 1): the answer to the app's own `Get`, the app's own acknowledged + * `Set`, or a `Notify` the app did not provoke (a press-and-hold on a bud, or the Buds' own change). `null` before the first report of this connection; + * reset at every Connect. Shown only in the Noise control card's details. + */ + val ancModeCause: Flow + /** `null` = no value read yet this connection (ARCHITECTURE.md §3.1): the Connect sequence reads it with * `ReadSetting 4:16` (ADR-034), so it stays `null` only until that answer arrives or if it failed — see [eqError]. */ val eqProfile: Flow @@ -143,9 +150,22 @@ interface BudsRepository { /** Non-null while the app is in read-only Safe Mode (ARCHITECTURE.md §8.1, DECISIONS.md ADR-042). */ val safeMode: Flow - /** What the Buds announced at connect (firmware), or `null` before the announcement / after a disconnect. */ + /** + * The "probably worn" reading of DECISIONS.md ADR-059 (`ai-sessions/0082` item 3): [wornReading] of [ancAvailability] + [ancAvailabilityUpdatedAt], the + * in-ear detection setting of [settings] and the charging flags of [batteryStatus] — state the app already holds, nothing new on the wire. As old as the last + * `Notify` (Connect, Refresh, a noise-control tap); never a bud named as worn. + */ + val wornReading: Flow + + /** What the Buds announced at connect (firmware), or `null` before the announcement / after a disconnect; since ADR-058 also the serial numbers once read. */ val deviceInfo: Flow + /** + * Why the one `GetHardwareInfo` per Connect (DECISIONS.md ADR-058) brought no serial numbers (`null` = it did, or none was attempted this connection): + * no announced channel, no answer in time, an error status, or an answer without the three strings ([BudsError.UnreadableAnswer]). Never retried. + */ + val serialsError: Flow + /** The earbud a Find My Buds ring was last started on and not yet stopped (`null` = none) — the ring keeps sounding on the Buds after the * channel is released and after Disconnect (`ai-sessions/0042`, `CAP-062`), so the UI must say so. Kept across Disconnect/Connect; cleared * only by an ACKed Stop (or replaced by a new Ring) — `ai-sessions/0048` I-6. */ diff --git a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/DeviceInfo.kt b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/DeviceInfo.kt index afa0d7dd..d80da7ba 100644 --- a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/DeviceInfo.kt +++ b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/DeviceInfo.kt @@ -47,16 +47,27 @@ enum class AncAvailability { * What the Buds announce about themselves when a session opens (their unsolicited `GetSoftwareInfo`, DECISIONS.md ADR-034): [firmware] = the distinct * firmware version strings, e.g. `release_5.203` (what the Safe-Mode gate checks, ADR-042). **Since `ai-sessions/0062` (F-5):** also [entries] — each entry of * the announcement's field 4 with its own index — the pw_rpc channel the Buds announced ([maestroChannel]) and when the announcement arrived - * ([announcedAtMillis]), for the Info tab. The entries' version numbers (`PROTOCOL.md` §2.2a, L-5) and the component serials (`GetHardwareInfo`, `AGENTS.md` - * §9) are not carried. + * ([announcedAtMillis]), for the Info tab. The entries' version numbers (`PROTOCOL.md` §2.2a, L-5) are not carried. **Since `ai-sessions/0082` (ADR-058):** + * [serials] — the component serial numbers the Buds answer to the app's one `GetHardwareInfo` per Connect, with [serialsReadAtMillis]; empty until the + * answer arrived. Serials are device identifiers (`AGENTS.md` §9): shown on the Info tab, never logged. */ data class DeviceInfo( val firmware: List, val entries: List = emptyList(), val maestroChannel: Int? = null, val announcedAtMillis: Long? = null, + val serials: List = emptyList(), + val serialsReadAtMillis: Long? = null, ) +/** + * One serial number of the `GetHardwareInfo` answer's field 7 (DECISIONS.md ADR-058, `ai-sessions/0082`): its field number [index] (1, 2 or 3 in every capture — + * 138 answers in 54 logs, always the same three strings in the same order) and the string. Which component an index is — **1 = Case, 2 = Right bud, 3 = Left + * bud** — is the official app's own reading of the answer by position (`gaa.java:45–96` → `fwg.java:182–215`, 🟢 for its code; the strings' EC/DR/DL marks + * agree; `PROTOCOL.md` §6, 2026-10-09); the labels are `:ui`'s. + */ +data class ComponentSerial(val index: Int, val serial: String) + /** * One entry of the announcement's field 4: its field number [index] (1, 2 or 3 in every capture) and its firmware string (field 2). Which component an index is * — 1 = Case, 2 = Left bud, 3 = Right bud — is the official app's own mapping (`PROTOCOL.md` §2.2a, 2026-10-01 `ai-sessions/0062` Update, 🟢 FACT for the diff --git a/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReading.kt b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReading.kt new file mode 100644 index 00000000..b76b75fa --- /dev/null +++ b/android/domain/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReading.kt @@ -0,0 +1,73 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.domain + +/** + * The "probably worn" indicator of DECISIONS.md ADR-059 (`ai-sessions/0082` item 3; the maintainer's decision in chat 2026-10-09: show it hedged, as + * "probably"). One reading derived from state the app already holds — **no new wire traffic**: + * + * - the Settable byte of the Buds' last `Notify ANC state` on this connection ([AncAvailability]) and its time — ADR-049: 🟢 `00` ⇒ a `Set` is NAKed; 🟡 + * (strong) `00` ⇒ no bud worn (56 filmed samples, `CAP-065`/`CAP-066`, 0 counter-examples); the converse is **refuted** (`CAP-064`: `e8` with in-ear + * detection off, and ≈ 28 s of `e8` with both buds on a table, in-ear detection on); + * - the in-ear detection setting (`qhr` field 2): with it off the byte says nothing about wearing (`CAP-064` 10394/10600); + * - each bud's charging state from the runtime-info stream (ADR-043 Update; 🟡 charging = in the case): a charging bud is not worn. + * + * The byte is not per bud — the reading never names a bud as worn. It is as old as the last `Notify` (Connect, Refresh, a noise-control tap), never live. + */ +sealed class WornReading { + /** No `Notify` on this connection yet: "—" (screen reader: not read from the Buds yet). */ + data object NotRead : WornReading() + + /** Field 2 = 0: with in-ear detection off the Buds report `e8` with no bud worn (`CAP-064`) — the byte says nothing about wearing. */ + data object InEarDetectionOff : WornReading() + + /** Field 2 not read on this connection: whether the byte means anything is unknown. */ + data object InEarDetectionNotRead : WornReading() + + /** Both buds report charging on this connection (6.2/6.3 field 2 = 2, `CAP-062` 4845): both in the case, so neither is worn. */ + data object BothInCase : WornReading() + + /** Settable `00` (`CAP-065` 5465/5707/6019): the Buds refuse a noise-control change — in every filmed sample no bud was in an ear (🟡 strong). */ + data class NotWorn(val checkedAtMillis: Long?) : WornReading() + + /** Settable non-zero (`CAP-065` 6334): the Buds allow a change — on film at least one bud in an ear, except `CAP-064`'s ≈ 28 s (🟡). */ + data class ProbablyWorn(val checkedAtMillis: Long?) : WornReading() +} + +/** + * ADR-059 item 1, in this order: no `Notify` this connection → [WornReading.NotRead]; field 2 = 0 → [WornReading.InEarDetectionOff]; field 2 not read → + * [WornReading.InEarDetectionNotRead]; both buds charging **on this connection** → [WornReading.BothInCase]; Settable `00` → [WornReading.NotWorn]; + * non-zero → [WornReading.ProbablyWorn]. A charging report from an earlier connection says nothing about now and is ignored (mutation M7: a bud whose flag + * is not `true` is never "in the case"). + */ +fun wornReading( + availability: AncAvailability, + checkedAtMillis: Long?, + inEarDetection: SettingReading?, + leftCharging: ChargingReading?, + rightCharging: ChargingReading?, +): WornReading { + if (availability == AncAvailability.UNKNOWN) return WornReading.NotRead + if (inEarDetection == null) return WornReading.InEarDetectionNotRead + if (!inEarDetection.value) return WornReading.InEarDetectionOff + fun inCase(reading: ChargingReading?) = reading != null && reading.charging && !reading.fromEarlierSession + if (inCase(leftCharging) && inCase(rightCharging)) return WornReading.BothInCase + return if (availability == AncAvailability.NOT_ALLOWED) WornReading.NotWorn(checkedAtMillis) else WornReading.ProbablyWorn(checkedAtMillis) +} diff --git a/android/domain/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReadingTest.kt b/android/domain/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReadingTest.kt new file mode 100644 index 00000000..dc8f004a --- /dev/null +++ b/android/domain/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/domain/WornReadingTest.kt @@ -0,0 +1,88 @@ +/* + * OpenControl for Pixel Buds Pro 2 + * Copyright (C) 2026 Ted Sluis + * + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at your + * option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero + * General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package io.github.tedsluis.opencontrolpixelbuds.domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test + +/** + * The "probably worn" reading of DECISIONS.md ADR-059 (`ai-sessions/0082` item 3) — one test per reading, each with the decoded values of a real frame + * (the frames themselves are decoded in `:data`'s codec tests; here their decoded values, with the frame number): the Settable byte as [AncAvailability] + * (`08 13 00 04 01 e8 `), `qhr` field 2 as a [SettingReading], the runtime-info charging flags as [ChargingReading]s. + */ +class WornReadingTest { + + private val at = 1_727_600_000_000L + private val inEarOn = SettingReading(true, at - 5_000) // `CAP-064` 1260: `ReadSetting` answer `4:{2:1}` + private val inEarOff = SettingReading(false, at - 5_000, changedByApp = true) // `CAP-064` 9922: `WriteSetting 4:{2:0}`, OK + private val notCharging = ChargingReading(false, at - 1_000, ChargingSource.RUNTIME_INFO) // `CAP-062` 2782: 6.2.2 = 1, 6.3.2 = 1 + private val charging = ChargingReading(true, at - 1_000, ChargingSource.RUNTIME_INFO) // `CAP-062` 4845: 6.2.2 = 2, 6.3.2 = 2 + + @Test + @DisplayName("no Notify on this connection -> not read (the dash), whatever the other inputs say") + fun `not read before the first Notify`() { + assertEquals(WornReading.NotRead, wornReading(AncAvailability.UNKNOWN, null, inEarOn, notCharging, notCharging)) + assertEquals(WornReading.NotRead, wornReading(AncAvailability.UNKNOWN, null, inEarOff, charging, charging)) + } + + @Test + @DisplayName("CAP-064 10394 (e8, in-ear detection off, both buds on the table): the byte says nothing -> unknown, in-ear detection is off") + fun `in-ear detection off makes the byte meaningless`() { + assertEquals(WornReading.InEarDetectionOff, wornReading(AncAvailability.ALLOWED, at, inEarOff, notCharging, notCharging)) + assertEquals(WornReading.InEarDetectionOff, wornReading(AncAvailability.NOT_ALLOWED, at, inEarOff, notCharging, notCharging), "a 00 is not 'not worn' either") + } + + @Test + @DisplayName("field 2 not read on this connection -> unknown, the setting was not read") + fun `in-ear detection not read`() { + assertEquals(WornReading.InEarDetectionNotRead, wornReading(AncAvailability.ALLOWED, at, null, notCharging, notCharging)) + assertEquals(WornReading.InEarDetectionNotRead, wornReading(AncAvailability.NOT_ALLOWED, at, null, notCharging, notCharging)) + } + + @Test + @DisplayName("CAP-062 4845 (both buds charging, this connection) -> both buds in the case, whatever the byte says") + fun `both buds charging means both in the case`() { + assertEquals(WornReading.BothInCase, wornReading(AncAvailability.NOT_ALLOWED, at, inEarOn, charging, charging)) + assertEquals(WornReading.BothInCase, wornReading(AncAvailability.ALLOWED, at, inEarOn, charging, charging)) + } + + @Test + @DisplayName("M7 guard: one bud charging (CAP-062 3760: Right only), a flag from the last connection, or no flag is never 'in the case'") + fun `one charging bud or a stale flag is not both in the case`() { + assertEquals(WornReading.ProbablyWorn(at), wornReading(AncAvailability.ALLOWED, at, inEarOn, notCharging, charging)) + assertEquals(WornReading.ProbablyWorn(at), wornReading(AncAvailability.ALLOWED, at, inEarOn, charging.copy(fromEarlierSession = true), charging)) + assertEquals(WornReading.ProbablyWorn(at), wornReading(AncAvailability.ALLOWED, at, inEarOn, null, charging)) + assertEquals(WornReading.NotWorn(at), wornReading(AncAvailability.NOT_ALLOWED, at, inEarOn, null, null)) + } + + @Test + @DisplayName("CAP-065 5465 (00, both buds loose on the table, in-ear detection on) -> not worn, with the report's time") + fun `Settable 00 is not worn`() { + assertEquals(WornReading.NotWorn(at), wornReading(AncAvailability.NOT_ALLOWED, at, inEarOn, notCharging, notCharging)) + assertEquals(WornReading.NotWorn(null), wornReading(AncAvailability.NOT_ALLOWED, null, inEarOn, notCharging, notCharging)) + } + + @Test + @DisplayName("CAP-065 6334 (e8, both worn, in-ear detection on) -> probably worn, with the report's time — never a bud named") + fun `Settable non-zero is probably worn`() { + assertEquals(WornReading.ProbablyWorn(at), wornReading(AncAvailability.ALLOWED, at, inEarOn, notCharging, notCharging)) + } +} diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt index 412cf1a0..4b812f99 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.tedsluis.opencontrolpixelbuds.domain.AncAvailability import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode +import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.isCurrent @@ -68,6 +69,9 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.isCurrent * **`ai-sessions/0057`:** four large mode buttons (2 × 2) — the Buds' current mode is the filled one with a check mark (not colour alone), the others outlined; * an unknown mode fills none and says "ANC mode: unknown". The times ("updated …", "checked …"), the session line and the Message-Stream explanation are in * the card's (i) details, from the same helpers. + * + * **`ai-sessions/0082` item 1 (the maintainer's wording, chat 2026-10-09):** while [ancModeCause] is [AncModeCause.CHANGED_BY_BUDS] — a `Notify` the app did + * not provoke changed the mode — the (i) carries [ancChangedByBudsLine]; nothing on the main surface, no notification. */ @Composable fun AncScreen( @@ -83,6 +87,7 @@ fun AncScreen( ancAvailabilityUpdatedAt: Long? = null, ancModeUnconfirmedAt: Long? = null, sessionSince: Long? = null, + ancModeCause: AncModeCause? = null, ) { val ready = connectionState.isReady() val notAllowed = ready && ancAvailability == AncAvailability.NOT_ALLOWED @@ -103,7 +108,7 @@ fun AncScreen( Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { CardTitle( "Noise control", - ancDetailLines(connectionState, ancMode, ancModeUpdatedAt, notAllowed, ancAvailabilityUpdatedAt, ancModeUnconfirmedAt, fromLastConnection), + ancDetailLines(connectionState, ancMode, ancModeUpdatedAt, notAllowed, ancAvailabilityUpdatedAt, ancModeUnconfirmedAt, fromLastConnection, ancModeCause), notCurrent = notCurrent, ) if (ancMode == null) Text(ancModeLine(null, null), style = MaterialTheme.typography.bodyLarge) @@ -151,8 +156,8 @@ internal fun ancModeLine(ancMode: AncMode?, updatedAt: Long?, fromLastConnection } /** - * The ANC card's (i) lines: the not-confirmed line after a cut-off answer (F-3), the mode with its time, the "checked HH:MM:SS" line while not allowed, the - * session line, the Message-Stream explanation. + * The ANC card's (i) lines: the not-confirmed line after a cut-off answer (F-3), the mode with its time, the "changed by the Buds" line when a `Notify` the app did + * not provoke changed the mode (`ai-sessions/0082` item 1), the "checked HH:MM:SS" line while not allowed, the session line, the Message-Stream explanation. */ internal fun ancDetailLines( connectionState: ConnectionState, @@ -162,9 +167,11 @@ internal fun ancDetailLines( checkedAt: Long?, unconfirmedAt: Long? = null, fromLastConnection: Boolean = false, + cause: AncModeCause? = null, ): List = listOfNotNull( unconfirmedAt?.let(::ancUnconfirmedLine), ancModeLine(ancMode, ancModeUpdatedAt, fromLastConnection), + if (cause == AncModeCause.CHANGED_BY_BUDS && ancMode != null) ancChangedByBudsLine(ancModeUpdatedAt) else null, if (notAllowed) ancNotAllowedLine(checkedAt) else null, "Connection: ${connectionState::class.simpleName}", MESSAGE_STREAM_HINT_TEXT, @@ -177,6 +184,14 @@ internal fun ancDetailLines( internal fun ancNotAllowedLine(checkedAt: Long?): String = ANC_NOT_ALLOWED_TEXT.removeSuffix(").") + (formatUpdatedAt(checkedAt)?.let { "; checked $it" } ?: "") + "). Tapping a mode checks again first." +/** + * `ai-sessions/0082` item 1 (the maintainer's wording, chat 2026-10-09): the (i) line while the shown mode came from a `Notify` the app did not provoke — a + * press-and-hold on a bud (`CAP-045` 1583/1755/1818) or the Buds' own change (`CAP-067` §2); the app cannot tell which and says so. [changedAt] is the + * mode's own receive time; without one the time is left out. + */ +internal fun ancChangedByBudsLine(changedAt: Long?): String = + "Changed by the Buds" + (formatUpdatedAt(changedAt)?.let { " at $it" } ?: "") + " (a press-and-hold on a bud, or the Buds' own change)." + /** F-3 (the maintainer's wording, chat 2026-10-01): the (i) line while the shown mode is not confirmed; [changedAt] is when the cut-off change was sent. */ internal fun ancUnconfirmedLine(changedAt: Long): String = "Not confirmed: the answer to the change" + (formatUpdatedAt(changedAt)?.let { " at $it" } ?: "") + diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ConnectionScreen.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ConnectionScreen.kt index 67ee9215..b94cd4c3 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ConnectionScreen.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ConnectionScreen.kt @@ -67,6 +67,7 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.SafeModeState import io.github.tedsluis.opencontrolpixelbuds.domain.SessionLine import io.github.tedsluis.opencontrolpixelbuds.domain.SessionLossCause import io.github.tedsluis.opencontrolpixelbuds.domain.StatusCard +import io.github.tedsluis.opencontrolpixelbuds.domain.WornReading import io.github.tedsluis.opencontrolpixelbuds.domain.statusCard /** @@ -104,6 +105,8 @@ fun ConnectionScreen( safeMode: SafeModeState? = null, batteryRefreshError: BudsError? = null, sessionSince: Long? = null, + /** DECISIONS.md ADR-059 (`ai-sessions/0082` item 3): the "probably worn" reading, a line on the battery card. */ + wornReading: WornReading = WornReading.NotRead, ) { Surface(modifier = modifier.fillMaxSize()) { Column( @@ -145,7 +148,7 @@ fun ConnectionScreen( if (connectionState is ConnectionState.Ready) { safeMode?.let { SafeModeCard(it) } // `ai-sessions/0057`: the firmware line (formerly its own card) is in the battery card's (i) details. - BatteryCard(batteryStatus, batteryStatusUpdatedAt, caseBatteryError, batteryRefreshError, deviceInfo, onRefreshBattery, sessionSince) + BatteryCard(batteryStatus, batteryStatusUpdatedAt, caseBatteryError, batteryRefreshError, deviceInfo, onRefreshBattery, sessionSince, wornReading) } } } @@ -333,22 +336,54 @@ internal const val BATTERY_EXPLANATION: String = /** * The battery card's (i) lines (`ai-sessions/0057` D-7): the explanation, then **the same lines the card showed before** — [budLine] for Left and Right, - * [caseLine] — with every time, "last seen" and "last connection", then [CASE_REFRESH_NOTE] and the firmware. + * [caseLine] — with every time, "last seen" and "last connection", then [CASE_REFRESH_NOTE], the worn line with its explanation (ADR-059, + * `ai-sessions/0082`) and the firmware. */ internal fun batteryDetailLines( status: BatteryStatus, batteryStatusUpdatedAt: Long?, deviceInfo: DeviceInfo?, sessionSince: Long? = null, + wornReading: WornReading = WornReading.NotRead, ): List = listOfNotNull( BATTERY_EXPLANATION, budLine("Left", status.left, status.leftCharging, batteryStatusUpdatedAt), budLine("Right", status.right, status.rightCharging, batteryStatusUpdatedAt), caseLine(status.case, batteryStatusUpdatedAt, caseFromLastConnection(status.case, batteryStatusUpdatedAt, sessionSince)), CASE_REFRESH_NOTE, + wornLine(wornReading), + WORN_EXPLANATION, firmwareLine(deviceInfo), ) +/** + * The worn line (DECISIONS.md ADR-059, the maintainer's texts in chat 2026-10-09): one of six readings, the time of the Buds' last report where there is one. + * "—" is [WornReading.NotRead]'s text on the (i); on the card it is [NotReadValue] with its screen-reader description. + */ +internal fun wornLine(reading: WornReading): String = when (reading) { + WornReading.NotRead -> "Worn: $NOT_READ_VALUE" + WornReading.InEarDetectionOff -> WORN_IN_EAR_OFF_TEXT + WornReading.InEarDetectionNotRead -> WORN_IN_EAR_NOT_READ_TEXT + WornReading.BothInCase -> WORN_BOTH_IN_CASE_TEXT + is WornReading.NotWorn -> "Not worn" + checkedSuffix(reading.checkedAtMillis) + is WornReading.ProbablyWorn -> "Probably worn" + checkedSuffix(reading.checkedAtMillis) +} + +private fun checkedSuffix(checkedAtMillis: Long?): String = formatUpdatedAt(checkedAtMillis)?.let { " (checked $it)" } ?: "" + +internal const val WORN_IN_EAR_OFF_TEXT: String = "Worn: unknown — in-ear detection is off" +internal const val WORN_IN_EAR_NOT_READ_TEXT: String = "Worn: unknown — the in-ear detection setting was not read" +internal const val WORN_BOTH_IN_CASE_TEXT: String = "Both buds in the case" + +/** + * ADR-059's (i) explanation (the maintainer's text in chat 2026-10-09): where the reading comes from and its two known limits — the byte says "at least one + * bud", never which, and `CAP-064`'s ≈ 28 s; ADR-049 item 3 stays 🟡 and this text says so in plain words. + */ +internal const val WORN_EXPLANATION: String = + "Worn: from the Buds' last noise-control report. 'Probably worn' = the Buds allow changing noise control — on film that always meant at least one " + + "bud in an ear, but the Buds do not say which bud, and once (CAP-064) they reported it for about half a minute with both buds on a table. " + + "'Not worn' = the Buds refuse a change — in 56 filmed samples no bud was in an ear. With in-ear detection off the report says nothing about wearing." + /** * A68-APP-02 (`ai-sessions/0069`): the Case value was reported before this connection opened. `false` while [sessionSince] is unknown (nothing to compare * with), so a caller that does not track sessions keeps the "no bud charging in the case" wording. @@ -378,11 +413,12 @@ private fun BatteryCard( deviceInfo: DeviceInfo?, onRefreshBattery: () -> Unit, sessionSince: Long? = null, + wornReading: WornReading = WornReading.NotRead, ) { val notCurrent = isNotCurrent(status.left, status.leftCharging) || isNotCurrent(status.right, status.rightCharging) || isNotCurrent(status.case) ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - CardTitle("Battery", batteryDetailLines(status, batteryStatusUpdatedAt, deviceInfo, sessionSince), notCurrent) + CardTitle("Battery", batteryDetailLines(status, batteryStatusUpdatedAt, deviceInfo, sessionSince, wornReading), notCurrent) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { BatteryColumn("Left", OpenControlIcons.Earbud, status.left, status.leftCharging, Modifier.weight(1f)) BatteryColumn("Case", OpenControlIcons.Case, status.case, null, Modifier.weight(1f)) @@ -391,6 +427,15 @@ private fun BatteryCard( if (status.case is BatteryLevel.Unavailable) { Text(caseBatteryError?.let(::caseErrorText) ?: CASE_NOT_REPORTED, style = MaterialTheme.typography.bodySmall) } + // ADR-059 (`ai-sessions/0082` item 3): the worn line under the columns — "—" with its screen-reader text while no Notify arrived this connection. + if (wornReading == WornReading.NotRead) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Worn:", style = MaterialTheme.typography.bodyMedium) + NotReadValue(style = MaterialTheme.typography.bodyMedium) + } + } else { + Text(wornLine(wornReading), style = MaterialTheme.typography.bodyMedium) + } // `ai-sessions/0052`: a Refresh that brought no new reading says so — the times in the (i) are then the old ones. batteryRefreshError?.let { Text(it.userMessage(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreen.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreen.kt index 9a8c468e..de52880f 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreen.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -36,7 +35,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.unit.dp import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeList @@ -52,7 +50,9 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.SettingsFailure * action per bud (field 7) — both writable under DECISIONS.md ADR-045 — the press-and-hold ANC-mode list (field 12, ADR-046) and the "In-ear detection" * setting (field 2, ADR-047) — `ai-sessions/0056`, the texts and placement the maintainer chose in chat 2026-09-28. **`ai-sessions/0074`** (the maintainer's * choices in chat 2026-10-06: "Gebaren eerst", "alleen labels"): "Head gestures" (field 29, ADR-052) below "Press and hold", "Multipoint" (field 11, - * ADR-053) below "In-ear detection", then "Case sounds" (fields 28 and 27, ADR-054) — labels only. + * ADR-053) below "In-ear detection" — labels only. **`ai-sessions/0082`** (the maintainer's choices in chat 2026-10-09): the "Case sounds" card (fields 28 + * and 27, ADR-054) moved to gear → Settings, and "Conversation detection" (field 22, ADR-045) moved here from Sound — its own card after "In-ear detection", + * the `0052` subtitle kept. */ @Composable fun ControlsScreen( @@ -66,8 +66,8 @@ fun ControlsScreen( modifier: Modifier = Modifier, onMultipointChanged: (Boolean) -> Unit = {}, onHeadGesturesChanged: (Boolean) -> Unit = {}, - onCaseSoundEarbudsReplacedChanged: (Boolean) -> Unit = {}, - onCaseSoundOtherAlertsChanged: (Boolean) -> Unit = {}, + /** `ai-sessions/0082` item 6: the "Conversation detection" switch (field 22, ADR-045), moved here from Sound. */ + onConversationDetectionChanged: (Boolean) -> Unit = {}, ) { val enabled = connectionState.isReady() Surface(modifier = modifier.fillMaxSize()) { @@ -102,46 +102,18 @@ fun ControlsScreen( SettingSwitchRow("In-ear detection", IN_EAR_DETECTION_SUBTITLE, settings.inEarDetection, enabled, onInEarDetectionChanged) Text(IN_EAR_DETECTION_OFF_NOTE, style = MaterialTheme.typography.bodySmall) } - SettingsCard(MULTIPOINT_LABEL, listOf("$MULTIPOINT_LABEL: ${settingTime(settings.multipoint)}"), enabled, listOf(settings.multipoint)) { - SettingSwitchRow(MULTIPOINT_LABEL, null, settings.multipoint, enabled, onMultipointChanged) - } + // `ai-sessions/0082` item 6 (the maintainer's choice in chat 2026-10-09): moved here from Sound, its own card, the subtitle of `0052` kept. SettingsCard( - CASE_SOUNDS_TITLE, - listOf( - "$CASE_SOUND_EARBUDS_REPLACED_LABEL: ${settingTime(settings.caseSoundEarbudsReplaced)}", - "$CASE_SOUND_OTHER_ALERTS_LABEL: ${settingTime(settings.caseSoundOtherAlerts)}", - ), + CONVERSATION_DETECTION_LABEL, + listOf("$CONVERSATION_DETECTION_LABEL: ${settingTime(settings.conversationDetection)}"), enabled, - listOf(settings.caseSoundEarbudsReplaced, settings.caseSoundOtherAlerts), + listOf(settings.conversationDetection), ) { - SettingSwitchRow(CASE_SOUND_EARBUDS_REPLACED_LABEL, null, settings.caseSoundEarbudsReplaced, enabled, onCaseSoundEarbudsReplacedChanged) - SettingSwitchRow(CASE_SOUND_OTHER_ALERTS_LABEL, null, settings.caseSoundOtherAlerts, enabled, onCaseSoundOtherAlertsChanged) + SettingSwitchRow(CONVERSATION_DETECTION_LABEL, CONVERSATION_DETECTION_SUBTITLE, settings.conversationDetection, enabled, onConversationDetectionChanged) + } + SettingsCard(MULTIPOINT_LABEL, listOf("$MULTIPOINT_LABEL: ${settingTime(settings.multipoint)}"), enabled, listOf(settings.multipoint)) { + SettingSwitchRow(MULTIPOINT_LABEL, null, settings.multipoint, enabled, onMultipointChanged) } - } - } -} - -/** - * One settings card. The (i) carries the dot while connected and one of [readings] is not read yet ([notRead]), or — `ai-sessions/0069` A68-APP-02 — while - * not connected and the card still shows the last connection's values, which are then dimmed and named as such in the first detail line. - */ -@Composable -private fun SettingsCard( - title: String, - detailLines: List, - enabled: Boolean, - readings: List?>, - notRead: Boolean = readings.any { it == null }, - content: @Composable () -> Unit, -) { - val fromLastConnection = settingsFromLastConnection(enabled, readings) - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - CardTitle(title, withLastConnectionLine(detailLines, fromLastConnection), (enabled && notRead) || fromLastConnection) - Column( - modifier = Modifier.alpha(if (fromLastConnection) NOT_CURRENT_ALPHA else 1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { content() } } } } @@ -214,13 +186,9 @@ internal const val MULTIPOINT_LABEL: String = "Multipoint" internal const val HEAD_GESTURES_TITLE: String = "Head gestures" internal const val HEAD_GESTURES_LABEL: String = "Use head gestures" -/** - * `ai-sessions/0074`: the "Case sounds" card — ADR-054's labels, chosen by the maintainer in chat 2026-10-06: 28 = "Earbuds replaced" (the official settings - * list's wording; its switch reads "Bud return", PROTOCOL.md §4.5.8), 27 = "Other alerts"; no note. - */ -internal const val CASE_SOUNDS_TITLE: String = "Case sounds" -internal const val CASE_SOUND_EARBUDS_REPLACED_LABEL: String = "Earbuds replaced" -internal const val CASE_SOUND_OTHER_ALERTS_LABEL: String = "Other alerts" +/** `ai-sessions/0052`'s label and subtitle of `qhr` field 22 (ADR-045), moved from the Sound tab to this one in `ai-sessions/0082` (item 6) unchanged. */ +internal const val CONVERSATION_DETECTION_LABEL: String = "Conversation detection" +internal const val CONVERSATION_DETECTION_SUBTITLE: String = "Switch from noise cancellation to transparency when you talk" internal const val IN_EAR_DETECTION_SUBTITLE: String = "Pauses audio when you take a bud out and resumes it when you put it back." diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreen.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreen.kt index f181cbeb..fa93be75 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreen.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreen.kt @@ -57,7 +57,7 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.EqPreset /** * The "Sound" tab (was "EQ"; ARCHITECTURE.md §2.4/§6): 5 band sliders within the confirmed * ±6.0 range (DECISIONS.md ADR-016) plus the confirmed presets, and — `ai-sessions/0052`, ADR-045 — - * below them volume balance, mono audio and conversation detection. No "Save as + * below them volume balance and mono audio (conversation detection moved to Controls in `ai-sessions/0082`, item 6). No "Save as * preset" affordance — PROTOCOL.md §4.2's field-16-vs-18 semantics remain * 🟡 HYPOTHESIS (DECISIONS.md ADR-020's own scope note), so this session does * not ship a UI action that specifically depends on that distinction being @@ -99,7 +99,6 @@ fun EqScreen( settingsError: SettingsFailure? = null, onVolumeBalanceChanged: (Int) -> Unit = {}, onMonoAudioChanged: (Boolean) -> Unit = {}, - onConversationDetectionChanged: (Boolean) -> Unit = {}, onVolumeEqChanged: (Boolean) -> Unit = {}, ) { val enabled = connectionState.isReady() @@ -142,7 +141,7 @@ fun EqScreen( } // `ai-sessions/0052` (DECISIONS.md ADR-045): below the EQ, as the prompt and the maintainer's layout choice put them. item { - val readings = listOf(settings.volumeBalance, settings.monoAudio, settings.conversationDetection) + val readings = listOf(settings.volumeBalance, settings.monoAudio) // conversation detection is on Controls since `ai-sessions/0082` val fromLastConnection = settingsFromLastConnection(enabled, readings) ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -158,13 +157,6 @@ fun EqScreen( ) { BalanceSlider(settings.volumeBalance, enabled, onVolumeBalanceChanged) SettingSwitchRow("Mono audio", "Same sound in both ears", settings.monoAudio, enabled, onMonoAudioChanged) - SettingSwitchRow( - "Conversation detection", - "Switch from noise cancellation to transparency when you talk", - settings.conversationDetection, - enabled, - onConversationDetectionChanged, - ) } } } @@ -191,7 +183,6 @@ internal const val EQ_NOT_READ_DETAIL: String = "The EQ is read from the Buds at internal fun soundSettingsDetailLines(settings: BudsSettings): List = listOf( "Balance: " + (settings.volumeBalance?.let { "${balanceText(it.value)} · ${settingTime(it)}" } ?: SETTING_NOT_READ), "Mono audio: ${settingTime(settings.monoAudio)}", - "Conversation detection: ${settingTime(settings.conversationDetection)}", ) /** diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt index 2074e6ca..37f7bb4f 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/OpenControlNavHost.kt @@ -134,7 +134,7 @@ data class OpenControlActions( val onMultipointChanged: (Boolean) -> Unit = {}, /** The "Use head gestures" switch (ADR-052, `ai-sessions/0074`). */ val onHeadGesturesChanged: (Boolean) -> Unit = {}, - /** The two "Case sounds" switches (ADR-054, `ai-sessions/0074`): "Earbuds replaced" (field 28) and "Other alerts" (field 27). */ + /** The two "Case sounds" switches (ADR-054, `ai-sessions/0074`): "Earbuds replaced" (field 28) and "Other alerts" (field 27) — on gear → Settings since `ai-sessions/0082`. */ val onCaseSoundEarbudsReplacedChanged: (Boolean) -> Unit = {}, val onCaseSoundOtherAlertsChanged: (Boolean) -> Unit = {}, /** The "Volume EQ" switch on Sound (ADR-055, `ai-sessions/0074`). */ @@ -195,14 +195,20 @@ data class OpenControlUiState( val ancAvailabilityUpdatedAt: Long? = null, /** When the last ANC change's answer was cut off — the shown mode is not confirmed until the next `Notify` (`ai-sessions/0062` F-3); `null` = confirmed. */ val ancModeUnconfirmedAt: Long? = null, + /** Why the shown ANC mode is what it is (`ai-sessions/0082` item 1): the Noise control (i) says "changed by the Buds" for a `Notify` the app did not provoke. */ + val ancModeCause: io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause? = null, /** Why the last *Refresh battery* brought no new reading (`null` = it did / none yet), `ai-sessions/0052`. */ val batteryRefreshError: BudsError? = null, /** Why the Case level could not be requested this connection (`null` = requested / not yet), ADR-043. */ val caseBatteryError: BudsError? = null, /** Non-null while the app is in read-only Safe Mode (ARCHITECTURE.md §8.1, ADR-042). */ val safeMode: io.github.tedsluis.opencontrolpixelbuds.domain.SafeModeState? = null, - /** What the Buds announced at connect (firmware); `null` = nothing yet. */ + /** What the Buds announced at connect (firmware); `null` = nothing yet; since ADR-058 also the serial numbers once read. */ val deviceInfo: DeviceInfo? = null, + /** Why the serial numbers were not read this connection (ADR-058, `ai-sessions/0082`); `null` = read / not attempted. */ + val serialsError: BudsError? = null, + /** The "probably worn" reading of the battery card (ADR-059, `ai-sessions/0082`). */ + val wornReading: io.github.tedsluis.opencontrolpixelbuds.domain.WornReading = io.github.tedsluis.opencontrolpixelbuds.domain.WornReading.NotRead, /** The Find My Buds ring this app started and has not seen stopped (`null` = none) — kept across Disconnect (I-6). */ val ringing: io.github.tedsluis.opencontrolpixelbuds.domain.RingNotice? = null, val eqProfile: EqBandGains?, @@ -337,6 +343,12 @@ fun OpenControlNavHost(state: OpenControlUiState, actions: OpenControlActions) { deviceInfo = state.deviceInfo, onOpenUrl = actions.onOpenUrl, onUseDifferentBuds = actions.onUseDifferentBuds, + serialsError = state.serialsError, + connectionState = state.connectionState, + settings = state.settings, + settingsError = state.settingsError, + onCaseSoundEarbudsReplacedChanged = actions.onCaseSoundEarbudsReplacedChanged, + onCaseSoundOtherAlertsChanged = actions.onCaseSoundOtherAlertsChanged, modifier = Modifier.padding(padding), ) } else { @@ -370,6 +382,7 @@ private fun TabContent(route: String, state: OpenControlUiState, actions: OpenCo sessionSince = state.sessionSince, safeMode = state.safeMode, batteryRefreshError = state.batteryRefreshError, + wornReading = state.wornReading, onRequestEnableBluetooth = actions.onRequestEnableBluetooth, onPair = actions.onPair, onRequestPermissions = actions.onRequestPermissions, @@ -386,6 +399,7 @@ private fun TabContent(route: String, state: OpenControlUiState, actions: OpenCo ancAvailabilityUpdatedAt = state.ancAvailabilityUpdatedAt, ancModeUnconfirmedAt = state.ancModeUnconfirmedAt, sessionSince = state.sessionSince, + ancModeCause = state.ancModeCause, onAncModeSelected = actions.onAncModeSelected, onRefreshAncMode = actions.onRefreshAncMode, onRequestAddAncTile = actions.onRequestAddAncTile, @@ -402,7 +416,6 @@ private fun TabContent(route: String, state: OpenControlUiState, actions: OpenCo settingsError = state.settingsError, onVolumeBalanceChanged = actions.onVolumeBalanceChanged, onMonoAudioChanged = actions.onMonoAudioChanged, - onConversationDetectionChanged = actions.onConversationDetectionChanged, onVolumeEqChanged = actions.onVolumeEqChanged, ) Routes.CONTROLS -> ControlsScreen( @@ -415,8 +428,7 @@ private fun TabContent(route: String, state: OpenControlUiState, actions: OpenCo onInEarDetectionChanged = actions.onInEarDetectionChanged, onMultipointChanged = actions.onMultipointChanged, onHeadGesturesChanged = actions.onHeadGesturesChanged, - onCaseSoundEarbudsReplacedChanged = actions.onCaseSoundEarbudsReplacedChanged, - onCaseSoundOtherAlertsChanged = actions.onCaseSoundOtherAlertsChanged, + onConversationDetectionChanged = actions.onConversationDetectionChanged, ) Routes.FIND_MY_BUDS -> FindMyBudsScreen( connectionState = state.connectionState, diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt index 348cdf67..5c594c9c 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenu.kt @@ -50,8 +50,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp +import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError +import io.github.tedsluis.opencontrolpixelbuds.domain.BudsSettings +import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.DarkMode import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceInfo +import io.github.tedsluis.opencontrolpixelbuds.domain.SettingsFailure import io.github.tedsluis.opencontrolpixelbuds.domain.UnidentifiedFrame /** @@ -70,8 +74,9 @@ internal enum class SettingsTab(val label: String) { SETTINGS("Settings"), DEBUG /** * The settings menu (`ai-sessions/0062` F-4, the maintainer's design in chats 2026-10-01): a full-screen destination reached from the top app bar's gear, outside - * the five bottom tabs (ARCHITECTURE.md §2.4), with three tabs — **Settings** (dark mode), **Debug** (today's [DebugScreen], unchanged) and **Info** (the app's - * build and the Buds' firmware). The selected tab survives a rotation; back (the top bar's arrow or the system's) returns to the tab the menu was opened from. + * the five bottom tabs (ARCHITECTURE.md §2.4), with three tabs — **Settings** (dark mode; since `ai-sessions/0082` item 5 also the "Case sounds" card, moved + * from Controls; "Use different Buds"), **Debug** (today's [DebugScreen], unchanged) and **Info** (the app's build, the Buds' firmware and — ADR-058 — their + * serial numbers). The selected tab survives a rotation; back (the top bar's arrow or the system's) returns to the tab the menu was opened from. */ @Composable fun SettingsMenuScreen( @@ -88,6 +93,14 @@ fun SettingsMenuScreen( modifier: Modifier = Modifier, /** "Use different Buds" (`ai-sessions/0069`, A68-APP-05): `:app` disconnects and removes this app's own pairing association. */ onUseDifferentBuds: () -> Unit = {}, + /** Why the serial numbers were not read on this connection (DECISIONS.md ADR-058, `ai-sessions/0082`); `null` = read, or not attempted yet. */ + serialsError: BudsError? = null, + /** `ai-sessions/0082` item 5: the "Case sounds" card (ADR-054) on the Settings tab — the Buds' values, the write callbacks, as on Controls before. */ + connectionState: ConnectionState = ConnectionState.Disconnected, + settings: BudsSettings = BudsSettings(), + settingsError: SettingsFailure? = null, + onCaseSoundEarbudsReplacedChanged: (Boolean) -> Unit = {}, + onCaseSoundOtherAlertsChanged: (Boolean) -> Unit = {}, ) { var selected by rememberSaveable { mutableIntStateOf(SettingsTab.SETTINGS.ordinal) } Column(modifier = modifier.fillMaxSize()) { @@ -101,14 +114,16 @@ fun SettingsMenuScreen( } } when (SettingsTab.entries[selected]) { - SettingsTab.SETTINGS -> DarkModeSettings(darkMode, onDarkModeChanged, onUseDifferentBuds) + SettingsTab.SETTINGS -> SettingsTabContent( + darkMode, onDarkModeChanged, onUseDifferentBuds, connectionState, settings, settingsError, onCaseSoundEarbudsReplacedChanged, onCaseSoundOtherAlertsChanged, + ) SettingsTab.DEBUG -> DebugScreen( debugModeEnabled = debugModeEnabled, onDebugModeChanged = onDebugModeChanged, unidentifiedFrames = unidentifiedFrames, onExportLog = onExportLog, ) - SettingsTab.INFO -> InfoTab(appBuild, deviceInfo, onOpenUrl) + SettingsTab.INFO -> InfoTab(appBuild, deviceInfo, onOpenUrl, serialsError) } } } @@ -120,9 +135,23 @@ internal fun darkModeLabel(mode: DarkMode): String = when (mode) { DarkMode.OFF -> "Off" } -/** F-6: one radio group; a choice is stored and applied at once (the theme follows the stored value — no restart). */ +/** + * The Settings tab — F-6: one radio group for dark mode; a choice is stored and applied at once (the theme follows the stored value — no restart). **Since + * `ai-sessions/0082` (item 5, the maintainer's order in chat 2026-10-09: Dark mode · Case sounds · Use different Buds):** the "Case sounds" card of ADR-054 + * between them — the same [SettingsCard]/[SettingSwitchRow] as on Controls, the same (i) lines, plus the note that the setting lives on the case. + */ @Composable -private fun DarkModeSettings(darkMode: DarkMode, onDarkModeChanged: (DarkMode) -> Unit, onUseDifferentBuds: () -> Unit) { +private fun SettingsTabContent( + darkMode: DarkMode, + onDarkModeChanged: (DarkMode) -> Unit, + onUseDifferentBuds: () -> Unit, + connectionState: ConnectionState, + settings: BudsSettings, + settingsError: SettingsFailure?, + onCaseSoundEarbudsReplacedChanged: (Boolean) -> Unit, + onCaseSoundOtherAlertsChanged: (Boolean) -> Unit, +) { + val enabled = connectionState.isReady() Surface(modifier = Modifier.fillMaxSize()) { // Scrollable since `ai-sessions/0069`: with "Use different Buds" below the radio group the tab no longer fits every screen or font size. Column( @@ -144,6 +173,21 @@ private fun DarkModeSettings(darkMode: DarkMode, onDarkModeChanged: (DarkMode) - } } } + // `ai-sessions/0082` item 5: the Case sounds card, moved from Controls (ADR-054's labels of `ai-sessions/0074`); a failed write says so here too. + if (enabled) SettingsFailureNotice(settingsError) + SettingsCard( + CASE_SOUNDS_TITLE, + listOf( + "$CASE_SOUND_EARBUDS_REPLACED_LABEL: ${settingTime(settings.caseSoundEarbudsReplaced)}", + "$CASE_SOUND_OTHER_ALERTS_LABEL: ${settingTime(settings.caseSoundOtherAlerts)}", + CASE_SOUNDS_NOTE, + ), + enabled, + listOf(settings.caseSoundEarbudsReplaced, settings.caseSoundOtherAlerts), + ) { + SettingSwitchRow(CASE_SOUND_EARBUDS_REPLACED_LABEL, null, settings.caseSoundEarbudsReplaced, enabled, onCaseSoundEarbudsReplacedChanged) + SettingSwitchRow(CASE_SOUND_OTHER_ALERTS_LABEL, null, settings.caseSoundOtherAlerts, enabled, onCaseSoundOtherAlertsChanged) + } // `ai-sessions/0069` A68-APP-05 (the maintainer's wording, chat 2026-10-03): the only way to control other Buds used to be clearing the app's data. Text(USE_DIFFERENT_BUDS_TITLE, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 16.dp)) Text(USE_DIFFERENT_BUDS_TEXT, style = MaterialTheme.typography.bodyMedium) @@ -154,6 +198,16 @@ private fun DarkModeSettings(darkMode: DarkMode, onDarkModeChanged: (DarkMode) - internal const val USE_DIFFERENT_BUDS_TITLE: String = "Use different Buds" +/** + * `ai-sessions/0074`: the "Case sounds" card — ADR-054's labels, chosen by the maintainer in chat 2026-10-06: 28 = "Earbuds replaced" (the official settings + * list's wording; its switch reads "Bud return", PROTOCOL.md §4.5.8), 27 = "Other alerts"; no note under the switches. On gear → Settings since + * `ai-sessions/0082` (item 5); [CASE_SOUNDS_NOTE] is the (i)'s last line (ADR-054: the setting is read at Connect; it lives on the case). + */ +internal const val CASE_SOUNDS_TITLE: String = "Case sounds" +internal const val CASE_SOUND_EARBUDS_REPLACED_LABEL: String = "Earbuds replaced" +internal const val CASE_SOUND_OTHER_ALERTS_LABEL: String = "Other alerts" +internal const val CASE_SOUNDS_NOTE: String = "These settings live on the case and are read when the app connects." + internal const val USE_DIFFERENT_BUDS_TEXT: String = "Forgets which Buds this app controls (the Bluetooth pairing in Android stays). You then pick the Buds again with Pair a device." @@ -187,6 +241,38 @@ internal fun firmwareInfoLines(deviceInfo: DeviceInfo?): List { return listOf(heading) + entries + listOfNotNull(deviceInfo.maestroChannel?.let { "Control channel: $it" }) } +/** + * ADR-058 (`ai-sessions/0082`): which part a `GetHardwareInfo` serial (field 7, by its field number) belongs to — the official app's own reading of the answer by + * position (`gaa.java:45–96` → `fwg.java:182–215`; the strings' EC/DR/DL marks agree): 1 = Case, 2 = Right bud, 3 = Left bud. Any other index is shown by its + * number, never guessed. Not the order of the firmware announcement ([firmwareComponentLabel]: 1 = Case, 2 = Left, 3 = Right). + */ +internal fun serialComponentLabel(index: Int): String = when (index) { + 1 -> "Case" + 2 -> "Right bud" + 3 -> "Left bud" + else -> "Part $index" +} + +/** Under the serial lines: where the labels come from (ADR-058 — the official app's reading, not a statement by the Buds). */ +internal const val SERIALS_NOTE: String = "Labelled by position as the official app labels them: the Buds list the Case, the Right bud, then the Left bud." + +/** + * ADR-058: the serial numbers of the Info tab — the heading with the answer's receive time and one line per string in field order; before the answer + * "Not read from the Buds yet"; after a failed read the reason. Nothing while nothing is announced (the firmware block already says "not connected yet"). + */ +internal fun serialInfoLines(deviceInfo: DeviceInfo?, serialsError: BudsError?): List { + if (deviceInfo == null) return emptyList() + val serials = deviceInfo.serials + return when { + serials.isNotEmpty() -> { + val heading = "Serial numbers (from the Buds" + (formatUpdatedAt(deviceInfo.serialsReadAtMillis)?.let { ", $it" } ?: "") + "):" + listOf(heading) + serials.sortedBy { it.index }.map { "${serialComponentLabel(it.index)}: ${it.serial}" } + SERIALS_NOTE + } + serialsError != null -> listOf("Serial numbers: not read — ${serialsError.userMessage()}") + else -> listOf("Serial numbers: $SETTING_NOT_READ") + } +} + /** * `ai-sessions/0064` F-6 (the maintainer's request and choice "Links + bundled licence", chat 2026-10-01; DECISIONS.md ADR-050): the project's links. **Since * `ai-sessions/0066`** (the maintainer's request, chat 2026-10-02) the licence is only read in the app ("Read the licence") — no link to `LICENSE`. Fixed @@ -212,7 +298,7 @@ internal const val TRADEMARK_NOTICE: String = internal const val LINKS_NOTE: String = "Links open in your browser; this app itself has no internet access." @Composable -private fun InfoTab(appBuild: AppBuildInfo, deviceInfo: DeviceInfo?, onOpenUrl: (String) -> Unit) { +private fun InfoTab(appBuild: AppBuildInfo, deviceInfo: DeviceInfo?, onOpenUrl: (String) -> Unit, serialsError: BudsError? = null) { var showLicence by rememberSaveable { mutableStateOf(false) } Surface(modifier = Modifier.fillMaxSize()) { Column( @@ -231,6 +317,10 @@ private fun InfoTab(appBuild: AppBuildInfo, deviceInfo: DeviceInfo?, onOpenUrl: Text(LINKS_NOTE, style = MaterialTheme.typography.bodySmall) Text("The Buds", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) firmwareInfoLines(deviceInfo).forEach { Text(it, style = MaterialTheme.typography.bodyMedium) } + // ADR-058 (`ai-sessions/0082`): the serial numbers under the firmware; the note in the smaller style. + serialInfoLines(deviceInfo, serialsError).forEach { + Text(it, style = if (it == SERIALS_NOTE) MaterialTheme.typography.bodySmall else MaterialTheme.typography.bodyMedium) + } } } if (showLicence) LicenceDialog(onClose = { showLicence = false }) diff --git a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsUi.kt b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsUi.kt index 801d544b..2915632d 100644 --- a/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsUi.kt +++ b/android/ui/src/main/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsUi.kt @@ -24,12 +24,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle @@ -105,6 +107,32 @@ internal fun SettingsFailureNotice(failure: SettingsFailure?) { Text(settingsFailureText(failure), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error) } +/** + * One settings card (Controls since `ai-sessions/0057`; gear → Settings since `ai-sessions/0082` item 5). The (i) carries the dot while connected and one of + * [readings] is not read yet ([notRead]), or — `ai-sessions/0069` A68-APP-02 — while not connected and the card still shows the last connection's values, + * which are then dimmed and named as such in the first detail line. + */ +@Composable +internal fun SettingsCard( + title: String, + detailLines: List, + enabled: Boolean, + readings: List?>, + notRead: Boolean = readings.any { it == null }, + content: @Composable () -> Unit, +) { + val fromLastConnection = settingsFromLastConnection(enabled, readings) + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + CardTitle(title, withLastConnectionLine(detailLines, fromLastConnection), (enabled && notRead) || fromLastConnection) + Column( + modifier = Modifier.alpha(if (fromLastConnection) NOT_CURRENT_ALPHA else 1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { content() } + } + } +} + /** * One on/off setting: title and an explanatory subtitle; the switch shows the Buds' value; a tap asks for the other value and the switch moves only once the * Buds acknowledged it. `ai-sessions/0056` U-1: while the value is not read no tap can write it blind (AGENTS.md §5). `ai-sessions/0057` D-7: the "read / diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt index 9637eed5..9f8c6a72 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/AncScreenTest.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import io.github.tedsluis.opencontrolpixelbuds.domain.AncAvailability import io.github.tedsluis.opencontrolpixelbuds.domain.AncMode +import io.github.tedsluis.opencontrolpixelbuds.domain.AncModeCause import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import org.junit.Assert.assertEquals @@ -53,6 +54,7 @@ class AncScreenTest { unconfirmedAt: Long? = null, connectionState: ConnectionState = ConnectionState.Ready, sessionSince: Long? = at - 1_000, // the connection opened a second before the Buds reported the mode + cause: AncModeCause? = null, ) = compose.setContent { OpenControlTheme(darkTheme = false) { AncScreen( @@ -67,6 +69,7 @@ class AncScreenTest { ancAvailabilityUpdatedAt = at, ancModeUnconfirmedAt = unconfirmedAt, sessionSince = sessionSince, + ancModeCause = cause, ) } } @@ -133,6 +136,41 @@ class AncScreenTest { compose.onNodeWithText("ANC mode: OFF — from the last connection (updated ${formatUpdatedAt(at)})").assertExists() } + // ---- `ai-sessions/0082` item 1: "Changed by the Buds" in the (i), only for a Notify the app did not provoke ---- + + @Test + fun `a mode the Buds changed says so in the (i), with the mode's time`() { + show(cause = AncModeCause.CHANGED_BY_BUDS) + compose.onNodeWithContentDescription("Noise control: $DETAILS_DESCRIPTION").performClick() // the (i) is plain: the mode is current + compose.onNodeWithText("Changed by the Buds at ${formatUpdatedAt(at)} (a press-and-hold on a bud, or the Buds' own change).").assertExists() + compose.onNodeWithText("ANC mode: OFF (updated ${formatUpdatedAt(at)})").assertExists() + } + + @Test + fun `a mode read from the Buds has no changed-by-the-Buds line`() { + show(cause = AncModeCause.READ) + compose.onNodeWithContentDescription("Noise control: $DETAILS_DESCRIPTION").performClick() + compose.onNodeWithText("ANC mode: OFF (updated ${formatUpdatedAt(at)})").assertExists() + compose.onNodeWithText("Changed by the Buds", substring = true).assertDoesNotExist() + } + + @Test + fun `a mode set by this app has no changed-by-the-Buds line`() { + show(cause = AncModeCause.SET_BY_APP) + compose.onNodeWithContentDescription("Noise control: $DETAILS_DESCRIPTION").performClick() + compose.onNodeWithText("ANC mode: OFF (updated ${formatUpdatedAt(at)})").assertExists() + compose.onNodeWithText("Changed by the Buds", substring = true).assertDoesNotExist() + } + + @Test + fun `the changed-by-the-Buds line leaves the time out when there is none, and needs a mode`() { + assertEquals("Changed by the Buds (a press-and-hold on a bud, or the Buds' own change).", ancChangedByBudsLine(null)) + assertEquals( + listOf("ANC mode: unknown", "Connection: Ready", MESSAGE_STREAM_HINT_TEXT), + ancDetailLines(ConnectionState.Ready, null, null, notAllowed = false, checkedAt = null, cause = AncModeCause.CHANGED_BY_BUDS), + ) + } + @Test fun `the mode line of a current mode is unchanged`() { assertEquals("ANC mode: OFF (updated ${formatUpdatedAt(at)})", ancModeLine(AncMode.OFF, at)) diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/BatteryCardTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/BatteryCardTest.kt index 88735551..f47ce8ac 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/BatteryCardTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/BatteryCardTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import io.github.tedsluis.opencontrolpixelbuds.domain.AndroidLink @@ -37,6 +38,7 @@ import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceStatus import io.github.tedsluis.opencontrolpixelbuds.domain.PermissionState import io.github.tedsluis.opencontrolpixelbuds.domain.PermissionStatus +import io.github.tedsluis.opencontrolpixelbuds.domain.WornReading import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -57,9 +59,10 @@ class BatteryCardTest { private val at = 1_727_600_000_000L - private fun show(status: BatteryStatus) = compose.setContent { + private fun show(status: BatteryStatus, wornReading: WornReading = WornReading.NotRead) = compose.setContent { OpenControlTheme(darkTheme = false) { ConnectionScreen( + wornReading = wornReading, connectionState = ConnectionState.Ready, deviceStatus = DeviceStatus.ControlledByApp, androidLink = AndroidLink.CONNECTED, @@ -128,6 +131,47 @@ class BatteryCardTest { assertEquals("Case: 84% (updated $time)", caseLine(case.copy(isStale = false), null, fromLastConnection = true)) } + // ---- ADR-059 (`ai-sessions/0082` item 3): the worn line on the battery card and its (i) explanation (the maintainer's texts, chat 2026-10-09) ---- + + @Test + fun `before a Notify the worn line is a dash with the screen-reader text`() { + show(BatteryStatus()) + compose.onNodeWithText("Worn:").assertExists() + compose.onNode(hasText(NOT_READ_VALUE) and SemanticsMatcher.expectValue(SemanticsProperties.ContentDescription, listOf(NOT_READ_DESCRIPTION))).assertExists() + compose.onNodeWithContentDescription("Battery: $DETAILS_DESCRIPTION").performClick() + compose.onNodeWithText("Worn: —").assertExists() + compose.onNodeWithText(WORN_EXPLANATION).assertExists() + } + + @Test + fun `probably worn and not worn carry the report's time, on the card and in the (i)`() { + show(BatteryStatus(), WornReading.ProbablyWorn(at)) + compose.onNodeWithText("Probably worn (checked ${formatUpdatedAt(at)})").assertExists() + compose.onNodeWithContentDescription("Battery: $DETAILS_DESCRIPTION").performClick() + compose.onAllNodesWithText("Probably worn (checked ${formatUpdatedAt(at)})").assertCountEquals(2) + compose.onNodeWithText( + "Worn: from the Buds' last noise-control report. 'Probably worn' = the Buds allow changing noise control — on film that always meant at least one " + + "bud in an ear, but the Buds do not say which bud, and once (CAP-064) they reported it for about half a minute with both buds on a table. " + + "'Not worn' = the Buds refuse a change — in 56 filmed samples no bud was in an ear. With in-ear detection off the report says nothing about wearing.", + ).assertExists() + assertEquals("Not worn (checked ${formatUpdatedAt(at)})", wornLine(WornReading.NotWorn(at))) + assertEquals("Not worn", wornLine(WornReading.NotWorn(null))) + } + + @Test + fun `the other readings are the literal texts`() { + show(BatteryStatus(), WornReading.BothInCase) + compose.onNodeWithText("Both buds in the case").assertExists() + assertEquals("Worn: unknown — in-ear detection is off", wornLine(WornReading.InEarDetectionOff)) + assertEquals("Worn: unknown — the in-ear detection setting was not read", wornLine(WornReading.InEarDetectionNotRead)) + assertEquals("Worn: —", wornLine(WornReading.NotRead)) + // Never a bud named as worn: no text of the six says "Left" or "Right" worn. + for (reading in listOf(WornReading.NotRead, WornReading.InEarDetectionOff, WornReading.InEarDetectionNotRead, WornReading.BothInCase, WornReading.NotWorn(at), WornReading.ProbablyWorn(at))) { + val line = wornLine(reading) + assertEquals(line, false, "Left" in line || "Right" in line) + } + } + @Test fun `the (i) dialog shows exactly the helper lines with their times`() { val status = BatteryStatus( diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreenTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreenTest.kt index 226701ee..d0ef4218 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreenTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/ControlsScreenTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.hasAnySibling import androidx.compose.ui.test.hasText import androidx.compose.ui.test.isToggleable @@ -65,8 +66,7 @@ class ControlsScreenTest { inEarDetection = SettingReading(true, at), multipoint = SettingReading(true, at), headGestures = SettingReading(false, at), - caseSoundEarbudsReplaced = SettingReading(true, at), - caseSoundOtherAlerts = SettingReading(false, at, changedByApp = true), + conversationDetection = SettingReading(true, at), ) /** The switch taps the screen reported, as "=" (`ai-sessions/0074`). */ @@ -84,8 +84,7 @@ class ControlsScreenTest { onInEarDetectionChanged = {}, onMultipointChanged = { taps += "multipoint=$it" }, onHeadGesturesChanged = { taps += "headGestures=$it" }, - onCaseSoundEarbudsReplacedChanged = { taps += "earbudsReplaced=$it" }, - onCaseSoundOtherAlertsChanged = { taps += "otherAlerts=$it" }, + onConversationDetectionChanged = { taps += "conversationDetection=$it" }, ) } } @@ -93,7 +92,7 @@ class ControlsScreenTest { @Test fun `connected and read - every card has a plain (i) with the read time`() { show(ConnectionState.Ready, read) - for (title in listOf("Touch controls", "Press and hold", "Head gestures", "In-ear detection", "Multipoint", "Case sounds")) { + for (title in listOf("Touch controls", "Press and hold", "Head gestures", "In-ear detection", "Conversation detection", "Multipoint")) { compose.onNodeWithContentDescription("$title: $DETAILS_DESCRIPTION").assertExists() } compose.onNodeWithContentDescription("Touch controls: $DETAILS_DESCRIPTION").performClick() @@ -121,18 +120,23 @@ class ControlsScreenTest { } @Test - fun `ai-sessions 0074 - the Case sounds card - two switches, each its own time line and its own callback`() { + fun `ai-sessions 0082 - Conversation detection moved here - own card after In-ear detection, before Multipoint, subtitle kept, a tap asks for the other value`() { show(ConnectionState.Ready, read) - compose.onNodeWithContentDescription("Case sounds: $DETAILS_DESCRIPTION").performScrollTo().performClick() - compose.onNodeWithText("Earbuds replaced: read ${formatUpdatedAt(at)}").assertExists() - compose.onNodeWithText("Other alerts: changed ${formatUpdatedAt(at)}").assertExists() + compose.onNodeWithContentDescription("Conversation detection: $DETAILS_DESCRIPTION").performScrollTo().performClick() + compose.onNodeWithText("Conversation detection: read ${formatUpdatedAt(at)}").assertExists() compose.onNodeWithText("Close").performClick() - // Both switches share the card with both labels (siblings), so they are taken in layout order: Earbuds replaced first, then Other alerts. - val switches = compose.onAllNodes(isToggleable() and hasAnySibling(hasText(CASE_SOUND_EARBUDS_REPLACED_LABEL)) and hasAnySibling(hasText(CASE_SOUND_OTHER_ALERTS_LABEL))) - switches.assertCountEquals(2) - switches[0].performScrollTo().assertIsOn().performClick() - switches[1].performScrollTo().assertIsOff().performClick() - assertEquals(listOf("earbudsReplaced=false", "otherAlerts=true"), taps) + compose.onNodeWithText(CONVERSATION_DETECTION_SUBTITLE).performScrollTo().assertExists() + compose.onNodeWithText("Switch from noise cancellation to transparency when you talk").assertExists() // the literal `0052` wording + compose.onNode(isToggleable() and hasAnySibling(hasText(CONVERSATION_DETECTION_LABEL))).performScrollTo().assertIsOn().performClick() + assertEquals(listOf("conversationDetection=false"), taps) + // Order on the tab (by position): In-ear detection, then Conversation detection, then Multipoint; no Case sounds card any more. + val top = { title: String -> compose.onNodeWithContentDescription("$title: $DETAILS_DESCRIPTION").performScrollTo().getBoundsInRoot().top } + val inEar = top("In-ear detection") + val conversation = top("Conversation detection") + val multipoint = top("Multipoint") + assertEquals("In-ear < Conversation < Multipoint: $inEar, $conversation, $multipoint", true, inEar < conversation && conversation < multipoint) + compose.onNodeWithContentDescription("Case sounds: $DETAILS_DESCRIPTION").assertDoesNotExist() + compose.onAllNodesWithText(CASE_SOUND_EARBUDS_REPLACED_LABEL).assertCountEquals(0) } @Test @@ -146,7 +150,7 @@ class ControlsScreenTest { @Test fun `not connected - the last connection's values are marked and named, and nothing can be changed`() { show(ConnectionState.Disconnected, read) - for (title in listOf("Touch controls", "Press and hold", "Head gestures", "In-ear detection", "Multipoint", "Case sounds")) { + for (title in listOf("Touch controls", "Press and hold", "Head gestures", "In-ear detection", "Conversation detection", "Multipoint")) { compose.onNodeWithContentDescription("$title: $DETAILS_NOT_CURRENT_DESCRIPTION").assertExists() } compose.onNodeWithContentDescription("Touch controls: $DETAILS_NOT_CURRENT_DESCRIPTION").performClick() @@ -162,9 +166,9 @@ class ControlsScreenTest { @Test fun `connected and nothing read - every switch is a dash with the screen-reader text, no switch is shown`() { show(ConnectionState.Ready, BudsSettings()) - // Use touch controls, Use head gestures, In-ear detection, Multipoint, Earbuds replaced, Other alerts. - compose.onAllNodes(notReadDash).assertCountEquals(6) - compose.onAllNodesWithText(NOT_READ_VALUE).assertCountEquals(6) + // Use touch controls, Use head gestures, In-ear detection, Conversation detection, Multipoint (the case sounds are on gear → Settings since 0082). + compose.onAllNodes(notReadDash).assertCountEquals(5) + compose.onAllNodesWithText(NOT_READ_VALUE).assertCountEquals(5) compose.onAllNodes(isToggleable()).assertCountEquals(0) } diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreenTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreenTest.kt index 6b4200e7..503688fc 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreenTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/EqScreenTest.kt @@ -116,10 +116,10 @@ class EqScreenTest { @Test fun `an unread EQ shows a dash for each band, never 0,0`() { show(gains = null) - // The five bands and the unread Volume EQ (`ai-sessions/0074`) in the Equalizer card; the card below (a lazy-list item of its own) adds three more — - // balance, mono audio, conversation detection — when it is composed. + // The five bands and the unread Volume EQ (`ai-sessions/0074`) in the Equalizer card; the card below (a lazy-list item of its own) adds two more — + // balance, mono audio — when it is composed (conversation detection is on Controls since `ai-sessions/0082`). val dashes = compose.onAllNodesWithText("—").fetchSemanticsNodes().size - assertTrue("dashes shown: $dashes", dashes == 6 || dashes == 9) + assertTrue("dashes shown: $dashes", dashes == 6 || dashes == 8) compose.onAllNodesWithText("%.1f".format(0f)).assertCountEquals(0) } @@ -133,7 +133,7 @@ class EqScreenTest { fun `each unread EQ band carries the screen-reader text, and nothing else on the card says 0,0`() { show(gains = null, settings = BudsSettings(volumeEq = SettingReading(true, 1L))) // only the five bands are unread in the Equalizer card val bands = compose.onAllNodes(notReadDash).fetchSemanticsNodes().size - assertTrue("bands read as not read: $bands", bands == 5 || bands == 8) // + balance, mono, conversation detection when composed + assertTrue("bands read as not read: $bands", bands == 5 || bands == 7) // + balance, mono when composed compose.onAllNodesWithText(NOT_READ_VALUE).assertCountEquals(bands) // every dash has it } @@ -145,7 +145,7 @@ class EqScreenTest { EqScreen( connectionState = ConnectionState.Ready, gains = EqBandGains.FLAT, eqProfileUpdatedAt = at, eqError = null, onGainsChanged = {}, onPresetSelected = {}, onRefresh = {}, - settings = BudsSettings(monoAudio = SettingReading(false, at), conversationDetection = SettingReading(true, at), volumeEq = SettingReading(true, at)), + settings = BudsSettings(monoAudio = SettingReading(false, at), volumeEq = SettingReading(true, at)), ) } } @@ -173,8 +173,8 @@ class EqScreenTest { show(gains = EqBandGains(upperTreble = 5f, treble = 3f, mid = 2f, bass = 0f, lowBass = -2f)) compose.onNodeWithText("%.1f".format(5f)).assertExists() compose.onNodeWithText("%.1f".format(-2f)).assertExists() - // Never a band: at most the unread Volume EQ in this card and, when the second card is composed, the balance, mono and conversation detection. - assertTrue(compose.onAllNodesWithText("—").fetchSemanticsNodes().size in listOf(1, 4)) + // Never a band: at most the unread Volume EQ in this card and, when the second card is composed, the balance and mono. + assertTrue(compose.onAllNodesWithText("—").fetchSemanticsNodes().size in listOf(1, 3)) } @Test @@ -226,6 +226,15 @@ class EqScreenTest { compose.onNodeWithText("Volume EQ: $SETTING_NOT_READ").assertExists() } + @Test + fun `ai-sessions 0082 - the Sound tab has no Conversation detection any more, its (i) lists Balance and Mono audio only`() { + show(gains = EqBandGains.FLAT, settings = BudsSettings(conversationDetection = SettingReading(true, 1L))) + compose.onNode(hasScrollAction()).performScrollToNode(balanceSlider) + compose.onAllNodesWithText("Conversation detection").assertCountEquals(0) + compose.onAllNodesWithText("Switch from noise cancellation to transparency when you talk").assertCountEquals(0) + assertEquals(listOf("Balance: $SETTING_NOT_READ", "Mono audio: $SETTING_NOT_READ"), soundSettingsDetailLines(BudsSettings())) + } + @Test fun `the settings card on Sound is marked only when it still holds values and nothing is connected`() { assertEquals(true, settingsFromLastConnection(ready = false, readings = listOf(SettingReading(true, 1L), null))) @@ -259,7 +268,6 @@ class EqScreenTest { settings = BudsSettings( volumeBalance = balance?.let { SettingReading(it, 1_727_600_000_000L) }, monoAudio = SettingReading(false, 1_727_600_000_000L), - conversationDetection = SettingReading(true, 1_727_600_000_000L), volumeEq = SettingReading(true, 1_727_600_000_000L), ), onVolumeBalanceChanged = { writes += it }, diff --git a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt index 543a3ccc..5faed72c 100644 --- a/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt +++ b/android/ui/src/test/kotlin/io/github/tedsluis/opencontrolpixelbuds/ui/SettingsMenuTest.kt @@ -26,8 +26,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.hasAnySibling +import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.getBoundsInRoot import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.junit4.v2.createComposeRule @@ -39,6 +45,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import io.github.tedsluis.opencontrolpixelbuds.domain.AndroidLink import io.github.tedsluis.opencontrolpixelbuds.domain.BatteryStatus +import io.github.tedsluis.opencontrolpixelbuds.domain.BudsError +import io.github.tedsluis.opencontrolpixelbuds.domain.BudsSettings +import io.github.tedsluis.opencontrolpixelbuds.domain.ComponentSerial +import io.github.tedsluis.opencontrolpixelbuds.domain.SettingReading import io.github.tedsluis.opencontrolpixelbuds.domain.ConnectionState import io.github.tedsluis.opencontrolpixelbuds.domain.DarkMode import io.github.tedsluis.opencontrolpixelbuds.domain.DeviceInfo @@ -263,6 +273,119 @@ class SettingsMenuTest { assertTrue("res/raw/license.txt == LICENSE", repo.contentEquals(bundled)) } + // ---- ADR-058 (`ai-sessions/0082` item 2): the serial numbers on the Info tab, labelled by position as the official app does ---- + + private val withSerials = announced.copy( + serials = listOf(ComponentSerial(1, "5707XXXXXXXX51"), ComponentSerial(2, "5708XXXXXXXX09"), ComponentSerial(3, "5707XXXXXXXX47")), + serialsReadAtMillis = at + 83, + ) + + @Test + fun `Info shows the serial numbers under the firmware, labelled Case, Right bud, Left bud, with the note and the time`() { + compose.setContent { + OpenControlTheme(darkTheme = false) { + SettingsMenuScreen(DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, withSerials) + } + } + compose.onNodeWithText("Info").performClick() + compose.onNodeWithText("Serial numbers (from the Buds, ${formatUpdatedAt(at + 83)}):").performScrollTo().assertExists() + compose.onNodeWithText("Case: 5707XXXXXXXX51").performScrollTo().assertExists() + compose.onNodeWithText("Right bud: 5708XXXXXXXX09").performScrollTo().assertExists() + compose.onNodeWithText("Left bud: 5707XXXXXXXX47").performScrollTo().assertExists() + compose.onNodeWithText(SERIALS_NOTE).performScrollTo().assertExists() + // The firmware block (its own order: Case, Left, Right) is still above it. + compose.onNodeWithText("Left bud: release_5.203").assertExists() + } + + @Test + fun `Info says not read before the answer, the reason after a failed read, and nothing without an announcement`() { + assertEquals(listOf("Serial numbers: $SETTING_NOT_READ"), serialInfoLines(announced, null)) + assertEquals(listOf("Serial numbers: not read — The Buds didn't respond in time."), serialInfoLines(announced, BudsError.Timeout)) + assertEquals( + listOf("Serial numbers: not read — The Buds answered with a value this app cannot read."), + serialInfoLines(announced, BudsError.UnreadableAnswer), + ) + assertEquals(emptyList(), serialInfoLines(null, BudsError.Timeout)) + // The labels follow the field number, in index order, never guessed for an index the Buds never used. + assertEquals(listOf("Case", "Right bud", "Left bud", "Part 4"), listOf(1, 2, 3, 4).map(::serialComponentLabel)) + val lines = serialInfoLines(withSerials.copy(serials = listOf(ComponentSerial(3, "c"), ComponentSerial(1, "a"))), null) + assertEquals(listOf("Case: a", "Left bud: c"), lines.drop(1).dropLast(1)) + } + + @Test + fun `the Info tab renders the failed-read line`() { + compose.setContent { + OpenControlTheme(darkTheme = false) { + SettingsMenuScreen(DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, announced, serialsError = BudsError.Timeout) + } + } + compose.onNodeWithText("Info").performClick() + compose.onNodeWithText("Serial numbers: not read — The Buds didn't respond in time.").performScrollTo().assertExists() + } + + // ---- `ai-sessions/0082` item 5: the Case sounds card on the Settings tab (ADR-054), between Dark mode and Use different Buds ---- + + private val caseSoundsRead = BudsSettings( + caseSoundEarbudsReplaced = SettingReading(true, at), + caseSoundOtherAlerts = SettingReading(false, at, changedByApp = true), + ) + + @Test + fun `the Settings tab shows the Case sounds card between Dark mode and Use different Buds, with its (i) lines and the case note`() { + val taps = mutableListOf() + compose.setContent { + OpenControlTheme(darkTheme = false) { + SettingsMenuScreen( + DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, announced, + connectionState = ConnectionState.Ready, settings = caseSoundsRead, + onCaseSoundEarbudsReplacedChanged = { taps += "earbudsReplaced=$it" }, onCaseSoundOtherAlertsChanged = { taps += "otherAlerts=$it" }, + ) + } + } + val darkMode = compose.onNodeWithText("Dark mode").getBoundsInRoot().top + val caseSounds = compose.onNodeWithContentDescription("Case sounds: $DETAILS_DESCRIPTION").performScrollTo().getBoundsInRoot().top + val useDifferent = compose.onNode(hasText(USE_DIFFERENT_BUDS_TITLE) and hasClickAction()).performScrollTo().getBoundsInRoot().top + assertTrue("Dark mode < Case sounds < Use different Buds: $darkMode, $caseSounds, $useDifferent", darkMode < caseSounds && caseSounds < useDifferent) + + compose.onNodeWithContentDescription("Case sounds: $DETAILS_DESCRIPTION").performScrollTo().performClick() + compose.onNodeWithText("Earbuds replaced: read ${formatUpdatedAt(at)}").assertExists() + compose.onNodeWithText("Other alerts: changed ${formatUpdatedAt(at)}").assertExists() + compose.onNodeWithText("These settings live on the case and are read when the app connects.").assertExists() + compose.onNodeWithText("Close").performClick() + // Both switches share the card with both labels (siblings): layout order, Earbuds replaced first, then Other alerts. + val switches = compose.onAllNodes(isToggleable() and hasAnySibling(hasText(CASE_SOUND_EARBUDS_REPLACED_LABEL)) and hasAnySibling(hasText(CASE_SOUND_OTHER_ALERTS_LABEL))) + switches.assertCountEquals(2) + switches[0].performScrollTo().assertIsOn().performClick() + switches[1].performScrollTo().assertIsOff().performClick() + assertEquals(listOf("earbudsReplaced=false", "otherAlerts=true"), taps) + } + + @Test + fun `the Case sounds card not connected keeps the last values dimmed and marked, and unread values are dashes`() { + compose.setContent { + OpenControlTheme(darkTheme = false) { + SettingsMenuScreen(DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, null, connectionState = ConnectionState.Disconnected, settings = caseSoundsRead) + } + } + compose.onNodeWithContentDescription("Case sounds: $DETAILS_NOT_CURRENT_DESCRIPTION").performScrollTo().performClick() + compose.onNodeWithText(FROM_LAST_CONNECTION_DETAIL).assertExists() + compose.onNodeWithText("Close").performClick() + compose.onAllNodes(isToggleable() and hasAnySibling(hasText(CASE_SOUND_EARBUDS_REPLACED_LABEL))).assertCountEquals(2) + } + + @Test + fun `the Case sounds card connected and unread shows two dashes with the screen-reader text`() { + compose.setContent { + OpenControlTheme(darkTheme = false) { + SettingsMenuScreen(DarkMode.SYSTEM, {}, false, {}, emptyList(), {}, AppBuildInfo.UNKNOWN, announced, connectionState = ConnectionState.Ready) + } + } + compose.onNodeWithContentDescription("Case sounds: $DETAILS_NOT_CURRENT_DESCRIPTION").performScrollTo().assertExists() + val dash = hasText(NOT_READ_VALUE) and SemanticsMatcher.expectValue(SemanticsProperties.ContentDescription, listOf(NOT_READ_DESCRIPTION)) + compose.onAllNodes(dash).assertCountEquals(2) + compose.onAllNodes(isToggleable()).assertCountEquals(0) + } + // ---- `ai-sessions/0069` A68-APP-05: "Use different Buds" on the Settings tab ---- @Test From d7456e45d3005e5a5a0f8f4140be163de78f9db8 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Fri, 9 Oct 2026 08:02:00 +0200 Subject: [PATCH 2/6] docs(re): the official app's volume-level notification and the serial-position attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: ai-sessions/0082 item 4 was a design study, not a build — the findings belong in REVERSE_ENGINEERING.md with file:line (HearingWellnessNotificationWorker, gau/hwy/gag/fsm, the 67 % / 7-day constants, no Buds-side push in 85 logs), and ADR-058 rests on how the official app labels GetHardwareInfo field 7 (gaa.java:45–96, fwg.java:182–215). Co-Authored-By: Claude Fable 5.1 --- REVERSE_ENGINEERING.md | 46 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/REVERSE_ENGINEERING.md b/REVERSE_ENGINEERING.md index d7d023e5..0d184802 100644 --- a/REVERSE_ENGINEERING.md +++ b/REVERSE_ENGINEERING.md @@ -3449,6 +3449,52 @@ natural next step for whoever picks this up (search for `X.class` and `X.a` refe further this pass); the 7-day check that motivated the original question belongs to an unrelated feature entirely. +### `HearingWellnessNotificationWorker` / `defpackage.fvp` / `fyh` / `fyf` / `gau` / `fsm` / `gag` (discriminator 10) / `hwy` / `his` / `hiw` — what decides a "volume level" notification (added 2026-10-09, `ai-sessions/0082` item 4; read-only, ADR-017 boundary) + +Question (the maintainer, chat 2026-10-09): how could OpenControl notify the user when "the recommended exposure limit is exceeded", as the official app's +"Volume level notifications" switch (`qhr` field 21, `PROTOCOL.md` §4.5.8a) promises? Answered on the code side (JADX, `v1.0.955078536-10253511`; one smali +read); nothing built — the design and the draft ADR are in `ai-sessions/0082` RESULT §C.4, the 🟡 list there. Every claim: `file:line`. + +- **The phone decides, not the Buds.** `HearingWellnessNotificationWorker` (`…/notification/hearingwellness/HearingWellnessNotificationWorker.java:27–66`) is an + `RxWorker`. `m(context, address)` (`:46–55`) enqueues **one-time** unique work `HEARING_WELLNESS_NOTIFICATION_WORK_
` with tag + `HEARING_WELLNESS_NOTIFICATION_WORK`, initial delay `Duration.ofMinutes(1)` (`:30`; `enh.g(Duration)` = `setInitialDelay`, `enh.java:72–78`) and policy int `2` + (`eng.h(String, int, …)`, `eng.java:24` — the enum name behind `2` is not recoverable, ⚪); `l(context)` (`:41–44`) cancels the tag. Not a periodic request. +- **Scheduled from the session, gated by field 21's stored value.** `fvp.i()` (`fvp.java:58–64`, the Dosimeter connection, started with the session per + `fut.java:102–103`) subscribes `this.l.C(new fst(14))` → `fsy(this, 7)`: `fst` case 14 = `gcl.x()`, and `fsy` case 7 logs "HearingWellnessNotificationWorker: + scheduled" + `m(…)` on `true`, "… cancelled" + `l(…)` on `false`. `gcl.x()` (`gcl.java:550–563`) is the device record's flag `gdw.x` when its presence bit + `2097152` is set, else `true` — the stored "Volume level notifications" switch (the same preference writes field 21, `hey.java:165–190`). `fyh.a()` + (`fyh.java:32–36`) also schedules it right after fetching both daily summaries; `fvp.b()` (`:51–56`, session end) cancels it. 🟢 (code): **field 21 gates only + this phone-side worker**; no code reads a Buds-initiated message for it. +- **What it computes** (`c()`, `:58–65`): `gck.b(address)` (the device record) → `ftq(13)` (`Optional.isPresent`) → `gau(11)` (`get`) → `gau(12)` + (`gau.java:104–131`): `false` when `!gcl.x()` or no summaries; else `hwy.aj(gcl.q(), gcl.r())` → `gau(13)` = `hwy.ai(…)` → `gag(lastNotified, 10)` → + `fsm(this, address, 5)`. + - `gcl.q()`/`r()` (`gcl.java:430–474`): the stored **left** (`gdf.c`) and **right** (`gdf.e`) daily summaries (`gdg`), filled by `fyf.java:49–94`, which calls + `maestro_pw.Dosimeter/FetchDailySummaries` per bud (`goq.LEFT_BT_CORE`/`RIGHT_BT_CORE` — channels 19 and 21, `fyh.java:28–36`, 3 retries) and copies `qhz`: + field 1 (`b`) → `gdg.c` = the **current day index**; field 2 (`c`, repeated `qia`) → `gdg.d`, each `qia` → `gda` {1 = day index, 6 = that day's dose, `gda.d`}; + field 3 (`d`) → `gdg.e`; field 4 (`e`) → `gdg.f`; field 5 (`f`) → `gdg.g` = the **24-hour dose** (`his.java:28`: exposure % = `g / 4.843351E10 × 100`). + - `hwy.aj(gdg, gdg)` (`hwy.java`): for day index `max(left.c, right.c)` and the 7 before it, the per-day dose is `(left + right) / 2` (one present → that one). + - `hwy.ai(gdg)` (`hwy.java:1979–1998`): the sum of the current day and the 6 before it, `round(sum / 3.3903457E11f × 100)` — a percentage of a **7-day** + constant; `3.3903457E11 / 4.843351E10 = 7.0`. + - `gag` discriminator 10 (`apktool-output/smali_classes2/gag.smali:438–468`): `if-lt p1, 0x43` → `false` below **67 %**; else `true` when no last-notified time is + stored or `now − last ≥ 0x240c8400` (604 800 000 ms = **7 days**) — the throttle `ai-sessions/0025` found (the `frb`/`fuh`/`glk`/`gjv` entry above). + - `fsm` case 5 (`fsm.java:171–215`): on `true` — channel `"HW APPROACHING MAX NOTIFICATION"` (name `maestro_notification_title` = "Pixel Buds", importance 3), + title "Pixel Buds", body `hearing_wellness_approaching_max_notification_body` = "Consider lowering the volume of your earbuds to limit your audio exposure" + (`strings.xml:301`), tap → the hearing-wellness fragment; app event 16; `gck.P(address, gcg(now, 13))` stores the time. +- **Constants in the code (quoted, no WHO/EU figure is named there):** 7-day limit `3.3903457E11`, 24-hour limit `4.843351E10`, notify at ≥ 67 % at most once per + 7 days; the live level "Loud" at ≥ 85 dB (`his.java:36`), shown as `round(10·log10(v))` dB, 0 below 1 (`hiw.java:32–37`), clamped 0–120. The dose unit is not in + the code (the live value is linear). The switch's own summary says "exceeded" (`strings.xml:799`); the code notifies at 67 %. +- **`qhz` on the wire** (`CAP-058` 2845, channel 21): `1:462 2:{1:228 6:…} 2:{1:456 …} … 2:{1:462 6:f32(204890640)} 5:f32(245338960)` — seven recent day entries and + one old; 24-h exposure 245 338 960 / 4.843351E10 = 0.5 %. 🔴 the day index's epoch. +- **Checked negatives (wire):** no Buds-initiated Dosimeter packet in any of the 85 logs (`python3 scripts/pwrpc_decode.py "$f" | grep -c "Dosimeter.*call_id=4294967295"` + → 0 in every log; positive control: `call_id=4294967295` matches `GetSoftwareInfo` in `CAP-036`); in the idle logs `CAP-009` (101 min) and `CAP-042` (37 min) + the only unsolicited packets are `GetSoftwareInfo` (9 / 1); every `SubscribeToLiveDb` `SERVER_STREAM` follows a `REQUEST` on its channel (`CAP-009`: 1505/1506 → + 9272/9273; `CAP-042`: 967/968). The Buds push nothing for the limit; the official app's own Hearing wellness page re-requests `FetchDailySummaries` on open + (`CAP-058` 5736) and at connect (2837/2838). +- **A second finding of the same session — `GetHardwareInfo` field 7 by position (ADR-058):** `gaa.java:45–96` (`gaa.a(qiv)`) copies `qjm`/`qjr` field 1 → `gdv.c`, + 2 → `gdv.d`, 3 → `gdv.e` by field number (`qjm.java:30`: Java `c`/`d`/`e` = proto fields 1/2/3); `fwg.java:182–215` reads `gdv.e` as left, `gdv.d` as right, `gdv.c` as + case — 7.1 = Case, 7.2 = Right bud, 7.3 = Left bud. This closes `ai-sessions/0073` §4.6's "🟡 that the holder keeps the wire order" on the code side (the holder + is filled field by field). `PROTOCOL.md` §6, Update of 2026-10-09 (🟢, maintainer-approved). + ### `BluetoothPriorityReceiver` — exported broadcast receiver requesting classic-BT connection priority, holding a direct `fzd` reference (new, from a Phase 3 item L manifest re-review) *(Added 2026-09-16, `ai-sessions/0025`, implementing `ai-sessions/0024`'s Phase 3 item L — a full From 589622253a274cec8e64e84c0eed1d36b04fc1d5 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Fri, 9 Oct 2026 08:02:00 +0200 Subject: [PATCH 3/6] =?UTF-8?q?docs:=20ADR-058/059=20accepted,=20draft=20A?= =?UTF-8?q?DR-060,=20PROTOCOL=20=C2=A76,=20the=20documents=20after=201.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: the documents follow the code (PROJECT_RULES.md). ADR-058 (one GetHardwareInfo per Connect) and ADR-059 (the "probably worn" indicator) were accepted by the maintainer in chat 2026-10-09; the serial-position attribution in PROTOCOL.md §6 is promoted with that approval. ADR-060 (volume-level notifications) stands as a labelled Proposed draft — not accepted, nothing built — because scripts/lint_docs.py requires a heading for every cited ADR. ARCHITECTURE §2.4/§3.1/§5a, PROJECT scope, README status, the CHANGELOG [1.2.0] - not yet released block with its known issues, TODO (the CAP-069 §12 item 8 line done; the 1.3.0 candidate and WELL-002 added), the registry rows. Co-Authored-By: Claude Fable 5.1 --- ARCHITECTURE.md | 32 +++++++++++----- CHANGELOG.md | 43 ++++++++++++++++++++++ DECISIONS.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++ PROJECT.md | 7 +++- PROTOCOL.md | 8 ++++ README.md | 17 ++++++--- TODO.md | 18 ++++++--- id_registry.csv | 6 +++ 8 files changed, 206 insertions(+), 22 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2a86e6d9..eccf6050 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,17 +140,18 @@ that gates only the hex lines of the log (§7/§12) — as built, (corrected 202 ``` Connection screen (start destination) ├─ ANC screen - ├─ Sound screen (the EQ with — since 1.1.0 — Volume EQ below its presets, plus volume balance, mono audio, conversation detection — `ai-sessions/0052`, `0074`) - ├─ Controls screen (touch controls, press-and-hold per bud, the press-and-hold ANC-mode list, head gestures, the in-ear detection switch, Multipoint and the - │ two case sounds — `ai-sessions/0052`, `0056`, `0074`) + ├─ Sound screen (the EQ with — since 1.1.0 — Volume EQ below its presets, plus volume balance and mono audio — `ai-sessions/0052`, `0074`; conversation + │ detection moved to Controls in 1.2.0, `ai-sessions/0082`) + ├─ Controls screen (touch controls, press-and-hold per bud, the press-and-hold ANC-mode list, head gestures, the in-ear detection switch, conversation + │ detection (since 1.2.0) and Multipoint — `ai-sessions/0052`, `0056`, `0074`, `0082`; the two case sounds moved to gear → Settings in 1.2.0) ├─ Battery (rendered as a section of the Connection screen, not a separate destination — │ battery is push-driven ambient state, not a user-operated control, so it doesn't need its │ own navigation stop; see §3.1's table) ├─ Find My Buds screen └─ Settings menu (since `ai-sessions/0062`; only reachable via the top app bar's gear — never shown - in the main bottom/side navigation), with three tabs: Settings (dark mode), Debug (the - developer-facing Debug screen, per AGENTS.md §6/§9 not part of ordinary use) and Info (the - app's build, the licence and the project links — `ai-sessions/0064`, ADR-050 — and the Buds' firmware) + in the main bottom/side navigation), with three tabs: Settings (dark mode; since 1.2.0 the two case sounds, `ai-sessions/0082`; "Use different Buds"), + Debug (the developer-facing Debug screen, per AGENTS.md §6/§9 not part of ordinary use) and Info (the + app's build, the licence and the project links — `ai-sessions/0064`, ADR-050 — the Buds' firmware and, since 1.2.0, their serial numbers — ADR-058) ``` **As built since `ai-sessions/0057`** (the maintainer's decisions in chat 2026-09-29): a Material 3 top app bar titled "OpenControl" and a bottom @@ -190,7 +191,12 @@ write per completed drag); a setting not read from the Buds yet cannot be writte in chat 2026-10-06, "— voor alle schakelaars") a switch that was not read shows "—" in place of the switch, and every "—" carries the content description "Not read from the Buds yet" for screen readers (`NotReadValue`, `SettingsUi.kt`). **Since 1.1.0** "Controls" also holds the cards "Head gestures" ("Use head gestures", below "Press and hold"), "Multipoint" and "Case sounds" ("Earbuds replaced", "Other alerts") below "In-ear detection", and the Equalizer card on - "Sound" ends with "Volume EQ" — labels only, no notes (the maintainer's choices in chat 2026-10-06, "Gebaren eerst", "alleen labels"). **Exception:** the press-and-hold action chips (`HoldRow`, + "Sound" ends with "Volume EQ" — labels only, no notes (the maintainer's choices in chat 2026-10-06, "Gebaren eerst", "alleen labels"). **Since 1.2.0 + (`ai-sessions/0082`, the maintainer's choices in chat 2026-10-09):** the "Case sounds" card is on gear → **Settings** (Dark mode · Case sounds · Use different + Buds; its (i) adds "These settings live on the case and are read when the app connects."), and "Conversation detection" is an own card on **Controls** after + "In-ear detection" (the `0052` subtitle kept); "Sound"'s second card holds Balance and Mono audio. The Noise control card's (i) says "Changed by the Buds at + HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)." for a `Notify` the app did not provoke (`AncModeCause`); the battery card has the "probably + worn" line (ADR-059); Info lists the serial numbers (ADR-058). **Exception:** the press-and-hold action chips (`HoldRow`, `ControlsScreen.kt`) are enabled with the connection only; a chip tapped before the read writes that one field (`ai-sessions/0058` A58-ARCH-05). - **Justification for `navigation-compose`** (`AGENTS.md` §10's dependency-policy requirement): pure AndroidX/Compose-first library, no network/analytics/GMS dependency, the standard Compose-idiomatic @@ -301,19 +307,22 @@ should not be assumed uniform):** | Feature | Reconciliation mechanism on (re)connect | Confidence | |---|---|---| -| ANC | **Corrected `ai-sessions/0040`:** the connect-time `Get`/`Notify` pair (ADR-021/ADR-022) is the *official client's* own query — a client that sends nothing gets no ANC `Notify`. This app therefore sends its own `AncFrame.Get` during each Message Stream claim (the Connect-time snapshot, ANC Refresh, and each ANC tap's reply) and treats the value as last-known between claims (ADR-032). **A `Set` is only counted once answered (2026-09-24):** ACK → requested mode, `Notify` → the Buds' mode, NAK/no answer → failure, previous mode kept. The `Notify`'s Settable byte is kept as `ancAvailability` (`ai-sessions/0048` I-3, ADR-024 Update 2026-09-25): while it reads `0x00` the Buds NAK a `Set` (reason `0x02`, `CAP-062` 10/10). **Since `ai-sessions/0054` (I-1, maintainer-confirmed in chat 2026-09-28; it reverses `0048` I-3's "claim and send nothing" for a user tap):** the ANC buttons and the tile stay tappable, with "ANC can only be changed while you wear the Buds (checked HH:MM:SS). Tapping a mode checks again first." (🟡 wording); such a tap does one ordinary claim with the `Get` and sends the `Set` **in that claim** only if the answering `Notify` reads Settable non-zero (then the usual ACK rules); if it still reads `0x00` nothing more is sent, the checked time moves and the tile shows a toast — because a `0x00` answer can be old (`CAP-063`: worn again 37 s after `Notify` 4774, disabled for 6 min). ~~Unknown (no `Notify` yet this connection) or non-zero ⇒ the `Set` is sent directly, as before.~~ **Since `ai-sessions/0062` (F-1/F-2, maintainer-confirmed in chat 2026-10-01): every ANC tap — the screen's and the tile's — does one claim with the `Get` first and sends the `Set` in that claim only if the claim's `Notify` reads Settable non-zero; on `0x00` nothing more is sent** (`CAP-064`: an 18-s-old `e8` led to `Set` 3433 → NAK 3440; `CAP-065` F7: an unknown availability sent `Set` 10790 without a `Get`). The tile's next mode is computed from that fresh `Notify`, not from the mode shown. Wording: "The Buds don't allow changing noise control right now (usually because no bud is in an ear). Tapping a mode checks again first." (tile subtitle "Not allowed now") — the byte means "not allowed now", not "not worn" (`CAP-064`: `e8` with no bud worn). **F-3:** a claim closed after its `Set`/`Get` was written and before the answer is `BudsError.AnswerCutOff` (never retried, never "didn't respond in time"); after a cut-off `Set` the shown mode is "not confirmed" (dimmed + the (i) dot) until the Buds' next `Notify` or ACK. **Hardware-verified in `CAP-066` (`ai-sessions/0063`, maintainer-approved in chat 2026-10-01):** 23 of 23 app claims sent `08 11` first; 4 `Set`s, each after that claim's `Notify` read `e8`, all ACKed; 12 `00` answers sent no `Set` (count corrected 2026-10-08, `ai-sessions/0081`: 23 app claims, 12 `00`, 0 `Set`s after `00`); the tile stepped from the fresh `Notify` (A2369 `40` → A2370 `20`). F-3 (`AnswerCutOff`) not exercised (no cut-off occurred). | 🟢 FACT for the `Get`/`Notify` pair; the per-claim use is ADR-032 | +| ANC | **Since 1.2.0 (`ai-sessions/0082` item 1):** every `ancMode` carries its cause (`AncModeCause`): the answer to the app's own `Get` is READ, the app's ACKed `Set` (or the Buds' `Notify` of the requested mode while the `Set` waits) is SET_BY_APP, any other `Notify` with a mode other than the shown one is CHANGED_BY_BUDS — derived from what the app had just sent, no timer; reset at Connect; shown only as the (i) line "Changed by the Buds at HH:MM:SS (…)". **Corrected `ai-sessions/0040`:** the connect-time `Get`/`Notify` pair (ADR-021/ADR-022) is the *official client's* own query — a client that sends nothing gets no ANC `Notify`. This app therefore sends its own `AncFrame.Get` during each Message Stream claim (the Connect-time snapshot, ANC Refresh, and each ANC tap's reply) and treats the value as last-known between claims (ADR-032). **A `Set` is only counted once answered (2026-09-24):** ACK → requested mode, `Notify` → the Buds' mode, NAK/no answer → failure, previous mode kept. The `Notify`'s Settable byte is kept as `ancAvailability` (`ai-sessions/0048` I-3, ADR-024 Update 2026-09-25): while it reads `0x00` the Buds NAK a `Set` (reason `0x02`, `CAP-062` 10/10). **Since `ai-sessions/0054` (I-1, maintainer-confirmed in chat 2026-09-28; it reverses `0048` I-3's "claim and send nothing" for a user tap):** the ANC buttons and the tile stay tappable, with "ANC can only be changed while you wear the Buds (checked HH:MM:SS). Tapping a mode checks again first." (🟡 wording); such a tap does one ordinary claim with the `Get` and sends the `Set` **in that claim** only if the answering `Notify` reads Settable non-zero (then the usual ACK rules); if it still reads `0x00` nothing more is sent, the checked time moves and the tile shows a toast — because a `0x00` answer can be old (`CAP-063`: worn again 37 s after `Notify` 4774, disabled for 6 min). ~~Unknown (no `Notify` yet this connection) or non-zero ⇒ the `Set` is sent directly, as before.~~ **Since `ai-sessions/0062` (F-1/F-2, maintainer-confirmed in chat 2026-10-01): every ANC tap — the screen's and the tile's — does one claim with the `Get` first and sends the `Set` in that claim only if the claim's `Notify` reads Settable non-zero; on `0x00` nothing more is sent** (`CAP-064`: an 18-s-old `e8` led to `Set` 3433 → NAK 3440; `CAP-065` F7: an unknown availability sent `Set` 10790 without a `Get`). The tile's next mode is computed from that fresh `Notify`, not from the mode shown. Wording: "The Buds don't allow changing noise control right now (usually because no bud is in an ear). Tapping a mode checks again first." (tile subtitle "Not allowed now") — the byte means "not allowed now", not "not worn" (`CAP-064`: `e8` with no bud worn). **F-3:** a claim closed after its `Set`/`Get` was written and before the answer is `BudsError.AnswerCutOff` (never retried, never "didn't respond in time"); after a cut-off `Set` the shown mode is "not confirmed" (dimmed + the (i) dot) until the Buds' next `Notify` or ACK. **Hardware-verified in `CAP-066` (`ai-sessions/0063`, maintainer-approved in chat 2026-10-01):** 23 of 23 app claims sent `08 11` first; 4 `Set`s, each after that claim's `Notify` read `e8`, all ACKed; 12 `00` answers sent no `Set` (count corrected 2026-10-08, `ai-sessions/0081`: 23 app claims, 12 `00`, 0 `Set`s after `00`); the tile stepped from the fresh `Notify` (A2369 `40` → A2370 `20`). F-3 (`AnswerCutOff`) not exercised (no cut-off occurred). | 🟢 FACT for the `Get`/`Notify` pair; the per-claim use is ADR-032 | | EQ | No connect-time *push* exists for EQ, but the official app **reads** it — and so does this app since `ai-sessions/0041` (DECISIONS.md ADR-034, maintainer-approved 2026-09-20): the Connect sequence waits for the Buds' unsolicited `GetSoftwareInfo` announcement (which names this connection's pw_rpc channel), then sends `ReadSetting 4:16` (active EQ) on that channel and fills `eqProfile` from the answer; the EQ screen can re-read on demand. `eqProfile` is `null` only until that answer arrives or if it failed — `BudsRepository.eqError` then carries the reason (no announcement in time, a channel with no known address, an error status, a timeout). A write is only counted as done once the Buds' `RESPONSE` arrives (`status` absent/OK); anything else is surfaced, never assumed. Field 18 (last saved custom EQ) never replaces the active value. | 🟢 FACT (ADR-034: identification, `ReadSetting 4:N` semantics, three second-capture chains); 🟡 HYPOTHESIS for what a fresh client must send first and for request/response matching (handled conservatively, `// TODO(verify)`, hardware re-test) | | Battery (DLCI 0x04 Option B, **implemented `ai-sessions/0040`, ADR-033; charging flag `ai-sessions/0041`**) | Push-based: the Buds send three `Group 0x03 Code 0x03` frames within ~10 ms of the channel opening and again on every change — so each Message Stream claim yields a reading. Each of `b1`/`b2` is `0bSVVVVVVV`: `V` = 0–100 % and `S` = charging (maintainer-accepted 2026-09-20); `V = 0x7F`/`> 100` reads "unavailable"; `b3` (Case) is never decoded (it read `0xff` in 60/60 frames). **The Case comes from DLCI 0x02** (**ADR-043**, 2026-09-24): after the Connect-time EQ read the app sends one `SubscribeRuntimeInfo` request on the announced channel; the Buds then push `SERVER_STREAM` packets by themselves and entry 6.1 field 1 is the Case % — a packet without that entry reads "unavailable". *Refresh battery* re-reads Left/Right — **since `ai-sessions/0052`** always on a **fresh** claim (a claim still lingering is released and opened again, because the Buds send the `03 03` burst only on an open; no burst ⇒ "No new battery reading from the Buds — try again.", values and times unchanged) and it also re-sends one `SubscribeRuntimeInfo` (ADR-043 Update 2026-09-26; no wait, no retry). **Since `ai-sessions/0048` (ADR-043 Update):** the same stream's 6.2/6.3 field 2 (fallback 7.2/7.1) gives each bud's charging state, shown as "charging in the case" with its own time; the newest charging report of either source wins (I-8); a packet without 6.1 keeps the last Case value as "last seen HH:MM:SS" (I-5, in memory only, marked last seen after a reconnect). The DLCI 0x08 claim of ADR-035/038/039 is withdrawn (its `0e 04` got no answer in `CAP-061`, 8/8). | 🟢 FACT (ADR-031 identity, ADR-033 unblock + charging-flag update; `PROTOCOL.md` §4.3 Option F + ADR-043 for the Case) | | Battery (HFP, Option C) — **removed `ai-sessions/0042`** | Push-based on the wire (`AT+BIEV`/`AT+CIND`, ADR-015/023) but **not consumable by an app**: `CAP-059` shows `AT+BIEV=2,100` twelve times on the wire (seven dissected as HFP, five on the undissected DLCI after the reconnect — corrected 2026-10-03) and **zero** vendor-specific events in the app's receiver (and the value is one earbud's, never the Case). `HfpBatteryReader` and its wiring were removed on the maintainer's decision (chat 2026-09-20); the wire facts stay. | 🟢 FACT on the wire; 🟢 not app-consumable (`CAP-059-FINDINGS.md` §7) | | DLCI 0x02 settings (`ai-sessions/0052`, `0056`, `0074`) | Read at Connect: after the EQ read and before `SubscribeRuntimeInfo`, one `ReadSetting 4:N` each for fields 2, 4, 7, 11, 12, 15, 17, 19, 22, 27, 28, 29 — the official app's ascending order (ADR-036, ADR-046, ADR-052; twelve since 1.1.0, measured 21–241 ms each in the official app's sweeps, `ai-sessions/0074` §A.4), sequential, ≤ 2 s each, never retried; the values are reset to "not read" at every Connect and shown with "read HH:MM:SS". The same pass runs once more on a user pull on "Sound" or "Controls" (`refreshSettings`, `ai-sessions/0057` D-11 — same fields, order and timeout; nothing sent unless `Ready`; a field not answered keeps its last value and time). Writes (ADR-045: 17, 19, 22, 4, 7; ADR-046: 12; ADR-047: 2; since 1.1.0 ADR-052: 29, written 1 = off / 2 = on with its own value type; ADR-053: 11; ADR-054: 27, 28; ADR-055: 15) are applied only on the Buds' empty `RESPONSE` status OK ("changed HH:MM:SS"); an error status or no answer keeps the previous value and shows the reason. **Field 12** always carries all four booleans, built from the list the Buds last reported (unread ⇒ refused), and a list with fewer than two modes is refused before anything is sent. A tap while the session is being (re)opened is refused with "The app's channel is being reopened — try again in a moment." (nothing queued). | 🟢 FACT for the field identities and the write acknowledgement (ADR-019/026/034/045/046/047, `PROTOCOL.md` §4.5 2026-09-26/28 Updates); hardware-verified in `CAP-063` (D1–D13), `CAP-064`, `CAP-070` §3 (89 writes) and `CAP-071` §3 (24 writes byte-identical) — cell corrected 2026-10-08, `ai-sessions/0081` | +| Serial numbers (`GetHardwareInfo`, **ADR-058**, 1.2.0) | One unary request per Connect, after the runtime-info subscription (`readHardwareInfo`): field 7's three strings → `DeviceInfo.serials` with their receive time, labelled Case / Right bud / Left bud by position (the official app's reading, `PROTOCOL.md` §6 2026-10-09); a read, not gated by Safe Mode; no retry (≤ 2 s); failure in `serialsError`; cleared at Disconnect/Connect; never logged (the count only). | 🟢 FACT (method identity since 2026-09-24; the attribution since 2026-10-09) | +| Worn indicator (**ADR-059**, 1.2.0) | No request: `wornReading` is a pure function of the last `Notify`'s Settable byte + time, the in-ear detection setting and the two charging flags of this connection (a `combine` of existing flows) — "—" / "Worn: unknown — in-ear detection is off" / "… not read" / "Both buds in the case" / "Not worn (checked …)" / "Probably worn (checked …)"; as old as the last `Notify`; never a bud named. | 🟡 by design (ADR-049 item 3 stays a hypothesis; the (i) says so) | | Find My Buds Left/Right | An action, not persisted state. **Since `ai-sessions/0048` (I-6):** the ring this app started is remembered in memory across Disconnect/Connect (it keeps sounding until Stop, `CAP-062`) and cleared only by an ACKed Stop or replaced by a new Ring; after a reconnect it is shown as "may still be ringing" — the app cannot know. | N/A | **Consequence for `:data`'s codec scope (updated `ai-sessions/0041`):** DLCI 0x02 is decoded as pw_hdlc → pw_rpc `RpcPacket` (`Hdlc`, `PwRpc`, `Maestro`); the only requests the app builds are the EQ `WriteSetting` (ADR-020), `ReadSetting` for fields 2, 4, 7, 16, 17, 18, 19, 22, — since `ai-sessions/0056` — 12 and — since `ai-sessions/0074` — 11, 15, 27, 28, 29 (ADR-034/036/046/052; `Maestro.readSettingRequest` returns `null` for anything else), `WriteSetting` for fields 4, 7, 17, 19, 22 (ADR-045), 12 (ADR-046, never fewer than two modes), 2 (ADR-047), 29 (ADR-052, `headGesturesRequest`, -1/2), 11 (ADR-053), 27, 28 (ADR-054) and 15 (ADR-055) (`SettingsCodec` — nothing else can be encoded) and `SubscribeRuntimeInfo` (ADR-043) — updated -`ai-sessions/0052`, `0056`, `0074`. The earlier statement that no EQ +1/2), 11 (ADR-053), 27, 28 (ADR-054) and 15 (ADR-055) (`SettingsCodec` — nothing else can be encoded), `SubscribeRuntimeInfo` (ADR-043) and — since 1.2.0 — +`GetHardwareInfo` (ADR-058, `Maestro.getHardwareInfoRequest`; the answer's field 7 read by `HardwareInfo`) — updated +`ai-sessions/0052`, `0056`, `0074`, `0082`. The earlier statement that no EQ read request may be built is superseded by ADR-034. ### 3.2 Timing constants (as built; added 2026-10-03, `ai-sessions/0069`, `A68-ARCH-05`) @@ -475,6 +484,9 @@ summary), the current state is (every write below additionally passes the Safe-M | Battery, Option B (DLCI 0x04 `Group 0x03 Code 0x03`) | DLCI 0x04 | 🟢 FACT (message *identity*; ADR-031; charging flag, ADR-033's 2026-09-20 update) | **ADR-033** (`ai-sessions/0040`, maintainer-approved; charging flag accepted 2026-09-20) | **Implemented** (`BatteryFrameDecoder`, `:data`) — `0bSVVVVVVV` per earbud; the Case (`b3`) is not decoded there. Left/Right hardware-seen in `CAP-059`–`CAP-061`. | | Battery, Case (Option F, DLCI 0x02 `SubscribeRuntimeInfo` entry 6.1) | DLCI 0x02 | 🟢 FACT (`PROTOCOL.md` §4.3 Option F, 2026-09-24) | **ADR-043** (one request per Connect; supersedes the DLCI 0x08 claim of ADR-035/038/039; its 2026-09-26 Update: one more per *Refresh battery*, built `ai-sessions/0052`, hardware-verified in `CAP-063` — 8 of 8 re-subscriptions answered, AY-14; (corrected 2026-10-03, `ai-sessions/0069`): this cell said "not hardware-verified") | **Implemented** (`Maestro.subscribeRuntimeInfoRequest`, `RuntimeInfoDecoder`, `:data`) — Case hardware-verified in `CAP-062`; per-bud charging and the last-seen Case (ADR-043 Update, `ai-sessions/0048`) hardware-verified in `CAP-064` ((i) dialog 10:17:13), `CAP-065` (11:13:46) and `CAP-068` (S4) — cell corrected 2026-10-08, `ai-sessions/0081`. | | §4.5's other DLCI 0x02 settings (Touch & Hold, Head gestures, In-ear detection, Mono audio, Volume EQ, Volume Balance, Case sounds, Multipoint, Conversation Detection) | DLCI 0x02 | 🟢 FACT for several individual fields' number/semantic identity (ADR-019 and its Updates) | **ADR-036** — read-only `ReadSetting` for fields 2, 4, 7, 11, 15, 17, 19, 22, 27, 28 (no subscription); **ADR-045** (2026-09-26) — `WriteSetting` for 17, 19, 22, 4, 7; **ADR-046** (2026-09-28) — read + write of field 12; **ADR-047** (2026-09-28) — write of field 2 (both built `ai-sessions/0056`); **ADR-052** (2026-10-04) — read + write of field 29; **ADR-053** (2026-10-04) — write of 11; **ADR-054** (2026-10-06) — writes of 27, 28; **ADR-055** (2026-10-06) — write of 15 (all four built `ai-sessions/0074`) | **As built `ai-sessions/0052`:** reads of 2, 4, 7, 17, 19, 22 at Connect (`SettingsCodec`, fixtures `CAP-036` 1445…1540); **writes of 17, 19, 22, 4, 7 per ADR-045** (fixtures `CAP-019` 1720/1808, `CAP-020` 1741/1995, `CAP-021` 1895/3619/4315/4976, `CAP-022` 1621/1823/1922…2099, byte-identical); UI tabs Sound and Controls. **`ai-sessions/0056`:** field 12 read at Connect and written (ADR-046; fixtures `CAP-056` 1529/1531, 1689/1725/1786/1815/1843, OK 1697; channel 21 `CAP-041` 2176/2192/2198, `CAP-036` 1514/1516) and field 2 written (ADR-047; `CAP-056` 2173/4048, 2849/3627). **`ai-sessions/0074` (1.1.0):** fields 11, 15, 27, 28, 29 read at Connect and written — head gestures 1 = off / 2 = on (`SettingValue.HeadGestures`), the others 0/1; fixtures byte-identical: `CAP-069` 3161/3212 (11, ch 21), 2492/2564 (29, ch 21), `CAP-024` 1988/2023/2053/2084 and `CAP-058` 5623/5643/5680/5697 (28/27, both channels), `CAP-022` 1871/1895 (15, ch 19), `CAP-041` 2461 (15 off, ch 21); reads `CAP-036` 1471…1561 and their ch-19 counterparts in `CAP-024`/`CAP-069`. The 0052 settings were hardware-verified in `CAP-063`; 12 and 2 in `CAP-064` (ADR-046/047 Updates); **11, 15, 27, 28, 29 in `CAP-070`** (1.1.0, build `0323849`: 89 of 89 writes byte-identical and ACKed on both channels, read back on the next connection — `CAP-070-FINDINGS.md` §2–§3). *(Updated 2026-10-07, `ai-sessions/0075`, maintainer-approved in chat 2026-10-07, `AskUserQuestion` "Docs":)* the three forms not seen before — 11 and 29 on channel 19, `15:1` on channel 21 — are on the wire in `CAP-070` (A3668/A3679, A3614/A3685, A4800), byte-identical to the derived frames; since `ai-sessions/0076` (2026-10-07) these bytes are the unit-test fixtures (`Settings070`), the labelled structural tests and ADR-055's `// TODO(verify)` are gone (ADR-055 Update of 2026-10-07). | +| Serial numbers (`GetHardwareInfo` field 7) | DLCI 0x02, pw_rpc `maestro_pw.Maestro` `GetHardwareInfo` | 🟢 FACT (method identity `PROTOCOL.md` §6 2026-09-24; the component order 7.1 Case / 7.2 Right / 7.3 Left, 2026-10-09) | **ADR-058** (2026-10-09) — one request per Connect, a read, not gated | **Implemented `ai-sessions/0082` (1.2.0)**: `Maestro.getHardwareInfoRequest`, `HardwareInfo`, `RoutedFrame.HardwareInfo`, `BudsRepositoryImpl.readHardwareInfo`; fixtures `CAP-036` 1415/1423 (ch 21) and `CAP-024` 801/832 (ch 19) — the answers with the serials redacted (`HardwareInfoFixtures`); the Info tab lines. **Not hardware-verified** (no OpenControl capture holds the exchange; `CAP-072` BH-3). | +| Worn indicator ("probably worn") | — (no request; the Settable byte of DLCI 0x04, field 2 and the runtime-info charging flags) | 🟡 ADR-049 item 3 | **ADR-059** (2026-10-09) — a user-visible interpretation of a hypothesis, labelled as such | **Implemented `ai-sessions/0082` (1.2.0)**: `WornReading`/`wornReading()` (`:domain`), `BudsRepository.wornReading`, the battery card's line and (i); fixtures `CAP-065` 5465/6334, `CAP-064` 10394 (+ the field-2 write 9922), `CAP-062` 4845/2782. **Not hardware-verified** (`CAP-072` BH-20 … BH-23, `INEAR-006`). | +| "Changed by the Buds" (ANC-mode cause) | DLCI 0x04 `Notify` (existing) | 🟢 FACT for the `Notify` (`PROTOCOL.md` §4.1) | none needed (UI + state, no wire change; `CAP-069` §12 item 8) | **Implemented `ai-sessions/0082` (1.2.0)**: `AncModeCause`, `BudsRepository.ancModeCause`, the (i) line; fixtures `CAP-045` 609/612/1583/1755/1818 (press-and-hold changes on a bud). **Not hardware-verified** (`CAP-072` BH-10/BH-11). | | Safe Mode / Startup Handshake | DLCI 0x02 announcement + DLCI 0x04 Model ID | 🟢 FACT (firmware string ADR-012/034; Model ID `PROTOCOL.md` §0.1) | **ADR-042** | **Implemented** (`SafeModeGate`, `:data`; Safe Mode card, `:ui`) | | Find My Buds Case / "ring both" | — | — | ADR-027 (premise narrowed 2026-09-24: spec `0x03` "ring both" untested) | **Out of scope**; sending `0x03` needs its own ADR | diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9e87bf..6e040d59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,49 @@ build commit `0323849`, hardware run `CAP-070`, `ai-sessions/0075`). See `TODO.m ## [Unreleased] +## [1.2.0] - not yet released + +Prepared in `ai-sessions/0082` (2026-10-09); its hardware run is `CAP-072` (Group BH), after which it is released — or rebuilt as the same version. Three new +things to see, two switches in new places, and one study. Nothing new is written to the Buds; one new read per connection. + +### Added + +- **"Changed by the Buds"** in the noise-control card's (i) details: when the Buds report a mode the app did not ask for — a press-and-hold on a bud, or the + Buds' own change — the (i) says "Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)." A mode the app set, or read when it + connected or refreshed, has no such line. Nothing on the main surface, no notification. (`CAP-069` §12 item 8; fixtures `CAP-045`.) +- **Serial numbers** on gear → Info, under the firmware (`DECISIONS.md` ADR-058): one `GetHardwareInfo` request per connection — the official app's own, sent + after the battery subscription, never retried — and the three strings of its answer, labelled Case / Right bud / Left bud by position, as the official app + labels them (the Buds list them in that order). "Not read" with the reason if the Buds do not answer. Serial numbers are never written to the app's log. +- **"Probably worn"** on the Connection screen's battery card (`DECISIONS.md` ADR-059): from the Buds' last noise-control report (at Connect, Refresh and every + noise-control tap), the in-ear detection setting and whether the buds are charging: "Probably worn (checked HH:MM:SS)", "Not worn (checked HH:MM:SS)", "Both buds + in the case", or "Worn: unknown — in-ear detection is off". The (i) explains the two limits: the Buds say "at least one bud", never which; and once (`CAP-064`) + they reported "worn" for about half a minute with both buds on a table. Nothing new is sent for it. + +### Changed + +- The two **Case sounds** switches ("Earbuds replaced", "Other alerts") moved from Controls to gear → **Settings** (Dark mode · Case sounds · Use different Buds); + their (i) adds "These settings live on the case and are read when the app connects." The requests are unchanged. +- **Conversation detection** moved from Sound to **Controls** (its own card after In-ear detection, the same subtitle). Sound keeps Balance, Mono audio and Volume + EQ. The request is unchanged. +- The app sends thirteen requests at connect instead of twelve settings reads plus the EQ read and the subscription: the one `GetHardwareInfo` above. + +### Studied, not built + +- **Volume-level notifications** (the official app's "Volume level notifications" switch): the official app decides on the phone — once per connection it + sums seven days of the Buds' `Dosimeter` daily doses and notifies at 67 % of its own constant, at most once per week; the Buds never push a value by + themselves. A design with a draft ADR (ADR-060, proposed, not accepted) is in `ai-sessions/0082` and `DECISIONS.md`; it needs a capture with a sound-level + meter first (`WELL-002`). A 1.3.0 candidate. + +### Known issues + +- The worn indicator is a hedged reading of a hypothesis (`DECISIONS.md` ADR-049 item 3): "Probably worn" means the Buds allow a noise-control change, which on + film always meant at least one bud in an ear — the app cannot say which bud, and once the Buds reported it with both buds on a table for about half a minute. +- The serial-number labels (Case / Right bud / Left bud) are the official app's reading of the answer's order, consistent with the strings' own marks; the Buds + do not name the parts. +- The three items above are not hardware-verified before `CAP-072`; the 1.1.1 known issues stay: the app says what a switch sets; the screen-reader text for "—" + is checked by the unit tests, not yet on a phone; English only; after you stop a ring by touching the bud, the app keeps saying it is ringing until you tap + Stop. + ## [1.1.1] - 2026-10-08 A maintenance release: nothing changes in what the app does or shows. It is built with current build tools and libraries (Kotlin, Jetpack Compose, diff --git a/DECISIONS.md b/DECISIONS.md index d22bc916..6cb13dc3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2559,5 +2559,102 @@ motivated this). current. A value never read shows "—" (screen reader: "Not read from the Buds yet"), never a default. - **Consequences**: as built (`ARCHITECTURE.md` §3.1, "One rule for 'current'"); every new setting follows it (ADR-052 … ADR-055 did). +## ADR-058 — DLCI 0x02: one `GetHardwareInfo` request per Connect for the component serial numbers + +- **Date**: 2026-10-09 +- **Status**: Accepted (maintainer, chat 2026-10-09, `ai-sessions/0082`) +- **Note on process**: drafted by an AI agent (`ai-sessions/0082` RESULT §A.2/§B); the decision is the maintainer's, given in the chat of 2026-10-09 + (`AskUserQuestion` "Item 2 ADR-058", option *"ADR-058, ongegate read, labels Case/Right/Left + 🟢 (Recommended)"*, with this text in the preview), per + `AGENTS.md` §6. The same answer promotes `PROTOCOL.md` §6's serial-attribution item (below). +- **Context**: `GetHardwareInfo` (`maestro_pw.Maestro`, method `0x28eca5e3` = `h65599("GetHardwareInfo")`) is 🟢 since 2026-09-24 (`PROTOCOL.md` §6: `CAP-036` + 1415 → 1423, 83 ms); the official app sends it once per connection on the announced channel — 138 requests and 138 answers in 54 logs (channel 19: 49, + 21: 88, 24: 1; `python3 scripts/pwrpc_decode.py | grep GetHardwareInfo`), every answer's field 7 the same three length-14 strings in the same order. + OpenControl never sent it (`Maestro.kt`: the id was a name for the debug log; 0 requests in the 14 logs of `CAP-067`/`068`/`070`/`071`), so `PROJECT.md`'s + scope line "serial numbers not read" stayed `[~]`. The official app reads the three strings **by position**: `gaa.java:45–96` (`gaa.a(qiv)`) copies `qjm` + field 1 → `gdv.c`, 2 → `gdv.d`, 3 → `gdv.e`; `fwg.java:182–215` reads `gdv.e` as the left serial, `gdv.d` as the right, `gdv.c` as the case — **7.1 = Case, + 7.2 = Right bud, 7.3 = Left bud**. The wire agrees: 7.1 ends `…EC0251`, 7.2 `…DR3209`, 7.3 `…DL3147` (`CAP-036` 1423, `CAP-024` 832, same on every channel). +- **Options considered**: (a) no request, serials stay unread; (b) one request per Connect, the strings shown "as reported" without labels; (c) one request + per Connect, gated by Safe Mode like a write; (d) one request per Connect, a read like the app's other reads, labelled by the official app's reading — chosen. +- **Decision**: + 1. Once per Connect, after `SubscribeRuntimeInfo`, the app sends one `GetHardwareInfo` REQUEST (empty payload, no call id) on the announced channel with its + ADR-034 address — byte-identical to `CAP-036` 1415 on channel 21 (`7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25 e3 a5 ec 28 f9 67 61 b5 7e`) and to `CAP-024` + 801 on channel 19 (`7e 00 3b 03 10 13 … ff 1e bb b7 7e`); no retry, ≤ 2 s wait (the existing settings-read bound), nothing queued. + 2. A read: **not** gated by Safe Mode (ADR-042 item 3: reads continue), like `ReadSetting` and `SubscribeRuntimeInfo`. + 3. From the `RESPONSE` only field 7's three strings are read (printable ASCII, 1–64 characters each, fields 7.1–7.3); fields 1, 2, 5, 6 and 8 are not + interpreted. An answer without them, an error status or no answer ⇒ "—" with the reason (`UnreadableAnswer`, `MaestroRejected`, `Timeout`). + 4. Settings → Info, under the firmware: "Serial numbers (from the Buds, HH:MM:SS):" with one line per string, labelled **Case / Right bud / Left bud by + position**, and the note "Labelled by position as the official app labels them: the Buds list the Case, the Right bud, then the Left bud." — + `PROTOCOL.md` §6's "which serial belongs to which component … stays 🟡" becomes **🟢 FACT** (the official app's own reading; wire-consistent on 19, 21, 24). + 5. Serials are device identifiers (`AGENTS.md` §9): never logged at `INFO` or above (the always-on log prints the pw_rpc summary and the count only; the raw + hex dump is Debug mode only); redacted to the first four and last two characters in fixtures, documents and this file. +- **Consequences**: `Maestro.getHardwareInfoRequest`, a `HardwareInfo` reader (field 7 only; never throws; fuzzed), `RoutedFrame.HardwareInfo`, + `BudsRepositoryImpl.readHardwareInfo` (after the subscription), `DeviceInfo.serials` + `serialsReadAtMillis`, `BudsRepository.serialsError`; the Info tab + lines. The Connect read sequence gains one unary request (≈ 83 ms in `CAP-036`). Hardware re-test (`CAP-072`, `FW-003`): the request/answer on the announced + channel, the serials on film against the official app's About screen (if available) or the strings' EC/DR/DL marks. Not covered: the other fields of the + answer (device type, SKU, hardware versions) — a later ADR if ever wanted. + +## ADR-059 — A "probably worn" indicator from the Settable byte, the in-ear detection setting and the runtime-info charging flags + +- **Date**: 2026-10-09 +- **Status**: Accepted (maintainer, chat 2026-10-09, `ai-sessions/0082`) +- **Note on process**: drafted by an AI agent (`ai-sessions/0082` RESULT §A.3/§B); the decision is the maintainer's — "probably worn" as the reading was the + maintainer's choice before the session (chat 2026-10-09, the prompt's §1 item 3), the texts and the place in the chat of 2026-10-09 (`AskUserQuestion` + "Item 3 ADR-059", option *"Batterijkaart + teksten zoals voorgesteld (Recommended)"*, with this text in the preview), per `AGENTS.md` §6. **No `PROTOCOL.md` + status change:** ADR-049 item 3 stays 🟡, and the app's (i) says so in plain words. +- **Context**: ADR-049 item 3: 🟡 (strong) "`00` ⇒ no bud worn" — 56 filmed samples (`CAP-065`, `CAP-066`), 0 counter-examples; the converse is **refuted** + (`CAP-064`: `e8` with in-ear detection off, 10394/10600; ≈ 28 s of `e8` with both buds on the table, in-ear detection on, 2299–2759). The runtime-info stream + gives each bud's charging state (ADR-043 Update of 2026-09-25; 🟡 charging = in the case). `PROJECT.md`'s one open `[ ]` scope line: "whether a bud is worn is + not shown". GSND CONTROL Code `0x05` is not a source (DLCI 0x08, not opened, ADR-043). +- **Options considered**: (a) not shown; (b) a plain "worn / not worn" (overclaims a 🟡); (c) a hedged "probably worn" from existing state — chosen; (d) a new + request or channel for it — rejected (nothing on the wire carries a per-bud worn state the app may read). +- **Decision**: + 1. **No new wire traffic.** A pure function in `:domain` (`wornReading`) derives one reading from state the app already holds — the Settable byte of the + Buds' last `Notify ANC state` on this connection with its time, the in-ear detection setting (`qhr` field 2), the two charging flags of this connection — in + this order: no `Notify` this connection → "—" (screen reader: "Not read from the Buds yet"); field 2 = 0 → "Worn: unknown — in-ear detection is off"; + field 2 not read → "Worn: unknown — the in-ear detection setting was not read"; both buds charging → "Both buds in the case"; Settable `00` → + "Not worn (checked HH:MM:SS)"; non-zero → "Probably worn (checked HH:MM:SS)". + 2. The reading is from the last report (Connect, Refresh, a noise-control tap) — never live; its time is always shown; pull-to-refresh re-reads it through + the existing `Get`. + 3. It never names a bud as worn (the byte is not per bud). + 4. One line on the Connection screen's battery card, under the three columns; the card's (i) carries the line and the explanation with both limits: "Worn: + from the Buds' last noise-control report. 'Probably worn' = the Buds allow changing noise control — on film that always meant at least one bud in an ear, + but the Buds do not say which bud, and once (CAP-064) they reported it for about half a minute with both buds on a table. 'Not worn' = the Buds refuse a + change — in 56 filmed samples no bud was in an ear. With in-ear detection off the report says nothing about wearing." + 5. ADR-049 item 3 stays 🟡; this ADR records a user-visible interpretation of it (`AGENTS.md` §13 "Implementing": a HYPOTHESIS with a verification plan), + not a promotion. +- **Verification** (`CAP-072`, `INEAR-006`, ears on film): both buds out → "Not worn"; one in → "Probably worn"; both in the case → "Both buds in the case"; + in-ear detection off → "Worn: unknown — in-ear detection is off"; buds straight from the case to the table, the indicator watched for 60 s (the ≈ 28 s case). +- **Consequences**: `WornReading` + `wornReading()` (`:domain`), `BudsRepository.wornReading` (a `combine` of four existing flows), the battery card's line and + (i) lines, one test per reading with the decoded values of real frames, the repository test with the real frames. If a capture refutes "`00` ⇒ no bud worn", + the texts are revisited; if ADR-049 item 3 is ever promoted, the hedge may go. + +## ADR-060 — Volume-level notifications from `maestro_pw.Dosimeter` (DRAFT — proposed, not accepted) + +- **Date**: 2026-10-09 (drafted) +- **Status**: **Proposed** — a draft awaiting maintainer sign-off; **not accepted, nothing built** (the maintainer's choice in chat 2026-10-09, `AskUserQuestion` + "Item 4 Dosimeter", option *"1.3.0-kandidaat, ADR-060 als concept (Recommended)"*). It stands here only because `scripts/lint_docs.py` requires a heading + for every registered `ADR-NNN` and the number is already cited (`ai-sessions/0082` prompt and RESULT, `TODO.md`); the agent that wrote it has no authority to + accept it (`AGENTS.md` §6). Accepting it is a maintainer decision in a later session, after the 🟡 below are 🟢. +- **Context** (`ai-sessions/0082` item 4, RESULT §A.4/§C.4; `REVERSE_ENGINEERING.md` section of 2026-10-09): the official app's "Volume level notifications" + switch writes `qhr` field 21 (🟢, `PROTOCOL.md` §4.5.8a), but the notification itself is decided by the **phone**: `HearingWellnessNotificationWorker` runs once + per session (1 min after connect), sums seven daily doses from `Dosimeter.FetchDailySummaries` (left and right averaged per day) and notifies at ≥ 67 % of the + constant `3.3903457E11` (7 × the 24-hour constant `4.843351E10`), at most once per 7 days; field 21 only schedules or cancels that worker. The Buds never push + a Dosimeter value unsolicited (0 in 85 logs). OpenControl has never sent a Dosimeter request. +- **Decision, if accepted later**: + 1. Once per Connect, after the existing requests, one `Dosimeter.SubscribeToLiveDb` REQUEST (`CAP-027` 1107/1214's bytes, the announced channel) and one + `FetchDailySummaries` REQUEST (`CAP-058` 5736) — two new requests; no polling, no re-request; they end with the session. (The official app asks both buds' + channels; one channel would be a deviation to test.) + 2. The phone computes the dose as the official app does: the seven day entries (`qia` field 6, linear) summed, left and right averaged, against the app's + constants — quoted, unit unknown, not asserted as a health figure. + 3. A local notification channel; at most one notification per 7 days at ≥ 67 % (the official rule); the field-21 switch (read + write, a new flag write + byte-identical to `CAP-058` 5747/5753 on channel 21) is the user's on/off; DataStore keeps only the last-notified time. + 4. The texts say: "the Buds' own exposure figure against the official app's limit; the unit is not verified." +- **🟡 that must become 🟢 first**: (i) `qir` field 2 is a sound level (`WELL-002`: values against a sound-level meter on film); (ii) `qia` field 6 is a per-day dose + in the same unit; (iii) the day index's epoch; (iv) the Buds answer a second client's `SubscribeToLiveDb`/`FetchDailySummaries`; (v) field 21's channel-19 + write form (uncaptured); (vi) the cost — ≈ 2 stream packets/s per channel for the whole session (`CAP-027`: 208 per channel in 4 min) — for the battery and the + log. +- **Consequences, if accepted**: a Dosimeter codec, two requests in the Connect sequence, a notification channel (`POST_NOTIFICATIONS` is already held), the + switch on a tab, DataStore for one timestamp; its own hardware run with the meter. Until then: `TODO.md` §5 carries the candidate. + --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/DECISIONS.md - https://tedsluis.github.io/opencontrolpixelbudspro2/DECISIONS diff --git a/PROJECT.md b/PROJECT.md index a6ebad85..3162b0e4 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -44,9 +44,9 @@ these run over local BLE/RFCOMM versus over the cloud/a Google account): - [x] Configure equalizer / sound profile (presets and custom bands) (ADR-020/034); Volume EQ on/off (field 15, read + write, ADR-055, `ai-sessions/0074`, 1.1.0) - [x] Configure touch controls and head gestures — "Use touch controls" and press-and-hold per bud built (read + write, ADR-036/045, `ai-sessions/0052`); the press-and-hold ANC-mode list built (field 12, read + write, ADR-046, `ai-sessions/0056`); "Use head gestures" built (field 29, read + write, ADR-052, `ai-sessions/0074`, 1.1.0) - [x] Volume balance, mono audio, conversation detection (read + write, ADR-036/045, `ai-sessions/0052`) -- [~] Read firmware version and serial numbers per component (firmware shown; serial numbers not read) +- [x] Read firmware version and serial numbers per component (firmware since `ai-sessions/0062`; serial numbers since 1.2.0 — one `GetHardwareInfo` per Connect, ADR-058, `ai-sessions/0082`; labelled Case / Right bud / Left bud by position, the official app's reading) - [x] "Find my Buds" functionality — Left/Right only (ADR-011); Case/"both" out of scope (ADR-027) -- [ ] In-ear detection status — the in-ear detection **setting** is read and written (field 2, ADR-047, `ai-sessions/0056`); whether a bud is worn is not shown +- [x] In-ear detection status — the in-ear detection **setting** is read and written (field 2, ADR-047, `ai-sessions/0056`); whether a bud is worn is shown as **"probably worn"** since 1.2.0 (ADR-059, `ai-sessions/0082`: derived from the Settable byte, field 2 and the charging flags — a hypothesis, labelled as such; never per bud) - [~] Manage multipoint connections — the Multipoint switch is built (field 11, read + write, ADR-053, `ai-sessions/0074`, 1.1.0); choosing or listing the connected devices is not - [x] Case sound settings (earbuds replaced, other notifications) — fields 28 and 27, read + write, ADR-054, `ai-sessions/0074`, 1.1.0 @@ -145,6 +145,9 @@ screen-reader text for "—"; its release run `CAP-070` (`ai-sessions/0075`, 202 **1.1.1** (`ai-sessions/0078`/`0079`: the same app built with the current toolchain) passed its release run `CAP-071` (`ai-sessions/0080`, 2026-10-08) in the user without Play: installed over 1.1.0 it kept the data and settings, and every Connect read, write, ANC `Set` and Ring was byte-identical to 1.1.0's and answered; verdict: release build `86a6fb3` (the maintainer, chat 2026-10-08). +**1.2.0** (prepared in `ai-sessions/0082`, 2026-10-09; not yet released): "Changed by the Buds" in the noise-control details, the component serial numbers on the +Info tab (ADR-058), the "probably worn" indicator on the battery card (ADR-059), the Case sounds switches moved to gear → Settings and Conversation detection +to Controls; volume-level notifications studied, not built (a draft ADR-060, a 1.3.0 candidate). Its hardware run is `CAP-072` (Group BH, planned). --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/PROJECT.md - https://tedsluis.github.io/opencontrolpixelbudspro2/PROJECT diff --git a/PROTOCOL.md b/PROTOCOL.md index 83e316f4..aa053354 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -2782,6 +2782,13 @@ leaving them buried in prose elsewhere. `1779298694`) and `0x1c256c5d`. It carries a settings read-back after all — the official app's `ReadSetting` sweep (consistent with ADR-034, and with `OBS-007`'s content-level negative, which compared the burst *across* settings states, not whether it reads them). This unblocks nothing; `CAP-057` (Group AS) is withdrawn as unnecessary. Which serial belongs to which component (the `EC`/`DR`/`DL` substrings suggest Case/Right/Left) stays 🟡. + **Update (2026-10-09, `ai-sessions/0082`, maintainer-approved in chat 2026-10-09, `AskUserQuestion` "Item 2 ADR-058", with ADR-058's text in the + preview) — the attribution is 🟢 FACT (the official app's reading; wire-consistent):** field 7 lists **the Case (7.1), the Right bud (7.2), the Left bud + (7.3)** — by position. Code: `gaa.java:45–96` (`gaa.a(qiv)`) copies `qjm`/`qjr` field 1 → `gdv.c`, 2 → `gdv.d`, 3 → `gdv.e`; `fwg.java:182–215` reads + `gdv.e` as the left serial ("Missing left serial number"), `gdv.d` as the right, `gdv.c` as the case. Wire: 138 answers in 54 logs — channel 19: 49, 21: 88, + 24: 1 — every one the same three strings in the same order (`for f in captures/*/*btsnoop_hci*.log*; do python3 scripts/pwrpc_decode.py "$f" | grep + GetHardwareInfo; done`; one request per connection on the announced channel, 138 of 138), their EC/DR/DL marks agreeing with the code's reading. The + app sends the request since 1.2.0 (`DECISIONS.md` ADR-058); the strings are device identifiers and are redacted everywhere outside the logs. - [ ] **Added 2026-09-04, `CAP-036-FINDINGS.md` §3; corrected same day by `DESKRESEARCH_FINDINGS.md`'s two-round cross-check across 10 capture files:** `CAP-036`'s "Notify ANC state" frame (`08 13 00 04 01 e8 00 20`, Settable=`0x00`) is one of **5** samples @@ -3682,6 +3689,7 @@ leaving them buried in prose elsewhere. | 2026-10-06 | **`ai-sessions/0073` — second APK pass (`v1.0.955078536-10253511`), every text maintainer-approved in chat 2026-10-06 (`AskUserQuestion` "PROTOCOL").** Dated notes, no status change: **§2.2a** `0xbf6c9399` = `a10a20.kpi.Kpi`/`KpiStream`, `0x8d99df93` = `JitterBuffer.SetJitterBufferSizePreference`; the official app never requests `GetSoftwareInfo` (0 of 295), fields 5 ("random id") and 6 (not-primary flag) in the app's words. **§4.2** the navigate-away path is a navigation destination listener. **§4.3 Option F** entry field 2 = charger-type enum. **§4.5.9** no Loud Noise Protection / Adaptive Audio control in this app version. **§6** the `CAP-051` Optional gate refuted. | `ai-sessions/0073_CROSSCHECK_RESULT_2026_10_06.md` | | 2026-10-07 | **`ai-sessions/0075` — `CAP-070` (Group BF, OpenControl 1.1.0 without Play services), maintainer-approved in chat 2026-10-07.** **§2.2a** L-1: the Left taken off on 19 with both buds out of the case ⇒ `DISC` + 21 (wear not on film); five Right changes on 19 without `DISC`; both docked ⇒ 21 or 19 — status unchanged (🟡). **§4.5.2/§4.5.4/§4.5.6/§4.5.7** dated notes: the channel-19 forms of 11 and 29, `15:1` on channel 21 and `17:7` on channel 19 are on the wire, byte-identical to the derived frames, ACKed. | Claude (AI), maintainer-approved | | 2026-10-07 | **`ai-sessions/0077` — the maintainer's pending decisions, approved in chat 2026-10-07.** **§4.3 Option E** dated Update: Code `0x03` field 3 = the lower bud level (🟡). **§2.3** note: Code `0x03`, Code `0x05` values `01`/`00`. **§4.1** note: Settable `00` ⇒ Current Off (🟡, 17/17 + 19/19). **§6** pointer to `CALL-001` (`CAP-008` §4; codec out of scope). No status change. | Claude (AI), maintainer-approved | +| 2026-10-09 | **`ai-sessions/0082` — 1.2.0, maintainer-approved in chat 2026-10-09.** **§6** `GetHardwareInfo` field 7: the component attribution (7.1 Case, 7.2 Right bud, 7.3 Left bud, by position — the official app's reading, wire-consistent on channels 19/21/24) 🟡 → 🟢 (ADR-058). No other status change: ADR-059 (the "probably worn" indicator) reads ADR-049 item 3 as the 🟡 it is. | Claude (AI), maintainer-approved | --- https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/PROTOCOL.md - https://tedsluis.github.io/opencontrolpixelbudspro2/PROTOCOL diff --git a/README.md b/README.md index bbf25e24..02031aa8 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,11 @@ tests, not yet on a phone. **1.1.1** (2026-10-08, `ai-sessions/0079`): the same app built with current build tools and libraries — nothing changes for you. Its hardware run `CAP-071` passed (`ai-sessions/0080`: the update keeps your settings, every request and answer is the same as 1.1.0's). Known issues: as 1.1.0. +**1.2.0 is prepared, not yet released** (2026-10-09, `ai-sessions/0082`; its hardware run `CAP-072` decides): "Changed by the Buds" in the noise-control details, +the serial numbers on the Info tab, a "probably worn" line on the battery card, and the Case sounds switches on gear → Settings and Conversation detection on +Controls. Known issues of 1.2.0 (also in [`CHANGELOG.md`](./CHANGELOG.md)): the worn line is a hedged reading of a hypothesis (the Buds say "at least one bud", +never which, and once reported it with both buds on a table); the serial labels are the official app's reading of the answer's order. + **Known issue since 1.0.1** (also in [`CHANGELOG.md`](./CHANGELOG.md)): - After you stop a ring by touching the bud, the app keeps saying it is ringing until you tap Stop. @@ -140,22 +145,24 @@ the Pixel Buds Pro 2 first has to be reconstructed through Bluetooth traffic analysis and reverse engineering of the Android APK. That knowledge is then used to design, implement, test, and document a native Android app. -## Current state (2026-10-08) +## Current state (2026-10-09) -- **Captures:** 71 registered sessions (`CAP-001`–`CAP-071`): 67 analyzed (the latest, `CAP-071` of 2026-10-08: the 1.1.1 release run — the update over 1.1.0 keeps the settings and every request is byte-identical, `ai-sessions/0080`), 2 planned, 2 withdrawn (`CAP-052`, +- **Captures:** 72 registered sessions (`CAP-001`–`CAP-072`): 67 analyzed (the latest, `CAP-071` of 2026-10-08: the 1.1.1 release run — the update over 1.1.0 keeps the settings and every request is byte-identical, `ai-sessions/0080`), 3 planned (`CAP-072`: the 1.2.0 release run, Group BH), 2 withdrawn (`CAP-052`, `CAP-057`) — see `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9 and `id_registry.csv`. `CAP-059`–`CAP-068`, `CAP-070` and `CAP-071` are captures of this project's own app (`CAP-067`: the 1.0.0 release APK, `CAP-068`: the 1.0.1 release APK, `CAP-070`: the 1.1.0 release APK, `CAP-071`: the 1.1.1 release APK, all without Google Play services); the Safe-Mode fix of `ai-sessions/0046` was hardware-verified in `CAP-062`/`CAP-063`. - **APK analysis:** one companion-app version fully pulled, decompiled, and analyzed (`v1.0.955078536-10253511`) — see `reverse-engineering/APK_VERSIONS.md`. DLCI 0x04/0x08's transport code is not in it (ADR-025): both channels are implemented independently, from wire-capture evidence (and, for DLCI 0x04, the public Fast Pair spec). -- **Decisions:** 57 ADRs (`DECISIONS.md`); every 🟢 FACT in `PROTOCOL.md` has a recorded maintainer sign-off, except ADR-009, whose +- **Decisions:** 59 accepted ADRs and one labelled draft (ADR-060, proposed, not accepted) in `DECISIONS.md`; every 🟢 FACT in `PROTOCOL.md` has a recorded maintainer sign-off, except ADR-009, whose approval was given but whose record is missing (the ADR says so). - **Implemented in the app:** ANC/Transparency/Adaptive (DLCI 0x04, ADR-009), Find My Buds Left/Right (ADR-011), EQ read and write (DLCI 0x02 pw_rpc, ADR-020/034), battery Left/Right with charging (ADR-033) and the Case (DLCI 0x02 `SubscribeRuntimeInfo`, ADR-043), the settings reads (ADR-036) and writes (touch controls, press and hold, conversation detection, balance, mono, in-ear detection — ADR-045/046/047; - from 1.1.0 head gestures, Multipoint, case sounds and Volume EQ — ADR-052…055), + from 1.1.0 head gestures, Multipoint, case sounds and Volume EQ — ADR-052…055), from 1.2.0 the serial numbers (one `GetHardwareInfo` per connection, ADR-058), + the "probably worn" line (ADR-059) and "Changed by the Buds" (`ai-sessions/0082`), firmware line, ANC Quick Settings tile, Safe Mode (ADR-042), the session re-open while visible (ADR-044). -- **Protocol-known but not built:** the "Volume level notifications" switch (`qhr` field 21, 🟢 since 2026-10-06, no ADR); the BLE battery advertisement +- **Protocol-known but not built:** the "Volume level notifications" switch (`qhr` field 21, 🟢 since 2026-10-06) and the notification behind it — studied in + `ai-sessions/0082` (the phone decides, from the `Dosimeter` daily doses; draft ADR-060, a 1.3.0 candidate, needs `WELL-002` first); the BLE battery advertisement (never matched on the wire). - **Still open (protocol):** EQ field 16-vs-18 save semantics, DLCI 0x08's own identity, why the Buds sometimes close the RFCOMM channels, whether the announced Maestro channel names the hosting bud — see `PROTOCOL.md` §6. diff --git a/TODO.md b/TODO.md index ccbfcd1c..add791a0 100644 --- a/TODO.md +++ b/TODO.md @@ -22,8 +22,9 @@ Open after `CAP-070` (the 1.1.0 release run, `ai-sessions/0075`, `CAP-070-FINDIN on channel 19, `15:1` on channel 21), the channel-19 balance `17:7`, the swipe between tabs (camera only), and P1 (a first install in user 10, 🟡). **`CAP-071`** (the 1.1.1 release run, analysed in `ai-sessions/0080`) settled the update path (1.1.1 over 1.1.0 kept data, dark mode and Debug mode) and the `SIGQUIT` source (the computer's `adb bugreportz`, `CAP-071-FINDINGS.md` §0) — both removed here. Its film 2 (BG-20 … BG-24) was not made and there was no screen -recording, so the items below stay open. **Next app run:** the lid open before the app starts (P8), no app use before the film (P9), Do Not Disturb on (an -incoming call put a third party's number on `CAP-071`'s film), the screen recording on, observations said aloud. +recording, so the items below stay open. **Next app run — `CAP-072` (Group BH, the 1.2.0 release run, skeleton by `ai-sessions/0082`):** the lid open before the app +starts (P8), no app use before the film (P9), Do Not Disturb on (an incoming call put a third party's number on `CAP-071`'s film), the screen recording on, +observations said aloud. `CAP-072` takes every item below except B4, the CDM picker, K5/BC-12, F-4 and the second device (`ai-sessions/0082` RESULT §I). - [ ] The screen-reader text on the phone: every "—" with the description "Not read from the Buds yet" — two `uiautomator dump`s (Controls, Sound) with Bluetooth off; not done in `CAP-070` (no dump; "—" itself is on film). (`APP_TESTPLAN.md` T11) — not done in `CAP-071` (no film 2, no dump). @@ -35,7 +36,7 @@ incoming call put a third party's number on `CAP-071`'s film), the screen record connected and during re-opens). S9 (needs two Pixel Buds paired) — not planned. - [ ] The Left bud taken out with both **worn** on channel 19, **head in view** (lead L-1; `CAP-070` BF-18 gave `DISC` + 21 with the Left identified, wear not on film) — 🟡 (`INEAR-005`). — not run in `CAP-071` (no film 2); `CAP-071` added two "only the Left out ⇒ 19" samples and one - 19 → 21 as the Right went back to the head (`PROTOCOL.md` §2.2a note of 2026-10-08). + 19 → 21 as the Right went back to the head (`PROTOCOL.md` §2.2a note of 2026-10-08). In `CAP-072` the same steps read the worn indicator (`INEAR-006`, ADR-059). - [ ] The case-sound and Volume-EQ observations (what is heard): the film needs an audio track (`CAP-070`'s had none), or the maintainer says it. — `CAP-071` had audio but nothing was said and no case chime was recorded with "Earbuds replaced" off or on (the Find ring was); still open. - [ ] B4 double tap (`AlreadyInProgress`) — needs the Buds forgotten and re-paired. @@ -150,7 +151,9 @@ Open questions (each with where it is described): - 🔴 Why Android's CDM picker listed nothing twice and then offered the bonded Buds directly (`CAP-068-FINDINGS.md` §8). - 🔴 `CAP-013`'s second BLE link (not re-examined; `CAP-016`'s was a heart-rate wearable — `PROTOCOL.md` §6, Update of 2026-10-03). - Candidates without a capture scenario: Audio switch (`SWITCH-001`), Hearing wellness (`WELL-001`), eartip fit test (`FIT-001`). - `maestro_pw.Dosimeter`: values on the wire, meaning 🟡; a display is out of scope (**M**, 2026-09-30). + `maestro_pw.Dosimeter`: values on the wire, meaning 🟡; a display is out of scope (**M**, 2026-09-30). **Volume-level notifications** studied in + `ai-sessions/0082` (item 4): the phone decides (the official app's worker, 67 % of a 7-day constant, once per 7 days; field 21 only gates it; the Buds push + nothing) — draft ADR-060 (proposed, not accepted) and the 🟡 that block it; a capture with a sound-level meter (`WELL-002`, no Group yet) comes first. ## 5. App: later work @@ -175,7 +178,12 @@ Open questions (each with where it is described): - [ ] **Balance precision:** `CAP-067` reached Right 4 on the 17th drag (7 of 17 snapped to Centre). Options: a live value label while dragging, slider `steps`, or the step buttons back. - [ ] `CAP-064` §10: whether the stale "100 %⚡" (I-4a) is drawn dimmed — a `:ui` Robolectric screenshot test settles it without hardware. -- [ ] `CAP-069` §12 item 8: say "changed by the Buds" in the (i) details when a `Notify` changes the mode without a tap (proposal, not decided). +- [ ] **1.3.0 candidate (M):** volume-level notifications from `maestro_pw.Dosimeter` — draft ADR-060 (`DECISIONS.md`, proposed, not accepted; `ai-sessions/0082` + RESULT §C.4): two new requests per Connect (`SubscribeToLiveDb`, `FetchDailySummaries`), the 7-day dose on the phone, a notification channel, the + field-21 switch. Blocked until `WELL-002` (the stream against a sound-level meter on film) and the other 🟡 of the draft are 🟢. +- [ ] After `CAP-072`: replace the redacted-answer fixtures of `GetHardwareInfo` (`HardwareInfoFixtures`: `CAP-036` 1423 / `CAP-024` 832 with the serials X-ed out) by + OpenControl's own exchange from that run (serials redacted the same way); ADR-058/ADR-059 Updates with the run's result (**M**). +- [ ] The worn indicator's (i) text names `CAP-064` to the user (the approved wording, `ai-sessions/0082` §B); reword without the capture id if the maintainer wants (**M**). - [ ] **String resources:** the UI texts are Kotlin literals; moving them to `strings.xml` is the precondition for any translation. - [ ] **Instrumented tests** — none exist: `OsConnectionObserver`, `BudsForegroundService`/`AncTileService`, the `BluetoothDevice`-dependent part of `BudsRepositoryImpl.connect()` and all of `:app` are covered only by pure-function and Robolectric tests. diff --git a/id_registry.csv b/id_registry.csv index 16367c12..eb996105 100644 --- a/id_registry.csv +++ b/id_registry.csv @@ -229,3 +229,9 @@ BATT-007,test,catalogued,"Battery Notification advertisement right after the cas SWITCH-001,test,catalogued,"Official app 'Audio switch' setting — candidate, no capture scenario (added 2026-10-03, ai-sessions/0069)",TESTPLAN_BLUETOOTH_HCI_SNOOP.md WELL-001,test,catalogued,"Official app 'Hearing wellness' screens (Dosimeter) — candidate, out of scope, no capture scenario (added 2026-10-03, ai-sessions/0069)",TESTPLAN_BLUETOOTH_HCI_SNOOP.md FIT-001,test,catalogued,"Eartip seal / fit test (maestro_pw.EartipFitTest) — candidate, never seen on the wire (added 2026-10-03, ai-sessions/0069)",TESTPLAN_BLUETOOTH_HCI_SNOOP.md +ADR-058,adr,accepted,"DLCI 0x02: one GetHardwareInfo request per Connect for the component serial numbers (field 7, read by position: 7.1 Case, 7.2 Right, 7.3 Left — the official app's reading), a read not gated by Safe Mode, serials never logged — accepted 2026-10-09 (ai-sessions/0082)",DECISIONS.md +ADR-059,adr,accepted,"A 'probably worn' indicator derived from the Settable byte, the in-ear detection setting and the runtime-info charging flags — no new wire traffic; ADR-049 item 3 stays a hypothesis — accepted 2026-10-09 (ai-sessions/0082)",DECISIONS.md +ADR-060,adr,proposed,"Volume-level notifications from maestro_pw.Dosimeter (SubscribeToLiveDb + FetchDailySummaries per Connect, the phone computes the 7-day dose, the field-21 switch) — DRAFT only, not accepted; a 1.3.0 candidate (ai-sessions/0082 RESULT §C.4, TODO.md §5)",DECISIONS.md +CAP-072,capture,planned,"session date/status: *planned* — Group BH (new): the 1.2.0 release run (Changed by the Buds, serial numbers, the worn indicator, the two tab moves; the open film items L-1/INEAR-005, C12, S12, T11, H5, the case-sound and Volume-EQ observations; P1 an update over 1.1.1), skeleton by ai-sessions/0082",CAPTURE_BLUETOOTH_HCI_SNOOP.md §9 +INEAR-006,test,catalogued,"The app's 'probably worn' indicator (ADR-059) read against the ears on film: both out, one in, both in the case, in-ear detection off, buds straight from the case to the table watched 60 s — added 2026-10-09, ai-sessions/0082",TESTPLAN_BLUETOOTH_HCI_SNOOP.md +WELL-002,test,catalogued,"maestro_pw.Dosimeter SubscribeToLiveDb values correlated with a sound-level meter on film (the 🟡 that blocks ADR-060) — candidate, no capture scenario yet (added 2026-10-09, ai-sessions/0082)",TESTPLAN_BLUETOOTH_HCI_SNOOP.md From 168d9a5ec74d79b9ee43b7589cbb337de644d53c Mon Sep 17 00:00:00 2001 From: tedsluis Date: Fri, 9 Oct 2026 08:02:01 +0200 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20CAP-072=20(Group=20BH)=20run=20skel?= =?UTF-8?q?eton,=20capture=20index,=20INEAR-006/WELL-002,=20APP=5FTESTPLAN?= =?UTF-8?q?=20=C2=A7V?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: 1.2.0 is released only after a hardware run (RELEASING.md). The skeleton in CAP-071's layout takes the three new things to see, the two moved switches byte for byte, ADR-059's six readings against the ears on film (INEAR-006, incl. CAP-064's ≈ 28 s case), lead L-1 (INEAR-005), and the open film items of TODO.md §2 (C12, S12, T11 with three dumps, H5, S6). Registered planned. Co-Authored-By: Claude Fable 5.1 --- APP_TESTPLAN.md | 46 +++- CAPTURE_BLUETOOTH_HCI_SNOOP.md | 24 ++ TESTPLAN_BLUETOOTH_HCI_SNOOP.md | 6 +- .../CAP-072-EVENT-NOTES.md | 212 ++++++++++++++++++ 4 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md diff --git a/APP_TESTPLAN.md b/APP_TESTPLAN.md index ca8dcc5c..37301448 100644 --- a/APP_TESTPLAN.md +++ b/APP_TESTPLAN.md @@ -21,7 +21,10 @@ Android's dark switch (K4, O13); a Bluetooth-off loss is named (K1); **section R **Updated 2026-10-06 for the `ai-sessions/0074` build (1.1.0):** five new switches — Multipoint, Use head gestures, Case sounds "Earbuds replaced" and "Other alerts" on Controls, Volume EQ on Sound (**section T**); a switch that was not read shows "—" in place of the switch (M1, N1); twelve settings reads at Connect and on a pull (O8); A5 corrected (`CAP-067` §7). **Updated 2026-10-08 for the 1.1.1 build** (`ai-sessions/0079`: the app of 1.1.0 rebuilt with the -toolchain of `ai-sessions/0078` — no new function; **section U**, a regression pass over what the new toolchain could change). +toolchain of `ai-sessions/0078` — no new function; **section U**, a regression pass over what the new toolchain could change). **Updated 2026-10-09 for the +1.2.0 build** (`ai-sessions/0082`, `DECISIONS.md` ADR-058/ADR-059): "Changed by the Buds" in the ANC (i); the serial numbers on gear → Info; the "probably worn" +line on the battery card; the **Case sounds** switches moved from Controls to gear → **Settings** (T5, T6 note; N1); **Conversation detection** moved from Sound to +**Controls** (M5 → N12; M1); thirteen MAESTRO requests at Connect (one `GetHardwareInfo` after the subscription; O8); **section V**. This is a *user-level* functional test of this project's own app; `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` is the separate catalogue of Buds/official-app behaviours, and the "Expected on the wire" column below only names what to look for in the HCI log afterwards (`ai-sessions/0046` RESULT §9 has the exact frames). @@ -148,18 +151,18 @@ HH:MM:SS"); a refused or unanswered write shows "The setting was not changed: : read HH:MM:SS" | — | ✅ | CAP-070 06:12:58–06:14:46 (order and (i) lines as specified; Equalizer (i) not opened) | | T3 | **Multipoint** off, then on. | Each after the Buds' OK, "Multipoint: changed HH:MM:SS" | `4:{11:0}`, `4:{11:1}` → empty `RESPONSE` | ✅ | Whether a second device stays connected is not claimed; CAP-070 A752/A1989 (ch 21 off, ch 19 on), all ACKed | | T4 | **Use head gestures** off, then on. | Each after the OK; no dialog | `4:{29:1}` (off), `4:{29:2}` (on) → `RESPONSE`; nothing on DLCI 0x08 | ✅ | CAP-070 A762/A1998; nothing on DLCI 0x08 | -| T5 | **Earbuds replaced** off; put a bud back into the case; then on, and again. Say whether the case sounded each time. | Each after the OK | `4:{28:0}`, `4:{28:1}` | ⚠️ partly | An observation, not a claim of the app; writes ✅ (A770, A1992); the case-sound observation not recorded (no audio) | -| T6 | **Other alerts** off, then on. | Each after the OK | `4:{27:0}`, `4:{27:1}` | ✅ | CAP-070 A777, A1995 | +| T5 | **Earbuds replaced** off; put a bud back into the case; then on, and again. Say whether the case sounded each time. (On Controls until 1.1.1; **on gear → Settings since 1.2.0**, V13.) | Each after the OK | `4:{28:0}`, `4:{28:1}` | ⚠️ partly | An observation, not a claim of the app; writes ✅ (A770, A1992); the case-sound observation not recorded (no audio) | +| T6 | **Other alerts** off, then on. (On gear → Settings since 1.2.0, V13.) | Each after the OK | `4:{27:0}`, `4:{27:1}` | ✅ | CAP-070 A777, A1995 | | T7 | Sound: **Volume EQ** off, then on; listen at low volume and say what you hear. | Each after the OK; Equalizer (i) "Volume EQ: changed …" | `4:{15:0}`, `4:{15:1}` | ⚠️ partly | Audibility is an observation; writes ✅ (A785, A1953, A3072, A3075); audibility not recorded (no audio) | | T8 | With T3–T7 left off: Disconnect, Connect. | All five read back as off, "read HH:MM:SS" | the twelve reads; the answers equal the last writes | ✅ | CAP-070 A824–A904: `11:0`, `15:0`, `27:0`, `28:0`, `29:1` | | T9 | Only the **Left** bud out of the case (Info: "Control channel: 19"): Multipoint and Use head gestures off, then on. | As T3/T4 | the channel-19 forms — first on the wire in `CAP-070` (BF-14/15) | ✅ | CAP-070 A3668/A3679, A3614/A3685 — the derived frames, ACKed | @@ -324,6 +328,35 @@ T11, H5) are the earlier IDs, run again in `CAP-071`. | U13 | Disconnect, Connect. | Every value of U11–U12 read back | the reads answer the last writes | ✅ | CAP-071 18:18:40–43: every value read back | | U14 | Find: Ring Left, Stop, Ring Right, Stop. | The ring follows each tap | `04 01 00 01 02` / `00` / `01` / `00` → ACK | ✅ | CAP-071 18:19:24–18:20:13: ACKed; the ring on the audio track | +## V. The 1.2.0 build (`ai-sessions/0082`: "Changed by the Buds", the serial numbers, the worn line, the two moves) + +In `CAP-072` as BH-1 … BH-26 (`captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md`, which has the reference bytes per request and +the wear sequence with the head in view). One new request per Connect (`GetHardwareInfo`, ADR-058); the worn line (ADR-059) and the cause line are derived +from frames the app already received; the moved switches send the same bytes as before. The robustness rows (C8, C9, J4, K1, K2, R6) and the open items +(C12, S6, S12, T11, H5) are the earlier IDs, run again. Serial numbers are device identifiers: **redact them** in every note (first 4 + last 2 characters). + +| ID | Steps | Expected on screen | Expected on the wire | Result | Notes | +|---|---|---|---|---|---| +| V1 | In 1.1.1: Settings → Dark mode **On**, Debug → Debug mode **on**; `dumpsys package … \| grep -E "firstInstallTime\|lastUpdateTime"`; install 1.2.0 **over** it; the same `dumpsys`; open the app (lid open). | Dark at once; Debug mode on; no crash; `firstInstallTime` unchanged, `lastUpdateTime` later | the twelve reads, `SubscribeRuntimeInfo`, then **one** `GetHardwareInfo` = `CAP-036` 1415 (21) / `CAP-024` 801 (19), answered | | BH-1 | +| V2 | Gear → **Info**: the build line, the firmware, **Serial numbers**. | "App: 1.2.0, build ()"; "Serial numbers (from the Buds, HH:MM:SS):" then "Case: …", "Right bud: …", "Left bud: …" and the note "Labelled by position …" | nothing new (the answer of V1) | | BH-2; the three strings = the answer's field 7 in order | +| V3 | Gear → **Settings**: the Case sounds card between Dark mode and Use different Buds; its (i). | Both switches with their values; the (i) "Earbuds replaced: read HH:MM:SS", "Other alerts: read …", "These settings live on the case and are read when the app connects." | — | | BH-2 | +| V4 | Both buds in the case, lid open: the battery card's **Worn** line and its (i). | "Both buds in the case"; the (i) explanation ("Worn: from the Buds' last noise-control report. 'Probably worn' = … 'Not worn' = … With in-ear detection off the report says nothing about wearing.") | the runtime-info charging flags already received | | BH-3 | +| V5 | Only the Left in the ear; pull on Connection. | "Probably worn (checked HH:MM:SS)" | `08 11` → `Notify` Settable `e8` | | BH-5 (`INEAR-006`) | +| V6 | Both in; pull. Then the Left out with both worn on 19 (head in view); pull. | "Probably worn" with a later time; the session re-opens by itself | `Notify` `e8`; lead L-1: Buds `DISC` + announcement 21 | | BH-6, BH-7 (`INEAR-005`) | +| V7 | Both buds on the table, in-ear detection on; pull; then an ANC tap. | "Not worn (checked …)"; the tap refused with the not-allowed text | `Notify` Settable `00`; no `Set` | | BH-8 | +| V8 | In-ear detection **off**; pull. Then **on**; pull. | "Worn: unknown — in-ear detection is off"; then "Not worn (checked …)" | `4:{2:0}` → OK; `Notify` (`e8` expected, says nothing); `4:{2:1}` → OK; `Notify` `00` | | BH-9 | +| V9 | Both in the ears; pull; the four ANC modes from the tab; the (i) after each. | "Probably worn"; each mode after its ACK; **no** "Changed by the Buds" line in the (i) | per tap `08 11` → `Notify` → `Set` → ACK | | BH-10 | +| V10 | **Press and hold a bud** (its noise-control cycle) with the ANC tab open; the (i). Twice. | The mode changes without a tap; the (i) shows "Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)."; the time moves the second time | an unprovoked `08 13 00 04 01 e8 e8 xx` — no `08 11` before it; nothing sent by the app | | BH-11 | +| V11 | Then a tap in the app; a pull on the ANC tab; the tile — the (i) after each. | The line is gone after each ("set …" / "read …") | each: a claim `08 11` → `Notify` (→ `Set` → ACK for the tap and the tile) | | BH-12 | +| V12 | Both buds straight from the ears into the case and out onto the table; pull at ≈ 5, 15, 30, 45, 60 s. | "Probably worn" for at most ≈ 30 s (`CAP-064`), then "Not worn" — or "Both buds in the case" first; say what you see | `Notify` `e8` → `00`; the charging flags 2 → 0 | | BH-13; a result, not a fault, if "Probably worn" lasts longer — report it | +| V13 | Gear → Settings → **Case sounds**: Earbuds replaced off (a bud into the case and out; say whether the case sounded), on (the same); Other alerts off, on; the (i). | Each after its OK; "changed HH:MM:SS" | `4:{28:0}`, `4:{28:1}`, `4:{27:0}`, `4:{27:1}` = `CAP-070`/`CAP-071`'s requests | | BH-14 (T5, T6) | +| V14 | Controls → **Conversation detection** off, on; speak 5 s. | Each after its OK; the subtitle kept | `4:{22:0}` = `CAP-019` 1720, `4:{22:1}` = 1808 | | BH-15 (N12) | +| V15 | Sound: Balanced, a slider, *Read EQ again*, Right 4, Centre, mono, Volume EQ; Controls: touch, press and hold, the mode list without Off, head gestures, Multipoint — say what you hear. | Each after its OK; no conversation-detection row on Sound | each request = its reference (`CAP-072-EVENT-NOTES.md`'s table) | | BH-16, BH-17 (H5 ★, the "Right 4" write ★) | +| V16 | Disconnect, Connect; Sound, Controls, Settings, Info. | Every value read back; the **same three serials** with a new time; the Worn line "—" then "Probably worn" | the thirteen requests; one `GetHardwareInfo`, the same answer bytes | | BH-18 | +| V17 | Find: Ring Left, Stop, Ring Right, Stop. | The ring follows each tap | `04 01 00 01 02` / `00` / `01` / `00` → ACK | | BH-19 | +| V18 | The case (lid open), Home 2 min, Export + Bluetooth off/on; grep every export for a serial (first 4 + last 2) and the MAC. | The 1.0.1 cause texts; re-open by itself; "Bluetooth is disabled."; **0 hits** in the exports (positive control: the "Control channel" line) | ADR-044's `SABM` ≈ 1.5 s after the link; the thirteen requests after each re-open | | BH-20…BH-22 (C8, C9, J4, K1) | +| V19 | Film 2: C12 (the early tap), S12 (the export across a rotation), T11 (three `uiautomator` dumps: Controls, Sound, **Settings**). | "The setting was not changed: …"; "Debug log saved (N lines)."; every "—" node's `content-desc` = `Not read from the Buds yet` (Controls seven, Sound eight, Settings two) | no `WriteSetting` after the early tap | | BH-23…BH-26 | + ## After the run (within 1 minute of the last action) | # | Collect | Done | @@ -359,6 +392,7 @@ T11, H5) are the earlier IDs, run again in `CAP-071`. | S 1.0.1 build | 13 | | | | | | T 1.1.0 build | 11 | 7 | 0 | 4 | 0 | | U 1.1.1 build | 14 | 11 | 0 | 3 | 0 | +| V 1.2.0 build | 19 | | | | | **Run `CAP-070` (2026-10-07, 1.1.0 `0323849`, the user without Play; `ai-sessions/0075`, `CAP-070-FINDINGS.md`):** section T — ✅ T2, T3, T4, T6, T8, T9, T10; ⚠️ T1 (no "—" caught at the first Connect; a first install, not an update), T5 and T7 (writes ✅, observations not recorded — no audio), T11 (no dump). Other diff --git a/CAPTURE_BLUETOOTH_HCI_SNOOP.md b/CAPTURE_BLUETOOTH_HCI_SNOOP.md index f7fb7d61..5f903f00 100644 --- a/CAPTURE_BLUETOOTH_HCI_SNOOP.md +++ b/CAPTURE_BLUETOOTH_HCI_SNOOP.md @@ -1775,6 +1775,29 @@ settings, 24 writes + 9 ANC `Set`s + 4 Ring/Stop byte-identical to their referen shows "Signal Catcher … reacting to signal 3" / "Wrote stack traces to tombstoned" once per pull — not an app fault (`CAP-071-FINDINGS.md` §0). **For the next run:** the case lid open before the update (P8), no app use before the film (P9), Do Not Disturb on (an incoming call put a third party's number on film). +#### Group BH — the 1.2.0 release build: "Changed by the Buds", the serial numbers, the worn line, the two tab moves, plus the open film items (run as `CAP-072`, skeleton by `ai-sessions/0082`) + +Pixel 9a / GrapheneOS, the release-signed **1.2.0** APK (`DECISIONS.md` ADR-058, ADR-059; `ai-sessions/0082`) in the secondary user without Google Play, +installed **over 1.1.1** (P1 records `firstInstallTime` and `lastUpdateTime` before and after). Scope chosen by the maintainer (chat 2026-10-09, the checkpoint +of `ai-sessions/0082`). The skeleton `captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md` holds: **I** the update and the start — the +Connect-time traffic now thirteen MAESTRO requests (the twelve settings reads, `SubscribeRuntimeInfo`, then **one `GetHardwareInfo`**, byte-identical to the +official app's `CAP-036` 1415 / `CAP-024` 801), the Info tab's three serial lines (Case, Right bud, Left bud — redacted in every note), the Case sounds card on +gear → Settings and the Conversation detection card on Controls, "Both buds in the case" on the battery card; **II** ADR-059's six readings against the ears +on film with a pull on the Connection tab after every wear change (`INEAR-006`: both in the case, only the Left in, both in, the Left out with both worn on +19 = lead L-1 `INEAR-005`, both on the table ⇒ "Not worn" and a refused ANC tap, in-ear detection off ⇒ "Worn: unknown — …", the `CAP-064` ≈ 28 s case +watched for 60 s), a press-and-hold on a bud with the app open ⇒ the unprovoked `08 13` and the (i) line "Changed by the Buds at …", then a tap, a pull and +the tile ⇒ no line; **III** every write once from its (new) place — the two case sounds with the sound observation, conversation detection, the EQ preset +and a slider, H5, balance, mono, Volume EQ, touch controls, press and hold, the mode list without Off, head gestures, Multipoint, Ring/Stop — each compared +byte for byte with `CAP-071`'s/`CAP-070`'s requests, and the read-back with the same serials after a reconnect; **IV** the case, Home, Bluetooth off/on with +a grep of every export for a serial or the MAC; **V** (film 2) C12, S12 and T11 with three `uiautomator` dumps (Controls, Sound, Settings). Test-IDs: +[`PAIR-003`], [`BATT-004`], [`FW-003`], [`INEAR-006`], [`INEAR-005`], [`INEAR-004`], [`ANC-001`]…[`ANC-004`], [`CASE-001`], [`CASE-002`], [`CONV-001`], +[`EQP-001`], [`EQS-001`], [`AUDIO-001`]…[`AUDIO-003`], [`HOLD-005`], [`HEAD-001`], [`MULTI-001`], [`CASE-004`], [`CASE-005`], [`FIND-001`], [`FIND-002`]. +**Lessons of `CAP-067`…`CAP-071` built in:** film with sound, the screen recording on (S6, C12 and the (i) texts need it), the system log, Do Not Disturb, +the lid open before the app starts, no rehearsal before the film, the `adb bugreportz` SIGQUIT note. + +**Status:** 🔲 planned. The analysing session writes the findings file (CAP-072-FINDINGS, as every analysed capture has), the `INEAR-006` table (time, ears, Settable byte, field 2, charging flags, the +Worn text per pull) and proposes — never decides — any change to ADR-049 item 3, ADR-059 or `PROTOCOL.md` §2.2a (L-1); the release verdict for 1.2.0 is its. + ### 4.3 Hardware Actions (either phone) **Source:** `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` sections 2 (User Actions via the Case & Buds) and 4 @@ -2174,6 +2197,7 @@ is how the 2026-08-18 `CAP-005`/`CAP-007`/`CAP-010` ID-reuse incident (see | `CAP-069` | 2026-10-04 | Pixel 7a | 17 (build not on film) | `release_5.203` | official Pixel Buds app **1.0.990706425** (updated 2026-09-30, on film) | BE (new) | `HEAD-001`, `MULTI-001`, `HOLD-003`, `HOLD-004`, `HOLD-005`, `ANC-001`–`ANC-004`, `ANC-006`, `EQP-001`, `EQS-004`, `INEAR-002`–`INEAR-004`, `CASE-004`, `CASE-005`, `FIND-001` (via Find Hub, failed), `FIND-005` (not observable), `PAIR-003`, `BATT-004` | Head gestures (field 29: 1 off / 2 on, 6 filmed writes) and Multipoint (field 11 + SASS bit 2, 4 filmed writes) — ADR-052/053; assistant sessions open with GSND `01 09 … 0a 01 03`; no `Set` for the selected ANC mode (0/3); Default = 0.0 × 5; wear sequence vs GSND Code `0x05`; no Find device in the app; new service `0xbf6c9399` and JitterBuffer on the wire; `CAP-069-FINDINGS.md` | — (no bugreport archive; the raw log) | `CAP-069-btsnoop_hci.log` (from the Bluetooth switch-on; no `.log.last`) | analyzed | | `CAP-070` | 2026-10-07 | Pixel 9a (secondary user 10 without Google Play) | 17 (`CP3A.260905.009`) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.1.0**, build `0323849`, release-signed (Info tab on film, no -dirty); a first install in user 10 (🟡), not an update | BF (new) | `MULTI-001`, `HEAD-001`, `CASE-001`, `CASE-002` (writes; the case-sound observation not recorded), `AUDIO-002` (writes; audibility not recorded), `AUDIO-003`, `INEAR-005` (wear not on film), `INEAR-002`–`INEAR-004` (ear changes not identifiable), `CASE-004`, `CASE-005`, `EQS-001`, `PAIR-003`, `BATT-004` | The 1.1.0 release test without Play services (`ai-sessions/0075`): 22 sessions each read exactly the twelve settings; 89 of 89 writes byte-identical to the expected frames and ACKed — incl. the first captures of 11 and 29 on channel 19 and `15:1` on channel 21; L-1: the Left taken off on 19 ⇒ `DISC` + 21; Bluetooth off/on + a new process; "—" on film; no audio, no screen recording, no `uiautomator` dumps; release verdict: release `0323849`. `CAP-070-FINDINGS.md` | — (no bugreport archive; the raw logs) | `CAP-070-btsnoop_hci1.log.last` (the unfilmed rehearsal, 05:44–06:10), `CAP-070-btsnoop_hci2.log.last` (06:10–06:37; `CAP-070-btsnoop_hci1.log` is its prefix), `CAP-070-btsnoop_hci2.log` (after the Bluetooth off/on) | analyzed | | `CAP-071` | 2026-10-08 | Pixel 9a (secondary user 10 without Google Play) | 17 (`CP3A.260905.009`) | `release_5.203` | OpenControl for Pixel Buds Pro 2 **1.1.1**, build `86a6fb3`, release-signed (Info tab on film, no -dirty; installed APK SHA-256 = `dist/1.1.1`), an update over 1.1.0 (`0323849`, itself added to user 10 at 17:40) | BG (new) | `PAIR-003`, `BATT-004`, `ANC-001`–`ANC-004`, `EQP-001` (preset Balanced), `EQS-001`, `AUDIO-001`–`AUDIO-003`, `CONV-001`, `HOLD-005`, `HEAD-001`, `MULTI-001`, `CASE-001`, `CASE-002` (writes; no chime recorded), `CASE-004`, `CASE-005`, `FIND-001`, `FIND-002`; `INEAR-004` not identifiable, `INEAR-005` not run | The 1.1.1 release test (`ai-sessions/0080`): the update kept data, dark mode and Debug mode; 11 sessions read the twelve settings in order; 24 writes, 9 ANC `Set`s (during playback) and 4 Ring/Stop byte-identical and answered; the read-back after a reconnect; 9 session ends recovered; no behaviour change from the toolchain; film with sound (the Find ring; no case chime), no screen recording, no film 2 (L-1, C12, S12, T11 open); release verdict: release `86a6fb3`. `CAP-071-FINDINGS.md` | — (two `bugreportz` archives, not kept; the raw logs) | `CAP-071-btsnoop_hci1.log.last` (17:37–17:51, before the film), `CAP-071-btsnoop_hci2.log.last` (17:51–18:26; `CAP-071-btsnoop_hci1.log` is its prefix), `CAP-071-btsnoop_hci2.log` (after the Bluetooth off/on) | analyzed | +| `CAP-072` | *planned* | Pixel 9a (secondary user without Google Play) | 17 (expected) | `release_5.203` (expected) | OpenControl for Pixel Buds Pro 2 **1.2.0** (versionCode 10200), release-signed, an update over 1.1.1 (`86a6fb3`) | BH (new) | `PAIR-003`, `BATT-004`, `FW-003`, `INEAR-006`, `INEAR-005`, `INEAR-004`, `ANC-001`–`ANC-004`, `CASE-001`, `CASE-002`, `CONV-001`, `EQP-001`, `EQS-001`, `AUDIO-001`–`AUDIO-003`, `HOLD-005`, `HEAD-001`, `MULTI-001`, `CASE-004`, `CASE-005`, `FIND-001`, `FIND-002` | The 1.2.0 release test (skeleton by `ai-sessions/0082`): the one new request per Connect (`GetHardwareInfo`, ADR-058) and the serial lines; the worn line (ADR-059) against the ears on film incl. the `CAP-064` ≈ 28 s case; "Changed by the Buds" after a press-and-hold; the two moved switches byte-identical; every write once; the case, Home, Bluetooth off/on; film 2: C12, S12, T11 (three dumps). `captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md` | TBD | TBD | planned | **Column notes:** diff --git a/TESTPLAN_BLUETOOTH_HCI_SNOOP.md b/TESTPLAN_BLUETOOTH_HCI_SNOOP.md index 560515c2..1f30f6f8 100644 --- a/TESTPLAN_BLUETOOTH_HCI_SNOOP.md +++ b/TESTPLAN_BLUETOOTH_HCI_SNOOP.md @@ -147,7 +147,7 @@ _Make sure the buds are connected and active._ | `AUDIO-003` | Shift the 'Volume balance' (Left/Right) | User (App) | H, AK, AY, AZ, BA, BB, BC, BF, BG | 🟢 | **Note:** stored locally on the earbuds themselves (persistent write, works across devices) — explicitly stated in the app's own screenshot. **Scale/direction resolved 2026-09-12 (`CAP-046`, Group AK), promoted 🟢 FACT 2026-09-13 (`DECISIONS.md` ADR-026)**: range ±100, `+100`=Left, `-100`=Right (opposite of the prior working assumption). | `PROTOCOL.md` §4.5.7, `CAP-046-FINDINGS.md` §2; `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064` I-3: 26 writes OK, Centre = `17:0` ×5, persisted (read 7044); `CAP-065`: field 17 read `17:0` (Centre) every time; no write — restore to Right 4 is `CAP-066` BB-10; `CAP-066` BB-10: 32 writes OK (A7723–7873), none `17:7` (Right 4) — the drag reached only Right 6 on the right side; ends at Centre `17:0`; `CAP-067` (1.0.0, no Play services): BC-3 Right 4 = `17:7` on the 17th drag (A876, = `CAP-064` 6671), 18 + 1 writes OK, BC-5 `17:0`, BC-R `17:7` (B834) read back; `CAP-070` §3 (ch-19 `17:7`, 53 writes OK); `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3) | | `FW-001` | Tap the 'Firmware up to date' check | User (App) | I | 🟢 | Forces a manual update check. | `CAP-023-FINDINGS.md` §4 (cached, not live-queried) | | `FW-002` | View firmware version per component (L/R/Case) | User (App, view) | I, AS (withdrawn) | 🟢 | Possibly triggers a status query when 'More settings' is opened. | `PROTOCOL.md` §0.1, `CAP-023-FINDINGS.md` §3 | -| `FW-003` | View serial numbers per component | User (App, view) | I, AS (withdrawn) | 🟢 | Same as above. | — (not exercised, `CAP-023-FINDINGS.md` §6 gap) | +| `FW-003` | View serial numbers per component | User (App, view) | I, AS (withdrawn), BH | 🟢 | Same as above. **Since 1.2.0 OpenControl reads the serials too** (`DECISIONS.md` ADR-058: one `GetHardwareInfo` per Connect, field 7 by position: Case, Right bud, Left bud). | `PROTOCOL.md` §6 (2026-10-09 Update); `CAP-072` (BH-3: the Info tab on film, the request/answer on the announced channel) | | `FW-004` | View connection status ("Earbud status: Connected") | User (App, view) | I | 🟢 | | — (not exercised, `CAP-023-FINDINGS.md` §6 gap) | | `INEAR-001` | Toggle 'In-ear detection' on/off | User (App) | J, AR, AZ, BA | 🟢 | Automatically plays/pauses audio when worn/not worn. | `PROTOCOL.md` §4.5.5; `CAP-056` 2173/2849/3627/4048/4344 — the "In-ear detection" switch = `qhr` field 2 on film, 🟢 (`PROTOCOL.md` §4.5.5 2026-09-28 Update, ADR-047); `CAP-064`: OpenControl's `4:{2:0}` 9922 / `4:{2:1}` 10817 → OK, SASS bit 4 follows (9927 `b0 00`, 10822 `b8 00`); with it off no pause on removal; `CAP-065`: field 2 read `2:1` at every Connect and on the Controls/Sound pulls (BA-12/13); no write | | `CASE-001` | Toggle case sound 'Earbuds replaced' on/off | User (App) | J, BF, BG, AT | 🟢 | Setting stored on the chip inside the case. | `PROTOCOL.md` §4.5.8; `CAP-070` §3 (OpenControl 1.1.0, ACKed; the case-sound observation not recorded); `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §3 (no chime recorded)); `CAP-058` (Group AT, `CAP-058-EVENT-NOTES.md`) | @@ -171,7 +171,8 @@ _Physical interactions with the device._ | `CASE-005` | Remove Right earbud from the case | User (Hardware) | M, AW, AX, AY, AZ, BA, BB, BF, BG, AP | 🔵 | | —; `CAP-061` (Right out 17:26:36–37, battery flag `e4 64` frame 3146); `CAP-062` (Right out ≈ 06:46:21.5, `e4 64` frame 7058, stream 7033); `CAP-063` (Group AY, `CAP-063-FINDINGS.md`); `CAP-064` (stream 6228, 9437); `CAP-065`: the Right (right slot) out at 11:12:52 and 11:20:54 with the Left docked (stream 4162, 9350: Left charging); `CAP-066`: the Right out of the case 16:17:24 (first → Buds' ConnReq A2804, ch 21) and ≈ 16:22:22 (stream A5966 → A5973); `CAP-069` §8; `CAP-070` §4; `CAP-071` (1.1.1 release run, `CAP-071-FINDINGS.md` §4); `CAP-054` (Group AP, `CAP-054-EVENT-NOTES.md`) | | `INEAR-002` | Insert Left earbud into the ear | User (Hardware) | M, AR, AZ, BA, BB, BC, BF | 🔵 | Triggers the in-ear sensor (audio starts, if In-ear detection is on). | `CAP-056` §4 (re-insertions with in-ear detection on/off; which ear not identifiable on film); `CAP-064`: Left insertions on film (head right of frame), e.g. 10:06:58–10:07:06; `CAP-065`: Left insertions on film (head right), 11:08:28, 11:16:28, 11:21:48 → `Notify … e8 e8 40` (1352, 10620); `CAP-066`: Left insertions on film (head right) 16:14:21, 16:16:08, 16:20:56, 16:24:00 → `Notify … e8 …` (A1427, A2125, A4760, A6779); no session change; `CAP-067`: Left insertion 08:05:24–31 (left-hand slot, head out of view; 6.2.2 = 1 at A906); `CAP-069` §8 | | `INEAR-003` | Insert Right earbud into the ear | User (Hardware) | M, AR, AZ, BA, BB, BC, BF | 🔵 | | —; `CAP-056` §4 (as `INEAR-002`); `CAP-064`: Right insertions on film (head left of frame), e.g. 10:06:44–52; `CAP-065`: Right insertions on film (head left), 11:09:10, 11:16:22, 11:21:58 → `Notify … e8 e8 40` (6224); `CAP-066`: Right insertions on film (head left) 16:14:12, 16:16:00, 16:20:48, 16:22:52, 16:24:50 → `Notify … e8 80` (A1396, A2090, A4741, A6274); 16:22:52 (Left on the table) → Buds `DISC` + ch 21 (A6278 → A6441); `CAP-067`: Right insertions on film (head left) 08:05:12 and 08:06:57; `CAP-069` §8 | -| `INEAR-005` | Take the Left bud out of the ear with both buds worn while the MAESTRO session is on channel 19 | User (Hardware) | BF | 🔴 | **Added 2026-10-06 (`ai-sessions/0074`, lead L-1 of `ai-sessions/0061`, `TODO.md` §2).** 🟡 predicted: the Buds `DISC` MAESTRO with the ACL up and announce channel 21 — the mirror of the Right-out-on-21 result (`CAP-066` 3 of 3, `CAP-067`). Head in view. | `PROTOCOL.md` §2.2a (Updates of 2026-10-01 and 2026-10-03); `CAP-070` §4 (BF-18: the Left taken off on 19 ⇒ `DISC` + 21; wear not on film); `CAP-071`: not run (BG-20, no film 2) | +| `INEAR-005` | Take the Left bud out of the ear with both buds worn while the MAESTRO session is on channel 19 | User (Hardware) | BF, BH | 🔴 | **Added 2026-10-06 (`ai-sessions/0074`, lead L-1 of `ai-sessions/0061`, `TODO.md` §2).** 🟡 predicted: the Buds `DISC` MAESTRO with the ACL up and announce channel 21 — the mirror of the Right-out-on-21 result (`CAP-066` 3 of 3, `CAP-067`). Head in view. | `PROTOCOL.md` §2.2a (Updates of 2026-10-01 and 2026-10-03); `CAP-070` §4 (BF-18: the Left taken off on 19 ⇒ `DISC` + 21; wear not on film); `CAP-071`: not run (BG-20, no film 2); `CAP-072` (BH-20, head in view) | +| `INEAR-006` | The app's "probably worn" indicator read against the ears on film | User (App, view) | BH | 🟡 | **Added 2026-10-09 (`ai-sessions/0082`, `DECISIONS.md` ADR-059).** The battery card's line — "Probably worn (checked HH:MM:SS)" / "Not worn (checked …)" / "Both buds in the case" / "Worn: unknown — in-ear detection is off" — against the ears on film: both out → Not worn; one in → Probably worn; both in the case → Both buds in the case; in-ear detection off → unknown; buds straight from the case to the table, watched 60 s (the ≈ 28 s `e8` of `CAP-064`). ADR-049 item 3 stays 🟡 whatever the result; a "Probably worn" with no bud in an ear beyond the first ≈ 30 s is a counter-example to record. | `DECISIONS.md` ADR-049/ADR-059; `CAP-072` (BH-20 … BH-23) | | `TOUCH-002` | Tap once on a bud | User (Hardware) | N, AY | 🔵 | Play/pause **or** answer call **or** (Pixel Buds Pro only) leave Conversation Detection mode **or** confirm a choice with Gemini. **Captured 2026-08-30 (`CAP-027`)** — confirmed on AVRCP, not RFCOMM. | `CAP-027-FINDINGS.md` §3; `CAP-063` (Group AY, `CAP-063-FINDINGS.md`) | | `TOUCH-003` | Double-tap on a bud | User (Hardware) | N | 🔵 | Next track **or** end/reject call **or** stop Gemini. **Captured 2026-08-30 (`CAP-027`)** — confirmed on AVRCP, not RFCOMM. | `CAP-027-FINDINGS.md` §3 | | `TOUCH-004` | Triple-tap on a bud | User (Hardware) | N | 🔵 | Previous track. **Captured 2026-08-30 (`CAP-027`)** — confirmed on AVRCP, not RFCOMM. | `CAP-027-FINDINGS.md` §3 | @@ -282,6 +283,7 @@ official-app capture can decide whether to exercise them._ |---|---|---|---|---|---|---| | `SWITCH-001` | "Audio switch" setting (Fast Pair audio switching / SASS preferences) | User (App) | AT | 🟢 | Screenshot `images/pixel_buds_app_audio_switch_1.png`; SASS (Group `0x07`) is seen on DLCI 0x04 at connect and around Multipoint (`PROTOCOL.md` §4.5.2), never with this screen open | `CAP-058` (Group AT, `CAP-058-EVENT-NOTES.md`) | | `WELL-001` | "Hearing wellness" screens (listening history) | User (App) | AT | 🟢 | Screenshot `images/pixel_buds_app_hearing_wellness_1.png`; on the wire only as `maestro_pw.Dosimeter` `FetchDailySummaries` / `SubscribeToLiveDb` at connect (`PROTOCOL.md` §2.2a); `qhr` field 21 is its toggle in the APK. Out of scope (maintainer, chat 2026-09-30: "Not now — note only") | `PROTOCOL.md` §2.2a; `CAP-058` (Group AT, `CAP-058-EVENT-NOTES.md`) | +| `WELL-002` | `Dosimeter.SubscribeToLiveDb` values against a sound-level meter on film | User (App) + Hardware | — (no capture scenario yet) | 🟡 | **Added 2026-10-09 (`ai-sessions/0082` item 4, the design study for volume-level notifications).** The 🟡 that blocks ADR-060 (a draft, not accepted): `qir` field 2 is linear and the official app shows `round(10·log10(v))` dB (`hiw.java:32–37`); a capture with the official app's Hearing wellness page open, a calibrated sound-level meter at the ear and a tone at known levels on film, would correlate the stream values with dB and the day entries (`qia` field 6) with a dose. Candidate; needs its own Group when planned. | `REVERSE_ENGINEERING.md` (2026-10-09 section), `ai-sessions/0082` RESULT §C.4 | | `FIT-001` | Eartip seal / fit test | User (App) | — | 🟡 | APK service name `maestro_pw.EartipFitTest` (`REVERSE_ENGINEERING.md`); never seen on the wire in any capture | — | ## 5. `PAIR` / secondary-transport area (cross-referenced from Group A / Pixel 9a §4.2, not duplicated) diff --git a/captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md b/captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md new file mode 100644 index 00000000..24f752c2 --- /dev/null +++ b/captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md @@ -0,0 +1,212 @@ +# Event Notes: OpenControl for Pixel Buds Pro 2 on Pixel 9a (GrapheneOS, the secondary user without Google Play) — Group BH, the 1.2.0 release APK (`CAP-072`) + +**Status:** 🔲 **Not yet captured — skeleton only** (written by `ai-sessions/0082`, 2026-10-09). Scope chosen by the maintainer in chat 2026-10-09 (the +checkpoint of `ai-sessions/0082`): the **1.2.0 release run** — the three new things to see (the "Changed by the Buds" line in the noise-control (i), the +component serial numbers on the Info tab per `DECISIONS.md` ADR-058, the "probably worn" line on the battery card per ADR-059), the two moved switches +(Case sounds on gear → Settings, Conversation detection on Controls), every write once byte for byte, and — for the third time — the open film items of +`TODO.md` §2 (lead L-1 with the head in view, C12, S12, T11, H5, S6, the case-sound and Volume-EQ observations). **Installed over 1.1.1** (P1 records both +`dumpsys` times). The maintainer films; a later CAPTURE session analyses it and gives the release verdict. After the run: rename this folder from the +placeholder to the film's first/last overlay times, and the film to CAP-072-recording.mp4. + +**Purpose.** 1.2.0 adds **one** request per connection (`GetHardwareInfo`, ADR-058) and nothing else on the wire; everything else is derived from frames +the app already received, or a control in a new place sending the same bytes. This run checks on the phone: + +- **I — the update and the start.** 1.2.0 over 1.1.1 in user 10 (the second update test); the stored choices read back; the Connect-time traffic now + **thirteen** MAESTRO requests plus the EQ read and the subscription — the twelve settings reads, `SubscribeRuntimeInfo`, then `GetHardwareInfo` last — each + equal to its reference; "—" in the first second (S6); the Info tab's three serial lines (ADR-058; the serials are **not** written into these notes or the + findings unredacted: first 4 + last 2 characters only). +- **II — the worn line and "Changed by the Buds", head in view.** ADR-059's six readings against the ears on film (`INEAR-006`), including the ≈ 28 s case of + `CAP-064` (buds straight from the case to the table, watched for 60 s); lead L-1 (`INEAR-005`) in the same sequence; a press-and-hold on a bud with the + app open → the (i) line; a tap in the app, a pull, the tile → no line. +- **III — every write once, byte for byte** (the codec is unchanged; the moved switches send what they sent from their old place): the two case sounds from + gear → Settings with the sound observation said aloud, conversation detection from Controls, and the rest as in `CAP-071`; the read-back after a reconnect + and the serial lines again. +- **IV — robustness:** the case (ADR-044), Home for two minutes, Bluetooth off/on. +- **V — the open film items of `TODO.md` §2** (film 2): C12 (a tap during a re-open), S12 (an export across a rotation), T11 (the screen-reader text from + three `uiautomator` dumps — the Settings tab's two switches are new). + +The run is two films in this folder: **film 1** = P0–P9 and sections I–IV (≈ 35 min), **film 2** = section V (≈ 10 min). If time is short, film 2 can be +another day — the build stays the same; say so in the notes. (`CAP-071` had no film 2; `ai-sessions/0082` RESULT §I asks the maintainer whether to split.) + +### Log Metadata + +| Field | Value | +|---|---| +| Capture ID | `CAP-072` | +| Group(s) | BH (new) | +| Date | TBD | +| Phone | Pixel 9a, GrapheneOS (Android 17 in `CAP-071`) — the secondary user without Google Play (user 10 in `CAP-071`; P0) | +| App under test | OpenControl for Pixel Buds Pro 2 **1.2.0** (versionCode 10200), the release APK built by `scripts/release.sh` and kept as `RELEASING.md` B3 says — on the **Info tab on film**: "App: 1.2.0, build ()", no "-dirty"; APK and certificate SHA-256 from the script's output | +| Previous version in this user | 1.1.1 (`86a6fb3`), installed 2026-10-08 for `CAP-071` — the update is from it | +| Official Pixel Buds app | Not used (not in this user). Pixel 7a: Bluetooth off | +| Buds | Pixel Buds Pro 2; firmware from the Info tab on film (`release_5.203` expected; any other ⇒ Safe Mode — the serial read still runs (ADR-058 item 2), say so and continue; the writes are blocked, so stop section III) | +| Video files | TBD — film 1 and film 2, **with sound** (P3) | +| Screen recording | TBD — Android's screen recorder in the test user, film 1 and film 2 (P4) | +| Log files | TBD — CAP-072-btsnoop_hci.log and .log.last (Bluetooth is toggled in P2, IV and V), the app's debug exports, the app logcat, the **system log** (P6), the three `uiautomator` dumps (BH-23), the P0/P1 outputs | + +### Preparation + +What `CAP-071` missed is marked ★ — please check those twice. + +| # | Check | Done | +|---|---|---| +| P0 | In the test user: `adb shell am get-current-user`; `adb shell pm list packages --user \| grep -i -E "gms\|vending"; echo "exit=$?"`; positive control: the same with `grep opencontrol` (exit 0). Save the outputs **with the exit statuses** into this folder | ☐ | +| P1 | **Before the update, in 1.1.1** (on film): gear → **Settings → Dark mode On**; **Debug → Debug mode on**; Info shows "App: 1.1.1, build 86a6fb3". Then `adb shell dumpsys package io.github.tedsluis.opencontrolpixelbuds \| grep -E "firstInstallTime\|lastUpdateTime\|versionCode"` → save. **Install 1.2.0 over it**: `adb install --user -r dist/1.2.0/opencontrol-pixelbudspro2-1.2.0.apk` — no uninstall, no "clear data". The same `dumpsys` again → save (**both times recorded**: `firstInstallTime` unchanged, `lastUpdateTime` later, `versionCode=10200`) | ☐ | +| P2 | Bluetooth HCI snoop log on (set in the Owner, then switch to the test user); switch Bluetooth **off and on on film** | ☐ | +| P3 | Camera films the phone, the case **and your head with both ears** at every wear step (head on the right of the frame = Left bud); **the camera records sound** — play back 3 s before starting | ☐ | +| P4 | Android's **screen recorder** on (Quick Settings) ★ (`CAP-071` had none — S6, C12 and the (i) texts need it) | ☐ | +| P5 | Status bar on film across a minute change at the start and at the end of each film | ☐ | +| P6 | **System log**: on the computer, before P2, `adb logcat -b all -v threadtime > CAP-072-logcat-all.txt` (stop it with Ctrl-C after the last export). Note: every `adb bugreportz` (the way the HCI logs are pulled) makes dumpstate send signal 3 to every Java process — "Signal Catcher … reacting to signal 3" once per pull is not an app fault (`CAP-071-FINDINGS.md` §0) | ☐ | +| P7 | A short music track ready at **low volume** for the sound observations (BH-12, BH-14, BH-15); **Do Not Disturb on** ★ (an incoming call put a third party's number on `CAP-071`'s film) | ☐ | +| P8 | Buds charged, both in the case, **lid open** ★ (`CAP-071` started with the lid closed: two failed Connects); Android shows them connected before the app is opened (the app then opens its session by itself, ADR-044) | ☐ | +| P9 | **No rehearsal before the film** ★ (`CAP-071` had an off-film Connect) — the app is not opened between P1's install and BH-1 | ☐ | + +**Rhythm:** one action, then wait 5–10 s; **say** what you do at each wear change and at each observation (the film has sound). Something unexpected: stop, +wait 10 s, say it, continue. **Export the debug log before any step that ends the process and after every Bluetooth off/on.** The worn line and the +"Changed by the Buds" line follow the Buds' **last `Notify`** — after every wear change in section II, **pull down on the Connection tab** (one `08 11` claim) +so the app asks the Buds again; it never asks by itself. Before committing: check the films for a street-address overlay, Wi-Fi names and other device +names (`CAP-070` privacy note), and **redact the serial numbers** on any screenshot or in any text (first 4 + last 2 characters). + +### Steps + +DLCIs are session-local (MAESTRO 0x02 or 0x03, Message Stream 0x04 or 0x05). Every MAESTRO request starts `7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25` on +**channel 21** or `7e 00 3b 03 10 13 1d ea 71 de 7d 5e 25` on **channel 19** (Info → "Control channel"). **Reference frames** — the 1.1.1 build's own requests +in `CAP-071` unless named; the new request's references are the official app's (`ai-sessions/0082` RESULT §A.2/§D): + +| Request | Channel 21 | Channel 19 | +|---|---|---| +| `ReadSetting 4:16`, then 2, 4, 7, 11, 12, 15, 17, 19, 22, 27, 28, 29 | `CAP-071` A (the 1.1.1 run, byte-identical to `CAP-070` A271, A296 … A329) | `CAP-070` A966, A979 … A1035 | +| `SubscribeRuntimeInfo` | `CAP-070` A332 `…90821ee66654bfab7e` | `CAP-070` A1038 `…90821ee6602d65a97e` | +| **`GetHardwareInfo` (new, ADR-058; last of the Connect-time requests)** | `CAP-036` 1415 `7e004b0310151dea71de7d5e25e3a5ec28f96761b57e` | `CAP-024` 801 `7e003b0310131dea71de7d5e25e3a5ec28ff1ebbb77e` | +| its answer (`CAP-036` 1423 / `CAP-024` 832): `RESPONSE` with `7:{1:<14 chars> 2:<14 chars> 3:<14 chars>}` | the three strings in the order Case, Right, Left | the same | +| Conversation detection `4:{22:0}` / `{22:1}` (now from Controls) | `CAP-019` 1720 `…2a052203b00100225fc3b77e` / 1808 `…2a052203b00101b46fc4c07e` | `CAP-071`'s 1.1.1 request on 19 if it ran there, else the codec's output (unit tests) | +| Earbuds replaced `4:{28:0}` / `{28:1}` (now from gear → Settings) | `CAP-070` A770 `…2a052203e0010092717fdb7e` / `CAP-058` 5643 `…2a052203e00101044178ac7e` | `CAP-024` 1988 `…2a052203e00100d2b7cefd7e` / `CAP-070` A1992 `…2a052203e001014487c98a7e` | +| Other alerts `4:{27:0}` / `{27:1}` (now from gear → Settings) | `CAP-070` A777 `…2a052203d80100bac507f17e` / `CAP-058` 5697 `…2a052203d801012cf500867e` | `CAP-024` 2053 `…2a052203d80100fa03b6d77e` / `CAP-070` A1995 `…2a052203d801016c33b1a07e` | +| Multipoint `4:{11:0}` / `{11:1}` | `CAP-070` A752 / `CAP-069` 3212 | `CAP-070` A3668 / A3679 | +| Head gestures `4:{29:1}` / `{29:2}` | `CAP-070` A762 / `CAP-069` 2564 | `CAP-070` A3614 / A3685 | +| Volume EQ `4:{15:0}` / `{15:1}` | `CAP-070` A785 / A4800 | `CAP-070` A3072 / A1953 | +| Balance Right 4 `4:{17:7}` | `CAP-064` 6671 | `CAP-070` A3747 | +| EQ preset Balanced `4:{16:[-3.5, 0.5, 1.0, -1.0, 2.5]}` | `CAP-059` 2188 | no captured frame — the codec's output (unit tests) | +| Mode list without Off `4:{12:{1:1 2:0 3:1 4:0}}` | `CAP-041` 2198 | no captured frame — the codec's output (unit tests) | +| ANC `Get` (each claim: a tap, a pull on Connection/ANC/Find, the tile) | `08 11 00 00` | | +| ANC `Notify` (the Buds' answer, and **unprovoked** after a press-and-hold) | `08 13 00 04 01 e8 e8 xx` — `xx` = `08` Active, `40` Adaptive, `80` Transparent, `20` Off; Settable `00` instead of the second `e8` when no bud is worn (`CAP-045` 612/1583/1755/1849) | | +| ANC `Set` Transparent / Off / Active / Adaptive | `CAP-068-btsnoop_hci.log` 1039 `08 12 00 14 01 e8 e8 80` + 16 × `00`; 1090 `…20…`; 1146 `…08…`; 1266 `…40…` | | +| Ring Left / Stop / Right | `CAP-068-btsnoop_hci2.log` 2142 `04 01 00 01 02`, 2416 `04 01 00 01 00`; `CAP-062-btsnoop_hci.log` 9660 `04 01 00 01 01` | | + +A request with no row (touch controls, press and hold, in-ear detection, mono, a slider value) is compared with the app's unit-test fixture for the same +request (`android/data/src/test`). **Any difference is the finding.** Nothing may appear on DLCI 0x08/0x0a and no MAESTRO request other than those listed +(the wire discipline of `ai-sessions/0082` §5). + +#### I. The update and the start (film 1; `APP_TESTPLAN.md` V1–V4, U1–U3, S6; `PAIR-003`, `BATT-004`, `FW-003`) + +| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | +|---|---|---|---|---|---|---| +| BH-1 | P0–P9 done, 1.2.0 just installed over 1.1.1; lid open; screen recording on | open the app; within the first second go to **Controls** | **dark** (the 1.1.1 choice kept); no crash; the session opens by itself; for ≈ 1 s the unread switches show "—", then the Buds' values (S6, screen recording); Controls shows **Conversation detection** as its own card after In-ear detection and **no** Case sounds card | DLCI 0x02 open; the announcement; `ReadSetting 4:16`, then twelve reads in the order of the table, `SubscribeRuntimeInfo`, then **one `GetHardwareInfo`** = its reference for the announced channel, answered with `RESPONSE` field 7; no retry; nothing else | `PAIR-003`, `BATT-004`, `FW-003` | a crash; light theme; a read missing or out of order; a second `GetHardwareInfo`; any other request | +| BH-2 | ready | gear → **Settings**: Dark mode shows **On**; the **Case sounds** card (Earbuds replaced, Other alerts with their values) between Dark mode and "Use different Buds"; open its (i); then **Debug** (Debug mode **on**); then **Info**: hold 3 s on the build line, on "Control channel" and on the **Serial numbers** block | the two 1.1.1 choices kept; the (i) ends with "These settings live on the case and are read when the app connects."; "App: 1.2.0, build ()", no "-dirty"; firmware `release_5.203` ×3; channel 19 or 21 — say it; **"Serial numbers (from the Buds, HH:MM:SS):" then "Case: …", "Right bud: …", "Left bud: …"** and the note "Labelled by position …" | nothing sent | `FW-003` | either choice lost; "-dirty"; "Serial numbers: Not read from the Buds yet" or "not read — …" while the answer is in the log; a label order other than Case, Right bud, Left bud | +| BH-3 | ready, both buds in the case, lid open | **Connection** tab: read the battery card's **Worn** line; open the (i) | **"Both buds in the case"** (both charging flags from the runtime-info stream); the (i) has the "Worn: …" explanation ending with the `CAP-064` sentence | the runtime-info `6.2`/`6.3` with field 2 = 2 for both buds already received | `INEAR-006` | "Probably worn" with both buds in the case | +| BH-4 | ready | gear → Settings → Dark mode **System** (leave it there) | the app follows Android's theme at once | — | — | — | + +#### II. The worn line and "Changed by the Buds", head in view (film 1; `APP_TESTPLAN.md` V5–V12, F1–F4, G3, N11; `INEAR-006`, `INEAR-005`, `INEAR-004`, `ANC-001`…`ANC-004`) + +Head and both ears in view at every step; say every wear change. After each wear change **pull down on the Connection tab** before reading the Worn +line. Note the Worn line's "(checked HH:MM:SS)" each time — it must move with each pull. + +| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | +|---|---|---|---|---|---|---| +| BH-5 | both buds in the case | take **only the Left** out, into the left ear; wait 10 s; Info → "Control channel" (say it); pull on Connection; read Worn | the session re-opens by itself if the channel moved; **"Probably worn (checked …)"** | the announcement (`10 13` ⇒ 19 expected with only the Left out, `PROTOCOL.md` §2.2a); the thirteen requests again on the new channel incl. one `GetHardwareInfo`; the pull: `08 11` → `Notify` with Settable `e8` | `INEAR-005`, `INEAR-006` | "Not worn" with a bud in an ear | +| BH-6 | Left worn, channel 19 | the **Right** into the right ear; wait 10 s; Info; pull on Connection; read Worn | "Probably worn (checked …)" with a later time | `Notify` Settable `e8` | `INEAR-006` | — | +| BH-7 | both worn on 19 (if the channel is already 21, say so, put both back in the case 10 s, retry BH-5 once) | take the **Left** out of the ear and **hold it in view**; say "Left out"; wait 10 s; Info; pull on Connection | the session closes and re-opens by itself; Info shows the new channel; Worn stays "Probably worn" (the Right is in) | 🟡 lead L-1 (`PROTOCOL.md` §2.2a Updates of 2026-10-01/03/07): Buds `DISC` of MAESTRO with the ACL up, then an announcement `10 15` (21), the thirteen requests | `INEAR-005`, `INEAR-004`, `INEAR-006` | no `DISC`, or `DISC` followed by 19 again | +| BH-8 | Right worn, Left on the table | take the **Right** out, both buds **on the table**, in-ear detection on; wait 10 s; pull on Connection; read Worn; then **ANC tab: tap Transparent** | **"Not worn (checked …)"**; the ANC tap is refused with the 1.0.x not-allowed text, the mode unchanged | `Notify` with Settable **`00`** (`CAP-045` 1849's form); the tap: `08 11` → `Notify` `00` → **no `Set`** | `INEAR-006`, `INEAR-004`, `ANC-004` | "Probably worn" with both buds on the table beyond the first ≈ 30 s (see BH-11), or a `Set` sent | +| BH-9 | both on the table | Controls → **In-ear detection off**; pull on Connection; read Worn; **In-ear detection on** again; pull; read | after the OK: **"Worn: unknown — in-ear detection is off"**; after the second OK: "Not worn (checked …)" again | `4:{2:0}` → OK; `08 11` → `Notify` (Settable `e8` expected with in-ear off, `CAP-064` 10394 — the byte says nothing); `4:{2:1}` → OK; `08 11` → `Notify` `00` | `INEAR-006`, `INEAR-004` | "Probably worn" shown while in-ear detection is off | +| BH-10 | both on the table, in-ear on | both buds **into the ears**; wait 10 s; pull on Connection; **ANC tab: tap Transparent, Adaptive, Off, Active**, 8 s apart; after each, open the ANC (i) | "Probably worn (checked …)"; each mode after its ACK; the (i) says "Noise control: set HH:MM:SS …" and has **no** "Changed by the Buds" line | per tap `08 11` → `Notify` `e8` → the `Set` = the reference → ACK `ff 01` | `ANC-004`, `ANC-003`, `ANC-001`, `ANC-002`, `INEAR-006` | the line shown after an app tap; a `Set` that differs | +| BH-11 | both worn, ANC on Active, the ANC tab on screen with its (i) **closed** | **press and hold the Left bud** (its noise-control cycle); say it; wait 5 s; open the ANC (i); close; press and hold again; (i) again | the mode changes **without a tap**; the (i) shows **"Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)."** with the time of the `Notify`; the second time the line's time moves | an **unprovoked** `08 13 00 04 01 e8 e8 xx` from the Buds — no `08 11` before it; **nothing sent by the app** | `ANC-001`…`ANC-004` | no mode change on screen (the Buds' `Notify` not applied); the line missing; the app sending anything in reaction | +| BH-12 | the line shown | **tap** the shown mode's neighbour in the app; (i); then **pull down on the ANC tab**; (i); then the **ANC tile** once; (i) | after the tap the line is **gone** ("set HH:MM:SS"); after the pull no line ("read HH:MM:SS"); after the tile no line | the tap: `08 11` → `Notify` → `Set` → ACK; the pull: `08 11` → `Notify`; the tile: a claim as the tap | `ANC-001`…`ANC-004` | the line still shown after the app's own tap, pull or tile | +| BH-13 | both worn | both buds **straight from the ears into the case** (lid open), then **straight out onto the table**, say "on the table" and start counting; pull on Connection at ≈ 5, 15, 30, 45 and 60 s; read Worn each time | the `CAP-064` case: **"Probably worn"** may show for the first ≈ 30 s, then **"Not worn"**; or "Both buds in the case" first if the charging flags arrived; say what you see with the count | `Notify` per pull: `e8` then `00` (or `00` at once); the runtime-info `6.2`/`6.3` field 2 = 2 then 0 | `INEAR-006` | "Probably worn" still at 60 s (the 🟡 of ADR-049 item 3 is then weaker — write it down as a result, not a fault of the app) | + +#### III. Every write once, buds worn (film 1; `APP_TESTPLAN.md` V13–V17, U10–U14, H2–H6, M2–M6, N2–N12, I1–I3, T3–T8; `CASE-001`, `CASE-002`, `CONV-001`) + +Both buds **in your ears** unless a step says otherwise; music at low volume for BH-14/BH-15. Every write: the control moves only after the Buds' OK. + +| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | +|---|---|---|---|---|---|---| +| BH-14 | worn, music | gear → **Settings → Case sounds**: **Earbuds replaced off** → take the **Left** out of the ear into the case and out again → **say whether the case sounded** → **on** → the same → say; **Other alerts** off, then on; open the card's (i) each time | each switch after its OK; the (i) "Earbuds replaced: changed HH:MM:SS", "Other alerts: changed …", the case note | `4:{28:0}` / `{28:1}`, `4:{27:0}` / `{27:1}` — each = its reference (the move changed nothing) | `CASE-001`, `CASE-002` | a request that differs; a switch on Controls | +| BH-15 | worn, music | **Controls → Conversation detection** off, then on; with it on **speak for 5 s** (ANC on Active); say what the Buds do | each after its OK; the card's subtitle "Switch from noise cancellation to transparency when you talk" | `4:{22:0}` = `CAP-019` 1720, `4:{22:1}` = 1808 (on 21) | `CONV-001` | a request that differs (field 19 or 22 swapped: the mutation M9 case); the row still on Sound | +| BH-16 | worn, music | **Sound**: preset **Balanced**; drag **Upper treble** up, release; **Read EQ again** (H5) ★; balance **Right 4**, then **Centre** ★; **Mono audio** on/off; **Volume EQ** off/on — say what you hear at each | each after its OK; *Read EQ again* shows the value just written; no conversation-detection row on Sound | Balanced = `CAP-059` 2188 on 21; one `WriteSetting 4:{16:…}` on release; `ReadSetting 4:16` = the write; `17:7`, `17:0`; `19:1`, `19:0`; `15:0`, `15:1` | `EQP-001`, `EQS-001`, `AUDIO-003`, `AUDIO-001`, `AUDIO-002` | a request that differs; a read that differs from its write | +| BH-17 | worn | **Controls**: **Use touch controls** off/on; press and hold **Left: Digital assistant**, then **Noise control**; mode list: **untick Off** (restores the Buds); **Use head gestures** off/on; **Multipoint** off/on | each after its OK | `4:{4:0}`/`{4:1}`; `4:{7:{1:{4:{1:6}}}}`/`{1:5}`; `4:{12:{1:1 2:0 3:1 4:0}}` (= `CAP-041` 2198 on 21); `29:1`/`29:2`; `11:0`/`11:1` — each = its reference | `HOLD-005`, `HEAD-001`, `MULTI-001` | a request that differs; the mode list keeps Off | +| BH-18 | worn | **Disconnect**, then **Connect**; open Sound, Controls, gear → Settings and Info | every value just written read back ("read …"): Balanced, Centre, mono off, conversation on, Volume EQ on, touch on, Left Noise control, mode list without Off, in-ear on, head gestures on, Multipoint on, both case sounds on; Info: the **same three serials** with a new time; the Worn line "—" for a moment, then "Probably worn" | the thirteen requests + `4:16`; each answer = the last write; one `GetHardwareInfo` with the same answer bytes as BH-1's | `PAIR-003`, `FW-003` | a read that differs from the last write; different serials; two `GetHardwareInfo` | +| BH-19 | both buds out of the ears, on the table | Find: **Ring Left**, **Stop**, **Ring Right**, **Stop** | the notice and the ring follow each tap | `04 01 00 01 02` → ACK; `… 00`; `… 01`; `… 00` | `FIND-001`, `FIND-002` | a ring that does not stop | + +#### IV. Robustness (film 1; `APP_TESTPLAN.md` C8, C9, J4, K1, K2, R6) + +| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | +|---|---|---|---|---|---|---| +| BH-20 | ready | both buds into the case, lid open; wait 10 s; take both out again (do not tap Connect) | the session closes with the 1.0.1 cause text; then ready **by itself**; the Worn line "—" during the gap, then a reading after the re-open's `Notify`; the serials shown again | Buds `DISC` 0x02 (or the ACL drops); the app's `SABM` 0x02 ≈ 1.5 s after the link is back (ADR-044); the thirteen requests | `CASE-004`, `CASE-005`, `PAIR-003` | no automatic re-open while the app is on screen | +| BH-21 | ready | **Home**; wait 2 minutes; return to the app | still ready, or a clear message and a re-open; no crash | — | — | a crash | +| BH-22 | ready | **Export** the debug log; Quick Settings **Bluetooth off**; wait 10 s; **Bluetooth on** | "Bluetooth is disabled.", then ready again by itself; Info's serials "Not read …" while off, read again after | export lines "Bluetooth adapter: ON -> TURNING_OFF", "Session loss cause: Bluetooth was switched off on this phone"; **no serial number and no MAC in the export** (grep it afterwards); after on, the automatic re-open and the thirteen requests | `PAIR-003` | a crash; no re-open; a serial in the export | +| BH-end1 | — | **Export** the debug log; status bar across a minute change; stop film 1 and the screen recording | "Debug log saved (N lines)." | — | — | — | + +#### V. The open film items of `TODO.md` §2 (film 2; `APP_TESTPLAN.md` C12, S12, T11) + +Start film 2 and the screen recording; keep the system log running (P6). (Lead L-1 / `INEAR-005` is BH-5…BH-7 above.) + +| Step | Pre-state | Action | Expected on screen | Expected on the wire | Test-ID | Refuted if | +|---|---|---|---|---|---|---| +| BH-23 | ready, both worn, Controls tab | put the **Right** bud into the case; **as soon as** the card shows that the app's channel was closed (≈ 0.5–1.5 s), **tap Multipoint** — before "ready" | "The setting was not changed: The app's channel is being reopened — try again in a moment."; the switch unchanged; after "ready" nothing is sent by itself | Buds `DISC` 0x02 → app `SABM` ≈ 1.5 s later; **no** `WriteSetting` for that tap | `CASE-004` | a write sent later by itself (if the tap comes after "ready", say so — C12 stays open) | +| BH-24 | ready | Debug → **Export debug log**; **rotate the phone while Android's "save as" dialog is open**; save (S12) | "Debug log saved (N lines)."; the file has content | — | — | an empty file, no toast | +| BH-25 | ready | **Export** again; Quick Settings **Bluetooth off**; on the computer `adb shell am force-stop io.github.tedsluis.opencontrolpixelbuds` (say it); open the app; **Controls** → `adb shell uiautomator dump /sdcard/CAP-072-controls.xml`; **Sound** (scroll to the Balance card) → `… /sdcard/CAP-072-sound.xml`; **gear → Settings** → `… /sdcard/CAP-072-settings.xml`; `adb pull` all three into this folder (T11) | "Bluetooth is disabled."; Controls: "—" in place of every switch (now seven, conversation detection included); Sound: the five EQ bands, Volume EQ, balance and mono audio "—"; Settings: the two Case sounds switches "—"; the battery card's Worn line "—" | — | — | in a dump, a node with `text="—"` whose `content-desc` is not `Not read from the Buds yet` | +| BH-26 | — | **Bluetooth on** → the app re-opens the session by itself (or **Connect**); **Export**; stop the system log (P6); status bar across a minute change; stop film 2 and the screen recording | ready; the values back; the serials back | the thirteen requests | `PAIR-003` | — | + +### Don'ts + +- Do not uninstall 1.1.1 and do not clear the app's data (P1 — the run is an update). +- Do not open the app before the film runs (P9); do not open the Owner user during a film. +- Do not tap two things within 5 s of each other, except in BH-23 (the early tap). +- Do not read the Worn line without a pull first after a wear change — the app asks the Buds only on a claim; a stale "(checked …)" time is not a finding. +- Do not leave the buds in the case when a step says "worn" — say it if a step is done differently. +- Do not write a serial number unredacted anywhere in this folder or the findings (first 4 + last 2 characters; the fixtures use `5707XXXXXXXX51`). + +### After the run + +Into this folder: both films, both screen recordings, the system log (CAP-072-logcat-all.txt), every debug export (`adb pull` from the test user's +storage), the app logcat if saved, both btsnoop_hci.log files, the three `uiautomator` dumps, the P0/P1 outputs (before and after the update), and +`dist/1.2.0/`'s `.sha256` and the script's printed certificate line. Then `sha256sum *` into a file. Commit on the release branch `release/1.2.0`, after the +build commit (`RELEASING.md` C3) — never on `main` (it takes no direct push). + +### Analysis checklist + +- [ ] Scope every filter to the Buds' ACL handle (show the Connection Complete for the Buds' address with that handle); DLCIs by content (`AGENTS.md` §13); + for a negative, the command, its exit status and a positive control (step 8). +- [ ] P0 (exit statuses, positive control), P1: user id; no `com.google.android.gms` / `com.android.vending`; **the update**: `firstInstallTime` unchanged + and `lastUpdateTime` later, `versionCode=10200`; the Info frame "1.2.0, build " against `git log` and the B1 hash; APK and certificate SHA-256 + against `dist/1.2.0/` and `RELEASING.md`. +- [ ] **The release APK against 1.1.1's:** `aapt2 dump badging` — permissions identical (no `INTERNET`), `targetSdkVersion:'34'`, `sdkVersion:'34'`, + `compileSdkVersion='37'`; `aapt2 dump xmltree` — no new ``, ``, ``, ``. +- [ ] Zero Play-services claims on the Message Stream (`frame contains 03:08:00:02:01:25`): command, exit status, positive control `CAP-066`. +- [ ] I: dark mode and Debug mode kept across the update; per session the **thirteen** requests in order with their answers (`python3 -I + scripts/pwrpc_decode.py --handle `) — `GetHardwareInfo` **once** per session, **last**, byte-identical to its reference, its answer's + field 7 strings compared (redacted) with the Info tab on film in the order Case, Right bud, Left bud; S6 on the screen recording; **no** + `GetHardwareInfo` outside Connect/re-open (`CAP-071` had none at all — positive control for the filter: the official app's in `CAP-036`). +- [ ] II: per pull the `08 11` → `Notify` and its Settable byte against the Worn line on the screen recording and the ears on film — one row per BH-5…BH-13 + reading (**the `INEAR-006` table**: time, ears, Settable byte, in-ear field 2, charging flags, Worn text); BH-11: the unprovoked `08 13` with no `08 11` + in the 2 s before it and nothing from the app after it, the (i) line's time = the `Notify`'s; BH-12: the line gone after the app's own claim; BH-13: + the ≈ 28 s timing (Settable `e8` → `00`) — a **proposal** for ADR-049 item 3 / ADR-059, no status change by the analysing session alone; BH-7: the + `DISC` direction and the announced channel with the head side on film (L-1, `PROTOCOL.md` §2.2a — a proposal). +- [ ] III: **every request against the reference table** (byte for byte, per channel; the others against the unit-test fixtures) — the two case sounds from + their new place and conversation detection from Controls first; every `RESPONSE`/ACK; the read-back of BH-18 and its serials; the sound observations + quoted as said (BH-14, BH-15, BH-16). +- [ ] IV: each session end with its logged cause; the automatic re-opens; Bluetooth off/on; **`grep -c` over every export for each serial's first 4 + last 2 + characters and for the Buds' MAC = 0**, with a positive control (the export's own "Control channel" line). +- [ ] V: BH-23 — no `WriteSetting` after the early tap, the text on the screen recording; BH-24's file; **the three dumps** (count `text="—"` nodes and their + `content-desc` per tab; Controls now seven, Settings two). +- [ ] The system log: crashes, ANRs, `StrictMode` (none expected in a release build); the `bugreportz` SIGQUITs discounted (P6 note). +- [ ] One row per step — done / not done / done differently; `APP_TESTPLAN.md` section V and the Summary updated; traceability (`AGENTS.md` §13 step 7): + `PAIR-003` (BH-1, BH-18, BH-20, BH-22, BH-26), `BATT-004` (BH-1), `FW-003` (BH-1, BH-2, BH-18), `INEAR-006` (BH-3, BH-5…BH-10, BH-13), `INEAR-005` + (BH-5, BH-7), `INEAR-004` (BH-7, BH-8, BH-9), `ANC-001`…`ANC-004` (BH-8, BH-10…BH-12), `CASE-001`, `CASE-002` (BH-14), `CONV-001` (BH-15), + `EQP-001`, `EQS-001`, `AUDIO-001`…`AUDIO-003` (BH-16), `HOLD-005`, `HEAD-001`, `MULTI-001` (BH-17), `CASE-004` (BH-20, BH-23), `CASE-005` (BH-20), + `FIND-001`, `FIND-002` (BH-19) — each referenced in the timeline or flagged "expected but not observed". +- [ ] Update `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9, `id_registry.csv`, `APP_TESTPLAN.md`, `TODO.md` §2/§4/§5 (the restored Buds, the items done, the fixture + swap of `HardwareInfoFixtures`), ADR-058/ADR-059 Updates (proposals), and write `CAP-072-FINDINGS.md`; the release verdict for 1.2.0 is the analysing + session's, with the maintainer. + +--- +https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES.md - https://tedsluis.github.io/opencontrolpixelbudspro2/captures/CAP-072-yyyy-MM-dd_HH-mm-ss_HH-mm-ss-Group_BH/CAP-072-EVENT-NOTES From 0926b32530360afaa46de988af6effb9318b8c8b Mon Sep 17 00:00:00 2001 From: tedsluis Date: Fri, 9 Oct 2026 08:02:01 +0200 Subject: [PATCH 5/6] chore(release): 1.2.0 (versionCode 10200), release-notes template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: RELEASING.md §4 steps 1–2, chosen at the ai-sessions/0082 checkpoint. No tag, no release.sh, no publication — the verdict waits for CAP-072. Co-Authored-By: Claude Fable 5.1 --- android/app/build.gradle.kts | 4 ++-- scripts/release_notes.template | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 35ac07e6..a13bd968 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,8 +54,8 @@ android { // Release candidates exist only for X.0.0 (`ai-sessions/0069`, `scripts/release.sh`): for any other version the candidate's code would not be // above the previous release. 1.0.1 = the hotfix of `ai-sessions/0069` (the maintainer's choice in chat 2026-10-03); 1.1.1 = the toolchain of `ai-sessions/0078` // (`ai-sessions/0079`, the maintainer's choice in chat 2026-10-08). - versionCode = 10101 - versionName = "1.1.1" + versionCode = 10200 + versionName = "1.2.0" buildConfigField("String", "GIT_COMMIT", "\"$gitCommit\"") buildConfigField("String", "GIT_COMMIT_DATE", "\"$gitCommitDate\"") } diff --git a/scripts/release_notes.template b/scripts/release_notes.template index 4369ef86..8f5976fd 100644 --- a/scripts/release_notes.template +++ b/scripts/release_notes.template @@ -1,6 +1,8 @@ An independent, open-source Android app to control Google Pixel Buds Pro 2 — fully offline: no Google Play services needed, no `INTERNET` permission, no location permission, no account. -**Features:** noise control (Noise cancellation, Adaptive, Transparency, Off) and a Quick Settings tile · equalizer with presets and Volume EQ · battery Left/Right (with charging) and the Case · Find My Buds (ring Left / Right) · touch controls, press and hold per bud and its noise-control modes · head gestures, Multipoint, case sounds · balance, mono audio, conversation detection, in-ear detection · dark mode. +**Features:** noise control (Noise cancellation, Adaptive, Transparency, Off) and a Quick Settings tile — with "Changed by the Buds" in its details when a bud's press-and-hold or the Buds themselves changed the mode · equalizer with presets and Volume EQ · battery Left/Right (with charging), the Case and a "probably worn" line · Find My Buds (ring Left / Right) · touch controls, press and hold per bud and its noise-control modes · head gestures, Multipoint, conversation detection, in-ear detection · balance, mono audio · case sounds and dark mode on gear → Settings · firmware and serial numbers on gear → Info. + +**New in 1.2.0:** "Changed by the Buds" in the noise-control details; the serial numbers of the Case and both buds on the Info tab; the "probably worn" line on the battery card; the Case sounds switches on gear → Settings; Conversation detection on Controls. **Known issues in 1.2.0:** the worn line is a hedged reading (the Buds say "at least one bud", never which; once they reported it with both buds on a table); the serial labels are the official app's reading of the answer's order; the app says what a switch sets, not what it does to the sound. **Not included:** ringing the Case or both buds, firmware updates, choosing the devices of a multipoint connection, anything that needs a Google account. From 531c99dc82f686a80fcebe5e99825f85a0f1a751 Mon Sep 17 00:00:00 2001 From: tedsluis Date: Fri, 9 Oct 2026 08:02:18 +0200 Subject: [PATCH 6/6] docs(ai-sessions): 0082 FEATURE prompt, result and INDEX row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: AI_SESSION_LOG_PROCEDURE.md — the prompt and the result of the 1.2.0 session (evidence per item with frames and commands, the checkpoint answers verbatim, the item-4 study with the draft ADR-060, the gate, mutation and compliance tables, the CAP-072 skeleton as a table, deferred items, commits). Co-Authored-By: Claude Fable 5.1 --- ai-sessions/0082_FEATURE_PROMPT_2026_10_09.md | 422 +++++++++++++ ai-sessions/0082_FEATURE_RESULT_2026_10_09.md | 590 ++++++++++++++++++ ai-sessions/INDEX.md | 1 + 3 files changed, 1013 insertions(+) create mode 100644 ai-sessions/0082_FEATURE_PROMPT_2026_10_09.md create mode 100644 ai-sessions/0082_FEATURE_RESULT_2026_10_09.md diff --git a/ai-sessions/0082_FEATURE_PROMPT_2026_10_09.md b/ai-sessions/0082_FEATURE_PROMPT_2026_10_09.md new file mode 100644 index 00000000..a89aa9be --- /dev/null +++ b/ai-sessions/0082_FEATURE_PROMPT_2026_10_09.md @@ -0,0 +1,422 @@ +# 0082_FEATURE_PROMPT_2026_10_09.md — Build 1.2.0: "Changed by the Buds" in the ANC details, the component serial numbers, a "probably worn" indicator (ADR-049), two tab moves, and a design study for volume-level notifications (Dosimeter) — with real capture bytes as fixtures, a green gate, the hardware-run skeleton and the release preparation + +**Number:** 0082 +**Category:** FEATURE +**Date:** 2026-10-09 +**Title:** Implement in the OpenControl app, as release 1.2.0: (1) the (i)-details line "Changed by the Buds" when a `Notify` changes the ANC mode +without a tap in the app; (2) the serial numbers per component (Case / Left / Right) from `GetHardwareInfo` field 7 on the Info tab; (3) a worn +indicator built on ADR-049's Settable byte and the runtime-info in-case flags, shown as "probably worn" (the maintainer's decision); (5) the two Case +sounds switches moved from the Controls tab to gear → Settings; (6) the Conversation detection switch moved from Sound to Controls — plus (4) a design +study, no code, of how volume-level ("recommended exposure limit exceeded") notifications could be built from `maestro_pw.Dosimeter`, including the +APK reverse engineering it needs and the ADR it would require. Real capture bytes as fixtures, a green test/lint gate with mutation checks, the +documents that follow the code, the skeleton of the hardware run (with the open film items of `TODO.md` §2), and the release preparation for 1.2.0 +(no tag, no publication) + +--- + +## 0. How to use this prompt + +You are an expert software architect, reverse engineer, Android/Kotlin engineer and technical auditor. This prompt is for a **fresh** Claude Code +session in this repository. Run the phases in §4 **strictly in order**; do not start a phase before the previous one is done and recorded. + +**Reading first (mandatory, `AGENTS.md` §0.1, `AI_SESSION_LOG_PROCEDURE.md` §8).** Before any other action, read **in full**, in this order: +`AGENTS.md`, `PROJECT_RULES.md`, `PROJECT.md`, `ARCHITECTURE.md`, `PROTOCOL.md` (in particular §2.2a with **all** its dated Updates — the +`GetHardwareInfo`/`GetSoftwareInfo` paragraphs and the Dosimeter paragraph of 2026-10-06 —, §4.1 "Notify ANC state" with its Settable-toggles bullet, +§4.3 Option F, §4.5 preamble, §4.5.8 and **§4.5.8a Volume level notifications**, and §6), `DECISIONS.md` (**every** ADR, ADR-001 … ADR-057, with every +dated Update — in particular ADR-009, ADR-021/022, ADR-032, ADR-034, ADR-042, ADR-043 with its Update, ADR-044, ADR-045, ADR-047, ADR-048, +**ADR-049 with its two Updates**, ADR-053, ADR-054, ADR-056, ADR-057), `TODO.md`. Then read `AI_SESSION_LOG_PROCEDURE.md` (in particular §4b and §9), +`ai-sessions/INDEX.md`, `RELEASING.md`, `APP_TESTPLAN.md`, `CHANGELOG.md`, `id_registry.csv`, `REVERSE_ENGINEERING.md`'s `qhr` register (field 21 and +its 2026-10-06 Update), its `maestro_pw.*` catalog and the "decoded shapes register" (`qhz`, `qia`, `qir`), and: + +- `ai-sessions/0074_FEATURE_PROMPT_2026_10_06.md` and `ai-sessions/0074_FEATURE_RESULT_2026_10_06.md` — the pattern of the last feature build + (reads, writes, fixtures, gate, mutations, run skeleton, release preparation); this prompt follows it; +- `ai-sessions/0076_FEATURE_RESULT_2026_10_07.md` — how fixtures were replaced by real `CAP-070` frames; +- `ai-sessions/0081_REVIEW_RESULT_2026_10_08.md` §C.5 — "What 1.2.0 should know first"; +- `ai-sessions/0073_CROSSCHECK_RESULT_2026_10_06.md` §4 — the code-side answers: `GetHardwareInfo` (`qiv`, `gck.java:288` "serial number %s, sku %s"), + the device-type key, the runtime-info charger-type enum, the `Dosimeter` shapes; +- `CAP-036-FINDINGS.md` (frame 1423, `GetHardwareInfo` RESPONSE with field 7), `CAP-058-FINDINGS.md` §6 and its EVENT-NOTES rows X11/X12 (the Hearing + wellness page and the field-21 switch), `CAP-064-FINDINGS.md` §3 and `CAP-065-FINDINGS.md` §3 and `CAP-066-FINDINGS.md` (the Settable-byte samples + with the ears on film), `CAP-069-FINDINGS.md` §12 item 8 and its §2 (taps on the current mode, the Buds' own `Notify`), `CAP-067-FINDINGS.md` §2 + (the Transparent → Active change without an app command), `CAP-045-FINDINGS.md` and `CAP-021-FINDINGS.md` §4 (press-and-hold ANC cycling on the + wire — the `Notify` frames a physical change produces); +- `CAP-071-EVENT-NOTES.md` and `CAP-071-FINDINGS.md` — the layout of the last release-build run and what it left open (film 2: L-1, C12, S12, T11). + +Read every Kotlin file you change **in full** before changing it — at least `DeviceInfo.kt` (`AncAvailability`, `DeviceInfo`, `FirmwareEntry`), +`BatteryStatus.kt` (`ChargingReading`, the runtime-info fields), `BudsRepository.kt`, `BudsRepositoryImpl.kt` (the ANC `Notify` path, `ancMode`, +`ancModeUpdatedAt`, `ancModeUnconfirmedAt`, the ADR-044 re-open, the runtime-info subscription, the Connect read sequence), `AncFrame.kt`, +`Maestro.kt` (`READABLE_FIELDS`, the request builders, whether `GetHardwareInfo` is **sent**), `PwRpc.kt`, `SoftwareInfo.kt`, `SettingFrame.kt`, +`BudsSettings.kt`, `AncScreen.kt` (`ancDetailLines`, `ancNotAllowedLine`), `ConnectionScreen.kt`, `ControlsScreen.kt`, `EqScreen.kt`, +`SettingsMenu.kt` (`SettingsTab`, `DarkModeSettings`, `InfoTab`), `SettingsUi.kt`, `Details.kt`, `OpenControlNavHost.kt`, `MainActivity.kt` (the state +holder, ADR-048), `BudsForegroundService.kt`, and their tests (`BudsRepositoryImplTest.kt`, `SettingsFixtures.kt`, `FakeBudsTransport.kt`, the `:ui` +Robolectric tests). Say in the RESULT which files you read in full and which only partly. + +**Resuming after a rate or token limit (`AI_SESSION_LOG_PROCEDURE.md` §5) — you must be able to continue automatically.** Create +**ai-sessions/0082_FEATURE_RESULT_2026_10_09.md** at the very start, with `**Status:** partial — resumed` and a **Progress** block, and update that +block at the end of every phase **and** after every item built (which items are done and tested, which files are touched but not yet tested, the +last gate result, where intermediate results live — the scratchpad directory: decoded frames, the mutation script; re-create them if the scratchpad +is gone). A resumed session reads this prompt, then the RESULT's Progress block, re-reads the files the unfinished step touched, and continues from +there. It never redoes a finished, recorded step, and never assumes an unrecorded step was done. + +**Nothing is taken on trust, including this prompt, the ADRs' frame numbers and the earlier RESULTs.** Re-derive every frame and byte you use from +its capture log (`PROJECT_RULES.md` rule 4a) before it becomes a fixture; if anything here disagrees with a capture or with the code, the capture +wins — record the correction in the RESULT and, where it touches an ADR's or a 🟢 text, bring it to the checkpoint. + +--- + +## 1. What the maintainer asked for (chat, 2026-10-09, translated) + +1. **"Changed by the Buds"** in the (i) details of the noise-control card when a `Notify` changes the ANC mode without a tap in the app + (`CAP-069-FINDINGS.md` §12 item 8). No protocol work — UI, the state behind it, and one test. +2. **Serial numbers per component** (the `[~]` line of `PROJECT.md`'s functional scope: "firmware shown; serial numbers not read"). The maintainer's + understanding: `GetHardwareInfo` is already read at Connect, so only the fields need showing. **Check whether that answer is 🟢 and whether the app + actually sends the request** (§2 item 2) before building. +3. **Worn indicator** — the only `[ ]` line of the functional scope ("whether a bud is worn is not shown"). ADR-049 item 3: Settable `00` ⇒ no bud + worn (🟡 strong); `e8` ⇒ at least one bud worn, except with in-ear detection off (`e8` always) and `CAP-064`'s ≈ 28 s of `e8` with both buds on the + table. **The maintainer's decision (M, chat 2026-10-09): show it as "probably worn"** — an honest, hedged indicator, not a certainty. The ADR that + records this is drafted in Phase A and approved at the checkpoint (§3). +4. **Volume-level notifications:** how could the app notify the user when the recommended exposure limit is exceeded, the way the official app's + "Volume level notifications" switch (Hearing wellness, `qhr` field 21, `PROTOCOL.md` §4.5.8a) promises? The maintainer expects this to need + further reverse engineering of the official APK. **This item is a design study with its RE, not an implementation** — see §2 item 4 and §3. +5. **Move the two Case sounds switches** ("Earbuds replaced", "Other alerts", ADR-054) from the Controls tab to **gear → Settings** (the first tab of + the settings menu, next to dark mode and "Use different Buds"). +6. **Move the Conversation detection switch** (field 22, ADR-045) from the Sound tab ("Balance and audio" card) to the **Controls** tab. + +Items 5 and 6 are the maintainer's new answer on tab placement (chat 2026-10-09); they replace the placement decided for `ai-sessions/0074` (Case +sounds on Controls) and `ai-sessions/0052` (Conversation detection on Sound). Do not ask again *which* tab; the order and grouping *within* the tab is +a checkpoint question. + +--- + +## 2. The items (verify each against the captures, the ADRs and the code before building) + +### Item 1 — "Changed by the Buds" (ANC details) + +- **What the wire gives:** on the app's own DLCI 0x04 claim (held for the session, ADR-044), the Buds push `Notify ANC state` (`08 13 00 04 …`) when + the mode changes by any cause — a press-and-hold on a bud (`CAP-021` §4, `CAP-045`), the Buds' own change (`CAP-067` §2: Transparent → Active with + no app command), or the app's own `Set` (`08 12`, ACKed, then the `Notify`). The repository already turns every `Notify` into `ancMode` + + `ancModeUpdatedAt` (`BudsRepository.kt`), without recording *why* the mode changed. +- **Build:** track the cause of the current `ancMode`: "set by this app" when the `Notify` follows the app's own ACKed `Set` for that mode within the + existing write window (re-use the ADR-045/F-3 bookkeeping — do not add a timer constant without the checkpoint); "read" for the connect-time or + refresh `Get`'s answer; "changed by the Buds" for any other `Notify` whose mode differs from the shown one. Show it only in the (i) details + (`ancDetailLines`) as one line: *"Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)."* — wording to confirm at + the checkpoint. Nothing on the main surface, no notification, no new request. +- **Fixture:** a real `Notify` sequence from a capture with a physical press-and-hold change while a client held the Message Stream — `CAP-045` + (Group AJ) lists them: 612 connect-time `Notify` (`… 01 e8 e8 40`, Adaptive), then Notify-only 1583 (`… e8 e8 80`, Transparency), 1755 (`… e8 e8 08`, + Noise cancellation), 1818 (`… e8 e8 40`, Adaptive) — `CAP-045-FINDINGS.md:55–58`; `CAP-021` §4 (`TOUCH-007`) is the second source. Re-derive the bytes + (`tshark -r -Y 'frame.number==1583' -T fields -e data.data`). One + repository test: `Set` → ACK → `Notify` (same mode) does **not** produce the line; an unsolicited `Notify` with another mode does; the connect `Get`'s + `Notify` does not. +- **Test-ID:** `ANC-006` and `TOUCH-007` rows of `TESTPLAN_BLUETOOTH_HCI_SNOOP.md` for the run skeleton (a press-and-hold on a bud while OpenControl is + open → the line appears). + +### Item 2 — Serial numbers per component + +- **What is known (re-derive):** `GetHardwareInfo` (`maestro_pw.Maestro`, method id `0x28eca5e3` = `h65599("GetHardwareInfo")`, `fux.java`) answers + with field 7 holding three strings — `CAP-036` frame 1415 (request) → 1423 (RESPONSE): `57071WRBEC0251`, `57081WRBDR3209` (corrected 2026-10-03), + `57071WRBDL3147`; the method identity and the field are 🟢 FACT (`PROTOCOL.md` §6, resolved 2026-09-24; §2.2a Update of 2026-10-06 names field 7 "the + component serials"). Which string is the Case, the Left or the Right bud is **not** 🟢: the substring reading "EC" = case, "DL"/"DR" = left/right is a + 🟡 from `ai-sessions/0017` (Group AS was withdrawn, `CAPTURE_BLUETOOTH_HCI_SNOOP.md`); `gck.java:288` logs "serial number %s, sku %s, hardware + version" (`ai-sessions/0073` §4) — check in the JADX output whether the official app assigns the three strings to components by **position** + (field 7's repeated order) or by **content**, and whether `GetHardwareInfo` is sent **per channel** (19 and 21) with the answer describing that + bud; the per-channel address table of `fux.java:90–103` (channels 18–22 ↔ CASE, LEFT_BT_CORE, LEFT_SENSOR_HUB, RIGHT_BT_CORE, RIGHT_SENSOR_HUB) is + the lead. +- **Does the app send it?** Phase A answers this from `Maestro.kt`/`BudsRepositoryImpl.kt`. A first grep while writing this prompt found the method id only + in the name table (`Maestro.kt:37` `METHOD_GET_HARDWARE_INFO`, `PwRpc.kt:198` for decoding) and no call site — so expect that the app decodes but does + **not** send it (verify). If `GetHardwareInfo` is **not** sent, showing serials + adds **one unary request per Connect** on the announced channel (the official app sends it inside its connect burst, `CAP-036` 1415; every later + capture has it, e.g. `CAP-058` §1's sweep) — a new wire request, which needs an **ADR** (draft **ADR-058** for the checkpoint: one request per + Connect, byte-identical to the official app's, through the ADR-042 Safe-Mode gate for the send, no retry; the reply read for field 7 only). If the + app already sends it, no ADR is needed and the item is UI only — say which in the RESULT with the `file:line`. +- **Build:** on the Info tab (`SettingsMenu.kt` `InfoTab`), under the firmware lines: "Serial numbers" with one line per string. **Honest state:** + attribute a string to Case / Left / Right only if Phase A makes the mapping 🟢 (code path + a wire sample on each channel); otherwise show the three + strings "as reported, in the Buds' order" with an (i) line saying the component is not confirmed — never a guess. "—" until the answer arrived. + Serial numbers are device identifiers: **redact them in every fixture, log line and RESULT** (keep the first four and last two characters, `AGENTS.md` + §9, §11) and never log them at `INFO` or above. +- **Fixture:** `CAP-036` 1423 (official app, channel 19) and one answer from an OpenControl capture if the app already sends the request (else from + `CAP-070`/`CAP-071`'s official-app-free logs if present; if none exists, the run skeleton records the first one). + +### Item 3 — Worn indicator ("probably worn") + +- **What is known (ADR-049 and its two Updates, re-derive the frames):** (1) 🟢 Settable `00` ⇒ the Buds NAK a `Set` (reason `0x02`); `e8` ⇒ ACK. + (2) 🟢 both buds in the case ⇒ `00`. (3) 🟡 strong: `00` ⇒ no bud worn — 28 + 28 samples with the ears on film (`CAP-065`, `CAP-066`), 0 counter-examples; + the converse "no bud worn ⇒ `00`" is **refuted**: in-ear detection **off** ⇒ `e8` with no bud worn (`CAP-064` 10394, 10600), and in-ear detection on ⇒ + `e8` for ≈ 28 s with both buds on the table straight from the case (`CAP-064` 2299…2759; did not recur in `CAP-065`/`CAP-066`). (4) The byte is not + per bud: `e8` means "at least one bud worn", never which one. (5) The runtime-info stream (`SubscribeRuntimeInfo`, ADR-043, `PROTOCOL.md` §4.3 + Option F) gives per bud whether it is **charging / in the case** (`7.2` Left, `7.1` Right; entry field 2 = the charger-type enum, `ai-sessions/0073` + §4.6) — a bud in the case is not worn (🟢 correlation), a bud out of the case may or may not be. (6) GSND CONTROL Code `0x05` (3/4/6 = none/one/both + worn, 🟡, `CAP-069` §8) is on DLCI 0x08, which the app does not open (ADR-043) — **not** a source for this item. +- **The maintainer's decision (M, chat 2026-10-09):** show the indicator as **"probably worn"** — hedged wording, with the time of the sample. +- **Build, without new wire traffic:** derive one of these from state the app already holds — "Probably worn (checked HH:MM:SS)" when the last + `Notify`'s Settable byte is non-zero **and** in-ear detection reads on (field 2 = 1) **and** not both buds report in-case; "Not worn (checked …)" when + the byte is `00`; "In the case" per bud from the runtime-info flags; "Unknown — in-ear detection is off" when field 2 = 0 (the byte then says nothing); + "—" when no `Notify` arrived in this session. The sample is the last `Notify` the app received (connect `Get`, pull-to-refresh, a tap) — **not live**; + always show its time, and let pull-to-refresh re-read it through the existing `Get`. Place it where the maintainer confirms at the checkpoint + (proposal: a line on the Connection screen's battery card, with the (i) details explaining the sources and the two known limits: the byte says + "at least one bud", and the ≈ 28 s case of `CAP-064`). +- **ADR:** this is a user-visible interpretation of ADR-049's 🟡 item 3 (`AGENTS.md` §13 "Implementing": a HYPOTHESIS with a verification plan may be + implemented when labelled as such). Draft **ADR-059** for the checkpoint: what is shown, from which bytes, the wording, the limits, the verification + the run skeleton adds (`INEAR-005`'s head-in-view steps: both out → "Not worn", one in → "Probably worn", both in the case → "In the case"). No + PROTOCOL.md status change. +- **Fixtures:** `CAP-065` BA-5/BA-6/BA-7/BA-8 `Notify` frames (5465, 5707, 6019, 6334 — re-derive) and `CAP-064` 2299/10394 for the two limits; a + runtime-info stream packet with `7:{1:0 2:0}` and one with a bud charging (`CAP-062` §4, re-derive); the field-2 read from `CAP-064`. One repository + test per displayed state; one `:ui` test for the texts. + +### Item 4 — Volume-level notifications (design study + reverse engineering; no code in 1.2.0 unless the maintainer says otherwise) + +- **What is known:** the official app's switch "Volume level notifications" (Device details → Hearing wellness) writes `qhr` field 21 (🟢, + `PROTOCOL.md` §4.5.8a, `CAP-058` X12 frames 5747/5753/5767/5790); opening the Hearing wellness page calls `maestro_pw.Dosimeter.FetchDailySummaries` + (`CAP-058` 5736; response `qhz`: fields 1, 3, 4 `UINT32`, field 2 a repeated `qia` — plausibly one entry per day —, field 5 `FLOAT`); the connect + burst subscribes `Dosimeter.SubscribeToLiveDb` on both channels (server stream, `qir` field 2 a `FLOAT`, "plausibly a live dB reading"; e.g. `CAP-027` + 416 packets, 208 per channel; values rise within a session — 🟡 a cumulative dose, unit unknown, `PROTOCOL.md` §2.2a 2026-10-06). The APK has a + `HearingWellnessNotificationWorker` with a once-per-7-days throttle (`REVERSE_ENGINEERING.md`, the `0x240c8400` = 604 800 000 ms check) — a strong + 🟡 that the **phone**, not the Buds, decides when to notify, from Dosimeter data. A Dosimeter **display** was declared out of scope on 2026-09-30 + ("Not now — note only", `PROTOCOL.md` §2.2a, `TODO.md` §4); the maintainer's question of 2026-10-09 re-opens the topic for **notifications** — as a + study first. +- **Reverse engineering (read-only, `AGENTS.md` §13 "Reverse engineering the APK", `REVERSE_ENGINEERING.md`'s workflow):** in + `reverse-engineering/apk/v1.0.955078536-10253511/jadx-output/` (the version with JADX output; `v1.0.990706425-10260911` has only the APK files — + decompile it only if the older code is inconclusive, with the tools under `reverse-engineering/tools`, and record the command), answer with + `file:line` citations: (a) what `HearingWellnessNotificationWorker` computes, from which RPC (`FetchDailySummaries`, `SubscribeToLiveDb`, or a + setting read), on which schedule (WorkManager periodic? only while connected?), and against which threshold (quote the constants — do **not** assert + a WHO/EU figure the code does not contain); (b) what `qhz`/`qia`/`qir`'s fields mean in the code (names, units, the daily entry's shape); (c) whether + field 21 changes anything **on the Buds** or only gates the phone-side worker (does any capture show a Buds-initiated message after the limit? + `CAP-009`'s 101-minute and `CAP-042`'s 37-minute idle logs are the places to look; a negative needs its command, exit status and a positive control); + (d) whether the Buds ever send a Dosimeter value unsolicited (outside the subscription); (e) the notification's text and channel in the official + app (strings, for the design — not to copy). +- **Design (RESULT only):** the app-side mechanism this project could build, within `AGENTS.md` §1/§5/§9: the foreground service already holds the + MAESTRO channel for the session — a `SubscribeToLiveDb` on the announced channel (one request per Connect, like `SubscribeRuntimeInfo`) would feed a + dose computation on the phone; no polling timer, no background scanning, no network; a local notification channel; the field-21 switch as the + user's on/off; what is stored locally (DataStore, per day) and what the (i) text can honestly claim; the battery and UX cost of holding the stream; + what cannot be claimed until a capture correlates `qir` values with a sound-level meter on film (a verification plan with a Test-ID proposal). End + with a **draft ADR-060** (one new server-stream request per Connect; the computation; the wording) and the list of 🟡 that must become 🟢 first. + **Nothing of this is built in this session** unless the maintainer, at the checkpoint, chooses "build it in 1.2.0" — then it becomes its own phase + after Phase F and gets the same fixture, gate and run-skeleton treatment as the other items. + +### Item 5 — Case sounds switches → gear → Settings + +- **Today:** `ControlsScreen.kt` shows the "Case sounds" card (fields 28 "Earbuds replaced", 27 "Other alerts", ADR-054, `ai-sessions/0074`) below + Multipoint/Head gestures; `SettingsMenu.kt`'s `SETTINGS` tab shows `DarkModeSettings` (dark mode, "Use different Buds"). +- **Build:** move the card, its labels, its (i) lines and its screen-reader text to the `SETTINGS` tab (pass the `BudsSettings` reading and the two + callbacks through `SettingsMenu`; keep the write path unchanged); the Controls tab loses the card; tests follow (`ControlsScreen` tests, a + `SettingsMenu` test that the two switches render with their values and times). Order and card title on the Settings tab: a checkpoint question with + an ASCII mock-up. Note in the card's (i) that the setting lives on the case and is read at Connect (unchanged wording, ADR-054). + +### Item 6 — Conversation detection → Controls + +- **Today:** `EqScreen.kt` (Sound tab) shows "Conversation detection" (field 22, ADR-045, `ai-sessions/0052`) in the "Balance and audio" card with + Balance and Mono audio. +- **Build:** move the switch row, its label, note and screen-reader text to the Controls tab (proposal: into the "In-ear detection" card or its own + card — a checkpoint question with the mock-up of the whole Controls tab after items 5 and 6); the Sound tab keeps Balance, Mono audio and Volume EQ. + Tests follow. `APP_TESTPLAN.md` sections M and N move their steps accordingly (M5 → N). + +--- + +## 3. Decisions already taken, and what still needs the maintainer + +- **Already decided (chat 2026-10-09):** the six items of §1 and their scope; **"probably worn"** as the indicator's reading (item 3); **Case sounds + on gear → Settings** (item 5) and **Conversation detection on Controls** (item 6) — placements, not ADRs; item 4 is a **study** (RE + design + draft + ADR), not an implementation, unless the maintainer changes that at the checkpoint. ADR-049, ADR-054, ADR-045, ADR-043 and ADR-044 cover the bytes + the items read; building on them needs no new approval **except** where an item adds a request on the wire (item 2 if `GetHardwareInfo` is not yet + sent) or shows a 🟡 to the user (item 3) — those get an ADR each. +- **To decide at the Phase B checkpoint (memory rule "Approvals: confirm in chat" — an approval quoted only in a file does not count), one + `AskUserQuestion` per decision, each option with pros and cons and one "(Recommended)", the exact draft text in the preview:** + 1. item 1's (i) wording and the cause bookkeeping (any timing constant); + 2. item 2: the Phase A finding (sent or not), **ADR-058** if a request is added, and the component attribution (🟢 by position/channel, or "as + reported"); + 3. item 3: **ADR-059** (the drafted text), the placement (battery card vs noise-control card), the four texts, the (i) explanation with its two limits; + 4. item 4: accept the design as a 1.3.0 candidate with **ADR-060** drafted (Recommended), or build it in 1.2.0 (its own phase; the maintainer accepts + the hardware-run dependency), or drop it; and the new Test-ID(s) it proposes; + 5. items 5 and 6: the layout of the Settings tab and of the Controls tab after the moves (ASCII mock-ups), and every changed user-visible text; + 6. the version (**1.2.0**, `versionCode` 10200 per `RELEASING.md` §4) and whether this session prepares the release commit (**REL**); + 7. any correction to an ADR's or a 🟢 text that Phase A finds (frame numbers, counts). +- **Not approved, not in scope:** anything else new on the wire (another field, `SubscribeToSettingsChanges`, a periodic or automatic request, a + read of setting 13, the `GetHardwareInfo` versions beyond field 7, a Dosimeter request before ADR-060 is accepted); anything on DLCI 0x08 or + 0x0a; a per-bud worn claim (the byte cannot give one); a notification for item 1 or item 3; a dependency, Gradle-plugin, manifest or toolchain + change (`targetSdk` stays 34 — its own session, `TODO.md` §5); the `TODO.md` §6 documentation sessions. If Phase A finds one of these worth doing, it + is a RESULT note with the ADR it would need — not a checkpoint proposal to widen this session, and never a silent addition. **No** `PROTOCOL.md` + status change and **no** ADR or ADR Update unless the maintainer approves it in this chat (`AGENTS.md` §6). + +--- + +## 4. Tasks + +### Phase 0 — set-up + +1. Create the RESULT file (header per `AI_SESSION_LOG_PROCEDURE.md` §4, `**Status:** partial — resumed`, Progress block). Record `git log -1`, the + branch (`git branch --show-current`; `main` takes no direct push — work on a new branch `feature/1.2.0` from an up-to-date `main`: `git fetch`, + `git status`), the next free capture number and Group letter (`id_registry.csv`, `CAPTURE_BLUETOOTH_HCI_SNOOP.md` §9 — `CAP-072`/Group BH if nothing + was added) and the next free Test-ID numbers you may need. +2. Baseline gate, **before any change**: `./gradlew --offline --max-workers=2 clean`, then the project's test and lint tasks as `ai-sessions/0074` + RESULT's gate table lists them (unit tests of `:data`, `:domain`, `:hardware`, `:ui`, `:app`; `lint`; the docs lint + `PYTHONDONTWRITEBYTECODE=1 python3 scripts/lint_docs.py`). Record test counts per module, warnings and lint results; the gate must be green before + and after every phase. If the baseline is not green, stop and report — do not fix unrelated failures silently. + +### Phase A — evidence and design check (no code change) + +3. **Item 1:** locate the `Notify` path in `BudsRepositoryImpl.kt` and the ADR-045/F-3 bookkeeping; find and re-derive the press-and-hold `Notify` + frames (`CAP-045`, `CAP-021` §4) and `CAP-067` §2's unexplained change; design the cause state (an enum next to `ancMode`, exposed as a `Flow`) and + the one test. Write the (i) wording for the checkpoint. +4. **Item 2:** determine from the code whether `GetHardwareInfo` is sent (`grep -n 0x28eca5e3`/`GetHardwareInfo` in `android/`); re-derive `CAP-036` + 1415/1423 and the field-7 strings; read the JADX code for the component attribution (`gck.java:288`, the `qiv` decoder, `fux.java:90–103`); check + the OpenControl captures (`CAP-067`…`CAP-071`) for a `GetHardwareInfo` request/answer (command + positive control). Draft ADR-058 if needed. +5. **Item 3:** re-derive the ADR-049 sample frames named in §2 and the runtime-info packets; read the field-2 handling (`ai-sessions/0056`); design the + derived state (one `Flow` in the repository, pure function tested in `:domain`), the texts, the placement; draft ADR-059 with its verification steps. +6. **Item 4:** do the reverse engineering of §2 item 4 (a)–(e) — read-only, `file:line` for every claim, commands with exit status and a positive + control for every negative; write the design and draft ADR-060; list the 🟡 that block a build. Record what you read in full and in part. If the + JADX output of 1.0.955078536 cannot answer (a), say so and estimate the decompilation of 1.0.990706425 as a step, do not start it without asking. +7. **Items 5 and 6:** read the three screens and `SettingsMenu`, the navigation host and `MainActivity`'s wiring; draw the two mock-ups; list the + `APP_TESTPLAN.md` steps and `ARCHITECTURE.md` §2.4 sentences that move. +8. **Run skeleton input:** list the `TODO.md` §2 items that fit this run (L-1 with the head in view / `INEAR-005`, C12, S12, T11, H5, the case-sound + and Volume-EQ observations, B4, the CDM picker on film) with their expected bytes or screens. +9. Record everything in the RESULT (per item: evidence, frames, design, open questions) and update the Progress block. + +### Phase B — checkpoint (stop and ask, in this chat, via `AskUserQuestion`) + +10. Show a short summary (per item: what Phase A found, what would be built, what it needs), then ask the questions of §3 — one per decision, each + option with pros and cons, one "(Recommended)", the exact draft text (ADR-058/059/060, the texts, the mock-ups) in the preview. Record every answer + verbatim in the RESULT. Build only what was approved; an item not approved becomes a RESULT note with its TODO line. + +### Phase C — Item 1: "Changed by the Buds" + +11. Build the cause state and the (i) line; the repository test with the real `Notify` fixture (frame number, time and command in a comment); the + `:ui` test for the line. Gate green. Progress block. + +### Phase D — Item 2: serial numbers + +12. If approved: the request (only if ADR-058 was approved — byte-identical to the official app's, through the gate, one per Connect) and/or the + decoding of field 7 (`PwRpc.kt`/`SoftwareInfo.kt` pattern: never throws, malformed → `MalformedFrame`); the Info tab lines with the agreed + attribution; redaction in logs; the codec test with `CAP-036` 1423 (redacted) and a fuzz case (`AGENTS.md` §11). Gate green. Progress block. + +### Phase E — Item 3: worn indicator + +13. Build the derived state as a pure function in `:domain` (inputs: last Settable byte + time, field-2 reading, the two in-case flags; output: one of + the five readings), its tests (one per reading, with the real fixtures), the repository `Flow`, the UI line and (i) text in the approved place, + the `:ui` test. No new request. Gate green. Progress block. + +### Phase F — Items 5 and 6: the two tab moves + +14. Move the Case sounds card to the Settings tab and the Conversation detection row to the Controls tab as approved; adapt the tests; verify with + the Robolectric tests that every switch still writes the same bytes (the fixtures of `ai-sessions/0076` stay valid — the wire is unchanged). + `APP_TESTPLAN.md` sections M and N (and T where the Case sounds steps live) follow. Gate green. Progress block. + +### Phase G — Item 4 build (only if the maintainer chose "build it in 1.2.0"; otherwise skip) + +15. As approved at the checkpoint, with ADR-060 accepted in chat: the request, the computation, the notification, DataStore, fixtures from a real + `SubscribeToLiveDb` stream (`CAP-027`), the honest texts, the tests, the run-skeleton steps with the sound-level-meter correlation. Gate green. + +### Phase H — gate, mutations, compliance + +16. `./gradlew --offline --max-workers=2 clean`, then the full gate of task 2: all green, no new lint issue, no new suppression, no new compiler + warning; record the new test counts per module. +17. Mutation checks — apply each with a script, run the relevant test task, restore, verify the file byte-identical with `sha256sum`. **Run them one + at a time with `--max-workers=2`** (parallel runs have exhausted memory before) and, after any crash, check for a leftover mutation before + continuing. At least: **M1** the cause set to "changed by the Buds" for the app's own ACKed `Set`; **M2** the cause not reset at Connect; **M3** + field 7 read from the wrong field number; **M4** a serial logged unredacted at `INFO` (must fail a test or a lint rule — add one if none exists); + **M5** "Probably worn" shown with Settable `00`; **M6** "Probably worn" shown with in-ear detection off; **M7** "In the case" shown for a bud whose + runtime-info flag is 0; **M8** the Case sounds switch writing field 27 for the "Earbuds replaced" row after the move; **M9** Conversation detection + writing field 19 (Mono audio) instead of 22 after the move; **M10** (if ADR-058) the `GetHardwareInfo` request sent past the Safe-Mode gate on an unverified firmware. Each + must fail at least one test; record which. +18. Compliance: no `INTERNET`, no new permission, no manifest or version-catalog change, no new dependency + (`git diff --name-only -- '*.toml' '*AndroidManifest.xml'` empty; the only Gradle change is the version of **REL**, if chosen); every + `transport.send` call site in the diff listed (expected: none new, or exactly the ADR-058 request); nothing on DLCI 0x08/0x0a; no new codec field + except field 7 of `GetHardwareInfo` (and nothing of item 4 unless Phase G ran); every write passes the ADR-042 gate; AGPL headers on any new Kotlin + file; no MAC address or serial number at `INFO` or above and no raw payload outside debug mode (`AGENTS.md` §9). + +### Phase I — the hardware-run skeleton (RUN) and the test plan + +19. Create the capture folder and its `…-EVENT-NOTES.md` skeleton in the layout of `CAP-071-EVENT-NOTES.md` (build and metadata, preparation with the + lessons of `CAP-067`…`CAP-071` — film with sound, screen recording on, the system log, `adb bugreportz`'s SIGQUIT note —, start, steps with + expected screen and expected HCI bracket, don'ts, collection, analysis checklist with Test-IDs and "Refuted if" lines). Register the capture + *planned* in `id_registry.csv`, add its Group section and Capture Index row (`CAPTURE_BLUETOOTH_HCI_SNOOP.md`), and new Test-IDs where a step has + none (`TESTPLAN_BLUETOOTH_HCI_SNOOP.md`, registered). Contents: + - item 1: a press-and-hold ANC change on a bud with OpenControl open → the `Notify` → the (i) line; a tap in the app → no line; + - item 2: the Info tab's serial lines on film; if ADR-058: the request/answer bytes on the announced channel; + - item 3: `INEAR-005`'s head-in-view sequence read against the indicator (both out → "Not worn"; one in → "Probably worn"; both in the case → + "In the case"; in-ear detection off → "Unknown"); the ≈ 28 s case: buds straight from the case to the table, the indicator watched for 60 s; + - items 5 and 6: each moved switch once off/on with its expected `WriteSetting` bytes (unchanged from `CAP-070`/`CAP-071`) and the read-back at a + reconnect; + - every `TODO.md` §2 item that fits (task 8), each with its expected bytes or screen — this run is the third chance for film 2's items; + - P1 with `dumpsys package … | grep -E "firstInstallTime|lastUpdateTime"`: an **update over 1.1.1**, record both times. + Keep the run to one sitting; if it does not fit, propose a split at the end of the session (a question, not a silent cut). +20. `APP_TESTPLAN.md`: a new section for this build (after section U), one step per item; move M5 to N and the Case sounds steps to a gear → Settings + section; correct any step the moves make stale. + +### Phase J — documentation and release preparation + +21. Documentation, only what changed: `ARCHITECTURE.md` (§5a rows: the ANC row's cause line, `GetHardwareInfo`/serials, the worn indicator with its + ADR; §2.4 for the tabs; §3.1 only if a rule changed — dated current text, rule 9a), `PROJECT.md` (the functional scope: `[~]` serials → `[x]` or + the honest state, `[ ]` worn → `[x]` with "probably"; "Status after 1.0.x"), `CHANGELOG.md` (`[Unreleased]`, or the 1.2.0 block if **REL** was + chosen), `README.md` (status and feature list), `TODO.md` (finished items deleted — among them `TODO.md` §5's `CAP-069` §12 item 8 line —, open + ones kept, new ones added: item 4's ADR-060 candidate and its verification capture), `REVERSE_ENGINEERING.md` (item 4's findings as a dated + section with `file:line`), `ai-sessions/INDEX.md` (the 0082 row). `PROTOCOL.md` and `DECISIONS.md`: only what was approved in this chat (ADR-058/ + 059/060 as accepted or as "proposed" drafts in the RESULT only — an unaccepted ADR is **not** written into `DECISIONS.md`). +22. **REL, only if chosen at the checkpoint:** `RELEASING.md` §4 steps 1–2 — `versionName` 1.2.0 and `versionCode` 10200 in + `android/app/build.gradle.kts`, the `CHANGELOG.md` block marked "not yet released" with its Known issues (among them: the worn indicator's two + limits; the serial attribution if not 🟢), `README.md`, `scripts/release_notes.template`. Do **not** run `scripts/release.sh`, create a tag, or + publish; print the commands of `RELEASING.md` §5–§6 for the maintainer and run none. +23. Run `python3 scripts/ensure_footers.py` and `PYTHONDONTWRITEBYTECODE=1 python3 scripts/lint_docs.py` — it must exit 0 + (`scripts/__pycache__/lint_docs.cpython-314.pyc` is tracked; `git checkout` it if it changed). + +### Phase K — finish + +24. Finish the RESULT: a plain-language summary first (what the app user will see differently; what the maintainer has to do next, step by step), + then per item what was built and where, the fixtures with their bytes and commands (serials redacted), the checkpoint answers verbatim, item 4's + RE findings and design, the gate table, the mutation table, compliance, the run skeleton as a table (step · action · expected screen · expected + HCI bracket · Test-ID), the `TODO.md` §2 items taken and not taken, external sources (URL + quoted sentence), what was read in full and in part, + and the two closing sections **"Deferred documentation"** (each item also in `TODO.md`, in the same words) and **"Commits"**. Status per + `AI_SESSION_LOG_PROCEDURE.md` §4 and §4b. +25. Show the maintainer a short summary and **ask whether to commit, push and open a pull request**. Only after a yes: Conventional Commits, one + commit per concern, each building and passing on its own (`PROJECT_RULES.md` rule 16) — e.g. `feat(app)` per item with its tests, `refactor(ui)` + for the two moves, `docs` for the documents, `docs(re)` for item 4's findings, `docs` for the run skeleton and the registry, `chore(release)` for + **REL**, `docs` for the session files — each with a *why* and ending with the attribution line from the session's system reminder; `git fetch` and + rebase before pushing, never force; nothing from a build directory, `android/.kotlin/`, `dist/`, `.vscode/` or `__pycache__` staged. The pull + request is not merged by the agent unless the maintainer says so. + +--- + +## 5. Guardrails (binding, in addition to `AGENTS.md`) + +- **Wire discipline.** Items 1, 3, 5 and 6 add **nothing** on the wire. Item 2 adds at most one unary `GetHardwareInfo` request per Connect, and only + with ADR-058 approved in this chat, byte-identical to the official app's, on the announced channel with its ADR-034 address, through the ADR-042 + gate — no retry, no queue. Item 4 adds nothing unless Phase G was chosen. Nothing on another DLCI, no subscription beyond those the app already + sends, no timer. +- **Honest state (`AGENTS.md` §5, `ARCHITECTURE.md` §3.1).** Every shown value keeps its own receive time; "—" for a value that was not read; the worn + indicator says "probably" and never names a bud as worn; a serial is attributed to a component only when the attribution is 🟢; a cause line says + "changed by the Buds" only for a `Notify` the app did not provoke. No text claims an audible or behavioural effect that no capture supports. +- **Labels.** Everything written down carries 🟢/🟡/⚪/🔴 (`PROJECT_RULES.md` §1). ADR-049 item 3 stays 🟡; the indicator's (i) text says so in plain + words. +- **Approvals only in this chat.** Never promote, demote or correct a 🟢 FACT, and never write or update an ADR, without the maintainer's approval + given in this chat (`AGENTS.md` §6). ADR-058/059/060 are drafts until approved; an unapproved draft lives in the RESULT and `TODO.md`, not in + `DECISIONS.md`. +- **Tests with real bytes (`AGENTS.md` §11).** Fixtures come from the logs with frame number, time and command in a comment; a hand-built frame only + as a labelled supplementary structural test next to real fixtures. Redact device identifiers (MAC, serials). +- **Evidence.** Zero creativity with hex (`AGENTS.md` §13 step 6); every claim in the RESULT links to a frame, a log line or a `file:line`; a negative + needs its command, exit status and a positive control (step 8); external claims cite the URL and the exact sentence; APK claims cite `file:line` in + the JADX output and never copy code (`AGENTS.md` §12). +- **Privacy.** Serial numbers are device identifiers: redacted in fixtures, RESULT and logs; never at `INFO` or above (`AGENTS.md` §9). +- **Scope.** `PROJECT.md`, the ADRs named and §1. Everything else is a RESULT note, never a silent addition. No dependency, Gradle-plugin, manifest + or toolchain change; `targetSdk` stays. +- **Subagents.** A subagent may only read and report; every write is done in the main session and verified; re-derive any number a subagent reports, + and say in the RESULT what was taken over without re-derivation. +- **Files.** Before deleting, moving or renaming any file, look at the target and diff a fresh listing; never `rm -rf` or a wildcard delete from + memory; no `git worktree add`. +- **Release.** No tag, no `scripts/release.sh`, no GitHub release, nothing public. The release verdict belongs to the session that analyses the + hardware run. +- **Commits.** Only after the maintainer confirms the final summary (task 25). + +--- + +## 6. Deliverables + +- **ai-sessions/0082_FEATURE_RESULT_2026_10_09.md** — Progress block, plain-language summary, per-item record with fixtures, the item-4 RE findings + and design with the draft ADR-060, checkpoint answers, gate/mutation/compliance tables, the run skeleton's table, "Deferred documentation", + "Commits", footer. +- The app with the "Changed by the Buds" line, the serial numbers on the Info tab, the "probably worn" indicator, the Case sounds switches on gear → + Settings and Conversation detection on Controls; their tests; a green gate. +- ADR-058 (if a request was added) and ADR-059 in `DECISIONS.md` **only if approved in chat**; otherwise as proposals in the RESULT and `TODO.md`. +- The hardware-run skeleton, registered *planned*, with the open `TODO.md` §2 items it takes and the verification steps of ADR-059. +- The documents that follow the code; `REVERSE_ENGINEERING.md`'s dated item-4 section; the release preparation for 1.2.0 if chosen. +- Nothing left silently open: every item built, moved to the run, registered in `TODO.md` for a named later session, or "awaiting maintainer" with + the question asked. + +--- +https://github.com/tedsluis/opencontrolpixelbudspro2/blob/main/ai-sessions/0082_FEATURE_PROMPT_2026_10_09.md - https://tedsluis.github.io/opencontrolpixelbudspro2/ai-sessions/0082_FEATURE_PROMPT_2026_10_09 diff --git a/ai-sessions/0082_FEATURE_RESULT_2026_10_09.md b/ai-sessions/0082_FEATURE_RESULT_2026_10_09.md new file mode 100644 index 00000000..8452cfe8 --- /dev/null +++ b/ai-sessions/0082_FEATURE_RESULT_2026_10_09.md @@ -0,0 +1,590 @@ +# 0082_FEATURE_RESULT_2026_10_09.md — Build 1.2.0: "Changed by the Buds" in the ANC details, the component serial numbers, a "probably worn" indicator (ADR-049), two tab moves, and a design study for volume-level notifications (Dosimeter) — with real capture bytes as fixtures, a green gate, the hardware-run skeleton and the release preparation + +**Number:** 0082 +**Category:** FEATURE +**Date:** 2026-10-09 +**Title:** Implement in the OpenControl app, as release 1.2.0: (1) the (i)-details line "Changed by the Buds" when a `Notify` changes the ANC mode +without a tap in the app; (2) the serial numbers per component (Case / Left / Right) from `GetHardwareInfo` field 7 on the Info tab; (3) a worn +indicator built on ADR-049's Settable byte and the runtime-info in-case flags, shown as "probably worn"; (5) the two Case sounds switches moved from +the Controls tab to gear → Settings; (6) the Conversation detection switch moved from Sound to Controls — plus (4) a design study, no code, of +volume-level notifications from `maestro_pw.Dosimeter`. Real capture bytes as fixtures, a green gate with mutation checks, the documents, the +hardware-run skeleton and the release preparation for 1.2.0 (no tag, no publication) +**Status:** complete (the task was resumed once after a context reset; finished 2026-10-09 — commits pending the maintainer's answer, "Commits" below) + +--- + +## Progress + +- **Phase 0 — done.** `git log -1`: `ad91f48 Merge pull request #19 from tedsluis/bot/sitemap`. `git status --short` before the branch: + `?? ai-sessions/0082_FEATURE_PROMPT_2026_10_09.md`. `git fetch && git log --oneline HEAD..origin/main`: empty. Branch `feature/1.2.0` created from + `main` at `ad91f48`. Next free numbers: capture `CAP-072`, Group **BH** (`id_registry.csv` ends at `CAP-071`; `captures/` ends at `Group_BG`; + `grep 'Group BH\|CAP-072'` over the registry, the capture index and the test plan → nothing). Highest Test-IDs in use: `ANC-006`, `TOUCH-007`, + `INEAR-005`, `CASE-009`, `HOLD-005`, `WELL-001`, `FW-004`, `OBS-007`. Highest ADR: ADR-057 (so ADR-058/059/060 are free). Baseline gate green (§A.0). +- **Phase A — done** (§A). No code changed. +- **Phase B — done** (§B, answers verbatim). +- **Phase C (item 1) — built and tested** (targeted: `:data` `BudsRepositoryImplTest` 150, `:ui` `AncScreenTest` 11, `:app` compile — green). Files: `AncModeCause.kt` (new, `:domain`), `BudsRepository.kt`, `BudsRepositoryImpl.kt`, `AncScreen.kt`, `OpenControlNavHost.kt`, `MainActivity.kt`; tests `Cap045Fixtures.kt` (new), `BudsRepositoryImplTest.kt` (5 tests), `AncScreenTest.kt` (4 tests). +- **Phase D (item 2) — built and tested** (targeted: `:data` 494 incl. `HardwareInfoCodecTest` 6 + 5 repository tests, `:ui` `SettingsMenuTest` 14, `:app` compile — green). Files: `DeviceInfo.kt` (`ComponentSerial`, `serials`), `BudsRepository.kt` (`serialsError`), `HardwareInfo.kt` (new reader), `Maestro.kt` (`getHardwareInfoRequest`), `CodecRouter.kt` (`RoutedFrame.HardwareInfo`), `BudsRepositoryImpl.kt` (`readHardwareInfo` after the subscription), `SettingsMenu.kt` (serial lines), `OpenControlNavHost.kt`, `MainActivity.kt`; tests `HardwareInfoFixtures.kt` + `HardwareInfoCodecTest.kt` (new), `BudsRepositoryImplTest.kt`, `SettingsMenuTest.kt`. +- **Phase E (item 3) — built and tested** (targeted: `:domain` 44 incl. `WornReadingTest` 7, `:data` 494 incl. the worn repository test, `:ui` `BatteryCardTest` 9, `:app` compile — green). Files: `WornReading.kt` (new, `:domain`: the sealed class + `wornReading()`), `BudsRepository.kt`/`BudsRepositoryImpl.kt` (`wornReading` = `combine` of four existing flows), `ConnectionScreen.kt` (the line, `wornLine`, `WORN_EXPLANATION`), `OpenControlNavHost.kt`, `MainActivity.kt`; tests `WornReadingTest.kt` (new), `BudsRepositoryImplTest.kt`, `BatteryCardTest.kt`. +- **Phase F (items 5 and 6) — built and tested** (targeted: `:ui` `ControlsScreenTest` + `EqScreenTest` + `SettingsMenuTest` = 43, `:app` compile — green; the write paths are untouched, the `ai-sessions/0076` fixtures stay valid). Files: `SettingsUi.kt` (`SettingsCard` now shared), `ControlsScreen.kt` (Case sounds out, Conversation detection card in), `EqScreen.kt` (row out), `SettingsMenu.kt` (the card + `CASE_SOUNDS_NOTE`), `OpenControlNavHost.kt`; tests the three classes. +- **Phase G — skipped** (item 4 is a 1.3.0 candidate, §B). +- **Phase H — done** (§H: the final clean gate exit 0; M1–M10 all killed; compliance clean). +- **Phase I — done** (§I: `CAP-072`/Group BH skeleton, registry, capture index, `INEAR-006`/`WELL-002`, `APP_TESTPLAN.md` §V). +- **Phase J — done** (§J: the documents; REL 1.2.0/10200; `ensure_footers` + `lint_docs` exit 0). +- **Phase K — this file; the closing question to the maintainer is in the chat.** +- Intermediate results (scratchpad of this session, `/tmp/claude-1000/-home-tedsluis-git-opencontrolpixelbudspro2/170c9fdb-4a0f-4dc7-994a-2ab9ab35b5dc/scratchpad`; + re-create if gone): the gate-baseline log (`gate_baseline`, a scratchpad file); the `tshark`/`pwrpc_decode.py` commands of §A are quoted in full and re-runnable. + +--- + +## A. Phase 0 and Phase A — evidence and design check (no code change) + +### A.0 Baseline gate (before any change) + +`cd android && ./gradlew --offline --max-workers=2 clean assembleDebug testDebugUnitTest test lint` → `BUILD SUCCESSFUL in 3m 43s`, exit 0. Unit tests +from the JUnit XML (`*/build/test-results/*/*.xml`): `:data` 1627 (debug) / 1627 (release), `:domain` 37, `:hardware` 64 / 64, `:ui` 70 / 70 — 0 failures, +0 errors, 0 skipped. Lint (`lint-results-debug.xml`): `:app` 1 (`OldTargetApi`, pre-existing, `TODO.md` §5), `:ui` 1 (`ModifierParameter`, +`SettingsMenu.kt`, pre-existing), `:data` 0, `:hardware` 0. Kotlin compiler warnings: 0 `w:` lines on a clean build. + +### A.1 Item 1 — "Changed by the Buds": evidence and design + +**Code path (read in full: `BudsRepositoryImpl.kt`, `BudsRepository.kt`, `AncFrame.kt`, `AncScreen.kt`).** Every `Notify ANC state` reaches +`handleRoutedFrame` (`BudsRepositoryImpl.kt:548–561`): it updates `ancAvailability` (the Settable byte), emits the `AncNotify` the F-1 tap waits for, and — +for a known mode byte — clears `ancModeUnconfirmedAt`, calls `emitAncMode` (mode + `ancModeUpdatedAt = clock()`) and feeds `_ancModeFresh`/`_ancOutcomes`. +The app's own `Set` applies the mode on the ACK (`ancSetOnClaim`, `:812–816`) or accepts the Buds' `Notify` as the outcome (`:817`). Nothing records *why* +the mode changed. The ADR-045/F-3 bookkeeping that exists: `sentAt` of a `Set` (`:799`) and `ancModeUnconfirmedAt` (a cut-off answer); the claim's `Get` +is sent by `sendAncGetAndAwait` (`:823`) for the Connect snapshot (`launchInitialSnapshot`, `:1291`), the Refresh (`refreshAncMode`) and every tap (`setAncModeAfterGet`). + +**Frames re-derived (`AGENTS.md` §13 step 1: `tshark -r CAP-045-btsnoop_hci.log -Y "bluetooth.addr == 04:00:6e:cf:6e:07" | wc -l` → 0, exit 0; the +Buds' handle from `-Y "bthci_evt.code==0x03"`: frame 294 `04:00:6e:cf:6e:07` handle `0x0002`).** Command: `tshark -r CAP-045-btsnoop_hci.log -Y +'btrfcomm.dlci==4 and btrfcomm.len>0' -T fields -e frame.number -e frame.time_utc -e frame.p2p_dir -e bthci_acl.chandle -e data.data | grep -E +"\s08(11|12|13)"` (exit 0; every line on handle `0x0002`): + +| Frame | UTC | Dir | Bytes | Reading | +|---|---|---|---|---| +| 609 | 06:22:57.420 | phone | `08110000` | the official client's connect-time `Get` | +| 612 | 06:22:57.471 | Buds | `0813000401e8e840` | the answer: Adaptive, Settable `e8` | +| 1583 | 06:23:21.948 | Buds | `0813000401e8e880` | `Notify` only (no `Get`, no `Set` before it): Transparency | +| 1755 | 06:23:32.507 | Buds | `0813000401e8e808` | `Notify` only: Noise cancellation | +| 1818 | 06:23:46.898 | Buds | `0813000401e8e840` | `Notify` only: Adaptive | +| 1849 | 06:23:54.405 | Buds | `0813000401e80020` | `Notify` only: Off, Settable `00` (a bud removed) | + +Byte-identical to `CAP-045-FINDINGS.md` §3's table (film: press-and-hold changes, `TOUCH-007`). The whole log holds **no** `08 12` (`grep -c "\s0812"` → 0; +positive control: `CAP-001` has 4). `CAP-021` §4's frames (5237 …) are field-12 `WriteSetting`s, not `Notify`s — the second source named by the prompt for +press-and-hold *cycling* is `CAP-021`'s **film** of holds; the wire there is DLCI 0x02, so `CAP-045` is the fixture source. `CAP-067` §2 (the Transparent → +Active change with no app command between 08:07:34 and 08:12:44, B474 `01 e8 e8 08` after a re-open's `Get`) is a change that this design classes as +**read** (it was learned from a `Get`'s answer on a new claim), not "changed by the Buds" — correctly: the app cannot know when it happened. + +**Design (no new wire traffic, no timer constant):** + +- `:domain` `enum class AncModeCause { SET_BY_APP, READ, CHANGED_BY_BUDS }`; `BudsRepository.ancModeCause: Flow` — the cause of the current + `ancMode`, `null` before any report of this connection (reset at Connect with the other per-connection state, before the bonded-device lookup, so a test + without a `BluetoothDevice` can check it — mutation M2). +- Bookkeeping inside the existing claim code, no new constant: (1) `getPending` is set when the claim's `Get` is sent (`sendAncGetAndAwait`) and cleared by + the first `Notify` after it or when that wait ends — that `Notify` is **READ**; (2) `setPendingMode` is set when the `Set` is sent (`ancSetOnClaim`, next to + `sentAt`) and cleared when its wait ends (ACK, NAK, timeout, cut-off) — the ACK is **SET_BY_APP**, and a `Notify` carrying the requested mode while it is + pending is **SET_BY_APP** too (the Buds answer a `Set` with ACK + `Notify` in either order, `PROTOCOL.md` §4.1); (3) any other `Notify` whose mode + **differs** from the shown one is **CHANGED_BY_BUDS**; (4) a `Notify` with the same mode and nothing pending leaves the cause as it is (only the time moves). +- UI: one line in `ancDetailLines` (the Noise control card's (i)), only while the cause is CHANGED_BY_BUDS — wording for the checkpoint: + *"Changed by the Buds at HH:MM:SS (a press-and-hold on a bud, or the Buds' own change)."* Nothing on the main surface, no notification. +- Tests: repository — the connect `Get` answered by `CAP-045` 612 → READ; `Set` → ACK → `Notify` same mode → SET_BY_APP (the existing autoAck path); an + unsolicited `CAP-045` 1583 → CHANGED_BY_BUDS; Connect resets it. `:ui` — the line appears only for CHANGED_BY_BUDS, with the time. + +### A.2 Item 2 — serial numbers: evidence + +**Is `GetHardwareInfo` sent? No.** `grep -rn "GetHardwareInfo\|METHOD_GET_HARDWARE_INFO" android/*/src/main` → `Maestro.kt:37` (the id, with the comment +"names only, for the debug log's method column — this app never sends them") and `PwRpc.kt:198` (the name table); no call site. On the wire: `for f in +captures/CAP-067-*/…log* CAP-068 CAP-070 CAP-071; do python3 scripts/pwrpc_decode.py "$f" | grep -c GetHardwareInfo; done` → **0 in all 14 logs** of the +OpenControl release runs (exit 0; the same loop counts `SubscribeRuntimeInfo` 2–89 per log; positive control `CAP-036`: 2 lines — the request 1415 and the +answer 1423). So showing serials adds **one unary request per Connect** — ADR-058 (draft §B). + +**The official app's request and answer, re-derived.** `CAP-036`: Connection Complete frame 906 `04:00:6e:cf:6e:07` handle `0x0005`; +`tshark -r CAP-036-btsnoop_hci.log -Y 'frame.number==1415 || frame.number==1423' -T fields -e frame.number -e frame.time_utc -e frame.p2p_dir -e +bthci_acl.chandle -e btrfcomm.dlci -e data.data` (exit 0): + +- 1415 (04:36:32.628 UTC, phone, handle `0x0005`, DLCI 2): `7e 00 4b 03 10 15 1d ea 71 de 7d 5e 25 e3 a5 ec 28 f9 67 61 b5 7e` — address `00 4b` (channel 21), + control `03`, `RpcPacket` `10 15` (channel 21) `1d ea 71 de 7e` (`maestro_pw.Maestro`) `25 e3 a5 ec 28` (`GetHardwareInfo` = `h65599` `0x28eca5e3`), no payload, + CRC `f9 67 61 b5`. `python3 scripts/pwrpc_decode.py CAP-036-btsnoop_hci.log | grep GetHardwareInfo` → `1415 … REQUEST ch=21 … method=GetHardwareInfo`. +- 1423 (04:36:32.711, Buds, 83 ms later; the RFCOMM frame carries three pw_hdlc frames, the first is the answer): `7e 00 a5 03 2a 44 08 06 10 11 28 09 30 07 3a 30 + 0a 0e 35 37 30 37 31 57 52 42 45 43 30 32 35 31 12 0e 35 37 30 38 31 57 52 42 44 52 33 32 30 39 1a 0e 35 37 30 37 31 57 52 42 44 4c 33 31 34 37 42 08 30 30 31 33 + 32 30 39 33 08 01 10 15 1d ea 71 de 7d 5e 25 e3 a5 ec 28 36 db 71 c0 7e` → decoded `RESPONSE ch=21 … GetHardwareInfo | 1:6 2:17 5:9 6:7 7:{1:"5707…51" + 2:"5708…09" 3:"5707…47"} 8:{…}` — field 7 = a message with three length-14 strings in fields 1, 2, 3 (`0a 0e`, `12 0e`, `1a 0e`). Serials are device + identifiers and are redacted to 4 + 2 characters here and in every fixture comment (`AGENTS.md` §9). + +**Census over every log** (`for f in captures/*/*btsnoop_hci*.log*; do python3 scripts/pwrpc_decode.py "$f" | grep GetHardwareInfo | grep RESPONSE …`): **138 +answers in 54 logs** — channel 19: 49, channel 21: 88, channel 24: 1 — **every one with the same three strings in the same order** (`1:5707…51 2:5708…09 +3:5707…47`), and 138 requests, one per connection, on the announced channel. The answer is the same whichever bud hosts the session: the request is **not** +per bud, one request yields all three serials. + +**Which string is which component — the code (`AGENTS.md` §13 "Reverse engineering", mechanical read, ADR-017).** `gaa.java:45–96` (`gaa.a(qiv)`, the +`GetHardwareInfo` response handler): when `qiv.c == 4` it reads the `qjm` of field 4, else when `== 7` the `qjr` of field 7 — both 3-string messages +(`qjm.java:30` / `qjr.java:30`: `"\u0001ဈ\u0000\u0002ဈ\u0001\u0003ဈ\u0002"` with Java fields `c`, `d`, `e` = proto fields 1, 2, 3) — and copies them **by field +number**: field 1 (`c`) → `gdv.c` (bit 1), field 2 (`d`) → `gdv.d` (bit 2), field 3 (`e`) → `gdv.e` (bit 4) (`gaa.java:60–96`, the `qjr` branch `:97–130` +identical). `fwg.java:182–215` then reads `gdv.e` as the **left** serial ("Missing left serial number"), `gdv.d` as **right**, `gdv.c` as **case**. So the +official app's reading is **by position: field 7.1 = Case, 7.2 = Right bud, 7.3 = Left bud** — 🟢 for the app's code. The wire agrees with the substring +reading: 7.1 ends `…EC0251` (case), 7.2 `…DR3209` (right), 7.3 `…DL3147` (left). This closes, on the code side, `ai-sessions/0073` §4.6's "🟡 that the holder +keeps the wire order" (the holder is filled field by field, not from a list). The `gck.java:288` log line ("serial number %s, sku %s, hardware version …") +prints the `gdv` holder as one Optional; fields 1 (device type 6), 2 (SKU 17), 5/6 (hardware versions) are not in scope (prompt §3). + +**Status proposal for the checkpoint:** the prompt's own bar for attributing a serial to a component — "code path + a wire sample on each channel" — is met +(code 🟢; wire: 49 samples on 19, 88 on 21, 1 on 24, same order). `PROTOCOL.md` §6's "Which serial belongs to which component … stays 🟡" would be +promoted to 🟢 FACT ("field 7 lists Case, Right, Left in that order — the official app's own reading, consistent with the strings' EC/DR/DL marks") — only +with the maintainer's word; otherwise the Info tab shows the three strings "as reported". + +**Safe Mode and the request.** The prompt's ADR-058 text says "through the ADR-042 Safe-Mode gate for the send". ADR-042 item 3 says reads continue in +Safe Mode ("Reads (ANC Get, battery, EQ ReadSetting, Case) continue"), and the app's other connect-time reads (`ReadSetting`, `SubscribeRuntimeInfo`) are sent +to an unverified firmware. A gated read would be a first; an ungated one follows ADR-042 as written. Both options are at the checkpoint (Q2). + +**Where it would live:** `Maestro.getHardwareInfoRequest(channelId)` (next to `subscribeRuntimeInfoRequest`); a `HardwareInfo` reader in `:data` (field 7 +only, three strings of printable ASCII ≤ 64 chars, anything else → not read; never throws; fuzzed with `OversizedLengthTest`'s pattern); `RoutedFrame.HardwareInfo`; +`routeMaestro` matches `RESPONSE` for the method with status OK; `BudsRepositoryImpl.launchInitialEqRead` sends it after `subscribeRuntimeInfo`, waits ≤ +`SETTING_READ_TIMEOUT_MS` for the answer (the existing 2 s bound, no new constant), no retry; `DeviceInfo` gains `serials: List` + the receive +time; the Info tab shows "Serial numbers (from the Buds, HH:MM:SS):" with one line per string under the firmware lines, "—" until the answer arrived, the +reason when it failed. Logging: the always-on log prints only the pw_rpc summary (method, status — `RpcPacket.summary()` has no payload); a serial never +appears in a log line (the raw hex dump is Debug mode only, `AGENTS.md` §9); a unit test asserts the log lines of the read contain no serial (M4). + +**Fixtures:** `CAP-036` 1415/1423 (channel 21); the channel-19 request is the same packet on `00 3b`/channel 19 — a real channel-19 request exists +(49 of them, e.g. the first log of the census with `ch=19`), to be quoted by frame in Phase D. No OpenControl capture holds the exchange (the app never sent +it); the run skeleton records the first one. + +### A.3 Item 3 — the worn indicator: evidence and design + +**Frames re-derived.** `CAP-065` (handle `0x000b` for `04:00:6e:cf:6e:07`, Connection Complete 600 … 9852): `tshark -r CAP-065-btsnoop_hci.log -Y +'bthci_acl.chandle==0x000b && (btrfcomm.dlci==4 || btrfcomm.dlci==5) && (frame.number==5465 || …6334)' -T fields -e frame.number -e frame.time_utc -e +frame.p2p_dir -e btrfcomm.dlci -e data.data` (exit 0): 5465 09:14:33.437 `0813000401e80020` (BA-5 both loose on the table), 5707 09:15:00.485 `…01e80020` +(BA-6 Left docked, Right loose), 6019 09:15:53.518 `…01e80020` (BA-7 lid closed, Right outside), 6334 09:16:41.787 `0813000401e8e820` (BA-8 both worn) — +as ADR-049's 2026-10-01 Update. `CAP-064` (handle `0x000b`): 2299 08:06:15.005 `0813000401e8e820`, 2542 `…e8e820`, 2699 `…e8e840`, 2759 `…e8e840` (the ≈ 28 s +of `e8` with both buds on the table, in-ear detection on); 10394 08:24:52.250 and 10600 08:25:16.548 `0813000401e8e820` on DLCI 5 (in-ear detection off, +no bud worn); 4103 `…e8e820` / 4533 `…e8e840` (one bud worn); 5591 `0813000401e80020` (one docked, one loose) — all as `CAP-064-FINDINGS.md` §3. +**Field 2:** `python3 scripts/pwrpc_decode.py CAP-064-btsnoop_hci.log | grep -E "ReadSetting.*4:2$|4:\{2:"` — every connect read answers `4:{2:1}` (1260, 2290, +3162, …); the writes `WriteSetting 4:{2:0}` 9922 and `4:{2:1}` 10817 (ADR-047's re-test) bracket the in-ear-off samples above. **Runtime info (`CAP-062`):** +2782 `3:0 6:{2:{1:100 2:1} 3:{1:100 2:1}} 7:{1:0 2:0 3:0}` (no 6.1 — no bud charging, both out), 3760 `6:{1:{1:60 2:1} 2:{1:100 2:1} 3:{1:100 2:2}} 7:{1:1 2:0 3:0}` +(Right charging), 4845 `… 2:{1:100 2:2} 3:{1:100 2:2}} 7:{1:1 2:1 3:0}` (both), 7033 `… 2:{1:100 2:2} 3:{1:100 2:1}} 7:{1:0 2:1 3:0}` (Left charging) — as +`CAP-062-FINDINGS.md` §4; the app already decodes these into `leftCharging`/`rightCharging` (`RuntimeInfoDecoder`, 6.x field 2 first, 7.x as fallback). + +**What the app already holds (no new wire traffic, no new decoder):** `ancAvailability` + `ancAvailabilityUpdatedAt` (the last `Notify`'s Settable byte and +its time; reset to UNKNOWN at Connect), `settings.inEarDetection` (field 2, read at Connect), `batteryStatus.leftCharging` / `rightCharging` +(`ChargingReading` with `fromEarlierSession`). Every input of the indicator exists; the derived reading is a pure function. + +**Design.** `:domain` `WornReading` (sealed): `NotRead` ("—", no `Notify` this connection), `InEarDetectionOff`, `InEarDetectionNotRead`, `BothInCase`, +`NotWorn(checkedAtMillis)`, `ProbablyWorn(checkedAtMillis)`; `fun wornReading(availability, checkedAt, inEarDetection: SettingReading?, left: +ChargingReading?, right: ChargingReading?)` with this precedence: no `Notify` → NotRead; field 2 = 0 → InEarDetectionOff (the byte says nothing then, +`CAP-064` 10394/10600); field 2 not read → InEarDetectionNotRead; both charging flags `true` **from this session** → BothInCase (a charging bud is not worn, +🟢 correlation); Settable `00` → NotWorn; non-zero → ProbablyWorn. `BudsRepository.wornReading: Flow` = `combine` of the four existing flows. +UI: one line on the Connection screen's **battery card** (proposal) plus the per-bud "in the case" state and the two limits in the card's (i); texts at the +checkpoint (Q3). Never names a bud as worn (the byte is not per bud). Tests: `:domain` one per reading with the fixtures above; repository: the flow follows +`CAP-065` 5465/6334 and `CAP-064` 10394 + the field-2 read; `:ui`: the texts. + +### A.4 Item 4 — volume-level notifications: reverse engineering (read-only, `v1.0.955078536-10253511`, JADX unless noted) + +(a) **What `HearingWellnessNotificationWorker` computes, from which RPC, on which schedule, against which threshold.** +- The class (`…/notification/hearingwellness/HearingWellnessNotificationWorker.java:27–66`) is an `RxWorker`. `m(context, address)` (`:46–55`) builds a + one-time request (`emt` = `OneTimeWorkRequest.Builder`; `enh.g(Duration)` sets `this.c.g = duration.toMillis()` = the initial delay, `enh.java:72–78`) with + tag `HEARING_WELLNESS_NOTIFICATION_WORK`, initial delay `j = Duration.ofMinutes(1)` (`:30`), input `DEVICE_ADDRESS`, and enqueues it as unique work + `HEARING_WELLNESS_NOTIFICATION_WORK_
` with policy int `2` (`eng.h(String, int, …)`, `eng.java:24`; the enum name behind `2` is not recoverable + from the stripped code — ⚪). `l(context)` (`:41–44`) cancels all work with that tag. +- **Scheduled** from the session: `fvp.i()` (`fvp.java:58–64`, run when the Dosimeter connection starts — `fut.java:102–103` per `ai-sessions/0073`) + subscribes `this.l.C(new fst(14))` → `fsy(this, 7)`: `fst` case 14 = `gcl.x()` (`fst.java`), and `fsy` case 7 (`fsy.java`): `true` → log "HearingWellnessNotificationWorker: + scheduled" + `m(context, address)`; `false` → "… cancelled" + `l(context)`. `gcl.x()` (`gcl.java:550–563`) returns the device record's flag `gdw.x` when its + presence bit `2097152` is set, else `true` — the stored value of the **"Volume level notifications" switch** (field 21 is written by `hey.java:165–190` + from the same preference, `REVERSE_ENGINEERING.md` `qhr` register row 21). Also scheduled by `fyh.a()` (`fyh.java:32–36`) right after it fetched both + daily summaries (below), and cancelled in `fvp.b()` (`:51–56`, the session's end). **No periodic work:** one-time, 1 minute after each session start, replaced/ + appended per policy `2` — not a `PeriodicWorkRequest`, not "only while connected" beyond that (WorkManager runs it when its delay elapses). +- **What it computes** (`c()`, `:58–65`): the device record `gck.b(address)` → `ftq(13)` (`Optional.isPresent`) → `gau(11)` (`get`) → `gau(12)` + (`gau.java:104–131`): returns `false` when `!gcl.x()` (switch off) or when both summaries are absent; else `hwy.aj(left = gcl.q(), right = gcl.r())` → + `gau(13)` = `hwy.ai(gdg)` → `gag(lastNotified, 10)` → `fsm(this, address, 5)`. + - `gcl.q()`/`r()` (`gcl.java:430–474`): the stored **left** (`gdf.c`) and **right** (`gdf.e`) daily summaries (`gdg`). + - `hwy.aj(gdg, gdg)` (`hwy.java`): for the day index `max(left.c, right.c)` and the 7 before it, the per-day dose is `(left.d[day] + right.d[day]) / 2` + (one present → that one, else 0) — **left and right averaged per day**. + - `hwy.ai(gdg)` (`hwy.java:1979–1998`): sums the doses of the current day index and the 6 before it (7 days) and returns + `round(sum / 3.3903457E11f × 100)` — a percentage of a **7-day** constant. + - `gag` discriminator 10 (`apktool-output/smali_classes2/gag.smali:438–468`, read in full): `if-lt p1, 0x43` → `false` when the percentage is **below 67**; + otherwise `true` when no last-notified time is stored, or when `Instant.now().minusMillis(last).toEpochMilli() >= 0x240c8400` (604 800 000 ms = 7 days). + - `fsm` case 5 (`fsm.java:171–215`): on `true` creates the channel `"HW APPROACHING MAX NOTIFICATION"` (name `R.string.maestro_notification_title`, importance 3), + posts a notification with title `maestro_notification_title` and body `hearing_wellness_approaching_max_notification_body`, tap → the app's hearing-wellness + fragment, logs app event type 16, and stores `now` as the last-notified time (`gck.P(address, new gcg(Long, 13))`). + - **Threshold constants in the code:** 7-day limit `3.3903457E11` (`hwy.java`), 24-hour limit `4.843351E10` (`his.java:28`: exposure % = `g / 4.843351E10 × 100` + from `qhz` field 5), ratio exactly 7.0; notify at **≥ 67 %** of the 7-day limit, at most once per **7 days**; the live level is "loud" at **≥ 85 dB** + (`his.java:36`, `max >= 85`). The code names no WHO/EU figure; the unit of the dose is not in the code (the live value is linear, shown as + `round(10·log10(v))` dB, `hiw.java:32–37`). +(b) **`qhz` / `qia` / `qir` in the code.** `fyf.java:49–94` calls `maestro_pw.Dosimeter/FetchDailySummaries` per bud (`goq.LEFT_BT_CORE` and `RIGHT_BT_CORE`, + i.e. channels 19 and 21, `fyh.java:28–36`, 3 retries) and copies `qhz` into `gdg`: field 1 (`b`) → `gdg.c` = the **current day index**; field 2 (`c`, repeated + `qia`) → `gdg.d`, each `qia` → `gda` {1 = day index, 6 = that day's dose (`gda.d`, float)}; field 3 (`d`) → `gdg.e` (summed in `hwy.aj`, unnamed); field 4 + (`e`) → `gdg.f` (unnamed); field 5 (`f`) → `gdg.g` = the **24-hour dose** (exposure %). `qir` field 2 = the live linear level per bud (`hiw.java`); the Hearing + wellness page shows `round(10·log10(v))` dB clamped 0–120 with "Loud" ≥ 85. Wire sample (`CAP-058` 2845, channel 21): `1:462 2:{1:228 6:…} 2:{1:456 …} … + 2:{1:462 6:f32(204890640)} 5:f32(245338960)` — 7 recent day entries 456–462 and one old (228); 24-h exposure = 245 338 960 / 4.843351E10 = **0.5 %**. +(c) **Does field 21 change anything on the Buds?** Code: it only gates the phone-side worker (`gcl.x()` → `fsy` case 7); the Buds' own `qhr` field 21 is + written and read back (`CAP-058` X12) but no code reads a Buds-initiated message for it. Wire: `python3 scripts/pwrpc_decode.py | grep + call_id=4294967295 | awk '{print $9}' | sort | uniq -c` → `CAP-009` (101 min idle): 9 × `method=GetSoftwareInfo`, nothing else; `CAP-042` (37 min idle): 1 × + `GetSoftwareInfo` (exit 0; the same filter finds the unsolicited announcement in every log — the positive control). No Buds-initiated Dosimeter or other + packet exists in either idle log. +(d) **Unsolicited Dosimeter values:** `grep -c "Dosimeter.*call_id=4294967295"` over all 85 logs → **0 in every log** (positive control: `call_id=4294967295` + matches `GetSoftwareInfo` in `CAP-036`). Every `SubscribeToLiveDb` `SERVER_STREAM` follows a `REQUEST` on its channel (`CAP-009`: requests 1505/1506, + first stream packets 9272/9273; re-requested at 14809, 15025, 18207, 18445, 19560, 19793, 20029, 26788 — each after a `FetchDailySummaries` pair); + `CAP-042`: `FetchDailySummaries` 769/771, two `CLIENT_ERROR FAILED_PRECONDITION` 888/893, then `SubscribeToLiveDb` 967/968 and 9 packets per channel. + The Buds never push a Dosimeter value without a subscription. +(e) **The notification's text and channel:** channel id `"HW APPROACHING MAX NOTIFICATION"`, channel name and title = `maestro_notification_title` = "Pixel Buds" + (`strings.xml:494`), body `hearing_wellness_approaching_max_notification_body` = "Consider lowering the volume of your earbuds to limit your audio exposure" + (`:301`), notification id `hearing_wellness_approaching_max_notification_id` (`:302`). The switch's own summary: `summary_hearing_wellness_notifications` + = "Pixel Buds will notify you if the recommended exposure limit is exceeded" (`:799`) — the UI says "exceeded", the code notifies at 67 %. + +**Design (RESULT only; nothing built):** see §B's draft ADR-060 and the list of 🟡 that block a build. + +### A.5 Items 5 and 6 — what moves + +`ControlsScreen.kt:103–123` holds the "Case sounds" card (`SettingsCard`, private to that file) with `CASE_SOUNDS_TITLE`, the two labels and the (i) lines +"