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:screenOrientation="portrait"
|
||||
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>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -122,7 +122,13 @@ object Mdt {
|
||||
}
|
||||
text?.let { emit(BLOCK_TXT, 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 header = ByteArray(0x88)
|
||||
|
||||
@@ -131,6 +131,7 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
||||
synchronized(lock) {
|
||||
val enc = encoder
|
||||
val f = outPath
|
||||
var drainError: String? = null
|
||||
try {
|
||||
if (enc != null) {
|
||||
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)
|
||||
}
|
||||
} 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?.release() }
|
||||
@@ -150,7 +157,11 @@ class Mp4Recorder(private val width: Int = 320, private val height: Int = 240) {
|
||||
encoder = null
|
||||
runCatching { inputSurface?.release() }
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,12 +101,26 @@ class AnalyzeViewModel(
|
||||
probes.clear()
|
||||
probes.addAll(loaded)
|
||||
if (counts != null) {
|
||||
_minTempC.value = mn / 1000f
|
||||
_maxTempC.value = mx / 1000f
|
||||
// mn/mx are NUC COUNTS — they must go through the temperature
|
||||
// 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
|
||||
_maxPos.value = mxPos
|
||||
_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() }
|
||||
|
||||
// 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))) {
|
||||
// ---- slim title row ----
|
||||
Row(
|
||||
|
||||
@@ -55,6 +55,9 @@ fun PhotoViewerScreen(
|
||||
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)) {
|
||||
val b = bmp
|
||||
if (b != null) {
|
||||
|
||||
@@ -121,6 +121,46 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
private val sessionListener = object : IrSession.Listener {
|
||||
override fun onStateChanged(state: IrSession.State, message: String?) {
|
||||
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(
|
||||
connected = state == IrSession.State.STREAMING,
|
||||
streaming = state == IrSession.State.STREAMING,
|
||||
@@ -131,6 +171,10 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
override fun onFrameReady(argb: IntArray) {
|
||||
if (framesRcvd == 0) {
|
||||
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++
|
||||
latestFrame = argb
|
||||
@@ -151,9 +195,34 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
init {
|
||||
session.setListener(sessionListener)
|
||||
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() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -165,15 +234,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
// camera re-enumerates (power blip / reboot): release the dead session
|
||||
usbDetachReceiver = object : android.content.BroadcastReceiver() {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
val dev: android.hardware.usb.UsbDevice? = usbDeviceFrom(i)
|
||||
com.mag160c.thermal.media.DebugLog.log(
|
||||
"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) ----
|
||||
|
||||
/**
|
||||
* 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. */
|
||||
fun connect() {
|
||||
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")
|
||||
return
|
||||
}
|
||||
if (now < nextConnectAllowedMs) {
|
||||
com.mag160c.thermal.media.DebugLog.log(
|
||||
"vm",
|
||||
"connect() backed off for ${(nextConnectAllowedMs - now)} ms " +
|
||||
"(failures=$connectFailures)",
|
||||
)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
com.mag160c.thermal.media.DebugLog.log("vm", "connect()")
|
||||
val context = getApplication<Application>()
|
||||
val transport = com.mag160c.thermal.usb.UsbTransport(context)
|
||||
val dev = transport.findDevice()
|
||||
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")
|
||||
_state.value = _state.value.copy(
|
||||
connected = false,
|
||||
streaming = false,
|
||||
status = "no_device",
|
||||
)
|
||||
releaseConnectLock(generation)
|
||||
return
|
||||
}
|
||||
transport.requestPermission { ok ->
|
||||
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) {
|
||||
val ddt = runCatching { context.assets.open("mag160c.ddt").readBytes() }
|
||||
.getOrDefault(ByteArray(0))
|
||||
com.mag160c.thermal.media.DebugLog.log(
|
||||
"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)
|
||||
} else {
|
||||
com.mag160c.thermal.media.DebugLog.log("vm", "permission denied")
|
||||
_state.value = _state.value.copy(connected = false, status = "no_permission")
|
||||
connectInFlightUi = false
|
||||
releaseConnectLock(generation)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
com.mag160c.thermal.media.DebugLog.log("vm", "permission flow failed: $e")
|
||||
_state.value = _state.value.copy(connected = false, status = "connect_fail")
|
||||
connectInFlightUi = false
|
||||
releaseConnectLock(generation)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// logging/USB must never kill the UI (round-12 crash fix)
|
||||
com.mag160c.thermal.media.DebugLog.log("vm", "connect failed: $e")
|
||||
_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 triggerFfc() = session.triggerFfc()
|
||||
@@ -590,4 +791,16 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
||||
session.destroy()
|
||||
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 MAX_CALI_LEN = 104857600
|
||||
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)
|
||||
@@ -82,6 +90,10 @@ class IrSession(context: Context) {
|
||||
private var listener: Listener? = null
|
||||
private var pipeline: RenderPipeline? = null
|
||||
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 identity = CameraIdentity(1, 0, 160, 120, 15)
|
||||
|
||||
@@ -121,9 +133,22 @@ class IrSession(context: Context) {
|
||||
|
||||
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) {
|
||||
if (running.get()) return
|
||||
if (!connectInFlight.compareAndSet(false, true)) {
|
||||
DebugLog.log("session", "start ignored: a connect attempt is already in flight")
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
startInternal(ddtBytes)
|
||||
@@ -133,11 +158,26 @@ class IrSession(context: Context) {
|
||||
transport.close()
|
||||
running.set(false)
|
||||
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) {
|
||||
// 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)
|
||||
// one camera, one owner: a stale session holding the device would
|
||||
// otherwise be robbed by our claimInterface and both would stall
|
||||
@@ -145,6 +185,12 @@ class IrSession(context: Context) {
|
||||
if (prev !== this) {
|
||||
DebugLog.log("session", "stopping stale previous session")
|
||||
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()
|
||||
@@ -263,6 +309,8 @@ class IrSession(context: Context) {
|
||||
notify(State.STREAMING, null)
|
||||
notifyIdentity()
|
||||
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)
|
||||
if (!writeCmd(conn, epOut, MagProtocol.cmd4(MagProtocol.CMD_START_TRANSFER_IMG), "StartTransferImg")) {
|
||||
DebugLog.log("session", "START write failed")
|
||||
@@ -396,13 +444,23 @@ class IrSession(context: Context) {
|
||||
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 src = conn.controlTransfer(0x80, 0, 0, epAddr, st, 2, 100)
|
||||
val halted = if (src == 2) (st[0].toInt() and 0x01) else -1
|
||||
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(
|
||||
"usb",
|
||||
"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,
|
||||
) {
|
||||
val conn: UsbDeviceConnection = transport.connection() ?: return
|
||||
loopRunning.set(true)
|
||||
val stream = FrameStream(38400)
|
||||
val frameBuf = ByteArray(0x38 + 38400)
|
||||
val out = IntArray(320 * 240)
|
||||
@@ -487,6 +546,7 @@ class IrSession(context: Context) {
|
||||
var frameCount = 0
|
||||
var renderCount = 0
|
||||
var timeouts = 0
|
||||
var consecutiveFailures = 0
|
||||
val t0 = android.os.SystemClock.elapsedRealtime()
|
||||
var lastLog = t0
|
||||
var firstReads = 0
|
||||
@@ -496,24 +556,59 @@ class IrSession(context: Context) {
|
||||
val n = conn.bulkTransfer(epStream, tmp, tmp.size, TIMEOUT_MS)
|
||||
if (n <= 0) {
|
||||
timeouts++
|
||||
// usbfs marks the endpoint halted after a timed-out transfer;
|
||||
// every later transfer then fails instantly until cleared.
|
||||
diagnoseEndpoint(conn, epStream.address, timeouts)
|
||||
consecutiveFailures++
|
||||
// A halted/dead endpoint makes bulkTransfer fail INSTANTLY (the
|
||||
// 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()
|
||||
if (readCount == 0 && now - t0 > 10000 && !noDataNotified) {
|
||||
noDataNotified = true
|
||||
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) {
|
||||
DebugLog.log(
|
||||
"stream",
|
||||
"stats: reads=$readCount frames=$frameCount rendered=$renderCount " +
|
||||
"timeouts=$timeouts (no data yet)",
|
||||
"timeouts=$timeouts consecutive=$consecutiveFailures (no data yet)",
|
||||
)
|
||||
lastLog = now
|
||||
}
|
||||
continue
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
readCount++
|
||||
if (firstReads < 3) {
|
||||
DebugLog.log(
|
||||
@@ -576,6 +671,11 @@ class IrSession(context: Context) {
|
||||
if (active === this) active = null
|
||||
sendCmd(MagProtocol.cmd4(MagProtocol.CMD_STOP_TRANSFER_IMG), epOut, epResp, "StopTransferImg")
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -616,6 +716,25 @@ class IrSession(context: Context) {
|
||||
// (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() {
|
||||
stop()
|
||||
scope.cancel()
|
||||
|
||||
@@ -108,16 +108,17 @@ class UsbTransport(private val context: Context) {
|
||||
"usb",
|
||||
"claimed interface 0: endpoints=${intf.endpointCount} configs=${dev.configurationCount}",
|
||||
)
|
||||
// Prefer configuration 2 when the device exposes it (vendor behavior).
|
||||
if (dev.configurationCount > 1) {
|
||||
val cfg = dev.getConfiguration(1)
|
||||
// USB SET_CONFIGURATION request = 9
|
||||
val rc = conn.controlTransfer(0x00, 0x09, cfg?.id ?: 2, 0, null, 0, 500)
|
||||
com.mag160c.thermal.media.DebugLog.log(
|
||||
"usb",
|
||||
"setConfiguration(${cfg?.id ?: 2}) controlTransfer rc=$rc",
|
||||
)
|
||||
}
|
||||
// DO NOT send SET_CONFIGURATION here.
|
||||
//
|
||||
// This used to "prefer configuration 2 (vendor behavior)" by issuing a
|
||||
// USB SET_CONFIGURATION control request right after claiming the
|
||||
// interface. That is a device-level reset: it invalidates the claim and
|
||||
// makes the camera re-enumerate. Measured on a real phone: with the app
|
||||
// stopped the camera sat at /dev/bus/usb/002/071 for 30 s; the moment the
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,46 @@ class PhotoNucMappingTest {
|
||||
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
|
||||
fun photosWithoutNucAreReportedAsUnmeasurable() {
|
||||
// older files (and plain captures) must NOT be silently measurable: the
|
||||
|
||||
Reference in New Issue
Block a user