Show unknown devices of all kinds.

This commit is contained in:
darken
2022-01-31 22:23:11 +01:00
parent 72448e678f
commit 52415cea0d
9 changed files with 191 additions and 29 deletions
@@ -32,9 +32,9 @@ class OverviewAdapter @Inject constructor() :
modules.add(TypedVHCreatorMod({ data[it] is DualApplePodsCardVH.Item }) { DualApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is SingleApplePodsCardVH.Item }) { SingleApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BasicSingleApplePodsCardVH.Item }) { BasicSingleApplePodsCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is MissingMainDeviceVH.Item }) { MissingMainDeviceVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is BluetoothDisabledVH.Item }) { BluetoothDisabledVH(it) })
modules.add(TypedVHCreatorMod({ data[it] is UnknownPodDeviceCardVH.Item }) { UnknownPodDeviceCardVH(it) })
}
override fun getItemCount(): Int = data.size
@@ -6,6 +6,7 @@ import eu.darken.capod.R
import eu.darken.capod.common.lists.binding
import eu.darken.capod.databinding.OverviewPodsUnknownItemBinding
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.lastSeenFormatted
import java.time.Instant
@@ -28,10 +29,14 @@ class UnknownPodDeviceCardVH(parent: ViewGroup) :
}
lastSeen.text = device.lastSeenFormatted(item.now)
reception.text = item.getReceptionText()
rawdata.text = device.rawDataHex
details.text = when (item.device) {
is ApplePods -> getString(R.string.pods_unknown_contact_dev)
else -> getString(R.string.pods_unknown_label)
}
rawdata.text = device.rawDataHex.joinToString("\n")
}
data class Item(
@@ -40,10 +40,13 @@ interface PodDevice {
val signalQuality: Float
get() = ((100 - abs(rssi)) / 100f) * (max(BASE_CONFIDENCE, confidence))
val rawData: ByteArray
val rawData: Map<Int, ByteArray>
get() = scanResult.manufacturerSpecificData
val rawDataHex: String
get() = rawData.joinToString(separator = " ") { String.format("%02X", it) }
val rawDataHex: List<String>
get() = rawData.entries.map { entry ->
"${entry.key}: ${entry.value.joinToString(separator = " ") { String.format("%02X", it) }}"
}
fun getLabel(context: Context): String = model.label
@@ -2,32 +2,34 @@ package eu.darken.capod.pods.core
import dagger.Reusable
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
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.pods.core.apple.AppleFactory
import eu.darken.capod.pods.core.unknown.UnknownDeviceFactory
import javax.inject.Inject
@Reusable
class PodFactory @Inject constructor(
private val appleFactory: AppleFactory
private val appleFactory: AppleFactory,
private val unknownFactory: UnknownDeviceFactory,
) {
suspend fun createPod(scanResult: BleScanResult): PodDevice? {
log(TAG, VERBOSE) { "Trying to create Pod for $scanResult" }
val pod = appleFactory.create(scanResult)
if (pod != null) {
log(TAG) { "Pod created: $pod" }
return pod
} else {
log(TAG, WARN) { "Not an AirPod : $scanResult" }
log(TAG, INFO) { "Decoding $scanResult" }
var device = appleFactory.create(scanResult)
if (device == null) {
log(TAG, VERBOSE) { "Using fallback factory" }
device = unknownFactory.create(scanResult)
}
log(TAG, WARN) { "Failed to find matching PodFactory for $scanResult" }
return null
log(TAG, INFO) { "Pod created: $device" }
return device
}
companion object {
@@ -2,18 +2,15 @@ package eu.darken.capod.pods.core.apple
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.*
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import eu.darken.capod.pods.core.apple.airpods.*
import eu.darken.capod.pods.core.apple.beats.*
import eu.darken.capod.pods.core.apple.protocol.ContinuityProtocol
import eu.darken.capod.pods.core.apple.protocol.ProximityPairing
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.*
import javax.inject.Inject
import javax.inject.Singleton
@@ -55,8 +52,6 @@ class AppleFactory @Inject constructor(
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
val pm = getMessage(scanResult) ?: return@withLock null
log(TAG, INFO) { "Decoding $scanResult" }
val factory = podFactories.firstOrNull { it.isResponsible(pm) }
val device = (factory ?: unknownAppleFactory).create(
@@ -4,16 +4,12 @@ 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
interface ApplePods : PodDevice {
val proximityMessage: ProximityPairing.Message
override val rawData: ByteArray
get() = scanResult.getManufacturerSpecificData(ContinuityProtocol.APPLE_COMPANY_IDENTIFIER)!!
// We start counting at the airpods prefix byte
val rawPrefix: UByte
get() = proximityMessage.data[0]
@@ -0,0 +1,30 @@
package eu.darken.capod.pods.core.unknown
import android.content.Context
import eu.darken.capod.R
import eu.darken.capod.common.bluetooth.BleScanResult
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import java.time.Instant
data class UnknownDevice(
override val identifier: PodDevice.Id = PodDevice.Id(),
override val seenLastAt: Instant = Instant.now(),
override val seenFirstAt: Instant = Instant.now(),
override val seenCounter: Int = 1,
override val scanResult: BleScanResult,
override val confidence: Float = 0f,
private val rssiAverage: Int? = null,
) : PodDevice {
override val model: PodDevice.Model = PodDevice.Model.UNKNOWN
override fun getLabel(context: Context): String = context.getString(R.string.pods_unknown_label)
override val rssi: Int
get() = rssiAverage ?: super.rssi
companion object {
private val TAG = logTag("PodDevice", "Unknown")
}
}
@@ -0,0 +1,131 @@
package eu.darken.capod.pods.core.unknown
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.debug.logging.logTag
import eu.darken.capod.pods.core.PodDevice
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.time.Duration
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UnknownDeviceFactory @Inject constructor() {
private val lock = Mutex()
suspend fun create(scanResult: BleScanResult): PodDevice? = lock.withLock {
var basic = UnknownDevice(
identifier = PodDevice.Id(),
scanResult = scanResult,
)
val result = searchHistory(basic)
if (result != null) basic = basic.copy(identifier = result.id)
updateHistory(basic)
if (result == null) return basic
return basic.copy(
identifier = result.id,
seenFirstAt = result.seenFirstAt,
seenCounter = result.seenCounter,
confidence = result.confidence,
rssiAverage = result.averageRssi(basic.rssi),
)
}
data class KnownDevice(
val id: PodDevice.Id,
val seenFirstAt: Instant,
val seenCounter: Int,
val history: List<PodDevice>
) {
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).median()
fun isOlderThan(age: Duration): Boolean {
val now = Instant.now()
return Duration.between(history.last().seenLastAt, now) > age
}
private fun List<Int>.median(): Int = this.sorted().let {
if (it.size % 2 == 0)
(it[it.size / 2] + it[(it.size - 1) / 2]) / 2
else
it[it.size / 2]
}
override fun toString(): String = "KnownDevice(history=${history.size}, last=${history.last()})"
companion object {
const val MAX_HISTORY = 10
}
}
private val knownDevices = mutableMapOf<PodDevice.Id, KnownDevice>()
private fun searchHistory(current: PodDevice): KnownDevice? {
val scanResult = current.scanResult
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))
}
val recognizedDevice: KnownDevice? = knownDevices.values
.firstOrNull { it.lastAddress == scanResult.address }
?.also { log(TAG, VERBOSE) { "searchHistory1: Recovered previous ID via address: $it" } }
if (recognizedDevice == null) {
log(TAG) { "searchHistory1: Didn't recognize: $current" }
}
return recognizedDevice
}
private fun updateHistory(device: PodDevice) {
val existing = knownDevices[device.identifier]
knownDevices[device.identifier] = when {
existing != null -> {
existing.copy(
seenCounter = existing.seenCounter + 1,
history = existing.history.plus(device)
)
}
else -> {
log(TAG) { "searchHistory1: Creating new history for $device" }
KnownDevice(
id = device.identifier,
seenFirstAt = device.seenFirstAt,
seenCounter = 1,
history = listOf(device)
)
}
}
}
companion object {
private val TAG = logTag("Pod", "Unknown", "Factory")
}
}
@@ -70,7 +70,7 @@
tools:text="Yours (RSSI -61)" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/contact_dev_hint"
android:id="@+id/details"
style="@style/TextAppearance.MaterialComponents.Body2"
android:layout_width="0dp"
android:layout_height="wrap_content"
@@ -97,7 +97,7 @@
android:text="@string/pods_unknown_raw_data_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/contact_dev_hint" />
app:layout_constraintTop_toBottomOf="@id/details" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/rawdata"