Compare commits

..
11 Commits
Author SHA1 Message Date
darken adef0b39e9 Version bump (1.3.0) 2022-01-27 22:49:51 +01:00
darken af12a0e5cb Improved signal quality heuristic and value caching. 2022-01-27 22:45:47 +01:00
darken 90fc2fac24 Improve sorting 2022-01-27 00:46:06 +01:00
darken 3be7d8b43f Improve logging 2022-01-26 18:29:02 +01:00
darken a35d8c57b4 Improve device sorting and main device determination 2022-01-26 18:17:52 +01:00
darken f792431710 Fluff 2022-01-26 18:17:35 +01:00
darken 7ff20acfc0 When bluetooth is disabled, stop emissions. 2022-01-26 14:50:48 +01:00
darken 8e20e0e108 Don't report cancellation exceptions. 2022-01-26 14:33:56 +01:00
darken a6fda04640 Fix handling of Bluetooth enable/disable and restarting the monitor. 2022-01-26 13:11:01 +01:00
darken 0bbcfb5e88 Code fluff 2022-01-26 13:09:15 +01:00
darken e79ba6bfe5 Version bump (1.2.1) 2022-01-26 12:53:13 +01:00
36 changed files with 582 additions and 339 deletions
@@ -109,9 +109,6 @@ class BillingClientConnectionProvider @Inject constructor(
delay(3000 * attempt)
true
}
.catch {
log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" }
}
companion object {
val TAG: String = logTag("Upgrade", "Gplay", "Billing", "Client", "ConnectionProvider")
@@ -28,6 +28,7 @@ class BillingDataRepo @Inject constructor(
) {
private val connectionProvider = billingClientConnectionProvider.connection
.catch { log(TAG, ERROR) { "Unable to provide client connection:\n${it.asLog()}" } }
.replayingShare(scope)
val billingData: Flow<BillingData> = connectionProvider
@@ -72,8 +72,8 @@ class BleScanner @Inject constructor(
)
if (adapter.isOffloadedScanBatchingSupported) {
when (scannerMode) {
ScannerMode.LOW_POWER -> setReportDelay(5000)
ScannerMode.BALANCED -> setReportDelay(2500)
ScannerMode.LOW_POWER -> setReportDelay(2000)
ScannerMode.BALANCED -> setReportDelay(1000)
ScannerMode.LOW_LATENCY -> setReportDelay(500)
}
} else {
@@ -87,7 +87,7 @@ class BleScanner @Inject constructor(
adapter.bluetoothLeScanner.flushPendingScanResults(callback)
when (scannerMode) {
ScannerMode.LOW_POWER -> break
ScannerMode.BALANCED -> delay(2500)
ScannerMode.BALANCED -> delay(1000)
ScannerMode.LOW_LATENCY -> delay(500)
}
}
@@ -34,7 +34,7 @@ fun <T : Any> Flow<T>.shareLatest(
)
.filterNotNull()
fun <T : Any> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
fun <T : Any?> Flow<T>.replayingShare(scope: CoroutineScope) = this.shareIn(
scope = scope,
replay = 1,
started = SharingStarted.WhileSubscribed(replayExpiration = Duration.ZERO)
@@ -106,11 +106,10 @@ class OverviewFragmentVM @Inject constructor(
debugSettings.isDebugModeEnabled.flow,
generalSettings.showAll.flow,
bluetoothManager.isBluetoothEnabled,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled ->
podMonitor.mainDevice,
) { _, permissions, pods, isDebugMode, showAll, isBluetoothEnabled, mainPod ->
val items = mutableListOf<OverviewAdapter.Item>()
val mainPod = podMonitor.mainDevice.first()
permissions
.map { perm ->
PermissionCardVH.Item(
@@ -38,9 +38,8 @@ class DualApplePodsCardVH(parent: ViewGroup) :
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = device.getSignalQuality(context)
if (item.isMainPod) {
reception.append("\n(${getString(R.string.pods_yours)})")
reception.text = device.getSignalQuality(context).let {
if (item.isMainPod) "$it\n(${getString(R.string.pods_yours)})" else it
}
// Left Pod
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.bluetooth.BleScanner
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.coroutine.AppScope
@@ -9,9 +10,11 @@ import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.replayingShare
import eu.darken.capod.main.core.GeneralSettings
import eu.darken.capod.main.core.ScannerMode
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.PodFactory
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -32,35 +35,44 @@ class PodMonitor @Inject constructor(
private val deviceCache = mutableMapOf<PodDevice.Id, PodDevice>()
private val cacheLock = Mutex()
private suspend fun List<BleScanResult>.preFilterAndMap(
scannerMode: ScannerMode
): List<PodDevice> = this
.groupBy { it.address }
.values
.map { sameAdrDevs ->
// For each address we only want the newest result, upstream may batch data
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest
}
.mapNotNull { podFactory.createPod(it) }
val devices: Flow<List<PodDevice>> = bluetoothManager.isBluetoothEnabled
.flatMapLatest { isBluetoothEnabled ->
if (isBluetoothEnabled) {
log(TAG) { "Bluetooth is enabled" }
generalSettings.scannerMode.flow
.flatMapLatest { mode ->
bleScanner.scan(scannerMode = mode).map { it.preFilterAndMap(mode) }
}
} else {
log(TAG, WARN) { "Bluetooth is currently disabled" }
emptyFlow()
flowOf(null)
}
}
.flatMapLatest { bleScanner.scan(scannerMode = it) }
.map { result ->
// For each address we only want the newest result, upstream may batch data
result.groupBy { it.address }
.values
.map { sameAdrDevs ->
val newest = sameAdrDevs.maxByOrNull { it.generatedAtNanos }!!
sameAdrDevs.minus(newest).let {
if (it.isNotEmpty()) log(TAG, VERBOSE) { "Discarding stale results: $it" }
}
newest
}
}
.map { scanResults ->
val newPods = scanResults
.mapNotNull { podFactory.createPod(it) }
.map { newPods ->
val pods = mutableMapOf<PodDevice.Id, PodDevice>()
cacheLock.withLock {
if (newPods == null) {
log(TAG) { "Null result, Bluetooth is disabled." }
deviceCache.clear()
return@map emptyList()
}
val now = Instant.now()
deviceCache.toList().forEach { (key, value) ->
if (Duration.between(value.lastSeenAt, now) > Duration.ofSeconds(20)) {
@@ -79,40 +91,50 @@ class PodMonitor @Inject constructor(
val now = Instant.now()
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
val mainDeviceModel = generalSettings.mainDeviceModel.value
val aboveTresholdSorted = pods.values
.filter { it.signalQuality > minimumSignalQuality }
.sortedWith(
compareBy<PodDevice> { Duration.between(it.lastSeenAt, now) }.thenByDescending { it.rssi }
compareByDescending<PodDevice> { it.model == mainDeviceModel && it.model != PodDevice.Model.UNKNOWN }
.thenBy {
val age = Duration.between(it.lastSeenAt, now).seconds
if (age < 3) 0L else age
}
.thenByDescending { it.signalQuality }
)
val belowThresholdSorted = pods.values
.filter { it.signalQuality <= minimumSignalQuality }
.sortedWith(
compareBy<PodDevice> { Duration.between(it.lastSeenAt, now) }.thenByDescending { it.rssi }
compareBy<PodDevice> { Duration.between(it.lastSeenAt, now) }.thenByDescending { it.signalQuality }
)
val main = pods.values.determineMainDevice()
(aboveTresholdSorted + belowThresholdSorted).sortedByDescending { it == main }
val presorted = (aboveTresholdSorted + belowThresholdSorted)
val main = presorted.determineMainDevice()
presorted.sortedByDescending { it == main }
}
.onStart { emit(emptyList()) }
.catch {
log(TAG, ERROR) { "PodMonitor failed:\n${it.asLog()}" }
.retryWhen { cause, attempt ->
log(TAG, WARN) { "PodMonitor failed (attempt=$attempt), will retry: ${cause.asLog()}" }
delay(3000)
true
}
.replayingShare(appScope)
val mainDevice: Flow<PodDevice?>
get() = devices.map { it.determineMainDevice() }
get() = devices
.map { it.determineMainDevice() }
.replayingShare(appScope)
private fun Collection<PodDevice>.determineMainDevice(): PodDevice? =
maxByOrNull { it.rssi }?.let filter@{ device ->
val minimumSignalQuality = generalSettings.minimumSignalQuality.value
generalSettings.mainDeviceModel.value.let {
if (device.model != it && it != PodDevice.Model.UNKNOWN) return@filter null
}
if (device.signalQuality > minimumSignalQuality) device else null
private fun List<PodDevice>.determineMainDevice(): PodDevice? = this.firstOrNull()?.let {
val mainDeviceModel = generalSettings.mainDeviceModel.value
when {
it.model == PodDevice.Model.UNKNOWN -> null
mainDeviceModel != PodDevice.Model.UNKNOWN && it.model != mainDeviceModel -> null
it.signalQuality <= generalSettings.minimumSignalQuality.value -> null
else -> it
}
}
companion object {
private val TAG = logTag("Monitor", "PodMonitor")
@@ -21,6 +21,7 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.*
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.reaction.ReactionHub
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.delay
@@ -68,9 +69,13 @@ class MonitorWorker @AssistedInject constructor(
Result.success(inputData)
} catch (e: Throwable) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
if (e !is CancellationException) {
Bugs.report(tag = TAG, "Execution failed", exception = e)
finishedWithError = true
Result.failure(inputData)
} else {
Result.success()
}
} finally {
this.workerScope.cancel("Worker finished (withError?=$finishedWithError).")
}
@@ -9,6 +9,7 @@ import eu.darken.capod.common.bluetooth.BleScanResult
import java.time.Instant
import java.util.*
import kotlin.math.abs
import kotlin.math.max
interface PodDevice {
@@ -16,21 +17,24 @@ interface PodDevice {
val model: Model
val address: String
get() = scanResult.address
val lastSeenAt: Instant
val scanResult: BleScanResult
val rssiHistory: List<Int>
val rssi: Int
get() = rssiHistory.sum() / rssiHistory.size
get() = scanResult.rssi
val confidence: Float
/**
* This is not correct but it works ¯\_(ツ)_/¯
* The range of the RSSI is device specific (ROMs).
*/
val signalQuality: Float
get() = (100 - abs(rssi)) / 100f
get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
val rawData: ByteArray
@@ -93,4 +97,8 @@ interface PodDevice {
"Unknown"
);
}
companion object {
const val BASE_CONFIDENCE = 0.5f
}
}
@@ -21,7 +21,7 @@ import javax.inject.Singleton
class AppleFactory @Inject constructor(
private val continuityProtocolDecoder: ContinuityProtocol.Decoder,
private val proximityPairingDecoder: ProximityPairing.Decoder,
private val podFactories: @JvmSuppressWildcards Set<ApplePods.Factory>,
private val podFactories: @JvmSuppressWildcards Set<ApplePodsFactory<out ApplePods>>,
private val unknownAppleFactory: UnknownAppleDevice.Factory,
) {
@@ -12,17 +12,17 @@ import eu.darken.capod.pods.core.apple.beats.*
@Module
abstract class AppleFactoryModule {
@Binds @IntoSet abstract fun airPodsGen1(factory: AirPodsGen1.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen2(factory: AirPodsGen2.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsGen3(factory: AirPodsGen3.Factory): ApplePods.Factory
@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 airPodsPro(factory: AirPodsPro.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun airPodsPro(factory: AirPodsPro.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun airPodsMax(factory: AirPodsMax.Factory): ApplePodsFactory<out ApplePods>
@Binds @IntoSet abstract fun beatsFlex(factory: BeatsFlex.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsSolo3(factory: BeatsSolo3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsStudio3(factory: BeatsStudio3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun beatsX(factory: BeatsX.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeats3(factory: PowerBeats3.Factory): ApplePods.Factory
@Binds @IntoSet abstract fun powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePods.Factory
@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 powerBeatsPro(factory: PowerBeatsPro.Factory): ApplePodsFactory<out ApplePods>
}
@@ -1,17 +1,11 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
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.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.toHex
import eu.darken.capod.common.upperNibble
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
interface ApplePods : PodDevice {
@@ -55,103 +49,4 @@ interface ApplePods : PodDevice {
get() = proximityMessage.data[8]
abstract class Factory(private val tag: String) {
internal data class ValueCache(
val caseBatteryPercentage: Float?
)
internal val cachedValues = mutableMapOf<PodDevice.Id, ValueCache>()
internal data class KnownDevice(
val identifier: PodDevice.Id,
val scanResult: BleScanResult,
val message: ProximityPairing.Message,
val rssiHistory: List<Int> = emptyList()
) {
val address: String
get() = scanResult.address
val rssi: Int
get() = scanResult.rssi
val timestampNanos: Duration
get() = Duration.ofNanos(scanResult.generatedAtNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
}
private val knownDevs = mutableMapOf<PodDevice.Id, KnownDevice>()
internal fun recognizeDevice(scanResult: BleScanResult, message: ProximityPairing.Message): KnownDevice {
val address = scanResult.address
var recognizedDevice: KnownDevice? = knownDevs.values
.firstOrNull { it.address == address }
?.also { log(tag, VERBOSE) { "recognizeDevice: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
val currentMarkers = message.getRecogMarkers()
recognizedDevice = knownDevs.values
.firstOrNull { it.message.getRecogMarkers() == currentMarkers }
?.also {
log(tag, DEBUG) { "recognizeDevice: Close match based similarity markers." }
log(tag, DEBUG) { "recognizeDevice: Old marker: ${it.message.getRecogMarkers()}" }
log(tag, DEBUG) { "recognizeDevice: New marker: $currentMarkers" }
}
}
recognizedDevice = if (recognizedDevice == null) {
log(tag, VERBOSE) { "recognizeDevice: Mapping as new device" }
KnownDevice(
identifier = PodDevice.Id(),
scanResult = scanResult,
message = message,
rssiHistory = listOf(scanResult.rssi)
)
} else {
recognizedDevice.copy(
scanResult = scanResult,
message = message,
rssiHistory = recognizedDevice.rssiHistory
.toMutableList()
.let {
if (it.size > 2) it.removeAt(0)
it.plus(scanResult.rssi)
}
)
}
knownDevs[recognizedDevice.identifier] = recognizedDevice
knownDevs.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
log(tag, VERBOSE) { "recognizeDevice: Removing stale known device: $knownDevice" }
knownDevs.remove(knownDevice.identifier)
}
}
return recognizedDevice
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods
}
}
@@ -0,0 +1,136 @@
package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.SystemClockWrap
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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.PodDevice
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Duration
abstract class ApplePodsFactory<PodType : ApplePods>(private val tag: String) {
data class Markings(
val vendor: UByte,
val length: UByte,
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
fun ProximityPairing.Message.getApplePodsMarkings(): Markings = Markings(
vendor = ProximityPairing.CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = ProximityPairing.PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
caseBatteryData = data[5].lowerNibble,
deviceColor = data[7]
)
data class KnownDevice(
val id: PodDevice.Id,
val history: List<ApplePods>
) {
val lastMessage: ProximityPairing.Message
get() = history.last().proximityMessage
val lastAddress: String
get() = history.last().address
val confidence: Float
get() = 0.75f + (history.size / (MAX_HISTORY * 4f))
fun averageRssi(latest: Int): Int =
history.map { it.rssi }.plus(latest).takeLast(10).average().toInt()
val timestampNanos: Duration
get() = Duration.ofNanos(history.last().scanResult.generatedAtNanos)
fun isOlderThan(age: Duration): Boolean {
val now = Duration.ofNanos(SystemClockWrap.elapsedRealtimeNanos)
return now - timestampNanos > age
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 10
}
}
internal val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
internal open fun searchHistory(current: PodType): KnownDevice? {
val scanResult = current.scanResult
val message = current.proximityMessage
knownDevices.values.toList().forEach { knownDevice ->
if (knownDevice.isOlderThan(Duration.ofSeconds(20))) {
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 = it.history.takeLast(KnownDevice.MAX_HISTORY))
}
var recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(tag, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
val currentMarkers = message.getApplePodsMarkings()
recognizedDevice = knownDevices.values
.firstOrNull { it.lastMessage.getApplePodsMarkings() == currentMarkers }
?.also { log(tag) { "searchHistory1: Close match based on similarity: $currentMarkers" } }
}
if (recognizedDevice == null) {
log(tag) { "searchHistory1: Didn't recognize: $message" }
}
return recognizedDevice
}
fun updateHistory(device: PodType) {
val existing = knownDevices[device.identifier]
knownDevices[device.identifier] = when {
existing != null -> {
existing.copy(history = existing.history.plus(device))
}
else -> {
log(tag) { "searchHistory1: Creating new history for $device" }
KnownDevice(
id = device.identifier,
history = listOf(device)
)
}
}
}
data class ModelInfo(
val full: UShort,
val dirty: UByte,
)
fun ProximityPairing.Message.getModelInfo(): ModelInfo = ModelInfo(
full = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
dirty = data[1]
)
abstract fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean
abstract fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods
}
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple
abstract class BasicSingleApplePodsFactory(private val tag: String) : ApplePodsFactory<BasicSingleApplePods>(tag)
@@ -182,4 +182,5 @@ interface DualApplePods : ApplePods, HasDualPods, HasDualEarDetection, HasCase {
constructor(raw: Int, @StringRes labelRes: Int) : this(raw.toUByte(), labelRes)
}
}
@@ -0,0 +1,84 @@
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
import eu.darken.capod.pods.core.apple.airpods.AirPodsPro
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 = deviceColor,
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: DualApplePods.DeviceColor,
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 }
}
}
fun KnownDevice.getLatestCaseBattery(): Float? = history
.filterIsInstance<DualApplePods>()
.mapNotNull { it.batteryCasePercent }
.lastOrNull()
fun KnownDevice.getLatestCaseLidState(basic: DualApplePods): DualApplePods.LidState? {
val definitive = setOf(DualApplePods.LidState.OPEN, DualApplePods.LidState.CLOSED)
if (definitive.contains(basic.caseLidState)) return null
return history
.filterIsInstance<AirPodsPro>()
.lastOrNull { it.caseLidState != DualApplePods.LidState.NOT_IN_CASE }
?.caseLidState
}
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
}
}
}
}
@@ -0,0 +1,3 @@
package eu.darken.capod.pods.core.apple
abstract class SingleApplePodsFactory(private val tag: String) : ApplePodsFactory<SingleApplePods>(tag)
@@ -14,27 +14,35 @@ data class UnknownAppleDevice(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>
override val confidence: Float = 0f,
private val rssiAverage: Int? = null,
) : ApplePods {
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePodsFactory<ApplePods>(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean = true
override fun create(
scanResult: BleScanResult,
proximityMessage: ProximityPairing.Message,
): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = UnknownAppleDevice(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return UnknownAppleDevice(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
@@ -5,6 +5,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.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,8 +15,10 @@ data class AirPodsGen1 constructor(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN1
@@ -23,27 +26,32 @@ data class AirPodsGen1 constructor(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsGen1(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen1(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,6 +5,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.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,8 +15,10 @@ data class AirPodsGen2 constructor(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN2
@@ -23,27 +26,32 @@ data class AirPodsGen2 constructor(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsGen2(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen2(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,6 +5,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.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,35 +15,42 @@ data class AirPodsGen3 constructor(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_GEN3
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsGen3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsGen3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -5,6 +5,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.SingleApplePods
import eu.darken.capod.pods.core.apple.SingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,24 +15,32 @@ data class AirPodsMax(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : SingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_MAX
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : SingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = AirPodsMax(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return AirPodsMax(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,8 @@ 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.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.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,8 +16,10 @@ data class AirPodsPro(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.AIRPODS_PRO
@@ -23,33 +27,38 @@ data class AirPodsPro(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = AirPodsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = AirPodsPro(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro")
companion object {
private val DEVICE_CODE = 0x0e20.toUShort()
private val TAG = logTag("PodDevice", "Apple", "AirPods", "Pro", "Factory")
}
}
}
@@ -5,6 +5,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.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,24 +15,32 @@ data class BeatsFlex(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_FLEX
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsFlex(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsFlex(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,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.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,24 +15,31 @@ data class BeatsSolo3(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_SOLO_3
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsSolo3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsSolo3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,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.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,24 +15,29 @@ data class BeatsStudio3(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_STUDIO_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsStudio3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsStudio3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,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.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,24 +15,32 @@ data class BeatsX(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.BEATS_X
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = BeatsX(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return BeatsX(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
@@ -5,6 +5,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.BasicSingleApplePods
import eu.darken.capod.pods.core.apple.BasicSingleApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,23 +15,31 @@ data class PowerBeats3(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
) : BasicSingleApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_3
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : BasicSingleApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().full == DEVICE_CODE
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
val basic = PowerBeats3(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
return PowerBeats3(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
rssiHistory = recognized.rssiHistory,
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
}
@@ -5,6 +5,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.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePodsFactory
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import java.time.Instant
import javax.inject.Inject
@@ -14,8 +15,10 @@ data class PowerBeatsPro(
override val lastSeenAt: Instant = Instant.now(),
override val scanResult: BleScanResult,
override val proximityMessage: ProximityPairing.Message,
private val cachedBatteryPercentage: Float?,
override val rssiHistory: List<Int>,
override val confidence: Float = PodDevice.BASE_CONFIDENCE,
private val rssiAverage: Int? = null,
private val cachedBatteryPercentage: Float? = null,
private val cachedCaseState: DualApplePods.LidState? = null
) : DualApplePods {
override val model: PodDevice.Model = PodDevice.Model.POWERBEATS_PRO
@@ -23,27 +26,33 @@ data class PowerBeatsPro(
override val batteryCasePercent: Float?
get() = super.batteryCasePercent ?: cachedBatteryPercentage
class Factory @Inject constructor() : ApplePods.Factory(TAG) {
override val caseLidState: DualApplePods.LidState
get() = cachedCaseState ?: super.caseLidState
override val rssi: Int
get() = rssiAverage ?: super.rssi
class Factory @Inject constructor() : DualApplePodsFactory(TAG) {
override fun isResponsible(proximityMessage: ProximityPairing.Message): Boolean =
proximityMessage.getModelInfo().dirty == DEVICE_CODE_DIRTY
override fun create(scanResult: BleScanResult, proximityMessage: ProximityPairing.Message): ApplePods {
val recognized = recognizeDevice(scanResult, proximityMessage)
var basic = PowerBeatsPro(scanResult = scanResult, proximityMessage = proximityMessage)
val result = searchHistory(basic)
val device = PowerBeatsPro(
identifier = recognized.identifier,
scanResult = scanResult,
proximityMessage = proximityMessage,
cachedBatteryPercentage = cachedValues[recognized.identifier]?.caseBatteryPercentage,
rssiHistory = recognized.rssiHistory,
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
confidence = result.confidence,
cachedBatteryPercentage = result.getLatestCaseBattery(),
rssiAverage = result.averageRssi(basic.rssi),
cachedCaseState = result.getLatestCaseLidState(basic)
)
cachedValues[recognized.identifier] = ValueCache(
caseBatteryPercentage = device.batteryCasePercent
)
return device
}
}
@@ -3,8 +3,6 @@ package eu.darken.capod.pods.core.apple.protocol
import android.bluetooth.le.ScanFilter
import dagger.Reusable
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.lowerNibble
import eu.darken.capod.common.upperNibble
import javax.inject.Inject
@@ -15,24 +13,6 @@ object ProximityPairing {
val length: Int,
val data: UByteArray
) {
data class Markers(
val vendor: UByte,
val length: UByte,
val device: UShort,
val podBatteryData: Set<UShort>,
val caseBatteryData: UShort,
val deviceColor: UByte,
)
fun getRecogMarkers(): Markers = Markers(
vendor = CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING,
length = PROXIMITY_PAIRING_MESSAGE_LENGTH.toUByte(),
device = (((data[1].toInt() and 255) shl 8) or (data[2].toInt() and 255)).toUShort(),
// Make comparison order independent
podBatteryData = setOf(data[4].upperNibble, data[4].lowerNibble),
caseBatteryData = data[5].lowerNibble,
deviceColor = data[7]
)
override fun toString(): String {
val dataHex = data.joinToString(separator = " ") { String.format("%02X", it.toByte()) }
@@ -81,6 +61,6 @@ object ProximityPairing {
}
private const val CONTINUITY_PROTOCOL_MESSAGE_LENGTH = 27
private val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
private const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
internal val CONTINUITY_PROTOCOL_MESSAGE_TYPE_PROXIMITY_PAIRING = 0x07.toUByte()
internal const val PROXIMITY_PAIRING_MESSAGE_LENGTH = 25
}
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,5c-3.87,0 -7,3.13 -7,7h2c0,-2.76 2.24,-5 5,-5s5,2.24 5,5h2c0,-3.87 -3.13,-7 -7,-7zM13,14.29c0.88,-0.39 1.5,-1.26 1.5,-2.29 0,-1.38 -1.12,-2.5 -2.5,-2.5S9.5,10.62 9.5,12c0,1.02 0.62,1.9 1.5,2.29v3.3L7.59,21 9,22.41l3,-3 3,3L16.41,21 13,17.59v-3.3zM12,1C5.93,1 1,5.93 1,12h2c0,-4.97 4.03,-9 9,-9s9,4.03 9,9h2c0,-6.07 -4.93,-11 -11,-11z" />
</vector>
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -79,7 +79,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<androidx.constraintlayout.widget.Barrier
@@ -66,7 +66,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/reception"
app:layout_constraintTop_toTopOf="@+id/reception"
app:srcCompat="@drawable/ic_baseline_signal_cellular_alt_24"
app:srcCompat="@drawable/ic_baseline_settings_input_antenna_24"
tools:text="Yours (RSSI -61)" />
<com.google.android.material.textview.MaterialTextView
+1 -1
View File
@@ -7,7 +7,7 @@ buildscript {
'version' : [
'major': 1,
'minor': 2,
'minor': 3,
'patch': 0,
'build': 0,
],