Compare commits

..
Author SHA1 Message Date
d4rken-org-releaser[bot] 39922df792 Release: 5.1.3-rc0 2026-05-01 19:52:12 +00:00
darken 5a8c4b94ef fix: Correct wear detection on AirPods Max 2
Bit 5 of pubStatus is always set on A3454 and no longer carries the wear flag (unlike Max gen 1). Read bits 1 and 3 instead — the per-earcup sensors. OR rather than AND so phones that only see one bit reliably still report worn correctly.

Closes #548
2026-05-01 21:50:51 +02:00
darken 0ffa87deea CI: Skip release bump-only workflow runs 2026-05-01 20:08:42 +02:00
darken fc98db09a2 fix: Correct PodModel feature flags for ear detection and call controls
Sync flags with what the BLE classes actually report and what iOS exposes:

- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle

- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)

- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)

- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)

- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual

- Generalize microphone mode description from 'AirPod' to 'earbud'

Tests rewritten as exhaustive set assertions plus implication invariants.
2026-05-01 20:08:29 +02:00
darken e1c0a702dd chore(ci): Rename stale step name and use client-id for App auth 2026-05-01 09:15:43 +02:00
14 changed files with 462 additions and 69 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ bats tools/release/bump.bats
Required org secrets (set on the d4rken-org organization, accessible to `capod`): Required org secrets (set on the d4rken-org organization, accessible to `capod`):
- `RELEASE_APP_ID` — numeric ID of the `d4rken-org-releaser` GitHub App - `RELEASE_APP_CLIENT_ID` — Client ID of the `d4rken-org-releaser` GitHub App (visible on the App's settings page, format `Iv1.<hex>` or similar)
- `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines) - `RELEASE_APP_PRIVATE_KEY` — full `.pem` contents (including BEGIN/END lines)
The App is installed on this repo and added as a bypass actor to: The App is installed on this repo and added as a bypass actor to:
+4 -1
View File
@@ -3,6 +3,9 @@ name: Code tests & eval
on: on:
push: push:
branches: [ main ] branches: [ main ]
paths-ignore:
- VERSION
- version.properties
pull_request: pull_request:
branches: [ main ] branches: [ main ]
@@ -101,4 +104,4 @@ jobs:
run: bats tools/release/bump.bats run: bats tools/release/bump.bats
- name: Verify version.properties + VERSION are consistent - name: Verify version.properties + VERSION are consistent
run: ./tools/release/bump.sh --mode=check run: ./tools/release/bump.sh --mode=check
@@ -4,6 +4,9 @@ on:
push: push:
branches: branches:
- main - main
paths-ignore:
- VERSION
- version.properties
pull_request: pull_request:
branches: branches:
- main - main
+8
View File
@@ -3,6 +3,14 @@ name: Deploy GitHub Pages
on: on:
push: push:
branches: [ main ] branches: [ main ]
paths:
- _config.yml
- _layouts/**
- README.md
- CHANGELOG.md
- PRIVACY_POLICY.md
- CNAME
- .github/workflows/pages.yml
workflow_dispatch: workflow_dispatch:
permissions: permissions:
+3 -3
View File
@@ -142,7 +142,7 @@ jobs:
id: app-token id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1 uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 #v3.1.1
with: with:
app-id: ${{ secrets.RELEASE_APP_ID }} client-id: ${{ secrets.RELEASE_APP_CLIENT_ID }}
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
- name: Resolve bot identity - name: Resolve bot identity
@@ -164,12 +164,12 @@ jobs:
persist-credentials: true persist-credentials: true
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
- name: Re-validate after approval wait - name: Verify state still matches plan from Job 1
run: | run: |
set -euo pipefail set -euo pipefail
./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}" ./tools/release/bump.sh --mode=check --expected-current="${CURRENT_NAME_AT_PLAN}"
- name: Re-check tag collision (state may have moved during approval) - name: Re-check tag collision (state may have moved between jobs)
run: | run: |
set -euo pipefail set -euo pipefail
if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then if git rev-parse --verify "refs/tags/v${NEW_NAME}" >/dev/null 2>&1; then
+1 -1
View File
@@ -1 +1 @@
5.1.2-rc1 50102010 5.1.3-rc0 50103000
@@ -22,7 +22,9 @@ enum class PodModel(
Features( Features(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true, hasMicrophoneMode = true,
hasEarDetectionToggle = true,
), ),
modelNumbers = setOf("A1523", "A1722"), // L/R earphones modelNumbers = setOf("A1523", "A1722"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left, leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -37,7 +39,9 @@ enum class PodModel(
Features( Features(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
hasEarDetection = true,
hasMicrophoneMode = true, hasMicrophoneMode = true,
hasEarDetectionToggle = true,
), ),
modelNumbers = setOf("A2031", "A2032"), // L/R earphones modelNumbers = setOf("A2031", "A2032"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen1_left, leftPodIconRes = R.drawable.device_airpods_gen1_left,
@@ -52,10 +56,13 @@ enum class PodModel(
Features( Features(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
hasEarDetection = true,
hasPressSpeed = true, hasPressSpeed = true,
hasPressHoldDuration = true, hasPressHoldDuration = true,
hasToneVolume = true, hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true, hasMicrophoneMode = true,
hasEarDetectionToggle = true,
), ),
modelNumbers = setOf("A2564", "A2565"), // L/R earphones modelNumbers = setOf("A2564", "A2565"), // L/R earphones
leftPodIconRes = R.drawable.device_airpods_gen3_left, leftPodIconRes = R.drawable.device_airpods_gen3_left,
@@ -74,6 +81,7 @@ enum class PodModel(
hasPressSpeed = true, hasPressSpeed = true,
hasPressHoldDuration = true, hasPressHoldDuration = true,
hasToneVolume = true, hasToneVolume = true,
hasEndCallMuteMic = true,
hasMicrophoneMode = true, hasMicrophoneMode = true,
hasEarDetectionToggle = true, hasEarDetectionToggle = true,
hasSleepDetection = true, hasSleepDetection = true,
@@ -314,7 +322,6 @@ enum class PodModel(
"Beats Solo Pro", "Beats Solo Pro",
R.drawable.device_beats_headphones, R.drawable.device_beats_headphones,
Features( Features(
hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
), ),
modelNumbers = setOf("A1881"), // headphones modelNumbers = setOf("A1881"), // headphones
@@ -343,7 +350,6 @@ enum class PodModel(
"Beats Studio 3", "Beats Studio 3",
R.drawable.device_beats_studio3, R.drawable.device_beats_studio3,
Features( Features(
hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
), ),
modelNumbers = setOf("A1914"), // headphones modelNumbers = setOf("A1914"), // headphones
@@ -410,6 +416,7 @@ enum class PodModel(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
hasEarDetection = true, hasEarDetection = true,
hasEarDetectionToggle = true,
), ),
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
leftPodIconRes = R.drawable.device_powerbeats_pro_left, leftPodIconRes = R.drawable.device_powerbeats_pro_left,
@@ -426,6 +433,7 @@ enum class PodModel(
hasCase = true, hasCase = true,
hasEarDetection = true, hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true, hasEarDetectionToggle = true,
hasSleepDetection = true, hasSleepDetection = true,
), ),
@@ -444,6 +452,8 @@ enum class PodModel(
hasCase = true, hasCase = true,
hasEarDetection = true, hasEarDetection = true,
hasAncControl = true, hasAncControl = true,
hasMicrophoneMode = true,
hasEarDetectionToggle = true,
), ),
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
leftPodIconRes = R.drawable.device_beats_fitpro_left, leftPodIconRes = R.drawable.device_beats_fitpro_left,
@@ -458,6 +468,7 @@ enum class PodModel(
Features( Features(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
hasEarDetection = true,
), ),
leftPodIconRes = R.drawable.device_airpods_gen1_left, leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right, rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -471,6 +482,7 @@ enum class PodModel(
Features( Features(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
hasEarDetection = true,
), ),
leftPodIconRes = R.drawable.device_airpods_gen1_left, leftPodIconRes = R.drawable.device_airpods_gen1_left,
rightPodIconRes = R.drawable.device_airpods_gen1_right, rightPodIconRes = R.drawable.device_airpods_gen1_right,
@@ -484,6 +496,7 @@ enum class PodModel(
Features( Features(
hasDualPods = true, hasDualPods = true,
hasCase = true, hasCase = true,
hasEarDetection = true,
), ),
leftPodIconRes = R.drawable.device_airpods_gen3_left, leftPodIconRes = R.drawable.device_airpods_gen3_left,
rightPodIconRes = R.drawable.device_airpods_gen3_right, rightPodIconRes = R.drawable.device_airpods_gen3_right,
@@ -44,8 +44,18 @@ data class AirPodsMax2(
return pubFlags.isBitSet(0) return pubFlags.isBitSet(0)
} }
// Aggregate "any wear sensor active" — NOT "both earcups worn".
// Observed on A3454: bit 5 of pubStatus is always set while advertising and
// no longer carries the wear flag (unlike Max gen 1). Bits 1 and 3 of
// pubStatus reflect the two earcup sensors (same byte positions used by
// DualApplePods, but without the primary/flip semantics).
//
// We OR them because some Android pairings only see one of the two bits
// reliably (issue #548: "both worn" advertises as 0x23 — bit 1 only —
// while macOS sees 0x2B with both bits set). AND-ing would falsely report
// "not worn" during normal use on those phones.
override val isBeingWorn: Boolean override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5) get() = pubStatus.isBitSet(1) || pubStatus.isBitSet(3)
class Factory @Inject constructor( class Factory @Inject constructor(
private val repo: PodHistoryRepo, private val repo: PodHistoryRepo,
+1 -1
View File
@@ -515,7 +515,7 @@
<!-- New device settings --> <!-- New device settings -->
<string name="device_settings_category_general_label">General</string> <string name="device_settings_category_general_label">General</string>
<string name="device_settings_microphone_mode_label">Microphone</string> <string name="device_settings_microphone_mode_label">Microphone</string>
<string name="device_settings_microphone_mode_description">Which AirPod is used as the microphone</string> <string name="device_settings_microphone_mode_description">Which earbud is used as the microphone</string>
<string name="device_settings_microphone_mode_auto">Auto</string> <string name="device_settings_microphone_mode_auto">Auto</string>
<string name="device_settings_microphone_mode_right">Right</string> <string name="device_settings_microphone_mode_right">Right</string>
<string name="device_settings_microphone_mode_left">Left</string> <string name="device_settings_microphone_mode_left">Left</string>
@@ -12,6 +12,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import eu.darken.capod.pods.core.apple.aap.AapPodState import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.ble.devices.ApplePods import eu.darken.capod.pods.core.apple.ble.devices.ApplePods
import eu.darken.capod.pods.core.apple.ble.devices.SingleApplePods
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.nulls.shouldNotBeNull
@@ -200,6 +201,23 @@ class PodDeviceTest : BaseTest() {
device.isEitherPodInEar shouldBe true device.isEitherPodInEar shouldBe true
} }
@Test
fun `BLE isBeingWorn delegates to HasEarDetection on single-pod devices`() {
val mock = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns true
}
val device = PodDevice(profileId = null, ble = mock, aap = null)
device.isBeingWorn shouldBe true
val mockNotWorn = mockk<SingleApplePods>(relaxed = true, moreInterfaces = arrayOf(HasEarDetection::class)) {
every { model } returns PodModel.AIRPODS_MAX2
every { (this@mockk as HasEarDetection).isBeingWorn } returns false
}
val deviceNotWorn = PodDevice(profileId = null, ble = mockNotWorn, aap = null)
deviceNotWorn.isBeingWorn shouldBe false
}
@Test @Test
fun `AAP ear detection preferred over BLE`() { fun `AAP ear detection preferred over BLE`() {
val mock = mockk<DualApplePods>(relaxed = true) { val mock = mockk<DualApplePods>(relaxed = true) {
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple package eu.darken.capod.pods.core.apple
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
@@ -7,79 +8,364 @@ import testhelpers.BaseTest
class ModelFeaturesTest : BaseTest() { class ModelFeaturesTest : BaseTest() {
@Test @Test
fun `AirPods Pro 3 has all features`() { fun `feature flags match supported model matrix`() {
val f = PodModel.AIRPODS_PRO3.features featureExpectations.forEach { expectation ->
f.hasDualPods shouldBe true withClue(expectation.name) {
f.hasCase shouldBe true modelsWith(expectation.predicate) shouldBe expectation.models
f.hasEarDetection shouldBe true }
f.hasAncControl shouldBe true }
} }
@Test @Test
fun `AirPods Gen 1 has dual pods and case but no ear detection or ANC`() { fun `models without ear detection match intentional matrix`() {
val f = PodModel.AIRPODS_GEN1.features modelsWithout { it.hasEarDetection } shouldBe setOf(
f.hasDualPods shouldBe true PodModel.BEATS_FLEX,
f.hasCase shouldBe true PodModel.BEATS_SOLO_3,
f.hasEarDetection shouldBe false PodModel.BEATS_SOLO_PRO,
f.hasAncControl shouldBe false PodModel.BEATS_SOLO_4,
} PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_3,
@Test PodModel.BEATS_STUDIO_BUDS,
fun `AirPods Max is single device with ANC but no dual pods or case`() { PodModel.BEATS_STUDIO_BUDS_PLUS,
val f = PodModel.AIRPODS_MAX.features PodModel.BEATS_STUDIO_PRO,
f.hasDualPods shouldBe false PodModel.BEATS_X,
f.hasCase shouldBe false PodModel.POWERBEATS_3,
f.hasEarDetection shouldBe true PodModel.POWERBEATS_4,
f.hasAncControl shouldBe true PodModel.UNKNOWN,
} )
@Test
fun `Beats Solo 3 has no features`() {
val f = PodModel.BEATS_SOLO_3.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
}
@Test
fun `PowerBeats Pro has dual pods, case, ear detection but no ANC`() {
val f = PodModel.POWERBEATS_PRO.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe false
} }
@Test @Test
fun `UNKNOWN model has no features`() { fun `UNKNOWN model has no features`() {
val f = PodModel.UNKNOWN.features PodModel.UNKNOWN.features shouldBe PodModel.Features()
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
} }
@Test @Test
fun `all ANC-capable dual-pod models have ear detection except Studio Buds`() { fun `ear detection toggle implies ear detection`() {
val noEarDetectionAncModels = setOf(
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
)
PodModel.entries PodModel.entries
.filter { it.features.hasAncControl && it.features.hasDualPods } .filter { it.features.hasEarDetectionToggle }
.filter { it !in noEarDetectionAncModels }
.forEach { model -> .forEach { model ->
model.features.hasEarDetection shouldBe true withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
} }
} }
@Test @Test
fun `all models with case also have dual pods`() { fun `sleep detection implies ear detection`() {
PodModel.entries
.filter { it.features.hasSleepDetection }
.forEach { model ->
withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test
fun `case implies dual pods`() {
PodModel.entries PodModel.entries
.filter { it.features.hasCase } .filter { it.features.hasCase }
.forEach { model -> .forEach { model ->
model.features.hasDualPods shouldBe true withClue(model.name) {
model.features.hasDualPods shouldBe true
}
} }
} }
}
@Test
fun `adaptive ANC implies ANC control`() {
PodModel.entries
.filter { it.features.hasAdaptiveAnc }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `adaptive audio noise implies adaptive ANC`() {
PodModel.entries
.filter { it.features.hasAdaptiveAudioNoise }
.forEach { model ->
withClue(model.name) {
model.features.hasAdaptiveAnc shouldBe true
}
}
}
@Test
fun `listening mode cycle implies ANC control`() {
PodModel.entries
.filter { it.features.hasListeningModeCycle }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `allow off option implies listening mode cycle`() {
PodModel.entries
.filter { it.features.hasAllowOffOption }
.forEach { model ->
withClue(model.name) {
model.features.hasListeningModeCycle shouldBe true
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()
private fun modelsWithout(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filterNot { predicate(it.features) }
.toSet()
private data class FeatureExpectation(
val name: String,
val predicate: (PodModel.Features) -> Boolean,
val models: Set<PodModel>,
)
private fun feature(
name: String,
predicate: (PodModel.Features) -> Boolean,
models: Set<PodModel>,
) = FeatureExpectation(name, predicate, models)
private val dualPodModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val earDetectionModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val ancControlModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.BEATS_SOLO_PRO,
PodModel.BEATS_STUDIO_3,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.BEATS_STUDIO_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val adaptiveAncModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val conversationAwarenessModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val ncOneAirpodModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val pressSpeedModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val volumeSwipeModels = setOf(
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val personalizedVolumeModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val toneVolumeModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val endCallMuteMicModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val adaptiveAudioNoiseModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val microphoneModeModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val earDetectionToggleModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val listeningModeCycleModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val stemConfigModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val sleepDetectionModels = setOf(
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
)
private val featureExpectations = listOf(
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
feature("hasCase", { it.hasCase }, dualPodModels),
feature("hasEarDetection", { it.hasEarDetection }, earDetectionModels),
feature("hasAncControl", { it.hasAncControl }, ancControlModels),
feature("hasAdaptiveAnc", { it.hasAdaptiveAnc }, adaptiveAncModels),
feature("hasConversationAwareness", { it.hasConversationAwareness }, conversationAwarenessModels),
feature("hasNcOneAirpod", { it.hasNcOneAirpod }, ncOneAirpodModels),
feature("hasPressSpeed", { it.hasPressSpeed }, pressSpeedModels),
feature("hasPressHoldDuration", { it.hasPressHoldDuration }, pressSpeedModels),
feature("hasVolumeSwipe", { it.hasVolumeSwipe }, volumeSwipeModels),
feature("hasVolumeSwipeLength", { it.hasVolumeSwipeLength }, volumeSwipeModels),
feature("hasPersonalizedVolume", { it.hasPersonalizedVolume }, personalizedVolumeModels),
feature("hasToneVolume", { it.hasToneVolume }, toneVolumeModels),
feature("hasEndCallMuteMic", { it.hasEndCallMuteMic }, endCallMuteMicModels),
feature("hasAdaptiveAudioNoise", { it.hasAdaptiveAudioNoise }, adaptiveAudioNoiseModels),
feature("hasMicrophoneMode", { it.hasMicrophoneMode }, microphoneModeModels),
feature("hasEarDetectionToggle", { it.hasEarDetectionToggle }, earDetectionToggleModels),
feature("hasListeningModeCycle", { it.hasListeningModeCycle }, listeningModeCycleModels),
feature("hasAllowOffOption", { it.hasAllowOffOption }, listeningModeCycleModels),
feature("hasStemConfig", { it.hasStemConfig }, stemConfigModels),
feature("hasSleepDetection", { it.hasSleepDetection }, sleepDetectionModels),
feature("hasDynamicEndOfCharge", { it.hasDynamicEndOfCharge }, setOf(PodModel.AIRPODS_PRO3)),
)
}
@@ -337,6 +337,7 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasPressSpeed shouldBe true f.hasPressSpeed shouldBe true
f.hasPressHoldDuration shouldBe true f.hasPressHoldDuration shouldBe true
f.hasToneVolume shouldBe true f.hasToneVolume shouldBe true
f.hasEndCallMuteMic shouldBe true
f.hasListeningModeCycle shouldBe false f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false f.hasStemConfig shouldBe false
f.hasSleepDetection shouldBe true f.hasSleepDetection shouldBe true
@@ -351,14 +352,31 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasStemConfig shouldBe false f.hasStemConfig shouldBe false
} }
@Test fun `Gen 1 has mic mode only`() { @Test fun `Gen 1 has mic mode and ear detection toggle`() {
val f = PodModel.AIRPODS_GEN1.features val f = PodModel.AIRPODS_GEN1.features
f.hasMicrophoneMode shouldBe true f.hasMicrophoneMode shouldBe true
f.hasEarDetectionToggle shouldBe false f.hasEarDetection shouldBe true
f.hasEarDetectionToggle shouldBe true
f.hasListeningModeCycle shouldBe false f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false f.hasStemConfig shouldBe false
} }
@Test fun `Gen 2 and Gen 3 expose ear detection toggle`() {
val gen2 = PodModel.AIRPODS_GEN2.features
gen2.hasMicrophoneMode shouldBe true
gen2.hasEarDetection shouldBe true
gen2.hasEarDetectionToggle shouldBe true
val gen3 = PodModel.AIRPODS_GEN3.features
gen3.hasMicrophoneMode shouldBe true
gen3.hasEarDetection shouldBe true
gen3.hasEarDetectionToggle shouldBe true
gen3.hasPressSpeed shouldBe true
gen3.hasPressHoldDuration shouldBe true
gen3.hasToneVolume shouldBe true
gen3.hasEndCallMuteMic shouldBe true
}
@Test fun `Max 2 has H2 features`() { @Test fun `Max 2 has H2 features`() {
val f = PodModel.AIRPODS_MAX2.features val f = PodModel.AIRPODS_MAX2.features
f.hasAdaptiveAnc shouldBe true f.hasAdaptiveAnc shouldBe true
@@ -382,6 +400,18 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasEarDetectionToggle shouldBe true f.hasEarDetectionToggle shouldBe true
f.hasAncControl shouldBe true f.hasAncControl shouldBe true
f.hasEarDetection shouldBe true f.hasEarDetection shouldBe true
f.hasMicrophoneMode shouldBe true
}
@Test fun `Powerbeats Pro and Beats Fit Pro expose ear detection toggle`() {
val powerbeatsPro = PodModel.POWERBEATS_PRO.features
powerbeatsPro.hasEarDetection shouldBe true
powerbeatsPro.hasEarDetectionToggle shouldBe true
val beatsFitPro = PodModel.BEATS_FIT_PRO.features
beatsFitPro.hasEarDetection shouldBe true
beatsFitPro.hasEarDetectionToggle shouldBe true
beatsFitPro.hasMicrophoneMode shouldBe true
} }
} }
@@ -413,6 +443,14 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
} }
} }
@Test fun `earDetectionToggle implies earDetection`() {
for (model in PodModel.entries) {
if (model.features.hasEarDetectionToggle) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test fun `adaptiveAudioNoise implies adaptiveAnc`() { @Test fun `adaptiveAudioNoise implies adaptiveAnc`() {
for (model in PodModel.entries) { for (model in PodModel.entries) {
if (model.features.hasAdaptiveAudioNoise) { if (model.features.hasAdaptiveAudioNoise) {
@@ -24,6 +24,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
batteryHeadsetPercent shouldBe 0.6f batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2 model shouldBe PodModel.AIRPODS_MAX2
} }
@@ -35,6 +36,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x20.toUByte() pubStatus shouldBe 0x20.toUByte()
batteryHeadsetPercent shouldBe 0.6f batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2 model shouldBe PodModel.AIRPODS_MAX2
} }
@@ -42,6 +44,7 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x21.toUByte() pubStatus shouldBe 0x21.toUByte()
batteryHeadsetPercent shouldBe 0.6f batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe false
model shouldBe PodModel.AIRPODS_MAX2 model shouldBe PodModel.AIRPODS_MAX2
} }
@@ -49,6 +52,17 @@ class AirPodsMax2Test : BaseBlePodsTest() {
pubStatus shouldBe 0x23.toUByte() pubStatus shouldBe 0x23.toUByte()
batteryHeadsetPercent shouldBe 0.6f batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2
}
// Synthetic: one earcup off, the other on (issue #548 sees 0x29 in this state on
// both macOS and Android; we don't claim left vs right side semantics here).
create<AirPodsMax2>("07 19 01 2D 20 29 F6 8F 03 14 04 E1 8B 99 98 20 0B C3 C1 12 2D B0 43 98 94 D6 A0") {
pubStatus shouldBe 0x29.toUByte()
batteryHeadsetPercent shouldBe 0.6f
isHeadsetBeingCharged shouldBe false
isBeingWorn shouldBe true
model shouldBe PodModel.AIRPODS_MAX2 model shouldBe PodModel.AIRPODS_MAX2
} }
} }
+2 -2
View File
@@ -1,7 +1,7 @@
### Updated by tools/release/bump.sh ### ### Updated by tools/release/bump.sh ###
project.versioning.major=5 project.versioning.major=5
project.versioning.minor=1 project.versioning.minor=1
project.versioning.patch=2 project.versioning.patch=3
project.versioning.build=1 project.versioning.build=0
project.versioning.type=rc project.versioning.type=rc
############################# #############################