Refactor Pods history to a central PodHistoryRepo

This moves the history tracking logic for discovered Apple Pods from individual `ApplePodsFactory` implementations to a new `PodHistoryRepo` class.

Benefits:
- Centralized logic for device history management.
- Simplifies individual `ApplePodsFactory` implementations.
- Improves code maintainability and testability.
This commit is contained in:
darken
2025-06-06 06:03:41 +02:00
committed by Matthias Urhahn
parent d59dc8c225
commit 3b2dcc1a5c
31 changed files with 442 additions and 365 deletions
@@ -23,7 +23,7 @@ class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val proximityMessageDecrypter: ProximityMessage.Decrypter,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
private val generalSettings: GeneralSettings,
private val rpaChecker: RPAChecker,
@@ -33,30 +33,30 @@ import eu.darken.capod.pods.core.apple.misc.FakeAirPodsPro2
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4(factory: AirPodsGen4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsGen4Anc(factory: AirPodsGen4Anc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2(factory: AirPodsPro2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsPro2Usbc(factory: AirPodsPro2Usbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun airPodsMax2(factory: AirPodsMaxUsbc.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeats4(factory: PowerBeats4.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun beatsFitPro(factory: BeatsFitPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun fakeAirPodsGen1(factory: FakeAirPodsGen1.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen2(factory: FakeAirPodsGen2.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsGen3(factory: FakeAirPodsGen3.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro(factory: FakeAirPodsPro.Factory): ApplePodsFactory
@Binds @IntoSet abstract fun fakeAirPodsPro2(factory: FakeAirPodsPro2.Factory): ApplePodsFactory
}
@@ -1,108 +1,26 @@
package eu.darken.capod.pods.core.apple
import android.R.id.message
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.collections.median
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.history.KnownDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Duration
import java.time.Instant
import kotlin.math.max
abstract class ApplePodsFactory<PodType : ApplePods>(
private val tag: String,
) {
interface ApplePodsFactory {
fun isResponsible(message: ProximityMessage): Boolean
fun create(scanResult: BleScanResult, payload: ProximityPayload): ApplePods
data class Identifier(
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
private fun ProximityPayload.getFuzzyIdentifier(): Identifier = Identifier(
device = (((public.data[1].toInt() and 255) shl 8) or (public.data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(public.data[4].upperNibble, public.data[4].lowerNibble),
caseBatteryData = public.data[5].lowerNibble,
deviceColor = public.data[7]
fun ProximityMessage.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
data class KnownDevice(
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>,
val lastCaseBattery: Float?,
) {
val lastPayload: ProximityPayload
get() = history.last().payload
val lastAddress: BluetoothAddress
get() = history.last().address
val reliability: Float
get() {
if (history.size < 2) return 0f
val now = Instant.now()
val pingInterval = List(history.dropLast(1).size) { index ->
Duration.between(history[index].seenLastAt, history[index + 1].seenLastAt).toMillis()
}.map { it.toInt() }.median()
val expectedPings: Float = LOOKBACK.toMillis() / pingInterval.toFloat()
val recentPings = history.filter {
Duration.between(it.seenLastAt, now).toMillis() < LOOKBACK.toMillis() + pingInterval
}.size
val reliability: Float = (recentPings / expectedPings)
// log("#####") { "interval=$pingInterval, expectedPerMinute=$expectedPings, count=$recentPings" }
return max(0.0f, reliability).coerceAtMost(1f)
}
fun rssiSmoothed(latest: Int): Int {
val now = Instant.now()
return history
.filter { Duration.between(it.seenLastAt, now) < LOOKBACK }
.map { it.rssi }
.plus(latest)
.median()
}
fun isOlderThan(age: Duration): Boolean {
val now = Instant.now()
return Duration.between(history.last().seenLastAt, now) > age
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
// 30 seconds at fastest interval
const val MAX_HISTORY = 60
val LOOKBACK = Duration.ofSeconds(30)
}
}
internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
fun KnownDevice.getLatestCaseBattery(): Float? = this.lastCaseBattery
private fun Collection<ApplePods>.determineLatestCaseBattery(): Float? = this
.filterIsInstance<HasCase>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(
DualApplePods.LidState.OPEN,
@@ -118,92 +36,4 @@ abstract class ApplePodsFactory<PodType : ApplePods>(
?.caseLidState
?: DualApplePods.LidState.NOT_IN_CASE
}
open fun historyTrimmer(
pods: List<ApplePods>
): List<ApplePods> {
return pods.takeLast(KnownDevice.MAX_HISTORY)
}
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val payload = current.payload
knownDevices.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(30))) {
log(tag, VERBOSE) { "searchHistory1: Removing stale known device: $knownDevice" }
knownDevices.remove(knownDevice.id)
}
}
knownDevices.values
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach {
knownDevices[it.id] = it.copy(history = historyTrimmer(it.history))
}
var recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(tag, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
// if()
if (recognizedDevice == null) {
val currentMarkers = payload.getFuzzyIdentifier()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
?.also { log(tag) { "searchHistory1: Close match based on similarity: $currentMarkers" } }
}
if (recognizedDevice == null) {
log(tag, WARN) { "searchHistory1: Didn't recognize: $message" }
}
return recognizedDevice
}
fun updateHistory(device: PodType) {
val existing = knownDevices[device.identifier]
knownDevices[device.identifier] = when {
existing != null -> {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
)
}
else -> {
log(tag) { "searchHistory1: Creating new history for $device" }
val history = listOf(device)
KnownDevice(
id = device.identifier,
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
)
}
}
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityMessage.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(message: ProximityMessage): Boolean
abstract fun create(
scanResult: BleScanResult,
payload: ProximityPayload,
): ApplePods
}
@@ -1,68 +0,0 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.pods.core.PodDevice
abstract class DualApplePodsFactory(private val tag: String) : ApplePodsFactory<DualApplePods>(tag) {
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = pubDeviceColor,
model = model
)
/**
* Split pods, one in case, one not.
*/
data class SplitPodsMarkings(
val leftPodBattery: Float?,
val rightPodBattery: Float?,
val microPhoneLeft: Boolean,
val microPhoneRight: Boolean,
val chargingLeft: Boolean,
val chargingRight: Boolean,
val color: UByte,
val model: PodDevice.Model,
)
private fun Collection<KnownDevice>.findSplitPodsMatch(device: DualApplePods): Collection<KnownDevice> {
val target = device.getCaseMatchMarkings()
return filter { known ->
known.history
.filterIsInstance<DualApplePods>()
.any { it.getCaseMatchMarkings() == target }
}
}
override fun searchHistory(current: DualApplePods): KnownDevice? {
val basicResult = super.searchHistory(current)
val caseIgnored = knownDevices.values.findSplitPodsMatch(current)
log(tag, DEBUG) { "searchHistory2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
return when (caseIgnored.size) {
0 -> basicResult
1 -> caseIgnored.single()
else -> {
log(tag) { "searchHistory2: More than one result when ignoring case markers." }
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
caseIgnored.minus(oldest).forEach {
log(tag) { "searchHistory2: Removing outlier: $it" }
knownDevices.remove(it.id)
}
oldest
}
}
}
}
@@ -1,3 +0,0 @@
package eu.darken.capod.pods.core.apple
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -36,7 +37,9 @@ data class AirPodsGen1(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -47,10 +50,10 @@ data class AirPodsGen1(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen1(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -36,7 +37,9 @@ data class AirPodsGen2(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -47,10 +50,10 @@ data class AirPodsGen2(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -36,7 +37,9 @@ data class AirPodsGen3(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -47,10 +50,10 @@ data class AirPodsGen3(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -36,7 +37,9 @@ data class AirPodsGen4(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -47,10 +50,10 @@ data class AirPodsGen4(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen4(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -36,7 +37,9 @@ data class AirPodsGen4Anc(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -47,10 +50,10 @@ data class AirPodsGen4Anc(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsGen4Anc(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -7,9 +7,10 @@ import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.HasAppleColor
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -38,7 +39,9 @@ data class AirPodsMax(
override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -49,10 +52,10 @@ data class AirPodsMax(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsMax(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -7,9 +7,10 @@ import eu.darken.capod.pods.core.HasChargeDetection
import eu.darken.capod.pods.core.HasEarDetection
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.HasAppleColor
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -38,7 +39,9 @@ data class AirPodsMaxUsbc(
override val isBeingWorn: Boolean
get() = pubStatus.isBitSet(5)
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -49,10 +52,10 @@ data class AirPodsMaxUsbc(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsMaxUsbc(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -6,9 +6,10 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -55,7 +56,9 @@ data class AirPodsPro(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -69,10 +72,10 @@ data class AirPodsPro(
scanResult = scanResult,
payload = payload,
)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -6,9 +6,10 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -55,7 +56,9 @@ data class AirPodsPro2(
override val rssi: Int
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -66,10 +69,10 @@ data class AirPodsPro2(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsPro2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -6,9 +6,10 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -55,7 +56,9 @@ data class AirPodsPro2Usbc(
override val rssi: Int
get() = rssiAverage ?: super<HasStateDetectionAirPods>.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -66,10 +69,10 @@ data class AirPodsPro2Usbc(
payload: ProximityPayload
): ApplePods {
var basic = AirPodsPro2Usbc(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -36,7 +37,9 @@ data class BeatsFitPro(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -47,10 +50,10 @@ data class BeatsFitPro(
payload: ProximityPayload
): ApplePods {
var basic = BeatsFitPro(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -28,7 +29,9 @@ data class BeatsFlex(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -39,10 +42,10 @@ data class BeatsFlex(
payload: ProximityPayload
): ApplePods {
var basic = BeatsFlex(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -27,7 +28,9 @@ data class BeatsSolo3(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -38,10 +41,10 @@ data class BeatsSolo3(
payload: ProximityPayload
): ApplePods {
var basic = BeatsSolo3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -25,7 +26,9 @@ data class BeatsStudio3(
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -36,10 +39,10 @@ data class BeatsStudio3(
payload: ProximityPayload
): ApplePods {
var basic = BeatsStudio3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -28,7 +29,9 @@ data class BeatsX(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -39,10 +42,10 @@ data class BeatsX(
payload: ProximityPayload
): ApplePods {
var basic = BeatsX(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -28,7 +29,9 @@ data class PowerBeats3(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -39,10 +42,10 @@ data class PowerBeats3(
payload: ProximityPayload
): ApplePods {
var basic = PowerBeats3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,9 +4,10 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -29,7 +30,9 @@ data class PowerBeats4(
override val rssi: Int
get() = rssiAverage ?: super<SingleApplePods>.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().full == DEVICE_CODE && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -40,10 +43,10 @@ data class PowerBeats4(
payload: ProximityPayload
): ApplePods {
var basic = PowerBeats4(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -4,8 +4,9 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
@@ -36,7 +37,9 @@ data class PowerBeatsPro(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
getModelInfo().dirty == DEVICE_CODE_DIRTY && length == ProximityPairing.PAIRING_MESSAGE_LENGTH
@@ -47,10 +50,10 @@ data class PowerBeatsPro(
payload: ProximityPayload
): ApplePods {
var basic = PowerBeatsPro(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -0,0 +1,68 @@
package eu.darken.capod.pods.core.apple.history
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.collections.median
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Duration
import java.time.Instant
import kotlin.math.max
data class KnownDevice(
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<ApplePods>,
val lastCaseBattery: Float?,
) {
val lastPayload: ProximityPayload
get() = history.last().payload
val lastAddress: BluetoothAddress
get() = history.last().address
val reliability: Float
get() {
if (history.size < 2) return 0f
val now = Instant.now()
val pingInterval = List(history.dropLast(1).size) { index ->
Duration.between(history[index].seenLastAt, history[index + 1].seenLastAt).toMillis()
}.map { it.toInt() }.median()
val expectedPings: Float = LOOKBACK.toMillis() / pingInterval.toFloat()
val recentPings = history.filter {
Duration.between(it.seenLastAt, now).toMillis() < LOOKBACK.toMillis() + pingInterval
}.size
val reliability: Float = (recentPings / expectedPings)
// log("#####") { "interval=$pingInterval, expectedPerMinute=$expectedPings, count=$recentPings" }
return max(0.0f, reliability).coerceAtMost(1f)
}
fun rssiSmoothed(latest: Int): Int {
val now = Instant.now()
return history
.filter { Duration.between(it.seenLastAt, now) < LOOKBACK }
.map { it.rssi }
.plus(latest)
.median()
}
fun isOlderThan(age: Duration): Boolean {
val now = Instant.now()
return Duration.between(history.last().seenLastAt, now) > age
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
// 30 seconds at fastest interval
const val MAX_HISTORY = 60
val LOOKBACK = Duration.ofSeconds(30)
}
}
@@ -0,0 +1,178 @@
package eu.darken.capod.pods.core.apple.history
import eu.darken.capod.common.debug.logging.Logging.Priority.DEBUG
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.pods.core.HasCase
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import eu.darken.capod.pods.core.apple.protocol.RPAChecker
import java.time.Duration
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PodHistoryRepo @Inject constructor(
private val generalSettings: GeneralSettings,
private val rpaChecker: RPAChecker,
) {
private val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
data class Identifier(
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
private fun ProximityPayload.getFuzzyIdentifier(): Identifier = Identifier(
device = (((public.data[1].toInt() and 255) shl 8) or (public.data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(public.data[4].upperNibble, public.data[4].lowerNibble),
caseBatteryData = public.data[5].lowerNibble,
deviceColor = public.data[7]
)
fun DualApplePods.getCaseMatchMarkings() = SplitPodsMarkings(
leftPodBattery = batteryLeftPodPercent,
rightPodBattery = batteryRightPodPercent,
microPhoneLeft = isLeftPodMicrophone,
microPhoneRight = isRightPodMicrophone,
chargingLeft = isLeftPodCharging,
chargingRight = isRightPodCharging,
color = pubDeviceColor,
model = model
)
/**
* Split pods, one in case, one not.
*/
data class SplitPodsMarkings(
val leftPodBattery: Float?,
val rightPodBattery: Float?,
val microPhoneLeft: Boolean,
val microPhoneRight: Boolean,
val chargingLeft: Boolean,
val chargingRight: Boolean,
val color: UByte,
val model: PodDevice.Model,
)
fun search(current: ApplePods): KnownDevice? {
val basicResult = baseSearch(current)
val caseIgnored = if (current is DualApplePods) {
val target = current.getCaseMatchMarkings()
knownDevices.values.filter { known ->
known.history.filterIsInstance<DualApplePods>().any { it.getCaseMatchMarkings() == target }
}
} else {
emptySet()
}
log(TAG, DEBUG) { "search2: Case ignored matches(${caseIgnored.size}): $caseIgnored" }
return when (caseIgnored.size) {
0 -> basicResult
1 -> caseIgnored.single()
else -> {
log(TAG) { "search2: More than one result when ignoring case markers." }
val oldest = caseIgnored.maxByOrNull { it.history.size } ?: return null
caseIgnored.minus(oldest).forEach {
log(TAG) { "search2: Removing outlier: $it" }
knownDevices.remove(it.id)
}
oldest
}
}
}
private fun baseSearch(current: ApplePods): KnownDevice? {
val scanResult = current.scanResult
val payload = current.payload
// Timeout
knownDevices.values.toList()
.filter { it.isOlderThan(Duration.ofSeconds(30)) }
.forEach { knownDevice ->
log(TAG, VERBOSE) { "search1: Removing stale known device: $knownDevice" }
knownDevices.remove(knownDevice.id)
}
// Trim history
knownDevices.values
.filter { it.history.size > KnownDevice.MAX_HISTORY }
.toList()
.forEach { knownDevices[it.id] = it.copy(history = it.history.takeLast(KnownDevice.MAX_HISTORY)) }
var recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(TAG, VERBOSE) { "search1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
generalSettings.mainDeviceIdentityKey.value?.let { key ->
if (!rpaChecker.verify(current.address, key)) {
log(TAG, VERBOSE) { "search1: Current device does not match IRK" }
return@let
}
recognizedDevice = knownDevices.values
.firstOrNull { rpaChecker.verify(it.lastAddress, key) }
.also { log(TAG, VERBOSE) { "search1: Recovered previous ID via IRK: $it" } }
}
}
if (recognizedDevice == null) {
val currentMarkers = payload.getFuzzyIdentifier()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastPayload.getFuzzyIdentifier() == currentMarkers }
?.also { log(TAG) { "search1: Close match based on similarity: $currentMarkers" } }
}
if (recognizedDevice == null) log(TAG, WARN) { "search1: Didn't recognize: $current" }
return recognizedDevice
}
fun updateHistory(device: ApplePods) {
val existing = knownDevices[device.identifier]
fun Collection<ApplePods>.determineLatestCaseBattery(): Float? = this
.filterIsInstance<HasCase>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
knownDevices[device.identifier] = if (existing != null) {
val history = existing.history.plus(device)
existing.copy(
seenCounter = existing.seenCounter + 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery() ?: existing.lastCaseBattery
)
} else {
log(TAG) { "Creating new history for $device" }
val history = listOf(device)
KnownDevice(
id = device.identifier,
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = history,
lastCaseBattery = history.determineLatestCaseBattery()
)
}
}
companion object {
private val TAG = logTag("Pod", "Apple", "Factory", "History")
}
}
@@ -9,6 +9,7 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
@@ -39,7 +40,9 @@ data class FakeAirPodsGen1(
override val rssi: Int
get() = rssiAverage ?: super<DualApplePods>.rssi
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen1>(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -51,10 +54,10 @@ data class FakeAirPodsGen1(
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsGen1(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -9,6 +9,7 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
@@ -37,7 +38,9 @@ data class FakeAirPodsGen2(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen2>(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -49,10 +52,10 @@ data class FakeAirPodsGen2(
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsGen2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -9,6 +9,7 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
@@ -39,7 +40,9 @@ data class FakeAirPodsGen3(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsGen3>(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -51,10 +54,10 @@ data class FakeAirPodsGen3(
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsGen3(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -11,6 +11,7 @@ import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
@@ -57,7 +58,9 @@ data class FakeAirPodsPro(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro>(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -69,10 +72,10 @@ data class FakeAirPodsPro(
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsPro(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -13,6 +13,7 @@ import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
@@ -61,7 +62,9 @@ data class FakeAirPodsPro2(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePodsFactory<FakeAirPodsPro2>(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = message.run {
// Official message length is 19HEX, i.e. binary 25, did they copy this wrong?
@@ -73,10 +76,10 @@ data class FakeAirPodsPro2(
payload: ProximityPayload
): ApplePods {
var basic = FakeAirPodsPro2(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic
@@ -7,6 +7,7 @@ import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.ApplePodsFactory
import eu.darken.capod.pods.core.apple.history.PodHistoryRepo
import eu.darken.capod.pods.core.apple.protocol.ProximityMessage
import eu.darken.capod.pods.core.apple.protocol.ProximityPayload
import java.time.Instant
@@ -30,7 +31,9 @@ data class UnknownAppleDevice(
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
class Factory @Inject constructor(
private val repo: PodHistoryRepo,
) : ApplePodsFactory {
override fun isResponsible(message: ProximityMessage): Boolean = true
override fun create(
@@ -38,10 +41,10 @@ data class UnknownAppleDevice(
payload: ProximityPayload,
): ApplePods {
var basic = UnknownAppleDevice(scanResult = scanResult, payload = payload)
val result = searchHistory(basic)
val result = repo.search(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
repo.updateHistory(basic)
if (result == null) return basic