android: fix launchMode (singleTask) root cause of reconnect storm; drop SET_CONFIGURATION reset; fix analysis temp units, NUC block size, viewer back keys
This commit is contained in:
@@ -28,7 +28,19 @@
|
|||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="portrait"
|
android:screenOrientation="portrait"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode"
|
||||||
android:resizeableActivity="true">
|
android:resizeableActivity="true"
|
||||||
|
android:launchMode="singleTask">
|
||||||
|
<!-- launchMode=singleTask is REQUIRED here, not cosmetic.
|
||||||
|
This activity declares a USB_DEVICE_ATTACHED intent filter, and
|
||||||
|
MIUI re-broadcasts that attach event continuously while the camera
|
||||||
|
is plugged in. With the default (standard) mode each broadcast
|
||||||
|
started ANOTHER MainActivity instance — the device had two live
|
||||||
|
instances, each with its own ViewModel, IrSession and broadcast
|
||||||
|
receivers, all fighting over the same camera. That was the real
|
||||||
|
cause of the endless connect storm, the reconnect loop and the
|
||||||
|
camera re-enumerating every couple of seconds (device number
|
||||||
|
climbing 060 -> 093 in one session). The official app uses
|
||||||
|
launchMode=2 (singleTask) for exactly this reason. -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -122,7 +122,13 @@ object Mdt {
|
|||||||
}
|
}
|
||||||
text?.let { emit(BLOCK_TXT, it) }
|
text?.let { emit(BLOCK_TXT, it) }
|
||||||
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) }
|
probes?.let { if (it.isNotEmpty()) emit(BLOCK_PROBES, it) }
|
||||||
nucPixels?.let { if (it.size == 38400) emit(BLOCK_NUC, it) }
|
// The NUC block is 1:1 with the photo, so its size follows the photo
|
||||||
|
// dimensions (240x320 = 153600 bytes for the standard rotation) — the
|
||||||
|
// first version only accepted exactly 38400 and silently DROPPED the
|
||||||
|
// block, which is why the analysis screen had no data to measure.
|
||||||
|
nucPixels?.let {
|
||||||
|
if (it.size >= 38400 && it.size % 2 == 0) emit(BLOCK_NUC, it)
|
||||||
|
}
|
||||||
|
|
||||||
val bodyBytes = body.toByteArray()
|
val bodyBytes = body.toByteArray()
|
||||||
val header = ByteArray(0x88)
|
val header = ByteArray(0x88)
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
|||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val enc = encoder
|
val enc = encoder
|
||||||
val f = outPath
|
val f = outPath
|
||||||
|
var drainError: String? = null
|
||||||
try {
|
try {
|
||||||
if (enc != null) {
|
if (enc != null) {
|
||||||
val idx = enc.dequeueInputBuffer(10_000)
|
val idx = enc.dequeueInputBuffer(10_000)
|
||||||
@@ -140,7 +141,13 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
|||||||
drainLocked(enc, true)
|
drainLocked(enc, true)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
DebugLog.log("rec", "drain on stop failed: ${e.javaClass.simpleName}: ${e.message}")
|
// The codec can already be in an error state by the time recording
|
||||||
|
// stops (e.g. a surface frame lost during an FFC). Keeping the log
|
||||||
|
// explicit lets a later reader tell "the tail is truncated" from
|
||||||
|
// "the file is fine": the muxer below still finalises whatever was
|
||||||
|
// written, so the MP4 stays playable.
|
||||||
|
drainError = "${e.javaClass.simpleName}: ${e.message}"
|
||||||
|
DebugLog.log("rec", "drain on stop failed (file kept): $drainError")
|
||||||
}
|
}
|
||||||
runCatching { muxer?.stop() }
|
runCatching { muxer?.stop() }
|
||||||
runCatching { muxer?.release() }
|
runCatching { muxer?.release() }
|
||||||
@@ -150,7 +157,11 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
|||||||
encoder = null
|
encoder = null
|
||||||
runCatching { inputSurface?.release() }
|
runCatching { inputSurface?.release() }
|
||||||
inputSurface = null
|
inputSurface = null
|
||||||
DebugLog.log("rec", "stopped: frames=$frameCount dropped=$droppedCount file=${f?.length()}")
|
DebugLog.log(
|
||||||
|
"rec",
|
||||||
|
"stopped: frames=$frameCount dropped=$droppedCount file=${f?.length()}" +
|
||||||
|
(drainError?.let { " (drain error, tail may be short)" } ?: ""),
|
||||||
|
)
|
||||||
return f
|
return f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,12 +101,26 @@ class AnalyzeViewModel(
|
|||||||
probes.clear()
|
probes.clear()
|
||||||
probes.addAll(loaded)
|
probes.addAll(loaded)
|
||||||
if (counts != null) {
|
if (counts != null) {
|
||||||
_minTempC.value = mn / 1000f
|
// mn/mx are NUC COUNTS — they must go through the temperature
|
||||||
_maxTempC.value = mx / 1000f
|
// curve like any other sample. The first version divided them
|
||||||
|
// by 1000 instead, so the panel showed 9.2/10.4 C for a
|
||||||
|
// 22.6-32.9 C scene while the centre readout (which did use
|
||||||
|
// the curve) was right.
|
||||||
|
_minTempC.value = TempMath.countsToTempMc(mn) / 1000f
|
||||||
|
_maxTempC.value = TempMath.countsToTempMc(mx) / 1000f
|
||||||
_minPos.value = mnPos
|
_minPos.value = mnPos
|
||||||
_maxPos.value = mxPos
|
_maxPos.value = mxPos
|
||||||
_centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120)
|
_centerTempC.value = measure(bmp?.width?.div(2) ?: 160, bmp?.height?.div(2) ?: 120)
|
||||||
}
|
}
|
||||||
|
// Log what the panel shows: the Compose text is drawn on a canvas
|
||||||
|
// and never appears in the view hierarchy, so a field log is the
|
||||||
|
// only way to verify the numbers from adb.
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"analyze",
|
||||||
|
"loaded ${bmp?.width}x${bmp?.height} measurable=$hasTemperatureData " +
|
||||||
|
"min=${_minTempC.value} max=${_maxTempC.value} " +
|
||||||
|
"center=${_centerTempC.value} probes=${loaded.size}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ fun AnalyzeViewer(
|
|||||||
|
|
||||||
LaunchedEffect(Unit) { vm.load() }
|
LaunchedEffect(Unit) { vm.load() }
|
||||||
|
|
||||||
|
// System back leaves the viewer instead of exiting the app. The viewer is a
|
||||||
|
// full-screen overlay, so without this the user could not get out with the
|
||||||
|
// back gesture (reported on device).
|
||||||
|
androidx.activity.compose.BackHandler(enabled = true) { onClose() }
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) {
|
Column(modifier = Modifier.fillMaxSize().background(Color(0xFF101014))) {
|
||||||
// ---- slim title row ----
|
// ---- slim title row ----
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ fun PhotoViewerScreen(
|
|||||||
vm.fullImage(item) { b -> bmp = b }
|
vm.fullImage(item) { b -> bmp = b }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System back closes the viewer rather than leaving the app
|
||||||
|
androidx.activity.compose.BackHandler(enabled = true) { onClose() }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
val b = bmp
|
val b = bmp
|
||||||
if (b != null) {
|
if (b != null) {
|
||||||
|
|||||||
@@ -121,6 +121,46 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private val sessionListener = object : IrSession.Listener {
|
private val sessionListener = object : IrSession.Listener {
|
||||||
override fun onStateChanged(state: IrSession.State, message: String?) {
|
override fun onStateChanged(state: IrSession.State, message: String?) {
|
||||||
com.mag160c.thermal.media.DebugLog.log("vm", "session state $state msg=$message")
|
com.mag160c.thermal.media.DebugLog.log("vm", "session state $state msg=$message")
|
||||||
|
when (state) {
|
||||||
|
IrSession.State.STREAMING -> {
|
||||||
|
// healthy again: clear the retry backoff.
|
||||||
|
//
|
||||||
|
// NOTE: connectInFlightUi and the connect lock are deliberately
|
||||||
|
// NOT released here. IrSession reports STREAMING before its
|
||||||
|
// `running` flag is set, so clearing them now opened a window
|
||||||
|
// where BOTH guards read false and another ATTACHED broadcast
|
||||||
|
// started a second session — which tore down the live one
|
||||||
|
// ("stopping stale previous session"), resetting the camera into
|
||||||
|
// a re-enumeration cascade. They are released when frames
|
||||||
|
// actually flow (see onFrameReady) or when the attempt ends.
|
||||||
|
connectFailures = 0
|
||||||
|
nextConnectAllowedMs = 0
|
||||||
|
}
|
||||||
|
IrSession.State.ERROR -> {
|
||||||
|
connectInFlightUi = false
|
||||||
|
releaseConnectLock(generationOfAttempt)
|
||||||
|
connectFailures++
|
||||||
|
val delayMs = minOf(10_000L, 500L shl minOf(connectFailures, 5))
|
||||||
|
nextConnectAllowedMs = android.os.SystemClock.elapsedRealtime() + delayMs
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm", "next connect in ${delayMs} ms (failures=$connectFailures)",
|
||||||
|
)
|
||||||
|
// Exactly one retry may be pending at a time. Retrying is what
|
||||||
|
// recovers a camera that rebooted itself; without it the UI sat
|
||||||
|
// on "no_handshake" forever after a single failure.
|
||||||
|
if (retryJob?.isActive != true) {
|
||||||
|
retryJob = viewModelScope.launch {
|
||||||
|
kotlinx.coroutines.delay(delayMs)
|
||||||
|
if (!session.isStreaming()) connect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IrSession.State.IDLE -> {
|
||||||
|
connectInFlightUi = false
|
||||||
|
releaseConnectLock(generationOfAttempt)
|
||||||
|
}
|
||||||
|
else -> {} // LINKING: an attempt is under way
|
||||||
|
}
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
connected = state == IrSession.State.STREAMING,
|
connected = state == IrSession.State.STREAMING,
|
||||||
streaming = state == IrSession.State.STREAMING,
|
streaming = state == IrSession.State.STREAMING,
|
||||||
@@ -131,6 +171,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
override fun onFrameReady(argb: IntArray) {
|
override fun onFrameReady(argb: IntArray) {
|
||||||
if (framesRcvd == 0) {
|
if (framesRcvd == 0) {
|
||||||
com.mag160c.thermal.media.DebugLog.log("vm", "first rendered frame -> UI")
|
com.mag160c.thermal.media.DebugLog.log("vm", "first rendered frame -> UI")
|
||||||
|
// frames are flowing: the attempt truly succeeded, so a later
|
||||||
|
// ATTACHED broadcast may start a fresh attempt if this one dies
|
||||||
|
connectInFlightUi = false
|
||||||
|
releaseConnectLock(generationOfAttempt)
|
||||||
}
|
}
|
||||||
framesRcvd++
|
framesRcvd++
|
||||||
latestFrame = argb
|
latestFrame = argb
|
||||||
@@ -151,9 +195,34 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
init {
|
init {
|
||||||
session.setListener(sessionListener)
|
session.setListener(sessionListener)
|
||||||
val ctx = getApplication<Application>()
|
val ctx = getApplication<Application>()
|
||||||
// auto permission + start when the camera is plugged in while running
|
// Auto-connect when the CAMERA is plugged in. The first version reacted to
|
||||||
|
// every ACTION_USB_DEVICE_ATTACHED without checking the vendor: on MIUI the
|
||||||
|
// system re-broadcasts attach events (and other USB devices appear), so a
|
||||||
|
// healthy stream kept being "reconfigured" — the heartbeat log showed the
|
||||||
|
// state flapping between streaming and no_device about once a second while
|
||||||
|
// frames were arriving normally.
|
||||||
usbReceiver = object : android.content.BroadcastReceiver() {
|
usbReceiver = object : android.content.BroadcastReceiver() {
|
||||||
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
|
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
|
||||||
|
val dev: android.hardware.usb.UsbDevice? = usbDeviceFrom(i)
|
||||||
|
val vid = dev?.vendorId ?: 0
|
||||||
|
if (vid != 0x833C) {
|
||||||
|
// some other USB device (or an event we cannot attribute):
|
||||||
|
// never disturb the camera session for it
|
||||||
|
return
|
||||||
|
}
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm", "usb attached vid=0x%04X".format(java.util.Locale.US, vid),
|
||||||
|
)
|
||||||
|
// The camera is already there and working: this broadcast is not
|
||||||
|
// news. MIUI repeats ATTACHED roughly every 2 s for a device that
|
||||||
|
// is simply plugged in (measured on device), and acting on each one
|
||||||
|
// tore down a healthy stream over and over.
|
||||||
|
if (session.isStreaming() || connectInFlightUi) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm", "attach ignored: session already up/starting",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
connect()
|
connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,15 +234,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
// camera re-enumerates (power blip / reboot): release the dead session
|
// camera re-enumerates (power blip / reboot): release the dead session
|
||||||
usbDetachReceiver = object : android.content.BroadcastReceiver() {
|
usbDetachReceiver = object : android.content.BroadcastReceiver() {
|
||||||
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
|
override fun onReceive(c: android.content.Context?, i: android.content.Intent?) {
|
||||||
val dev: android.hardware.usb.UsbDevice? = if (android.os.Build.VERSION.SDK_INT >= 33) {
|
val dev: android.hardware.usb.UsbDevice? = usbDeviceFrom(i)
|
||||||
i?.getParcelableExtra(
|
|
||||||
android.hardware.usb.UsbManager.EXTRA_DEVICE,
|
|
||||||
android.hardware.usb.UsbDevice::class.java,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
i?.getParcelableExtra(android.hardware.usb.UsbManager.EXTRA_DEVICE)
|
|
||||||
}
|
|
||||||
com.mag160c.thermal.media.DebugLog.log(
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
"vm", "usb detached vid=0x%04X".format(java.util.Locale.US, dev?.vendorId ?: 0),
|
"vm", "usb detached vid=0x%04X".format(java.util.Locale.US, dev?.vendorId ?: 0),
|
||||||
)
|
)
|
||||||
@@ -222,6 +283,83 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
|
|
||||||
// ---- LAN remote preview host (Phase F) ----
|
// ---- LAN remote preview host (Phase F) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoff after failed connects. On the device, a dead endpoint made every
|
||||||
|
* attempt fail instantly, and the attach broadcast + UI effect kept calling
|
||||||
|
* connect() — the log showed ~10 attempts/second at 107% CPU. Each failure
|
||||||
|
* now doubles the wait (up to 10 s), so a broken camera costs a retry, not a
|
||||||
|
* spin loop. A success resets it.
|
||||||
|
*/
|
||||||
|
private var connectFailures = 0
|
||||||
|
private var nextConnectAllowedMs = 0L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monotonic attempt id. The USB permission callback fires LATER and is not
|
||||||
|
* covered by the debounce above, so a stale callback (from an attempt that
|
||||||
|
* has since been superseded) used to call session.start() directly — that is
|
||||||
|
* how the device ended up with several sessions racing. Only the newest
|
||||||
|
* attempt may start.
|
||||||
|
*/
|
||||||
|
private var connectGeneration = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True from the moment a connect is kicked off until its session is up or it
|
||||||
|
* has failed. [IrSession.isStreaming] only becomes true once frames flow, so
|
||||||
|
* without this the repeated ATTACHED broadcasts slip through the gap (measured:
|
||||||
|
* the state went LINKING -> STREAMING -> torn down by the next broadcast,
|
||||||
|
* forever, never stabilising).
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var connectInFlightUi = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One retry job at a time. Without this, every failure scheduled another
|
||||||
|
* retry while older ones were still pending, so several connect() calls ran
|
||||||
|
* concurrently, each fighting for the same USB device (device log showed
|
||||||
|
* "GetParameter1 write=-1" from two different threads at the same instant).
|
||||||
|
*/
|
||||||
|
private var retryJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialises connect attempts for the whole process.
|
||||||
|
*
|
||||||
|
* The flags above cannot fully cover it: the guard is tested on the main
|
||||||
|
* thread, connect() then suspends on permission/USB work, and the ATTACHED
|
||||||
|
* broadcast is delivered on the same main thread — so an interleaving still
|
||||||
|
* got through and two sessions raced for the camera (device log showed three
|
||||||
|
* concurrent LINKING/STREAMING sequences, each ending in no_handshake and a
|
||||||
|
* re-enumeration). A mutex makes the whole attempt atomic.
|
||||||
|
*
|
||||||
|
* Held from the start of an attempt until it RESOLVES (first frame, error, or
|
||||||
|
* permission refusal) — see [connectOwnerGeneration].
|
||||||
|
*/
|
||||||
|
private val connectMutex = kotlinx.coroutines.sync.Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generation of the attempt that owns [connectMutex]. A stale attempt's ERROR
|
||||||
|
* (which arrives after a newer attempt has already started) must NOT release
|
||||||
|
* the lock that the newer attempt now holds — releasing it was exactly how the
|
||||||
|
* reconnect storm kept restarting.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var connectOwnerGeneration = -1
|
||||||
|
|
||||||
|
/** Release the lock only if [generation] still owns it. */
|
||||||
|
private fun releaseConnectLock(generation: Int) {
|
||||||
|
if (generation == connectOwnerGeneration && connectMutex.isLocked) {
|
||||||
|
connectOwnerGeneration = -1
|
||||||
|
runCatching { connectMutex.unlock() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generation of the attempt the SESSION events belong to. Set when an attempt
|
||||||
|
* starts and used by the listener so that a late ERROR/IDLE from an older
|
||||||
|
* attempt cannot release a lock now owned by a newer one.
|
||||||
|
*/
|
||||||
|
@Volatile
|
||||||
|
private var generationOfAttempt = -1
|
||||||
|
|
||||||
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
|
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
|
||||||
fun connect() {
|
fun connect() {
|
||||||
val now = android.os.SystemClock.elapsedRealtime()
|
val now = android.os.SystemClock.elapsedRealtime()
|
||||||
@@ -229,46 +367,109 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
com.mag160c.thermal.media.DebugLog.log("vm", "connect() debounced")
|
com.mag160c.thermal.media.DebugLog.log("vm", "connect() debounced")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (now < nextConnectAllowedMs) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm",
|
||||||
|
"connect() backed off for ${(nextConnectAllowedMs - now)} ms " +
|
||||||
|
"(failures=$connectFailures)",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
lastConnectMs = now
|
lastConnectMs = now
|
||||||
|
// MIUI broadcasts ATTACHED several times within milliseconds (measured:
|
||||||
|
// 3 events in 60 ms). Each one used to build a NEW session, and starting a
|
||||||
|
// session takes seconds, so they all raced. Refuse to start another while
|
||||||
|
// one is already up; the in-flight case is covered by IrSession's gate.
|
||||||
|
if (session.isStreaming()) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "connect() skipped: already streaming")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (connectInFlightUi) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "connect() skipped: attempt in flight")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectInFlightUi = true
|
||||||
|
val generation = ++connectGeneration
|
||||||
|
// Hold the lock until the attempt RESOLVES (streaming or failed), not just
|
||||||
|
// until this function returns: the USB permission callback is asynchronous,
|
||||||
|
// so releasing early let a second attempt start while the first was still
|
||||||
|
// working — three sessions then raced for the camera and every one ended in
|
||||||
|
// no_handshake, sending the device into a re-enumeration loop.
|
||||||
|
if (!connectMutex.tryLock()) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log("vm", "connect() skipped: attempt already running")
|
||||||
|
connectInFlightUi = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
connectOwnerGeneration = generation
|
||||||
|
generationOfAttempt = generation
|
||||||
try {
|
try {
|
||||||
com.mag160c.thermal.media.DebugLog.log("vm", "connect()")
|
com.mag160c.thermal.media.DebugLog.log("vm", "connect()")
|
||||||
val context = getApplication<Application>()
|
val context = getApplication<Application>()
|
||||||
val transport = com.mag160c.thermal.usb.UsbTransport(context)
|
val transport = com.mag160c.thermal.usb.UsbTransport(context)
|
||||||
val dev = transport.findDevice()
|
val dev = transport.findDevice()
|
||||||
if (dev == null) {
|
if (dev == null) {
|
||||||
|
// do NOT report no_device while a session is running: the device
|
||||||
|
// list can transiently miss the camera during a re-enumeration,
|
||||||
|
// and overwriting the status made the UI claim "no camera" while
|
||||||
|
// frames were arriving (seen on device)
|
||||||
|
connectInFlightUi = false
|
||||||
|
if (session.isStreaming()) {
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm", "no_device ignored: a session is already streaming",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
com.mag160c.thermal.media.DebugLog.log("vm", "no_device")
|
com.mag160c.thermal.media.DebugLog.log("vm", "no_device")
|
||||||
_state.value = _state.value.copy(
|
_state.value = _state.value.copy(
|
||||||
connected = false,
|
connected = false,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
status = "no_device",
|
status = "no_device",
|
||||||
)
|
)
|
||||||
|
releaseConnectLock(generation)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
transport.requestPermission { ok ->
|
transport.requestPermission { ok ->
|
||||||
try {
|
try {
|
||||||
|
if (generation != connectGeneration) {
|
||||||
|
// superseded by a newer attempt: drop this callback
|
||||||
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
|
"vm", "stale permission callback (gen=$generation) ignored",
|
||||||
|
)
|
||||||
|
return@requestPermission
|
||||||
|
}
|
||||||
if (ok) {
|
if (ok) {
|
||||||
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
||||||
.getOrDefault(ByteArray(0))
|
.getOrDefault(ByteArray(0))
|
||||||
com.mag160c.thermal.media.DebugLog.log(
|
com.mag160c.thermal.media.DebugLog.log(
|
||||||
"vm", "permission ok, ddt ${ddt.size} bytes, starting session",
|
"vm", "permission ok, ddt ${ddt.size} bytes, starting session",
|
||||||
)
|
)
|
||||||
|
// NOTE: the connect lock stays held here — the session is
|
||||||
|
// only really up once frames arrive (onFrameReady releases
|
||||||
|
// it) or it fails (the ERROR branch releases it).
|
||||||
session.start(ddt)
|
session.start(ddt)
|
||||||
} else {
|
} else {
|
||||||
com.mag160c.thermal.media.DebugLog.log("vm", "permission denied")
|
com.mag160c.thermal.media.DebugLog.log("vm", "permission denied")
|
||||||
_state.value = _state.value.copy(connected = false, status = "no_permission")
|
_state.value = _state.value.copy(connected = false, status = "no_permission")
|
||||||
|
connectInFlightUi = false
|
||||||
|
releaseConnectLock(generation)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
com.mag160c.thermal.media.DebugLog.log("vm", "permission flow failed: $e")
|
com.mag160c.thermal.media.DebugLog.log("vm", "permission flow failed: $e")
|
||||||
_state.value = _state.value.copy(connected = false, status = "connect_fail")
|
_state.value = _state.value.copy(connected = false, status = "connect_fail")
|
||||||
|
connectInFlightUi = false
|
||||||
|
releaseConnectLock(generation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// logging/USB must never kill the UI (round-12 crash fix)
|
// logging/USB must never kill the UI (round-12 crash fix)
|
||||||
com.mag160c.thermal.media.DebugLog.log("vm", "connect failed: $e")
|
com.mag160c.thermal.media.DebugLog.log("vm", "connect failed: $e")
|
||||||
_state.value = _state.value.copy(connected = false, status = "connect_fail")
|
_state.value = _state.value.copy(connected = false, status = "connect_fail")
|
||||||
|
connectMutex.unlock()
|
||||||
|
connectInFlightUi = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Release the connect lock once the attempt has resolved. */
|
||||||
fun disconnect() = session.stop()
|
fun disconnect() = session.stop()
|
||||||
|
|
||||||
fun triggerFfc() = session.triggerFfc()
|
fun triggerFfc() = session.triggerFfc()
|
||||||
@@ -590,4 +791,16 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
session.destroy()
|
session.destroy()
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Attach/detach intent -> USB device (older and newer API shapes). */
|
||||||
|
private fun usbDeviceFrom(i: android.content.Intent?): android.hardware.usb.UsbDevice? =
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 33) {
|
||||||
|
i?.getParcelableExtra(
|
||||||
|
android.hardware.usb.UsbManager.EXTRA_DEVICE,
|
||||||
|
android.hardware.usb.UsbDevice::class.java,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
i?.getParcelableExtra(android.hardware.usb.UsbManager.EXTRA_DEVICE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ class IrSession(context: Context) {
|
|||||||
private const val MIN_CALI_LEN = 65536
|
private const val MIN_CALI_LEN = 65536
|
||||||
private const val MAX_CALI_LEN = 104857600
|
private const val MAX_CALI_LEN = 104857600
|
||||||
private const val CALI_NO_DATA_LIMIT_MS = 5000
|
private const val CALI_NO_DATA_LIMIT_MS = 5000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive failed stream reads before the session gives up. With the
|
||||||
|
* backoff schedule this is roughly a minute of a dead link — long enough
|
||||||
|
* to survive a camera reboot, short enough that a zombie session does not
|
||||||
|
* block the next connection.
|
||||||
|
*/
|
||||||
|
private const val FAILURE_LIMIT = 2000
|
||||||
}
|
}
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
@@ -82,6 +90,10 @@ class IrSession(context: Context) {
|
|||||||
private var listener: Listener? = null
|
private var listener: Listener? = null
|
||||||
private var pipeline: RenderPipeline? = null
|
private var pipeline: RenderPipeline? = null
|
||||||
private val running = AtomicBoolean(false)
|
private val running = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** True while the stream reader thread is alive (for hand-over waits). */
|
||||||
|
private val loopRunning = AtomicBoolean(false)
|
||||||
|
|
||||||
private var streaming = false
|
private var streaming = false
|
||||||
private var identity = CameraIdentity(1, 0, 160, 120, 15)
|
private var identity = CameraIdentity(1, 0, 160, 120, 15)
|
||||||
|
|
||||||
@@ -121,9 +133,22 @@ class IrSession(context: Context) {
|
|||||||
|
|
||||||
fun identitySnapshot(): CameraIdentity = identity.copy()
|
fun identitySnapshot(): CameraIdentity = identity.copy()
|
||||||
|
|
||||||
/** Connect + start the live stream. Must be called after USB permission. */
|
/**
|
||||||
|
* Connect + start the live stream. Must be called after USB permission.
|
||||||
|
*
|
||||||
|
* Re-entrancy: the guard used to be [running], but that flag is only set once
|
||||||
|
* the stream is up — so a FAILING connect could be re-entered without limit.
|
||||||
|
* On the real device that produced a reconnect storm (~10 sessions/second,
|
||||||
|
* 107% CPU): each attempt failed on a dead endpoint, the caller retried, and
|
||||||
|
* the previous reader thread was still spinning. A dedicated flag now covers
|
||||||
|
* the whole connect attempt, and a failed attempt must clear it.
|
||||||
|
*/
|
||||||
fun start(ddtBytes: ByteArray) {
|
fun start(ddtBytes: ByteArray) {
|
||||||
if (running.get()) return
|
if (running.get()) return
|
||||||
|
if (!connectInFlight.compareAndSet(false, true)) {
|
||||||
|
DebugLog.log("session", "start ignored: a connect attempt is already in flight")
|
||||||
|
return
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
startInternal(ddtBytes)
|
startInternal(ddtBytes)
|
||||||
@@ -133,11 +158,26 @@ class IrSession(context: Context) {
|
|||||||
transport.close()
|
transport.close()
|
||||||
running.set(false)
|
running.set(false)
|
||||||
notify(State.ERROR, "exception:${e.javaClass.simpleName}")
|
notify(State.ERROR, "exception:${e.javaClass.simpleName}")
|
||||||
|
} finally {
|
||||||
|
// cleared when the stream loop takes over (or the attempt failed),
|
||||||
|
// so the next genuine connect is not blocked
|
||||||
|
if (!running.get()) connectInFlight.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while a connect attempt is in flight (guards against storms). */
|
||||||
|
private val connectInFlight = AtomicBoolean(false)
|
||||||
|
|
||||||
private fun startInternal(bundledDdt: ByteArray) {
|
private fun startInternal(bundledDdt: ByteArray) {
|
||||||
|
// Never disturb a session that is already streaming. Callers can be
|
||||||
|
// trigger-happy (Android re-broadcasts ATTACHED while the camera is simply
|
||||||
|
// plugged in), and replacing a working session sends the camera through a
|
||||||
|
// reset/re-enumeration for no reason.
|
||||||
|
if (running.get()) {
|
||||||
|
DebugLog.log("session", "start ignored: already streaming")
|
||||||
|
return
|
||||||
|
}
|
||||||
notify(State.LINKING, null)
|
notify(State.LINKING, null)
|
||||||
// one camera, one owner: a stale session holding the device would
|
// one camera, one owner: a stale session holding the device would
|
||||||
// otherwise be robbed by our claimInterface and both would stall
|
// otherwise be robbed by our claimInterface and both would stall
|
||||||
@@ -145,6 +185,12 @@ class IrSession(context: Context) {
|
|||||||
if (prev !== this) {
|
if (prev !== this) {
|
||||||
DebugLog.log("session", "stopping stale previous session")
|
DebugLog.log("session", "stopping stale previous session")
|
||||||
prev.stop()
|
prev.stop()
|
||||||
|
// WAIT for the old reader to actually exit before claiming the
|
||||||
|
// camera. stop() only flips a flag, and with the new backoff the
|
||||||
|
// old loop may still be sleeping — a second live reader on the
|
||||||
|
// same endpoints makes both fail (observed on device: one healthy
|
||||||
|
// stream plus one "no data yet" zombie reporting forever).
|
||||||
|
prev.awaitStopped(2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val dev = transport.findDevice()
|
val dev = transport.findDevice()
|
||||||
@@ -263,6 +309,8 @@ class IrSession(context: Context) {
|
|||||||
notify(State.STREAMING, null)
|
notify(State.STREAMING, null)
|
||||||
notifyIdentity()
|
notifyIdentity()
|
||||||
running.set(true)
|
running.set(true)
|
||||||
|
// the stream loop now owns the session lifecycle; connectInFlight is
|
||||||
|
// released in start()'s finally because running is set by then
|
||||||
Thread.sleep(50)
|
Thread.sleep(50)
|
||||||
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) {
|
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) {
|
||||||
DebugLog.log("session", "START write failed")
|
DebugLog.log("session", "START write failed")
|
||||||
@@ -396,13 +444,23 @@ class IrSession(context: Context) {
|
|||||||
listener?.onIdentity(identitySnapshot())
|
listener?.onIdentity(identitySnapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Log endpoint status + clear a possible halt (usbfs timeout artifact). */
|
/**
|
||||||
private fun diagnoseEndpoint(conn: UsbDeviceConnection, epAddr: Int, failureCount: Int = 0) {
|
* Log endpoint status + clear a possible halt (usbfs timeout artifact).
|
||||||
|
* Two control transfers per call, so a caller polling a dead endpoint must
|
||||||
|
* pass [quiet] to stop the log spam (the transfers still happen — clearing a
|
||||||
|
* halt is the only way back).
|
||||||
|
*/
|
||||||
|
private fun diagnoseEndpoint(
|
||||||
|
conn: UsbDeviceConnection,
|
||||||
|
epAddr: Int,
|
||||||
|
failureCount: Int = 0,
|
||||||
|
quiet: Boolean = false,
|
||||||
|
) {
|
||||||
val st = ByteArray(2)
|
val st = ByteArray(2)
|
||||||
val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100)
|
val src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100)
|
||||||
val halted = if (src == 2) (st[0].toInt() and 0x01) else -1
|
val halted = if (src == 2) (st[0].toInt() and 0x01) else -1
|
||||||
val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100)
|
val clr = conn.controlTransfer(0x02, 1, 0, epAddr, null, 0, 100)
|
||||||
if (failureCount <= 5 || failureCount % 100 == 0) {
|
if (!quiet && (failureCount <= 5 || failureCount % 100 == 0)) {
|
||||||
DebugLog.log(
|
DebugLog.log(
|
||||||
"usb",
|
"usb",
|
||||||
"ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d".format(
|
"ep 0x%02X fail#$failureCount get_status rc=%d halted=%d clear_halt rc=%d".format(
|
||||||
@@ -476,6 +534,7 @@ class IrSession(context: Context) {
|
|||||||
epResp: UsbEndpoint,
|
epResp: UsbEndpoint,
|
||||||
) {
|
) {
|
||||||
val conn: UsbDeviceConnection = transport.connection() ?: return
|
val conn: UsbDeviceConnection = transport.connection() ?: return
|
||||||
|
loopRunning.set(true)
|
||||||
val stream = FrameStream(38400)
|
val stream = FrameStream(38400)
|
||||||
val frameBuf = ByteArray(0x38 + 38400)
|
val frameBuf = ByteArray(0x38 + 38400)
|
||||||
val out = IntArray(320 * 240)
|
val out = IntArray(320 * 240)
|
||||||
@@ -487,6 +546,7 @@ class IrSession(context: Context) {
|
|||||||
var frameCount = 0
|
var frameCount = 0
|
||||||
var renderCount = 0
|
var renderCount = 0
|
||||||
var timeouts = 0
|
var timeouts = 0
|
||||||
|
var consecutiveFailures = 0
|
||||||
val t0 = android.os.SystemClock.elapsedRealtime()
|
val t0 = android.os.SystemClock.elapsedRealtime()
|
||||||
var lastLog = t0
|
var lastLog = t0
|
||||||
var firstReads = 0
|
var firstReads = 0
|
||||||
@@ -496,24 +556,59 @@ class IrSession(context: Context) {
|
|||||||
val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS)
|
val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS)
|
||||||
if (n <= 0) {
|
if (n <= 0) {
|
||||||
timeouts++
|
timeouts++
|
||||||
// usbfs marks the endpoint halted after a timed-out transfer;
|
consecutiveFailures++
|
||||||
// every later transfer then fails instantly until cleared.
|
// A halted/dead endpoint makes bulkTransfer fail INSTANTLY (the
|
||||||
diagnoseEndpoint(conn, epStream.address, timeouts)
|
// 800 ms timeout is not spent), so this loop would spin at ~1000
|
||||||
|
// iterations/second, each doing two control transfers inside
|
||||||
|
// diagnoseEndpoint — measured on a real device: 28000 "fail#"
|
||||||
|
// lines and 136% CPU while the camera was re-enumerating. Back
|
||||||
|
// off: try hard to recover for the first few attempts, then poll
|
||||||
|
// slowly (the reader keeps running so a replugged camera is
|
||||||
|
// picked up, but it no longer burns the battery).
|
||||||
|
val backoffMs = when {
|
||||||
|
consecutiveFailures <= 3 -> 0L // immediate: may be a stale halt
|
||||||
|
consecutiveFailures <= 100 -> 20L // fast recovery window (~2 s)
|
||||||
|
else -> 250L // dead link: idle poll
|
||||||
|
}
|
||||||
|
// Only run the (costly) endpoint diagnosis while it can help
|
||||||
|
diagnoseEndpoint(
|
||||||
|
conn, epStream.address, timeouts,
|
||||||
|
quiet = consecutiveFailures > 100,
|
||||||
|
)
|
||||||
|
if (backoffMs > 0) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(backoffMs)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
val now = android.os.SystemClock.elapsedRealtime()
|
val now = android.os.SystemClock.elapsedRealtime()
|
||||||
if (readCount == 0 && now - t0 > 10000 && !noDataNotified) {
|
if (readCount == 0 && now - t0 > 10000 && !noDataNotified) {
|
||||||
noDataNotified = true
|
noDataNotified = true
|
||||||
notify(State.STREAMING, "no_stream_data")
|
notify(State.STREAMING, "no_stream_data")
|
||||||
}
|
}
|
||||||
|
// Give up on a link that has been dead for a full minute: the
|
||||||
|
// camera is gone (or wedged), and a zombie session holding the
|
||||||
|
// device would block a fresh one after replug.
|
||||||
|
if (consecutiveFailures == FAILURE_LIMIT) {
|
||||||
|
DebugLog.log(
|
||||||
|
"stream",
|
||||||
|
"giving up after $consecutiveFailures consecutive failures " +
|
||||||
|
"(${now - t0} ms) -> closing session",
|
||||||
|
)
|
||||||
|
notify(State.ERROR, "stream_dead")
|
||||||
|
break
|
||||||
|
}
|
||||||
if (now - lastLog >= 2000) {
|
if (now - lastLog >= 2000) {
|
||||||
DebugLog.log(
|
DebugLog.log(
|
||||||
"stream",
|
"stream",
|
||||||
"stats: reads=$readCount frames=$frameCount rendered=$renderCount " +
|
"stats: reads=$readCount frames=$frameCount rendered=$renderCount " +
|
||||||
"timeouts=$timeouts (no data yet)",
|
"timeouts=$timeouts consecutive=$consecutiveFailures (no data yet)",
|
||||||
)
|
)
|
||||||
lastLog = now
|
lastLog = now
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
consecutiveFailures = 0
|
||||||
readCount++
|
readCount++
|
||||||
if (firstReads < 3) {
|
if (firstReads < 3) {
|
||||||
DebugLog.log(
|
DebugLog.log(
|
||||||
@@ -576,6 +671,11 @@ class IrSession(context: Context) {
|
|||||||
if (active === this) active = null
|
if (active === this) active = null
|
||||||
sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg")
|
sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg")
|
||||||
transport.close()
|
transport.close()
|
||||||
|
// release the connect gate so a fresh session (e.g. after a replug or a
|
||||||
|
// recovered camera) is not blocked by this finished one
|
||||||
|
running.set(false)
|
||||||
|
connectInFlight.set(false)
|
||||||
|
loopRunning.set(false)
|
||||||
notify(State.IDLE, null)
|
notify(State.IDLE, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -616,6 +716,25 @@ class IrSession(context: Context) {
|
|||||||
// (StopTransferImg -> close -> IDLE)
|
// (StopTransferImg -> close -> IDLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block until the reader loop has finished (or [timeoutMs] elapses).
|
||||||
|
* Used when a new session takes over: the old reader must be gone before the
|
||||||
|
* new one claims the endpoints, otherwise both compete for the same URBs.
|
||||||
|
*/
|
||||||
|
private fun awaitStopped(timeoutMs: Long) {
|
||||||
|
val deadline = android.os.SystemClock.elapsedRealtime() + timeoutMs
|
||||||
|
while (loopRunning.get() && android.os.SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(20)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loopRunning.get()) {
|
||||||
|
DebugLog.log("session", "previous reader did not exit within ${timeoutMs} ms")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun destroy() {
|
fun destroy() {
|
||||||
stop()
|
stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|||||||
@@ -108,16 +108,17 @@ class UsbTransport(private val context: Context) {
|
|||||||
"usb",
|
"usb",
|
||||||
"claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}",
|
"claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}",
|
||||||
)
|
)
|
||||||
// Prefer configuration 2 when the device exposes it (vendor behavior).
|
// DO NOT send SET_CONFIGURATION here.
|
||||||
if (dev.configurationCount > 1) {
|
//
|
||||||
val cfg = dev.getConfiguration(1)
|
// This used to "prefer configuration 2 (vendor behavior)" by issuing a
|
||||||
// USB SET_CONFIGURATION request = 9
|
// USB SET_CONFIGURATION control request right after claiming the
|
||||||
val rc = conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500)
|
// interface. That is a device-level reset: it invalidates the claim and
|
||||||
com.mag160c.thermal.media.DebugLog.log(
|
// makes the camera re-enumerate. Measured on a real phone: with the app
|
||||||
"usb",
|
// stopped the camera sat at /dev/bus/usb/002/071 for 30 s; the moment the
|
||||||
"setConfiguration(${cfg?.id ?: 2}) controlTransfer rc=$rc",
|
// app started, the device number climbed 071 -> 077 while every command
|
||||||
)
|
// failed (write=-1). The official app never sends it either — its
|
||||||
}
|
// UsbCommunication only calls claimInterface (see
|
||||||
|
// analysis/sdk_re/android_app/jadx_magcx/.../UsbCommunication.java:234).
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,46 @@ class PhotoNucMappingTest {
|
|||||||
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
|
for (i in counts.indices) assertEquals("sample $i", counts[i], back[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression for the on-device failure (2026-09-12): the real NUC block is
|
||||||
|
* 1:1 with the PHOTO (240x320 = 153600 B), but compose() accepted only exactly
|
||||||
|
* 38400 bytes and silently dropped anything else — so every photo taken with
|
||||||
|
* the live orientation came out unmeasurable while the capture log still said
|
||||||
|
* "nuc=yes".
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun fullSizePhotoNucBlockIsStored() {
|
||||||
|
val photoCounts = PhotoSaver.buildPhotoOrderedCounts(
|
||||||
|
IntArray(19200) { it }, PhotoSaver.Orientation(90, false, false),
|
||||||
|
)
|
||||||
|
assertEquals("240x320 photo", 240 * 320, photoCounts.size)
|
||||||
|
val bytes = PhotoSaver.countsToBytes(photoCounts)
|
||||||
|
assertEquals("two bytes per photo pixel", 153600, bytes.size)
|
||||||
|
|
||||||
|
val mdt = Mdt.compose(
|
||||||
|
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
|
||||||
|
info0 = null, info1 = null, framePixels = ByteArray(38400),
|
||||||
|
nucPixels = bytes,
|
||||||
|
)
|
||||||
|
val parsed = Mdt.parse(mdt)!!
|
||||||
|
assertTrue(
|
||||||
|
"a 153600-byte block must be stored, not silently dropped",
|
||||||
|
parsed.hasTemperatureData,
|
||||||
|
)
|
||||||
|
assertEquals(153600, parsed.nucPixels!!.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oddSizedNucBlocksAreRejected() {
|
||||||
|
// a malformed length would corrupt the u16 lookup; it must not be stored
|
||||||
|
val mdt = Mdt.compose(
|
||||||
|
jpg = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte(), 0xD9.toByte()),
|
||||||
|
info0 = null, info1 = null, framePixels = null,
|
||||||
|
nucPixels = ByteArray(100),
|
||||||
|
)
|
||||||
|
assertTrue("too small -> dropped", !Mdt.parse(mdt)!!.hasTemperatureData)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun photosWithoutNucAreReportedAsUnmeasurable() {
|
fun photosWithoutNucAreReportedAsUnmeasurable() {
|
||||||
// older files (and plain captures) must NOT be silently measurable: the
|
// older files (and plain captures) must NOT be silently measurable: the
|
||||||
|
|||||||
Binary file not shown.
@@ -489,6 +489,73 @@
|
|||||||
"未连接"分支显示,正常出图时用户看不到任何反馈)。
|
"未连接"分支显示,正常出图时用户看不到任何反馈)。
|
||||||
- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。
|
- [x] 单测 66 → **76 项全绿**;debug + release(R8) 双构建通过;APK 已更新。
|
||||||
|
|
||||||
|
## 用户反馈修复 第二十一轮(2026-09-12,无线 adb 真机调试:根因是 launchMode)
|
||||||
|
|
||||||
|
**本轮最大的发现**:前几轮反复出现的"连接风暴/重连循环/相机每 2 秒重枚举"
|
||||||
|
(设备号 060→093 一路涨、CPU 136%、状态在 streaming/no_device 间抖动)
|
||||||
|
**根因是 `MainActivity` 少了 `android:launchMode="singleTask"`**。
|
||||||
|
|
||||||
|
- Manifest 给 MainActivity 声明了 `USB_DEVICE_ATTACHED` intent-filter,而
|
||||||
|
**MIUI 在相机插着时会持续重复广播该事件**。默认 `standard` 启动模式下,
|
||||||
|
每次广播都创建一个**新的 MainActivity 实例**——真机 `dumpsys` 实测同时存在
|
||||||
|
**2 个实例**,各自持有自己的 ViewModel、IrSession、广播接收器,全部争抢同一台
|
||||||
|
相机;谁被挤掉谁就重新握手,于是相机被反复复位→重枚举。
|
||||||
|
- 官方 App 的 manifest 正是 `launchMode=2 (singleTask)`(aapt 实测)。
|
||||||
|
- 加上 `singleTask` 后实测:Activity 实例 1 个、**设备号 65 恒定 40 秒不变**、
|
||||||
|
状态稳定 streaming、fps 15.1、CPU 归零。
|
||||||
|
- 排查过程中曾按症状加过多层防御(connect 互斥锁、代际守卫、attach 去重、
|
||||||
|
失败退避+自动重试、stream 循环退避)。这些**已一并保留**:它们各自修掉了
|
||||||
|
真实存在的小问题(见下),但都不是那个根因;根因只有 launchMode 一处。
|
||||||
|
|
||||||
|
**本轮真机(小米 22041211AC / Android 12 / MIUI)实测通过的功能**:
|
||||||
|
|
||||||
|
- [x] 出流:冷启动一次成功,15fps、ref=true、timeouts=0
|
||||||
|
- [x] **拍照**:`capture: nuc=yes rot=90 saved=true`;文件入 `DCIM/MAG160C/`
|
||||||
|
- [x] **照片含正确温度数据**:NUC 块 153600B / 76800 样本(照片分辨率 1:1)、
|
||||||
|
零空洞、温度 22.6~32.9℃(均值 25.9℃)——不再有 -161℃/145℃
|
||||||
|
- [x] **录像**:开始→停止**不闪退**,MP4 2.2MB / 134 帧,`moov` box 完整可播
|
||||||
|
- [x] **分析页温度正确**:`min=22.606 max=32.877 center=24.683`,
|
||||||
|
与文件真值**逐位吻合**
|
||||||
|
- [x] **分析页加测温点 + 另存新照片**:生成 `_edit.jpg`,含 PROBES 块
|
||||||
|
(`116,102,Pt1,24674` / `150,169,Pt2,24838`,数值合理)+ NUC 块保留
|
||||||
|
- [x] **相册**:列出 4 张 → 点开全屏查看器(返回/删除)→ 返回键回列表 →
|
||||||
|
删除确认对话框 → 删除后文件 4→3、列表同步
|
||||||
|
- [x] **分析 tab 只列可测温照片**(旧的无 NUC 照片被过滤,符合设计)
|
||||||
|
|
||||||
|
**本轮顺带修掉的真实缺陷**(都在真机上复现过):
|
||||||
|
|
||||||
|
1. `UsbTransport.open()` 在 `claimInterface` 之后发 **SET_CONFIGURATION**——
|
||||||
|
这是设备级复位,会让相机立刻重新枚举。官方代码从不发(仅 claimInterface)。
|
||||||
|
**已删除**。(实测:app 停止时相机 30 秒稳定不动,启动后设备号立刻开始爬升。)
|
||||||
|
2. 分析页 `_minTempC.value = mn / 1000f`——`mn` 是 NUC **counts** 却被当温度,
|
||||||
|
显示 9.2/10.4℃(centre 走了正确路径所以是对的)。已改为过温度曲线。
|
||||||
|
3. `Mdt.compose` 只接受 `nucPixels.size == 38400`,而真实块是照片分辨率
|
||||||
|
(153600)→ **静默丢弃**,而 capture 日志仍打 `nuc=yes` 掩盖了它。
|
||||||
|
已改为按尺寸下限校验。
|
||||||
|
4. 三个全屏查看器(相册查看器、分析查看器)**都没有 BackHandler**——
|
||||||
|
返回键直接退出 app。已补。
|
||||||
|
5. USB attach 广播未校验 VID,任何 USB 事件都会触发 `connect()`;MIUI 重复
|
||||||
|
广播时把健康会话的状态覆盖成 `no_device`(画面在跑却显示"未检测到热像仪")。
|
||||||
|
已改为只认 0x833C 且已有会话时忽略。
|
||||||
|
6. `streamLoop` 在端点 halt 后 `bulkTransfer` **立即返回失败**(不等超时),
|
||||||
|
循环以约 1000 次/秒空转、每次两次控制传输——实测 28000 条失败日志、
|
||||||
|
**CPU 136%**。已加三级退避(0/20/250ms)+ 日志静默 + 死链约 1 分钟后收尾。
|
||||||
|
7. `connect()` 失败后无人重试(backoff 只拦截、不重发),UI 会永远停在
|
||||||
|
`no_handshake`。已加单实例自动重试。
|
||||||
|
8. `Mp4Recorder.stop()` 的收尾 drain 异常现在被记录说明(`file kept`),
|
||||||
|
不再让人误以为文件没生成;实测该异常下 MP4 仍完整可播。
|
||||||
|
|
||||||
|
**调试方法记录**(供后续排查):
|
||||||
|
- 设备端日志:`/sdcard/Download/MAG160C/debug_*.log.txt`(DCIM 被 MIUI 拒收
|
||||||
|
text/plain,自动回退到 Download),可 `adb pull` 取回
|
||||||
|
- MIUI 禁止 adb 注入输入(`INJECT_EVENTS`),但设备有 **Magisk root**:
|
||||||
|
`adb shell su -c 'input tap X Y'` 可以,`uiautomator dump` 配合读控件坐标
|
||||||
|
- 相机是否在重枚举:`dumpsys usb | grep -oE 'bus/usb/002/[0-9]+'` 连续采样看
|
||||||
|
设备号是否变化——这是判断"是硬件问题还是我们代码问题"的最快手段
|
||||||
|
(app 停止时稳定、启动后爬升 = 我们的代码在复位设备)
|
||||||
|
- Activity 实例数:`dumpsys activity activities | grep 'Activities=\['`
|
||||||
|
出现两个 MainActivity = launchMode 问题
|
||||||
|
|
||||||
## 用户反馈修复 第二十轮(2026-09-11,第三轮真机:分析测温/标注/相册/界面)
|
## 用户反馈修复 第二十轮(2026-09-11,第三轮真机:分析测温/标注/相册/界面)
|
||||||
|
|
||||||
用户第三轮实机测试(含截图)报出以下问题,本轮全部处理:
|
用户第三轮实机测试(含截图)报出以下问题,本轮全部处理:
|
||||||
|
|||||||
Reference in New Issue
Block a user