diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index ab4f946..b702ab4 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -12,7 +12,6 @@
-
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt
index 54bffc3..243298e 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/core/RenderPipeline.kt
@@ -56,6 +56,26 @@ class RenderPipeline(
private var refCnt = 0
private var startupFfcDone = false
+ /** Raw counts scratch used while collecting FFC reference frames. */
+ private val refScratch = IntArray(npix)
+
+ /**
+ * True once at least one frame completed the full render path. Until then
+ * [copyNuc]/[probeTemp] must not be trusted: before the first render `nuc`
+ * still holds zeros, and countsToTempMc(0) is about -161 C, which the UI
+ * would happily display. Also false while an FFC window is running, so the
+ * OSD freezes instead of showing the shutter-closed data.
+ */
+ @Volatile
+ private var renderedOnce = false
+
+ /** Last phase returned by the FFC state machine: 0 normal, 1 hidden, 2 reference. */
+ @Volatile
+ private var lastPhase = 0
+
+ /** True while [frameRemote] is inside the host's reference window. */
+ private var remoteRefWindow = false
+
private val ref = IntArray(npix)
private val refAcc = IntArray(npix)
private val nuc = IntArray(npix)
@@ -599,7 +619,10 @@ class RenderPipeline(
*/
fun frame(frame: ByteArray, hasHdr: Boolean, outArgb: IntArray): Boolean = synchronized(lock) {
globalFrames++
- if (globalFrames < warmFrames) return false
+ if (globalFrames < warmFrames) {
+ lastPhase = 1
+ return false
+ }
var shutterValue = shutter
if (hasHdr) {
@@ -611,11 +634,15 @@ class RenderPipeline(
shutter = shutterValue
val st = ffcStep(shutterValue)
+ lastPhase = st
if (st != 0) {
if (st == 2) {
- val off = if (hasHdr) 0x1C else 0
- ByteReader.decodeU16(frame, off, npix, nuc)
- refPush(nuc)
+ // collect the reference in a SCRATCH buffer: decoding into `nuc`
+ // would leave raw (uncompensated) counts there, and the OSD
+ // samples `nuc` on a timer — that is what made the max/min
+ // readouts jump to ~150 C for a moment during every FFC.
+ ByteReader.decodeU16(frame, if (hasHdr) 0x1C else 0, npix, refScratch)
+ refPush(refScratch)
}
return false
}
@@ -636,9 +663,73 @@ class RenderPipeline(
outArgb[i] = pal[gray320[i].toInt() and 0xFF]
i++
}
+ renderedOnce = true
return true
}
+ /**
+ * Process one raw frame on behalf of a REMOTE host (LAN preview client).
+ *
+ * The host streams its raw sensor frames plus the metadata its own pipeline
+ * used, so the client reproduces the host's image instead of running an
+ * independent (and easily desynchronised) FFC state machine:
+ * - [phase] is the host's FFC phase for this frame (see [ffcPhase]);
+ * - [shutterIn] is the frame's camera temperature, required for the NUC
+ * table interpolation — without it the endpoint tables extrapolate
+ * wildly and every count saturates.
+ *
+ * Reference frames are averaged exactly like the host does, so the client's
+ * noise reference matches the host's.
+ */
+ fun frameRemote(frame: ByteArray, phase: Int, shutterIn: Int, outArgb: IntArray): Boolean =
+ synchronized(lock) {
+ shutter = shutterIn
+ lastPhase = phase
+ when (phase) {
+ 1 -> return false // shutter closed / settling: hold the last frame
+ 2 -> {
+ if (!remoteRefWindow) {
+ remoteRefWindow = true
+ refCnt = 0
+ hasRef = false
+ renderedOnce = false
+ rebuildTables(shutterIn)
+ }
+ ByteReader.decodeU16(frame, 0, npix, refScratch)
+ refPush(refScratch)
+ return false
+ }
+ else -> {
+ remoteRefWindow = false
+ if (!hasRef) return false
+ ByteReader.decodeU16(frame, 0, npix, nuc)
+ nucAndBlind(nuc, nuc)
+ statsWindow()
+ lutRebuild()
+ grayMap()
+ upscale2x()
+ val pal = this.pal
+ var i = 0
+ while (i < npix * 4) {
+ outArgb[i] = pal[gray320[i].toInt() and 0xFF]
+ i++
+ }
+ renderedOnce = true
+ return true
+ }
+ }
+ }
+
+ /** FFC phase of the frame just processed: 0 normal, 1 hidden, 2 reference. */
+ fun ffcPhase(): Int = synchronized(lock) { lastPhase }
+
+ /** Camera temperature of the frame just processed (raw sensor units). */
+ fun lastShutter(): Int = synchronized(lock) { shutter }
+
+ /** True once a frame completed the full render path (temps are meaningful). */
+ fun tempsReady(): Boolean = synchronized(lock) { renderedOnce }
+
+
fun setShutter(value: Int) {
synchronized(lock) { shutter = value }
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt
index 7ed61af..9c217c8 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt
@@ -126,16 +126,31 @@ class RemoteSession(
/** Control lines from the host; only meaningful before the stream starts. */
val lines: Flow = _lines.asSharedFlow()
- private val _frames = MutableSharedFlow(
+ private val _frames = MutableSharedFlow(
extraBufferCapacity = 8,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
- /** Raw 38400-byte sensor payloads, ready for the local RenderPipeline. */
- val frames: Flow = _frames.asSharedFlow()
+ /** Frame records (raw counts + the host's FFC metadata), ready to render. */
+ val frames: Flow = _frames.asSharedFlow()
private val closed = AtomicBoolean(false)
+ /**
+ * Command queue + single writer coroutine.
+ *
+ * Commands MUST be written by one coroutine: launching a writer per command
+ * (the first implementation) let two coroutines interleave on the same
+ * socket stream, so a `hello` immediately followed by `start` could reach the
+ * host in the opposite order — the host then answered `stream-start` first
+ * and the late `welcome` line arrived after the reader had switched to frame
+ * mode, where it was discarded as garbage (~6.7% of runs).
+ */
+ private val outQueue = kotlinx.coroutines.channels.Channel(
+ capacity = kotlinx.coroutines.channels.Channel.UNLIMITED,
+ )
+ private val writerStarted = AtomicBoolean(false)
+
@Volatile
private var streaming = false
@@ -255,14 +270,25 @@ class RemoteSession(
private fun send(cmd: String) {
if (closed.get()) return
+ ensureWriter()
+ // trySend from the CALLER's thread keeps enqueue order == call order
+ outQueue.trySend(cmd)
+ }
+
+ /** Start the single writer coroutine lazily, on first command. */
+ private fun ensureWriter() {
+ if (!writerStarted.compareAndSet(false, true)) return
scope.launch(Dispatchers.IO) {
try {
val out = socket.getOutputStream()
- out.write((cmd + "\n").toByteArray(Charsets.UTF_8))
- out.flush()
+ for (line in outQueue) {
+ if (closed.get()) break
+ out.write((line + "\n").toByteArray(Charsets.UTF_8))
+ out.flush()
+ }
} catch (e: Exception) {
if (!closed.get()) {
- DebugLog.log("remote", "send failed: ${e.javaClass.simpleName}: ${e.message}")
+ DebugLog.log("remote", "writer ended: ${e.javaClass.simpleName}: ${e.message}")
}
}
}
@@ -270,6 +296,7 @@ class RemoteSession(
fun close() {
if (!closed.getAndSet(true)) {
+ runCatching { outQueue.close() }
runCatching { socket.close() }
}
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt
index 9a8e0e5..794d4c5 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt
@@ -7,8 +7,15 @@ package com.mag160c.thermal.net
* - discovery: UDP broadcast on port 47510, one UTF-8 JSON line per second:
* {"app":"mag160c-remote","role":"host","name":"","tcp":47511,"serial":...}
* - control: TCP 47511, newline-delimited UTF-8 JSON, one line <= 4 KB
- * - frames: after {"cmd":"start"} the host writes fixed 38412-byte binary
- * records: [u32 LE 0x1BB1B11B][u32 LE counter][u32 LE 38400][38400 B u16 LE]
+ * - frames: after {"cmd":"start"} the host writes fixed frame records:
+ * [u32 LE magic 0x1BB1B11B][u32 LE counter][u32 LE 38400]
+ * [u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400 B u16 LE pixels]
+ * Header = 24 bytes, record = 38424 bytes.
+ * flags bit 0 = "renders" (a usable image frame); ffcPhase mirrors the HOST's
+ * FFC state machine (0 normal / 1 shutter closed / 2 reference) and shutter is
+ * the frame's camera temperature. The client needs both to reproduce the
+ * host's image: the NUC tables interpolate on shutter, and the reference
+ * frames must be averaged exactly as the host averages them.
* - the client renders locally with its own RenderPipeline + bundled DDT, so
* palette / zoom never cross the wire
* - keepalive: host sends {"type":"ping"} after 3 s without frames; the
@@ -24,11 +31,20 @@ object RemoteContract {
const val APP_TAG = "mag160c-remote"
const val FRAME_MAGIC = 0x1BB1B11B
const val FRAME_PIXELS = 38400
- const val FRAME_TOTAL = 12 + FRAME_PIXELS // 38412
+ const val FRAME_HEADER = 24
+ const val FRAME_TOTAL = FRAME_HEADER + FRAME_PIXELS // 38424
const val PING_AFTER_MS = 3000L
const val DEAD_AFTER_MS = 10000L
const val MAX_LINE = 4096
+ /** flags bit 0: the host produced an image for this frame. */
+ const val FLAG_RENDERS = 1
+
+ /** FFC phase values mirrored from the host pipeline. */
+ const val PHASE_NORMAL = 0
+ const val PHASE_HIDDEN = 1
+ const val PHASE_REFERENCE = 2
+
/** One discovery beacon / host descriptor. */
data class HostInfo(
val name: String,
@@ -125,6 +141,9 @@ object RemoteContract {
fun okLine(): String = "{\"type\":\"ok\"}"
+ /** Sent to a second client while another one is already streaming. */
+ fun busyLine(): String = "{\"type\":\"busy\"}"
+
fun errorLine(message: String): String = "{\"type\":\"error\",\"message\":\"${escape(message)}\"}"
fun welcomeLine(w: Int, h: Int, fps: Int, serial: Long): String =
@@ -171,8 +190,25 @@ object RemoteContract {
fun isKeepalive(text: String): Boolean = typeOf(text) == "ping"
+ /** One decoded frame record. */
+ class FramePacket(
+ val counter: Int,
+ val flags: Int,
+ val ffcPhase: Int,
+ val shutter: Int,
+ val pixels: ByteArray,
+ ) {
+ val renders: Boolean get() = flags and FLAG_RENDERS != 0
+ }
+
/** Build one frame record for the wire. */
- fun encodeFramePacket(raw: ByteArray, counter: Int): ByteArray {
+ fun encodeFramePacket(
+ raw: ByteArray,
+ counter: Int,
+ flags: Int = FLAG_RENDERS,
+ ffcPhase: Int = PHASE_NORMAL,
+ shutter: Int = 0,
+ ): ByteArray {
require(raw.size == FRAME_PIXELS) {
"frame payload must be $FRAME_PIXELS bytes, got ${raw.size}"
}
@@ -180,7 +216,10 @@ object RemoteContract {
putU32(out, 0, FRAME_MAGIC)
putU32(out, 4, counter)
putU32(out, 8, FRAME_PIXELS)
- System.arraycopy(raw, 0, out, 12, FRAME_PIXELS)
+ putU32(out, 12, flags)
+ putU32(out, 16, ffcPhase)
+ putU32(out, 20, shutter)
+ System.arraycopy(raw, 0, out, FRAME_HEADER, FRAME_PIXELS)
return out
}
@@ -198,7 +237,7 @@ object RemoteContract {
}
/**
- * Incremental reassembly of the TCP byte stream into frame payloads.
+ * Incremental reassembly of the TCP byte stream into frame records.
* Handles partial records and several records in one read (both occur with
* raw socket reads). Malformed data resynchronises on the next magic
* instead of dropping the connection.
@@ -207,12 +246,12 @@ object RemoteContract {
private var buffer = ByteArray(FRAME_TOTAL * 4)
private var size = 0
- /** Feed bytes, get back every complete frame payload available. */
- fun feed(bytes: ByteArray): List {
+ /** Feed bytes, get back every complete frame available. */
+ fun feed(bytes: ByteArray): List {
append(bytes)
- val out = ArrayList(2)
+ val out = ArrayList(2)
while (true) {
- if (size < 12) break
+ if (size < FRAME_HEADER) break
var magicAt = -1
for (i in 0..size - 4) {
if (u32(buffer, i) == FRAME_MAGIC) {
@@ -226,13 +265,21 @@ object RemoteContract {
break
}
if (magicAt > 0) dropBefore(magicAt)
- if (size < 12) break
+ if (size < FRAME_HEADER) break
if (u32(buffer, 8) != FRAME_PIXELS) { // bogus header
dropBefore(1)
continue
}
if (size < FRAME_TOTAL) break
- out.add(buffer.copyOfRange(12, FRAME_TOTAL))
+ out.add(
+ FramePacket(
+ counter = u32(buffer, 4),
+ flags = u32(buffer, 12),
+ ffcPhase = u32(buffer, 16),
+ shutter = u32(buffer, 20),
+ pixels = buffer.copyOfRange(FRAME_HEADER, FRAME_TOTAL),
+ ),
+ )
dropBefore(FRAME_TOTAL)
}
return out
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt
index 64bd6f6..e82cb9e 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt
@@ -23,12 +23,14 @@ import java.util.concurrent.atomic.AtomicBoolean
* Host side of the LAN remote preview (Phase F).
*
* Broadcasts a discovery beacon on UDP 47510 once a second and serves exactly
- * one preview client on TCP 47511. Frames arrive as raw 38400-byte sensor
- * payloads (the same bytes the thermal pipeline consumes) and go out as fixed
- * 38412-byte records; all rendering happens on the client.
+ * one preview client on TCP 47511 (a second client is told `busy` and closed).
+ * Frames arrive as raw 38400-byte sensor payloads plus the host pipeline's
+ * metadata for that frame (FFC phase, camera temperature); the client needs both
+ * to reproduce the host's image, because the NUC tables interpolate on the
+ * camera temperature and the FFC reference frames must be averaged identically.
*
* Concurrency: ONE coroutine owns the client socket — it reads control lines
- * and writes both control replies and frame records. A second coroutine only
+ * and writes both control replies and frame records. A second coroutine only
* feeds a bounded queue, so a frame can never interleave into the middle of a
* write (which would desynchronise the client's frame reader).
*
@@ -53,13 +55,34 @@ class RemoteHost(
@Volatile
var onFfcRequest: (() -> Unit)? = null
+ /**
+ * Supplies the pipeline metadata for the frame just pushed via [offerFrame].
+ * Set by LiveViewModel: it must describe the SAME frame, which is why the
+ * session invokes rawHook after the pipeline processed it.
+ */
+ @Volatile
+ var frameMeta: (() -> FrameMeta)? = null
+
+ /** FFC phase + camera temperature of one raw frame. */
+ data class FrameMeta(val phase: Int, val shutter: Int, val renders: Boolean)
+
private var scope: CoroutineScope? = null
private var serverSocket: ServerSocket? = null
private var discoverySocket: DatagramSocket? = null
private val running = AtomicBoolean(false)
+ /**
+ * Only one client is served at a time. The accept loop must NOT block inside
+ * serve(), otherwise a second connection would wait in the kernel backlog and
+ * never be told `busy` — so each client is served in its own coroutine and the
+ * flag rejects newcomers immediately.
+ */
+ private val serving = AtomicBoolean(false)
+
/** Bounded queue; a slow client drops the oldest frame instead of stalling. */
- private var frameQueueRef: Channel? = null
+ private var frameQueueRef: Channel? = null
+
+ private class OutFrame(val pixels: ByteArray, val meta: FrameMeta)
val isRunning: Boolean get() = running.get()
@@ -89,11 +112,12 @@ class RemoteHost(
DebugLog.log("remote", "host stopped")
}
- /** Push one raw sensor frame from the live session. */
+ /** Push one raw sensor frame from the live session together with its metadata. */
fun offerFrame(raw: ByteArray) {
if (!running.get()) return
if (raw.size != RemoteContract.FRAME_PIXELS) return
- frameQueueRef?.trySend(raw)
+ val meta = frameMeta?.invoke() ?: FrameMeta(RemoteContract.PHASE_NORMAL, 0, true)
+ frameQueueRef?.trySend(OutFrame(raw, meta))
}
/** One UDP beacon per second on the broadcast address. */
@@ -145,7 +169,28 @@ class RemoteHost(
DebugLog.log("remote", "accept failed: $e")
break
}
- serve(socket)
+ // single client: tell later arrivals we are busy instead of silently
+ // queueing them in the kernel backlog (plan F2 requires the busy line)
+ if (!serving.compareAndSet(false, true)) {
+ val addr = socket.inetAddress?.hostAddress ?: "?"
+ DebugLog.log("remote", "rejecting $addr: already serving a client")
+ runCatching {
+ val o = socket.getOutputStream()
+ o.write((RemoteContract.busyLine() + "\n").toByteArray(Charsets.UTF_8))
+ o.flush()
+ }
+ runCatching { socket.close() }
+ continue
+ }
+ // serve OFF the accept loop so the loop can keep accepting (and
+ // rejecting) while this client is connected
+ scope?.launch {
+ try {
+ serve(socket)
+ } finally {
+ serving.set(false)
+ }
+ }
}
runCatching { server.close() }
}
@@ -153,8 +198,13 @@ class RemoteHost(
/**
* Serve one client to completion. Single-threaded by design: every write to
* the socket happens here, so control replies and frame records stay framed.
+ *
+ * While streaming, a command reply must NOT be written: the client is in
+ * frame mode and would see the JSON line as stray bytes inside a frame
+ * record. Commands are therefore acknowledged only before the stream starts;
+ * `ffc` is executed and logged without a reply while streaming.
*/
- private suspend fun serve(socket: Socket) {
+ private fun serve(socket: Socket) {
val addr = socket.inetAddress?.hostAddress ?: "?"
DebugLog.log("remote", "client connected from $addr")
clientAddress = addr
@@ -180,7 +230,7 @@ class RemoteHost(
if (text.isEmpty()) continue
when (RemoteContract.commandOf(text)) {
"hello" ->
- writeLine(out, RemoteContract.welcomeLine(160, 120, 15, serial))
+ if (!streaming) writeLine(out, RemoteContract.welcomeLine(160, 120, 15, serial))
"start" -> if (!streaming) {
streaming = true
state = State.STREAMING
@@ -194,9 +244,10 @@ class RemoteHost(
}
"ffc" -> {
onFfcRequest?.invoke()
- writeLine(out, RemoteContract.okLine())
+ // no reply while streaming (see the note above)
+ if (!streaming) writeLine(out, RemoteContract.okLine())
}
- else -> if (RemoteContract.typeOf(text).isEmpty()) {
+ else -> if (RemoteContract.typeOf(text).isEmpty() && !streaming) {
writeLine(out, RemoteContract.errorLine("unknown command"))
}
}
@@ -205,8 +256,16 @@ class RemoteHost(
if (streaming) {
var wrote = false
while (true) {
- val frame = frameQueueRef?.tryReceive()?.getOrNull() ?: break
- out.write(RemoteContract.encodeFramePacket(frame, counter++))
+ val f = frameQueueRef?.tryReceive()?.getOrNull() ?: break
+ out.write(
+ RemoteContract.encodeFramePacket(
+ raw = f.pixels,
+ counter = counter++,
+ flags = if (f.meta.renders) RemoteContract.FLAG_RENDERS else 0,
+ ffcPhase = f.meta.phase,
+ shutter = f.meta.shutter,
+ ),
+ )
sentFrames++
wrote = true
}
@@ -221,7 +280,9 @@ class RemoteHost(
DebugLog.log("remote", "frames=$sentFrames to $addr")
}
}
- kotlinx.coroutines.delay(5)
+ // blocking sleep is correct here: this method runs on its own IO
+ // dispatcher thread and must not yield the socket to another writer
+ Thread.sleep(5)
}
} catch (e: Exception) {
DebugLog.log("remote", "client $addr error: ${e.javaClass.simpleName}: ${e.message}")
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt
new file mode 100644
index 0000000..b1d9d47
--- /dev/null
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt
@@ -0,0 +1,149 @@
+package com.mag160c.thermal.ui.live
+
+/**
+ * Pure geometry for the live image: the rotation/flip transform, the letterbox
+ * fit rect, and the sensor<->screen mapping shared by the renderer (markers,
+ * colour bar) and the tap handler. Deliberately free of Android types so it can
+ * be unit tested on the JVM — a mismatch between the drawn image and the marker
+ * mapping is exactly the class of bug this file exists to prevent.
+ *
+ * ## Why the image rotates at all
+ *
+ * The activity is portrait-locked, so the composition (top bar / image area /
+ * bottom bar) never moves — that stays as the user demanded. But the IMAGE
+ * CONTENT must stay aligned with the world, otherwise turning the phone makes
+ * the scene turn with it.
+ *
+ * The official app rotates the image according to the display rotation:
+ * Display.rotation 0/1/2/3 -> image 90/0/270/180 (MainActivity
+ * windowOrientationListener -> DeviceController.setPreviewOrientation, applied
+ * as matrix.postRotate in ImageViewer.drawImage). Its window also auto-rotates,
+ * so the net on-screen rotation is constant. With a LOCKED window the same
+ * appearance requires
+ *
+ * rot = 90 - gripDeg (mod 360)
+ *
+ * where gripDeg is the clockwise physical rotation of the phone (DeviceOrientation
+ * reports 0/90/180/270). Sanity check against the official mapping:
+ * official image rotation 90+displayRotation_delta equals 90 + gripDeg; the
+ * window contributes -gripDeg, so the visible result matches.
+ *
+ * [userRotateDeg] and the two flips are the official-style manual corrections
+ * ("旋转USB画面" / "水平翻转" / "竖直翻转") for setups where the sensor is mounted
+ * differently.
+ */
+object ImageTransform {
+ const val SENSOR_W = 160
+ const val SENSOR_H = 120
+
+ /**
+ * @param rotDeg clockwise rotation applied to the image content, in buffer space
+ * @param flipH mirror the source horizontally (before rotation)
+ * @param flipV mirror the source vertically (before rotation)
+ */
+ data class Params(
+ val rotDeg: Int,
+ val flipH: Boolean,
+ val flipV: Boolean,
+ )
+
+ fun params(gripDeg: Int, userRotateDeg: Int = 0, flipH: Boolean = false, flipV: Boolean = false): Params =
+ Params((((90 - gripDeg + userRotateDeg) % 360) + 360) % 360, flipH, flipV)
+
+ /** True when the drawn image is taller than wide on screen (rot 90/270). */
+ fun swapped(rotDeg: Int): Boolean = rotDeg % 180 != 0
+
+ /** Letterboxed rect for the image inside the available area. */
+ class Fit(val left: Float, val top: Float, val width: Float, val height: Float) {
+ val cx: Float get() = left + width / 2f
+ val cy: Float get() = top + height / 2f
+ val right: Float get() = left + width
+ val bottom: Float get() = top + height
+ }
+
+ /** On-screen aspect of the drawn image: the source is 4:3, sideways 3:4. */
+ fun screenAspect(rotDeg: Int): Float = if (swapped(rotDeg)) 3f / 4f else 4f / 3f
+
+ fun fit(availLeft: Float, availTop: Float, availW: Float, availH: Float, rotDeg: Int): Fit {
+ val aspect = screenAspect(rotDeg)
+ var w = availW
+ var h = availW / aspect
+ if (h > availH) {
+ h = availH
+ w = availH * aspect
+ }
+ return Fit(availLeft + (availW - w) / 2f, availTop + (availH - h) / 2f, w, h)
+ }
+
+ /** Digital-zoom crop in normalised source units; 0,0,1,1 = no zoom. */
+ fun cropForZoom(zoom: Int): FloatArray {
+ if (zoom <= 1) return floatArrayOf(0f, 0f, 1f, 1f)
+ val inset = (1f - 1f / zoom) / 2f
+ return floatArrayOf(inset, inset, 1f - inset, 1f - inset)
+ }
+
+ /** Pre-rotation draw size for a fit rect (the rotated footprint). */
+ private fun preRotationSize(fit: Fit, rotDeg: Int): FloatArray =
+ if (swapped(rotDeg)) floatArrayOf(fit.height, fit.width)
+ else floatArrayOf(fit.width, fit.height)
+
+ /**
+ * Sensor pixel -> buffer point. Sampled at pixel centres so markers land on
+ * the middle of the pixel they describe.
+ */
+ fun sensorToScreen(sx: Float, sy: Float, p: Params, fit: Fit, crop: FloatArray): FloatArray {
+ var u = (sx + 0.5f) / SENSOR_W
+ var v = (sy + 0.5f) / SENSOR_H
+ if (p.flipH) u = 1f - u
+ if (p.flipV) v = 1f - v
+ val size = preRotationSize(fit, p.rotDeg)
+ val lx = (u - crop[0]) / (crop[2] - crop[0])
+ val ly = (v - crop[1]) / (crop[3] - crop[1])
+ val px = fit.cx - size[0] / 2f + lx * size[0]
+ val py = fit.cy - size[1] / 2f + ly * size[1]
+ return rotate(px, py, fit.cx, fit.cy, p.rotDeg.toFloat())
+ }
+
+ /**
+ * Buffer point -> sensor pixel (inverse of [sensorToScreen]).
+ * Returns null when the point falls outside the drawn image.
+ */
+ fun screenToSensor(x: Float, y: Float, p: Params, fit: Fit, crop: FloatArray): Pair? {
+ val q = rotate(x, y, fit.cx, fit.cy, -p.rotDeg.toFloat())
+ val size = preRotationSize(fit, p.rotDeg)
+ val lx = (q[0] - (fit.cx - size[0] / 2f)) / size[0]
+ val ly = (q[1] - (fit.cy - size[1] / 2f)) / size[1]
+ if (lx < 0f || lx > 1f || ly < 0f || ly > 1f) return null
+ var u = crop[0] + lx * (crop[2] - crop[0])
+ var v = crop[1] + ly * (crop[3] - crop[1])
+ if (p.flipH) u = 1f - u
+ if (p.flipV) v = 1f - v
+ val sx = (u * SENSOR_W).toInt()
+ val sy = (v * SENSOR_H).toInt()
+ if (sx < 0 || sy < 0 || sx >= SENSOR_W || sy >= SENSOR_H) return null
+ return sx to sy
+ }
+
+ /** Clockwise rotation of a point about a pivot, in buffer (y-down) coords. */
+ private fun rotate(x: Float, y: Float, cx: Float, cy: Float, deg: Float): FloatArray {
+ val r = Math.toRadians(deg.toDouble())
+ val c = kotlin.math.cos(r).toFloat()
+ val s = kotlin.math.sin(r).toFloat()
+ val dx = x - cx
+ val dy = y - cy
+ return floatArrayOf(cx + dx * c - dy * s, cy + dx * s + dy * c)
+ }
+
+ /**
+ * Half extents of a text box drawn with a clockwise [textRotDeg] rotation.
+ * Used to place labels flush against their target (e.g. the colour-bar
+ * ends) whatever the grip: the box is positioned by its bounding box, not
+ * by a baseline anchor, so it stays aligned when the text is turned 90 deg.
+ */
+ fun rotatedBoxHalfExtents(textW: Float, textH: Float, textRotDeg: Float): FloatArray {
+ val r = Math.toRadians(textRotDeg.toDouble())
+ val c = kotlin.math.abs(kotlin.math.cos(r)).toFloat()
+ val s = kotlin.math.abs(kotlin.math.sin(r)).toFloat()
+ return floatArrayOf((textW * c + textH * s) / 2f, (textW * s + textH * c) / 2f)
+ }
+}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt
index e59e66a..3e4ad68 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveRenderer.kt
@@ -89,7 +89,7 @@ class LiveRenderer(
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
val frame = vm.latestFrame ?: return
- bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
+ setFramePixels(frame)
// available area (minus UI overlays)
val top = vm.uiTopPx.toFloat()
@@ -98,33 +98,33 @@ class LiveRenderer(
val availH = bottom - top
if (availH <= 0) return
- // fit the 3:4 (rotated) image into the available rect (fixed orientation)
- var dstW = availW
- var dstH = availW * 4f / 3f
- if (dstH > availH) {
- dstH = availH
- dstW = availH * 3f / 4f
- }
- val left = (availW - dstW) / 2f
- val vpTop = top + (availH - dstH) / 2f
- viewport.set(left, vpTop, left + dstW, vpTop + dstH)
+ // Grip-compensated orientation (see ImageTransform): the composition —
+ // bars, insets, image area — stays glued to the portrait frame as
+ // demanded, but the IMAGE CONTENT counter-rotates so the scene stays
+ // aligned with the world when the phone is turned.
+ val params = vm.imageParams(orientationDeg)
+ val fit = ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
+ viewport.set(fit.left, fit.top, fit.right, fit.bottom)
- // draw the source bitmap rotated 90 deg CW around the viewport center
- val cx = viewport.centerX()
- val cy = viewport.centerY()
val zoom = vm.state.value.zoom
+ val crop = ImageTransform.cropForZoom(zoom)
val srcRect = if (zoom > 1) {
- val cw = 320 / zoom
- val ch = 240 / zoom
+ val cw = (320 * (crop[2] - crop[0])).toInt()
+ val ch = (240 * (crop[3] - crop[1])).toInt()
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
} else null
+ val size = if (ImageTransform.swapped(params.rotDeg)) {
+ floatArrayOf(fit.height, fit.width)
+ } else {
+ floatArrayOf(fit.width, fit.height)
+ }
canvas.save()
- canvas.rotate(90f, cx, cy)
- // pre-rotation draw rect (source aspect 4:3 inside the rotated 3:4 viewport)
- val w0 = dstH
- val h0 = dstW
- val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
+ canvas.rotate(params.rotDeg.toFloat(), fit.cx, fit.cy)
+ val dst = android.graphics.RectF(
+ fit.cx - size[0] / 2f, fit.cy - size[1] / 2f,
+ fit.cx + size[0] / 2f, fit.cy + size[1] / 2f,
+ )
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.restore()
@@ -132,6 +132,36 @@ class LiveRenderer(
drawOsd(canvas, vm.state.value)
}
+ /** Grip angle in 0/90/180/270; drives the OSD pre-rotation. */
+ private val orientationDeg: Int
+ get() = com.mag160c.thermal.ui.DeviceOrientation.deg.value
+
+ /**
+ * Copy the rendered frame into the draw bitmap, applying the user's
+ * mirror settings. Doing the flip on the pixel copy (instead of a negative
+ * scale in the draw matrix) keeps the buffer-space mapping in
+ * [ImageTransform] exact and testable.
+ */
+ private fun setFramePixels(frame: IntArray) {
+ val p = vm.imageParams(orientationDeg)
+ if (!p.flipH && !p.flipV) {
+ bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
+ return
+ }
+ val flipped = IntArray(320 * 240)
+ for (y in 0 until 240) {
+ val sy = if (p.flipV) 239 - y else y
+ val srcRow = sy * 320
+ val dstRow = y * 320
+ if (!p.flipH) {
+ System.arraycopy(frame, srcRow, flipped, dstRow, 320)
+ } else {
+ for (x in 0 until 320) flipped[dstRow + x] = frame[srcRow + (319 - x)]
+ }
+ }
+ bitmap.setPixels(flipped, 0, 320, 0, 0, 320, 240)
+ }
+
private fun drawTempMarker(canvas: Canvas, sx: Int, sy: Int, tempC: Float?, label: String?) {
if (sx < 0 || sy < 0 || tempC == null) return
val p = vm.probeToScreen(sx, sy)
@@ -146,20 +176,52 @@ class LiveRenderer(
markerPaint.style = Paint.Style.FILL
val text = (label?.let { "$it " } ?: "") + "%.1f℃".format(tempC)
- val tw = textPaint.measureText(text)
+ val half = gripTextHalf(text)
val pad = 6f * density
- var tx = cx + 14f * density
- var ty = cy + textPaint.textSize
- if (tx + tw + pad > viewport.right) tx = cx - 14f * density - tw
- if (ty > viewport.bottom - 4f * density) ty = cy - 10f * density
- if (ty < viewport.top + textPaint.textSize) ty = cy + textPaint.textSize + 4f * density
- // pivot around the marker anchor: the label stays attached while upright
+ // place the label beside the marker in the TEXT's own frame, so with a
+ // 90 deg grip the label still sits clear of the dot instead of drifting
+ val candidates = floatArrayOf(dotR + 5f * density, -(dotR + 5f * density))
+ var best: FloatArray? = null
+ for (off in candidates) {
+ val c = offsetInTextFrame(cx, cy, off, 0f)
+ val fits = c[0] - half[0] - pad >= viewport.left + 2f * density &&
+ c[0] + half[0] + pad <= viewport.right - 2f * density &&
+ c[1] - half[1] >= viewport.top + 2f * density &&
+ c[1] + half[1] <= viewport.bottom - 2f * density
+ if (fits) { best = c; break }
+ if (best == null) best = c
+ }
+ val c = best!!
+ drawGripText(canvas, text, c[0], c[1])
+ }
+
+ /** Rotate a text-local offset into buffer space and add it to an anchor. */
+ private fun offsetInTextFrame(ax: Float, ay: Float, offX: Float, offY: Float): FloatArray {
+ val r = Math.toRadians(textRot.toDouble())
+ val c = kotlin.math.cos(r).toFloat()
+ val s = kotlin.math.sin(r).toFloat()
+ return floatArrayOf(ax + offX * c - offY * s, ay + offX * s + offY * c)
+ }
+
+ /** Draw OSD text pre-rotated by the grip angle, centred on a buffer point. */
+ private fun drawGripText(canvas: Canvas, text: String, centerX: Float, centerY: Float) {
+ val tw = textPaint.measureText(text)
+ val fm = textPaint.fontMetrics
canvas.save()
- canvas.rotate(textRot, cx, cy)
- canvas.drawText(text, tx, ty, textPaint)
+ canvas.rotate(textRot, centerX, centerY)
+ val baseline = centerY - (fm.ascent + fm.descent) / 2f
+ canvas.drawText(text, centerX - tw / 2f, baseline, textPaint)
canvas.restore()
}
+ /** Half extents of the drawn text box in buffer space. */
+ private fun gripTextHalf(text: String): FloatArray {
+ val fm = textPaint.fontMetrics
+ return ImageTransform.rotatedBoxHalfExtents(
+ textPaint.measureText(text), fm.descent - fm.ascent, textRot,
+ )
+ }
+
private fun drawColorBar(canvas: Canvas, state: LiveViewModel.LiveState) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
@@ -171,24 +233,23 @@ class LiveRenderer(
val n = 96
for (i in 0 until n) {
val c = pal[255 - i * 255 / (n - 1)]
+ seg.color = c
val sy = y0 + barH * i / n
val ey = y0 + barH * (i + 1) / n
- seg.color = c
canvas.drawRect(x, sy, x + barW, ey + 0.5f, seg)
}
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
- val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
- val labelMaxX = x + barW / 2f - textPaint.measureText(minT) / 2
- canvas.save()
- canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
- canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
- canvas.restore()
- canvas.save()
- canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
- canvas.drawText(minT, labelMaxX, y0 + barH + textPaint.textSize, textPaint)
- canvas.restore()
+ val cx = x + barW / 2f
+ val gap = 8f * density
+ // Position by the ROTATED bounding box: with a 90 deg grip the text runs
+ // along the bar, so centring a baseline anchor (the old code) made the
+ // numbers overlap the bar instead of sitting beside its ends.
+ val halfMax = gripTextHalf(maxT)
+ val halfMin = gripTextHalf(minT)
+ drawGripText(canvas, maxT, cx, y0 - gap - halfMax[1])
+ drawGripText(canvas, minT, cx, y0 + barH + gap + halfMin[1])
}
private fun drawOsd(canvas: Canvas, state: LiveViewModel.LiveState) {
@@ -196,15 +257,19 @@ class LiveRenderer(
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
- canvas.save()
- canvas.rotate(textRot, ox, oy)
- canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
- canvas.restore()
+ val text = "中心 %.1f℃".format(it)
+ val half = gripTextHalf(text)
+ // keep the readout inside the image rect whatever the grip
+ val cx = (ox + half[0]).coerceAtMost(viewport.right - half[0] - 4f * density)
+ val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
+ drawGripText(canvas, text, cx, cy)
}
+ // the trace toggle governs BOTH extremes (it used to leave the min
+ // marker drawn, which read as "the switch only works halfway")
if (state.maxTraceOn) {
drawTempMarker(canvas, state.maxPos % 160, state.maxPos / 160, state.maxTempC, "高")
+ drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, "低")
}
- drawTempMarker(canvas, state.minPos % 160, state.minPos / 160, state.minTempC, null)
for (p in state.probes) {
drawTempMarker(canvas, p.x, p.y, p.tempC, p.label)
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt
index 5f78d4c..529cb83 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveScreen.kt
@@ -99,6 +99,11 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
kotlinx.coroutines.delay(400)
navPx = UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
+ // pick up orientation changes made on the settings tab
+ val s = com.mag160c.thermal.ui.settings.AppSettings(context)
+ vm.userRotateDeg = s.imageRotateDeg
+ vm.flipH = s.imageFlipH
+ vm.flipV = s.imageFlipV
vm.refreshTemps()
}
}
@@ -127,11 +132,14 @@ fun LiveScreen(vm: LiveViewModel = viewModel(), onOpenGallery: () -> Unit = {})
Box(
modifier = Modifier
.fillMaxSize()
- .pointerInput(Unit) {
+ .pointerInput(phi) {
detectTapGestures { offset ->
+ // the same grip angle the renderer used, so the tap maps
+ // to the pixel actually under the finger
vm.tapImage(
offset.x, offset.y,
size.width.toFloat(), size.height.toFloat(),
+ phi,
)
}
},
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
index c1a23f9..03b924c 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt
@@ -47,6 +47,28 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
private val session = IrSession(app)
+ // declared before init{} so the hook wiring there can reference it
+ private val remoteHost = com.mag160c.thermal.net.RemoteHost(
+ deviceName = android.os.Build.MODEL ?: "Android",
+ serial = 0L,
+ )
+
+ val remoteHostState: com.mag160c.thermal.net.RemoteHost get() = remoteHost
+
+ /** Start/stop the preview server. Requires an active USB session. */
+ fun setRemoteHostEnabled(enabled: Boolean): Boolean {
+ if (enabled) {
+ if (!session.isStreaming()) return false
+ remoteHost.onFfcRequest = { triggerFfc() }
+ remoteHost.start()
+ } else {
+ remoteHost.stop()
+ }
+ return true
+ }
+
+ fun isRemoteHostRunning(): Boolean = remoteHost.isRunning
+
companion object {
/** PIP overlay width in dp for size index 0/1/2. */
val PIP_WIDTHS_DP = intArrayOf(96, 128, 160)
@@ -149,37 +171,27 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
rec.offerFrame(bmp)
}
}
- // feed the LAN remote host (Phase F) with raw sensor frames
+ // feed the LAN remote host (Phase F) with raw sensor frames. The session
+ // calls this AFTER the pipeline processed the frame, so the metadata
+ // read here describes exactly this frame.
session.rawHook = { raw ->
if (raw.size >= 0x1C + 38400) {
remoteHost.offerFrame(raw.copyOfRange(0x1C, 0x1C + 38400))
}
}
+ // metadata the remote client needs to reproduce the host's image
+ remoteHost.frameMeta = {
+ val pipe = session
+ com.mag160c.thermal.net.RemoteHost.FrameMeta(
+ phase = pipe.ffcPhase(),
+ shutter = pipe.lastShutter(),
+ renders = pipe.tempsReady(),
+ )
+ }
}
// ---- LAN remote preview host (Phase F) ----
- private val remoteHost = com.mag160c.thermal.net.RemoteHost(
- deviceName = android.os.Build.MODEL ?: "Android",
- serial = 0L,
- )
-
- val remoteHostState: com.mag160c.thermal.net.RemoteHost get() = remoteHost
-
- /** Start/stop the preview server. Requires an active USB session. */
- fun setRemoteHostEnabled(enabled: Boolean): Boolean {
- if (enabled) {
- if (!session.isStreaming()) return false
- remoteHost.onFfcRequest = { triggerFfc() }
- remoteHost.start()
- } else {
- remoteHost.stop()
- }
- return true
- }
-
- fun isRemoteHostRunning(): Boolean = remoteHost.isRunning
-
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
fun connect() {
val now = android.os.SystemClock.elapsedRealtime()
@@ -291,33 +303,67 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
* (3:4 vertical) and does NOT move with the phone; only OSD text follows
* the screen. Insets carve the available area.
*/
- fun tapImage(screenX: Float, screenY: Float, viewW: Float, viewH: Float) {
+ /**
+ * Manual orientation corrections, mirroring the official app's settings
+ * ("旋转USB画面" / "水平翻转" / "竖直翻转"). Applied on top of the automatic
+ * grip compensation, for sensor mounts that need a fixed offset.
+ */
+ @Volatile
+ var userRotateDeg: Int = 0
+
+ @Volatile
+ var flipH: Boolean = false
+
+ @Volatile
+ var flipV: Boolean = false
+
+ /**
+ * Orientation actually used for drawing and for the sensor<->screen mapping.
+ * [gripDeg] is the accelerometer grip angle (0/90/180/270).
+ * One definition for both the renderer and the tap/probe mapping — they must
+ * never disagree, which is how markers ended up on the wrong pixel.
+ */
+ fun imageParams(gripDeg: Int): ImageTransform.Params =
+ ImageTransform.params(gripDeg, userRotateDeg, flipH, flipV)
+
+ /** Fit rect of the drawn image for the current view/insets/orientation. */
+ private fun currentFit(gripDeg: Int): ImageTransform.Fit {
+ val viewW = uiViewW.toFloat().coerceAtLeast(1f)
+ val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
+ return ImageTransform.fit(
+ 0f, uiTopPx.toFloat(), viewW, availH,
+ imageParams(gripDeg).rotDeg,
+ )
+ }
+
+ /**
+ * Tap on the live image: add a probe point, or delete an existing one when
+ * tapping near it. Coordinates go through the SAME transform the renderer
+ * used, so a tap always lands on the pixel under the finger whatever the
+ * grip angle and flip settings are.
+ */
+ fun tapImage(
+ screenX: Float,
+ screenY: Float,
+ viewW: Float,
+ viewH: Float,
+ gripDeg: Int = 0,
+ ) {
val s = _state.value
if (!s.streaming) return
- val top = uiTopPx.toFloat()
- val bottom = viewH - uiBottomPx.toFloat()
- if (screenY < top || screenY > bottom) return
- // fit the 3:4 (rotated 90 CW) image into the available rect
- val availW = viewW
- val availH = bottom - top
- var dstW = availW
- var dstH = availW * 4f / 3f
- if (dstH > availH) {
- dstH = availH
- dstW = availH * 3f / 4f
- }
- val left = (availW - dstW) / 2f
- val top2 = top + (availH - dstH) / 2f
- if (screenX < left || screenX > left + dstW || screenY < top2 || screenY > top2 + dstH) return
- val fx = (screenX - left) / dstW
- val fy = (screenY - top2) / dstH
- // inverse of the fixed 90 CW mapping: fx = 1 - sy/120, fy = sx/160
- val sy = (1f - fx) * 120f
- val sx = fy * 160f
+ val availH = (viewH - uiBottomPx - uiTopPx).coerceAtLeast(1f)
+ val fit = ImageTransform.fit(
+ 0f, uiTopPx.toFloat(), viewW, availH,
+ imageParams(gripDeg).rotDeg,
+ )
+ val crop = ImageTransform.cropForZoom(s.zoom)
+ val sensor = ImageTransform.screenToSensor(
+ screenX, screenY, imageParams(gripDeg), fit, crop,
+ ) ?: return
// near an existing probe (compare in screen space)? delete it instead
- val thr = dstW * 0.06f
+ val thr = fit.width * 0.06f
val existing = s.probes.firstOrNull { p ->
- val scr = probeToScreen(p.x, p.y)
+ val scr = probeToScreen(p.x, p.y, gripDeg)
val dx = screenX - scr[0]
val dy = screenY - scr[1]
dx * dx + dy * dy < thr * thr
@@ -326,32 +372,26 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(probes = _state.value.probes - existing)
return
}
- val label = "Pt${s.probes.size + 1}"
- val p = ProbePoint(
- sx.toInt().coerceIn(0, 159),
- sy.toInt().coerceIn(0, 119),
- label,
- null,
- )
+ val p = ProbePoint(sensor.first, sensor.second, "Pt${s.probes.size + 1}", null)
_state.value = _state.value.copy(probes = _state.value.probes + p)
refreshTemps()
}
- /** Screen coords of a sensor point under the fixed 90 CW rotation + insets. */
- fun probeToScreen(sx: Int, sy: Int): FloatArray {
- val viewW = uiViewW.toFloat().coerceAtLeast(1f)
- val availH = (uiViewH - uiTopPx - uiBottomPx).toFloat().coerceAtLeast(1f)
- var dstW = viewW
- var dstH = viewW * 4f / 3f
- if (dstH > availH) {
- dstH = availH
- dstW = availH * 3f / 4f
- }
- val left = (viewW - dstW) / 2f
- val top = uiTopPx + (availH - dstH) / 2f
- val fx = 1f - sy / 120f
- val fy = sx / 160f
- return floatArrayOf(left + fx * dstW, top + fy * dstH)
+ /**
+ * Screen coords of a sensor point, using the renderer's current transform.
+ * Defaults to the LIVE grip angle so renderer and callers cannot disagree —
+ * a mismatched grip here is exactly how markers land on the wrong pixel.
+ */
+ fun probeToScreen(
+ sx: Int,
+ sy: Int,
+ gripDeg: Int = com.mag160c.thermal.ui.DeviceOrientation.deg.value,
+ ): FloatArray {
+ val fit = currentFit(gripDeg)
+ val crop = ImageTransform.cropForZoom(state.value.zoom)
+ return ImageTransform.sensorToScreen(
+ sx.toFloat(), sy.toFloat(), imageParams(gripDeg), fit, crop,
+ )
}
/** Last known surface size, for probe screen mapping. */
@@ -391,15 +431,26 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
)
}
if (!session.isStreaming()) return
- val center = session.probeTemp(80, 60)
+ // Before the first completed render `nuc` is still zeros and
+ // countsToTempMc(0) is about -161 C; during an FFC the buffers are
+ // mid-update. Skip rather than show nonsense.
+ if (!session.tempsReady()) return
+ val centerMc = session.probeTemp(80, 60)
val nuc = IntArray(19200)
if (!session.copyNuc(nuc)) return
- updateTemps(center, nuc)
+ updateTemps(centerMc, nuc)
}
private var uiTick = 0
- private fun updateTemps(center: Int?, nuc: IntArray) {
+ /**
+ * @param centerMc centre temperature in MILLIDEGREES C, as returned by
+ * [IrSession.probeTemp] — it must NOT be converted again here (the old
+ * code ran countsToTempMc() on an already-converted value, which showed
+ * e.g. 108.7 C for a ~24 C scene).
+ * @param nuc raw NUC counts, converted here once.
+ */
+ private fun updateTemps(centerMc: Int?, nuc: IntArray) {
var mn = Int.MAX_VALUE
var mx = -1
var mnPos = -1
@@ -419,7 +470,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
p.copy(tempC = TempMath.countsToTempMc(nuc[p.y * 160 + p.x]) / 1000f)
}
_state.value = _state.value.copy(
- centerTempC = center?.let { TempMath.countsToTempMc(it) / 1000f },
+ centerTempC = centerMc?.let { it / 1000f },
maxTempC = if (mx >= 0) TempMath.countsToTempMc(mx) / 1000f else null,
minTempC = if (mn <= Int.MAX_VALUE) TempMath.countsToTempMc(mn) / 1000f else null,
maxPos = mxPos,
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt
index 1ab08ee..23fa270 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt
@@ -76,7 +76,6 @@ class RemoteRendererHost(
val h = canvas.height.toFloat()
canvas.drawColor(Color.BLACK)
val frame = vm.latestFrame ?: return
- bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
val top = vm.uiTopPx.toFloat()
val bottom = h - vm.uiBottomPx.toFloat()
@@ -84,31 +83,33 @@ class RemoteRendererHost(
val availH = bottom - top
if (availH <= 0) return
- // fit the 3:4 (rotated) image into the available rect (same as local view)
- var dstW = availW
- var dstH = availW * 4f / 3f
- if (dstH > availH) {
- dstH = availH
- dstW = availH * 3f / 4f
- }
- val left = (availW - dstW) / 2f
- val vpTop = top + (availH - dstH) / 2f
- viewport.set(left, vpTop, left + dstW, vpTop + dstH)
+ // Same orientation pipeline as the live view: the remote screen must
+ // present the host's image identically, including the grip compensation
+ // and the manual rotate/flip corrections.
+ val params = vm.imageParams(DeviceOrientation.deg.value)
+ setFramePixels(frame, params)
+ val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, top, availW, availH, params.rotDeg)
+ viewport.set(fit.left, fit.top, fit.right, fit.bottom)
- val cx = viewport.centerX()
- val cy = viewport.centerY()
val zoom = vm.state.value.zoom
+ val crop = com.mag160c.thermal.ui.live.ImageTransform.cropForZoom(zoom)
val srcRect = if (zoom > 1) {
- val cw = 320 / zoom
- val ch = 240 / zoom
+ val cw = (320 * (crop[2] - crop[0])).toInt()
+ val ch = (240 * (crop[3] - crop[1])).toInt()
android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2)
} else null
+ val size = if (com.mag160c.thermal.ui.live.ImageTransform.swapped(params.rotDeg)) {
+ floatArrayOf(fit.height, fit.width)
+ } else {
+ floatArrayOf(fit.width, fit.height)
+ }
canvas.save()
- canvas.rotate(90f, cx, cy)
- val w0 = dstH
- val h0 = dstW
- val dst = android.graphics.RectF(cx - w0 / 2f, cy - h0 / 2f, cx + w0 / 2f, cy + h0 / 2f)
+ canvas.rotate(params.rotDeg.toFloat(), fit.cx, fit.cy)
+ val dst = android.graphics.RectF(
+ fit.cx - size[0] / 2f, fit.cy - size[1] / 2f,
+ fit.cx + size[0] / 2f, fit.cy + size[1] / 2f,
+ )
if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint)
else canvas.drawBitmap(bitmap, null, dst, paint)
canvas.restore()
@@ -117,6 +118,29 @@ class RemoteRendererHost(
drawOsd(canvas, vm.state.value)
}
+ /** Copy the frame into the draw bitmap, honouring the user's mirror settings. */
+ private fun setFramePixels(
+ frame: IntArray,
+ p: com.mag160c.thermal.ui.live.ImageTransform.Params,
+ ) {
+ if (!p.flipH && !p.flipV) {
+ bitmap.setPixels(frame, 0, 320, 0, 0, 320, 240)
+ return
+ }
+ val flipped = IntArray(320 * 240)
+ for (y in 0 until 240) {
+ val sy = if (p.flipV) 239 - y else y
+ val srcRow = sy * 320
+ val dstRow = y * 320
+ if (!p.flipH) {
+ System.arraycopy(frame, srcRow, flipped, dstRow, 320)
+ } else {
+ for (x in 0 until 320) flipped[dstRow + x] = frame[srcRow + (319 - x)]
+ }
+ }
+ bitmap.setPixels(flipped, 0, 320, 0, 0, 320, 240)
+ }
+
private fun drawColorBar(canvas: Canvas, state: RemoteViewerViewModel.State) {
if (state.maxTempC == null || state.minTempC == null) return
val pal = com.mag160c.thermal.core.Palettes.buildAll()[state.paletteIndex]
@@ -134,16 +158,14 @@ class RemoteRendererHost(
textPaint.color = Color.WHITE
val maxT = "%.1f".format(state.maxTempC)
val minT = "%.1f".format(state.minTempC)
- val labelX = x + barW / 2f - textPaint.measureText(maxT) / 2
- val labelMinX = x + barW / 2f - textPaint.measureText(minT) / 2
- canvas.save()
- canvas.rotate(textRot, x + barW / 2f, y0 - 8f * density)
- canvas.drawText(maxT, labelX, y0 - 8f * density, textPaint)
- canvas.restore()
- canvas.save()
- canvas.rotate(textRot, x + barW / 2f, y0 + barH + textPaint.textSize)
- canvas.drawText(minT, labelMinX, y0 + barH + textPaint.textSize, textPaint)
- canvas.restore()
+ val cx = x + barW / 2f
+ val gap = 8f * density
+ // position by the ROTATED bounding box so the numbers stay beside the bar
+ // ends whatever the grip (baseline anchoring drifted with rotation)
+ val halfMax = gripTextHalf(maxT)
+ val halfMin = gripTextHalf(minT)
+ drawGripText(canvas, maxT, cx, y0 - gap - halfMax[1])
+ drawGripText(canvas, minT, cx, y0 + barH + gap + halfMin[1])
}
private fun drawOsd(canvas: Canvas, state: RemoteViewerViewModel.State) {
@@ -151,42 +173,62 @@ class RemoteRendererHost(
val ox = viewport.left + 12f * density
val oy = viewport.top + textPaint.textSize + 10f * density
state.centerTempC?.let {
- canvas.save()
- canvas.rotate(textRot, ox, oy)
- canvas.drawText("中心 %.1f℃".format(it), ox, oy, textPaint)
- canvas.restore()
+ val text = "中心 %.1f℃".format(it)
+ val half = gripTextHalf(text)
+ val cx = (ox + half[0]).coerceAtMost(viewport.right - half[0] - 4f * density)
+ val cy = (oy - textPaint.textSize / 2f).coerceAtLeast(viewport.top + half[1] + 4f * density)
+ drawGripText(canvas, text, cx, cy)
}
- // max-temperature marker, same geometry as the local view
+ val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
+ }
+ // the trace toggle governs both extremes, as on the live screen
if (state.maxTraceOn && state.maxPos >= 0 && state.maxTempC != null) {
- val sx = state.maxPos % 160
- val sy = state.maxPos / 160
- val p = probeToScreen(sx, sy)
- val markerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- color = Color.WHITE
- setShadowLayer(3f * density, 0f, 0f, Color.BLACK)
- }
+ val p = probeToScreen(state.maxPos % 160, state.maxPos / 160)
markerPaint.style = Paint.Style.STROKE
markerPaint.strokeWidth = 2.5f * density
- canvas.drawCircle(p[0], p[1], 7f * density, markerPaint)
+ canvas.drawCircle(p[0], p[1], 9f * density, markerPaint)
markerPaint.style = Paint.Style.FILL
- canvas.drawCircle(p[0], p[1], 3.5f * density, markerPaint)
+ canvas.drawCircle(p[0], p[1], 4.5f * density, markerPaint)
+ }
+ if (state.maxTraceOn && state.minPos >= 0 && state.minTempC != null) {
+ val p = probeToScreen(state.minPos % 160, state.minPos / 160)
+ markerPaint.style = Paint.Style.STROKE
+ markerPaint.strokeWidth = 2.5f * density
+ canvas.drawCircle(p[0], p[1], 9f * density, markerPaint)
+ markerPaint.style = Paint.Style.FILL
+ canvas.drawCircle(p[0], p[1], 4.5f * density, markerPaint)
}
}
- /** Sensor pixel -> screen position under the fixed 90 CW rotation + insets. */
+ /** Draw OSD text pre-rotated by the grip angle, centred on a buffer point. */
+ private fun drawGripText(canvas: Canvas, text: String, centerX: Float, centerY: Float) {
+ val tw = textPaint.measureText(text)
+ val fm = textPaint.fontMetrics
+ canvas.save()
+ canvas.rotate(textRot, centerX, centerY)
+ val baseline = centerY - (fm.ascent + fm.descent) / 2f
+ canvas.drawText(text, centerX - tw / 2f, baseline, textPaint)
+ canvas.restore()
+ }
+
+ private fun gripTextHalf(text: String): FloatArray {
+ val fm = textPaint.fontMetrics
+ return com.mag160c.thermal.ui.live.ImageTransform.rotatedBoxHalfExtents(
+ textPaint.measureText(text), fm.descent - fm.ascent, textRot,
+ )
+ }
+
+ /** Sensor pixel -> screen position, sharing the live view's geometry. */
private fun probeToScreen(sx: Int, sy: Int): FloatArray {
val viewW = vm.uiViewW.toFloat().coerceAtLeast(1f)
val availH = (vm.uiViewH - vm.uiTopPx - vm.uiBottomPx).toFloat().coerceAtLeast(1f)
- var dstW = viewW
- var dstH = viewW * 4f / 3f
- if (dstH > availH) {
- dstH = availH
- dstW = availH * 3f / 4f
- }
- val left = (viewW - dstW) / 2f
- val top = vm.uiTopPx + (availH - dstH) / 2f
- val fx = 1f - sy / 120f
- val fy = sx / 160f
- return floatArrayOf(left + fx * dstW, top + fy * dstH)
+ val params = vm.imageParams(DeviceOrientation.deg.value)
+ val fit = com.mag160c.thermal.ui.live.ImageTransform.fit(0f, vm.uiTopPx.toFloat(), viewW, availH, params.rotDeg)
+ val crop = com.mag160c.thermal.ui.live.ImageTransform.cropForZoom(vm.state.value.zoom)
+ return com.mag160c.thermal.ui.live.ImageTransform.sensorToScreen(
+ sx.toFloat(), sy.toFloat(), params, fit, crop,
+ )
}
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt
index 0ec4a49..18e8286 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt
@@ -66,6 +66,7 @@ fun RemoteViewerScreen(
val state by vm.state.collectAsState()
val phi by DeviceOrientation.deg.collectAsState()
val density = LocalDensity.current.density
+ val context = androidx.compose.ui.platform.LocalContext.current
var showPalette by remember { mutableStateOf(false) }
var navPx by remember { mutableStateOf(com.mag160c.thermal.ui.UiInsets.navPx) }
var shutterPx by remember { mutableStateOf(0) }
@@ -76,15 +77,15 @@ fun RemoteViewerScreen(
kotlinx.coroutines.delay(400)
navPx = com.mag160c.thermal.ui.UiInsets.navPx
vm.uiBottomPx = navPx + shutterPx
+ // keep the remote image oriented like the live view
+ val s = com.mag160c.thermal.ui.settings.AppSettings(context)
+ vm.userRotateDeg = s.imageRotateDeg
+ vm.flipH = s.imageFlipH
+ vm.flipV = s.imageFlipV
}
}
LaunchedEffect(Unit) {
- vm.disconnected.collect {
- // read the live status, not a value captured at composition time
- onDisconnected(
- if (vm.state.value.status == "connect_fail") "无法连接主机" else "连接已断开",
- )
- }
+ vm.disconnected.collect { reason -> onDisconnected(reason) }
}
Box(modifier = Modifier.fillMaxSize()) {
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt
index 579ea83..fd6d9d6 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt
@@ -63,13 +63,31 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
@Volatile
var uiViewH: Int = 2280
+ /**
+ * Manual orientation corrections, persisted like the live screen's. The
+ * remote view must use the SAME settings as the host's live view, otherwise
+ * the two screens disagree about which way is up.
+ */
+ @Volatile
+ var userRotateDeg: Int = 0
+
+ @Volatile
+ var flipH: Boolean = false
+
+ @Volatile
+ var flipV: Boolean = false
+
+ /** See [com.mag160c.thermal.ui.live.ImageTransform.params]. */
+ fun imageParams(gripDeg: Int): com.mag160c.thermal.ui.live.ImageTransform.Params =
+ com.mag160c.thermal.ui.live.ImageTransform.params(gripDeg, userRotateDeg, flipH, flipV)
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var session: RemoteSession? = null
private var pipeline: RenderPipeline? = null
private var renderJob: Job? = null
/** Emits once when the link drops (UI shows a snackbar and returns). */
- private val _disconnected = MutableSharedFlow(extraBufferCapacity = 1)
+ private val _disconnected = MutableSharedFlow(extraBufferCapacity = 1)
val disconnected = _disconnected
/** Connect, then immediately start the stream. */
@@ -78,7 +96,7 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
val s = RemoteClient.connect(host, port, scope)
if (s == null) {
_state.value = _state.value.copy(connected = false, status = "connect_fail")
- _disconnected.tryEmit(Unit)
+ _disconnected.tryEmit("无法连接主机")
return@launch
}
session = s
@@ -98,18 +116,30 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
pipe.setPalette(_state.value.paletteIndex)
pipeline = pipe
- // control lines (welcome / stream-start) for logging
+ // control lines (welcome / busy / stream-start) for logging + UX
launch {
- s.lines.collect { line -> DebugLog.log("remote", "line: $line") }
+ s.lines.collect { line ->
+ DebugLog.log("remote", "line: $line")
+ if (RemoteContract.isBusyLine(line)) {
+ // the host is already serving someone else
+ _state.value = _state.value.copy(status = "busy")
+ _disconnected.tryEmit("主机正忙(已有客户端连接)")
+ }
+ }
}
- // frames -> local pipeline -> latestFrame
+ // frames -> local pipeline -> latestFrame.
+ // The host sends the raw counts plus the metadata ITS pipeline used
+ // (FFC phase + camera temperature). Running an independent FFC state
+ // machine on the client produced a wrong image (the NUC tables
+ // interpolate on the camera temperature, which was missing, so the
+ // counts saturated and the readout showed ~-161 C).
val out = IntArray(320 * 240)
renderJob = launch {
- s.frames.collect { raw ->
+ s.frames.collect { pkt ->
val n = _state.value.frames + 1
if (n == 1) DebugLog.log("remote", "first remote frame")
_state.value = _state.value.copy(frames = n)
- if (pipe.frame(raw, false, out)) {
+ if (pipe.frameRemote(pkt.pixels, pkt.ffcPhase, pkt.shutter, out)) {
latestFrame = out.copyOf()
}
}
@@ -117,7 +147,7 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
launch {
s.closedFlow.collect {
_state.value = _state.value.copy(connected = false, status = "disconnected")
- _disconnected.tryEmit(Unit)
+ _disconnected.tryEmit("连接已断开")
}
}
s.hello("mag160c-client")
@@ -132,7 +162,10 @@ class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) {
private fun refreshTemps() {
val pipe = pipeline ?: return
- // pipeline.probeTemp already returns millidegrees C (counts -> temp)
+ // gate on a completed render: before the first one the buffers hold
+ // zeros and countsToTempMc(0) reads about -161 C
+ if (!pipe.tempsReady()) return
+ // pipeline.probeTemp already returns millidegrees C
val centerMc = pipe.probeTemp(80, 60)
val nuc = IntArray(19200)
pipe.copyNuc(nuc)
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt
index cb21e24..8b06c02 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt
@@ -33,6 +33,22 @@ class AppSettings(context: Context) {
com.mag160c.thermal.cloud.CloudClient.setEnabled(v)
}
+ /**
+ * Manual image orientation corrections, mirroring the official app's
+ * "旋转USB画面" / "水平翻转" / "竖直翻转" settings.
+ */
+ var imageRotateDeg: Int
+ get() = sp.getInt("imageRotate", 0)
+ set(v) = sp.edit().putInt("imageRotate", ((v % 360) + 360) % 360).apply()
+
+ var imageFlipH: Boolean
+ get() = sp.getBoolean("imageFlipH", false)
+ set(v) = sp.edit().putBoolean("imageFlipH", v).apply()
+
+ var imageFlipV: Boolean
+ get() = sp.getBoolean("imageFlipV", false)
+ set(v) = sp.edit().putBoolean("imageFlipV", v).apply()
+
init {
com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled)
}
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt
index 634d180..28e763e 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt
@@ -34,6 +34,10 @@ fun SettingsScreen(
var dialog by remember { mutableStateOf(null) }
// mirror the persisted flag in Compose state so the row label updates
var cloudEnabled by remember { mutableStateOf(settings.cloudEnabled) }
+ // orientation corrections: local Compose state + persistence
+ var rotateDeg by remember { mutableStateOf(settings.imageRotateDeg) }
+ var flipH by remember { mutableStateOf(settings.imageFlipH) }
+ var flipV by remember { mutableStateOf(settings.imageFlipV) }
// remote-preview server toggle (Phase F); off by default, needs live USB
var remoteOn by remember { mutableStateOf(remoteHostRunning) }
var showNeedDevice by remember { mutableStateOf(false) }
@@ -46,6 +50,16 @@ fun SettingsScreen(
) { dialog = "emissivity" }
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
SettingRow("语言", settings.language) { dialog = "language" }
+ // manual orientation corrections (the official app has the same three)
+ SettingRow("旋转USB画面", "$rotateDeg°") { dialog = "rotate" }
+ SettingRow("水平翻转", if (flipH) "已开启" else "已关闭") {
+ flipH = !flipH
+ settings.imageFlipH = flipH
+ }
+ SettingRow("竖直翻转", if (flipV) "已开启" else "已关闭") {
+ flipV = !flipV
+ settings.imageFlipV = flipV
+ }
SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" }
SettingRow("远程预览服务端", if (remoteOn) "已开启" else "已关闭") {
if (remoteOn) {
@@ -114,8 +128,30 @@ fun SettingsScreen(
initialC = settings.alarmTempC,
onDone = { settings.alarmTempC = it; dialog = null },
)
- "cloud" -> AlertDialog(
+ "rotate" -> AlertDialog(
onDismissRequest = { dialog = null },
+ title = { Text("旋转USB画面") },
+ text = {
+ Column {
+ listOf(0, 90, 180, 270).forEach { deg ->
+ Text(
+ "$deg°",
+ color = if (deg == rotateDeg) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier
+ .clickable {
+ rotateDeg = deg
+ settings.imageRotateDeg = deg
+ dialog = null
+ }
+ .padding(14.dp),
+ )
+ }
+ }
+ },
+ confirmButton = {},
+ )
+ "cloud" -> AlertDialog( onDismissRequest = { dialog = null },
title = { Text("云同步") },
text = {
Text(
diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt
index b16f00c..8c38fe9 100644
--- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt
+++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt
@@ -539,8 +539,11 @@ class IrSession(context: Context) {
)
}
lastRawFrame = frameBuf.copyOf()
- lastRawFrame?.let { raw -> rawHook?.invoke(raw) }
val rendered = pipe.frame(frameBuf, true, out)
+ // After frame(): the host-side pipeline metadata (FFC phase and
+ // camera temperature) refers to THIS frame, which is what a
+ // remote client needs to reproduce the image.
+ lastRawFrame?.let { raw -> rawHook?.invoke(raw) }
if (rendered) {
renderCount++
listener?.onFrameReady(out)
@@ -589,6 +592,15 @@ class IrSession(context: Context) {
/** Slow-path probe: temperature at a sensor pixel in millidegrees C. */
fun probeTemp(x: Int, y: Int): Int? = pipeline?.probeTemp(x, y)
+ /** True once a frame completed the render path (temperatures are meaningful). */
+ fun tempsReady(): Boolean = pipeline?.tempsReady() ?: false
+
+ /** FFC phase of the frame just processed: 0 normal, 1 hidden, 2 reference. */
+ fun ffcPhase(): Int = pipeline?.ffcPhase() ?: 0
+
+ /** Camera temperature of the frame just processed (raw sensor units). */
+ fun lastShutter(): Int = pipeline?.lastShutter() ?: 0
+
/** Snapshot of the current NUC counts (already blind-compensated). */
fun copyNuc(out: IntArray): Boolean {
val p = pipeline ?: return false
diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt
new file mode 100644
index 0000000..26d0af9
--- /dev/null
+++ b/android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt
@@ -0,0 +1,120 @@
+package com.mag160c.thermal.core
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Regressions for the on-device temperature defects (2026-09-11):
+ *
+ * 1. the OSD showed ~150 C for a moment during every FFC, because the FFC
+ * reference frames were decoded into the same `nuc` buffer the OSD samples;
+ * 2. before the first completed render `nuc` is all zeros and
+ * countsToTempMc(0) is about -161 C, which the UI displayed;
+ * 3. the centre readout was double-converted (countsToTempMc applied to an
+ * already-converted value), showing e.g. 108.7 C for a ~24 C scene.
+ */
+class PipelineTemperatureStateTest {
+ private fun res(name: String): ByteArray =
+ javaClass.classLoader.getResourceAsStream(name)!!.readBytes()
+
+ private fun frame(buf: ByteArray, counter: Int, filler: Int): ByteArray {
+ // 0x38 header + 38400 payload + 0x38 tail, as the camera sends it
+ val f = ByteArray(0x38 + 38400)
+ MagProtocolBytes.putU32(f, 0, 0x1BB1B11B)
+ MagProtocolBytes.putU32(f, 4, counter)
+ MagProtocolBytes.putU32(f, 8, 38400)
+ MagProtocolBytes.putU32(f, 12, 0)
+ for (i in 0 until 38400) f[0x1C + i] = ((filler + i) and 0xFF).toByte()
+ MagProtocolBytes.putU32(f, 0x1C + 38400, 0x1BB1B11C)
+ MagProtocolBytes.putU32(f, 0x1C + 38400 + 4, counter)
+ MagProtocolBytes.putU32(f, 0x1C + 38400 + 8, 7000) // fpaTemp / shutter
+ MagProtocolBytes.putU32(f, 0x1C + 38400 + 12, 0)
+ return f
+ }
+
+ private fun pipeline(): RenderPipeline = RenderPipeline(
+ w = 160, h = 120, ffcPeriod = 1800, ffcDrift = 250, warmFrames = 2,
+ force75 = false, onFfc = {},
+ ).also { assertTrue("DDT must load", it.loadDdt(res("mag160c_official.ddt"))) }
+
+ @Test
+ fun temperaturesAreNotReadyBeforeTheFirstCompletedRender() {
+ val p = pipeline()
+ assertFalse("fresh pipeline must not report ready temps", p.tempsReady())
+ val out = IntArray(320 * 240)
+ // warm-up frames return false (nothing rendered yet)
+ assertFalse(p.frame(frame(ByteArray(0x38 + 38400), 0, 10), true, out))
+ assertFalse("still warming", p.tempsReady())
+ }
+
+ @Test
+ fun ffcReferenceWindowDoesNotLeakRawCountsIntoNuc() {
+ val p = pipeline()
+ val out = IntArray(320 * 240)
+ // drive the pipeline until it has rendered at least once
+ var counter = 0
+ var rendered = 0
+ while (rendered == 0 && counter < 200) {
+ if (p.frame(frame(ByteArray(0x38 + 38400), counter, counter), true, out)) rendered++
+ counter++
+ }
+ assertEquals("a frame must render", 1, rendered)
+ assertTrue("temps ready after a render", p.tempsReady())
+
+ val good = IntArray(19200)
+ p.copyNuc(good)
+ val goodMax = good.max()
+
+ // trigger a manual FFC and walk through the whole cycle, sampling `nuc`
+ // the way the OSD timer does
+ p.requestFfc()
+ var sawInflated = false
+ for (i in 0 until 40) {
+ // deliberately feed a very different raw frame during the window: if
+ // it were decoded into `nuc`, the sampled maximum would explode
+ p.frame(frame(ByteArray(0x38 + 38400), counter, 250), true, out)
+ counter++
+ val sample = IntArray(19200)
+ p.copyNuc(sample)
+ val mx = sample.max()
+ // raw counts are stored as sent (up to 250+... here) and NUC output is
+ // blind-compensated; anything near the full 16-bit range means raw
+ // data leaked into the OSD buffer
+ if (mx > 60000) sawInflated = true
+ }
+ assertFalse(
+ "raw counts must never appear in the OSD buffer during an FFC " +
+ "(max seen would be ~65535 -> about 150 C)",
+ sawInflated,
+ )
+ }
+
+ @Test
+ fun zeroCountsConvertToTheDocumentedFloorNotAPlausibleTemperature() {
+ // This is WHY the UI has to gate on tempsReady(): zero counts are a
+ // legal-looking number that converts to -161 C, not an error.
+ val t = TempMath.countsToTempMc(0)
+ assertEquals(-160995, t)
+ assertEquals(-161.0f, t / 1000f, 0.05f)
+ // and the double-conversion bug: converting an already-converted value
+ val correct = TempMath.countsToTempMc(7100)
+ assertTrue("a ~24 C scene", correct in 0..60_000)
+ val doubleConverted = TempMath.countsToTempMc(correct)
+ assertTrue(
+ "double conversion produces a wildly wrong number ($doubleConverted mC)",
+ doubleConverted < -50_000,
+ )
+ }
+}
+
+/** Minimal little-endian writer for the test frame header. */
+internal object MagProtocolBytes {
+ fun putU32(dst: ByteArray, off: Int, v: Int) {
+ dst[off] = (v and 0xFF).toByte()
+ dst[off + 1] = ((v shr 8) and 0xFF).toByte()
+ dst[off + 2] = ((v shr 16) and 0xFF).toByte()
+ dst[off + 3] = ((v ushr 24) and 0xFF).toByte()
+ }
+}
diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt
index af77260..ee255fd 100644
--- a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt
+++ b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt
@@ -2,6 +2,7 @@ package com.mag160c.thermal.net
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -9,8 +10,9 @@ import org.junit.Test
import java.util.Random
/**
- * Phase F wire contract: encode/feed round trip plus the two cases a raw socket
- * read always produces — truncated records and several records glued together.
+ * Phase F wire contract: encode/feed round trip plus the cases a raw socket read
+ * always produces — truncated records and several records glued together — and
+ * the frame metadata the client needs to reproduce the host's image.
*/
class RemoteContractTest {
private fun payload(seed: Int): ByteArray {
@@ -22,22 +24,44 @@ class RemoteContractTest {
@Test
fun encodeFramePacketLayoutMatchesTheContract() {
val raw = payload(1)
- val pkt = RemoteContract.encodeFramePacket(raw, counter = 7)
- assertEquals(38412, pkt.size)
+ val pkt = RemoteContract.encodeFramePacket(
+ raw, counter = 7, flags = RemoteContract.FLAG_RENDERS,
+ ffcPhase = RemoteContract.PHASE_REFERENCE, shutter = 1234,
+ )
+ assertEquals(38424, pkt.size)
assertEquals(RemoteContract.FRAME_MAGIC, RemoteContract.u32(pkt, 0))
assertEquals(7, RemoteContract.u32(pkt, 4))
assertEquals(RemoteContract.FRAME_PIXELS, RemoteContract.u32(pkt, 8))
- assertArrayEquals(raw, pkt.copyOfRange(12, pkt.size))
+ assertEquals(RemoteContract.FLAG_RENDERS, RemoteContract.u32(pkt, 12))
+ assertEquals(RemoteContract.PHASE_REFERENCE, RemoteContract.u32(pkt, 16))
+ assertEquals(1234, RemoteContract.u32(pkt, 20))
+ assertArrayEquals(raw, pkt.copyOfRange(RemoteContract.FRAME_HEADER, pkt.size))
}
@Test
- fun singleFrameRoundTrip() {
+ fun singleFrameRoundTripPreservesMetadata() {
val raw = payload(2)
- val reader = RemoteContract.FramePacketReader()
- val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 1))
+ val pkt = RemoteContract.encodeFramePacket(raw, 1, RemoteContract.FLAG_RENDERS, 2, 4242)
+ val frames = RemoteContract.FramePacketReader().feed(pkt)
assertEquals(1, frames.size)
- assertArrayEquals(raw, frames[0])
- assertEquals(0, reader.pending())
+ val f = frames[0]
+ assertEquals(1, f.counter)
+ assertEquals(2, f.ffcPhase)
+ assertEquals(4242, f.shutter)
+ assertTrue(f.renders)
+ assertArrayEquals(raw, f.pixels)
+ }
+
+ @Test
+ fun nonRenderingFrameIsFlagged() {
+ // the host marks frames it could not render (FFC/hidden) so the client
+ // does not mistake them for image data
+ val pkt = RemoteContract.encodeFramePacket(
+ payload(21), 5, flags = 0, ffcPhase = RemoteContract.PHASE_HIDDEN, shutter = 90,
+ )
+ val f = RemoteContract.FramePacketReader().feed(pkt)[0]
+ assertFalse("flags=0 means not rendered", f.renders)
+ assertEquals(RemoteContract.PHASE_HIDDEN, f.ffcPhase)
}
@Test
@@ -50,7 +74,8 @@ class RemoteContractTest {
assertTrue("still incomplete", reader.feed(pkt.copyOfRange(7, 1000)).isEmpty())
val frames = reader.feed(pkt.copyOfRange(1000, pkt.size))
assertEquals(1, frames.size)
- assertArrayEquals(raw, frames[0])
+ assertArrayEquals(raw, frames[0].pixels)
+ assertEquals(0, reader.pending())
}
@Test
@@ -58,23 +83,24 @@ class RemoteContractTest {
val raws = listOf(payload(4), payload(5), payload(6))
val glued = java.io.ByteArrayOutputStream()
raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
- val reader = RemoteContract.FramePacketReader()
- val frames = reader.feed(glued.toByteArray())
+ val frames = RemoteContract.FramePacketReader().feed(glued.toByteArray())
assertEquals(3, frames.size)
- frames.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) }
+ frames.forEachIndexed { i, f ->
+ assertEquals(i, f.counter)
+ assertArrayEquals(raws[i], f.pixels)
+ }
}
@Test
fun gluedWithRaggedBoundaries() {
- // 3 frames, fed in chunks that straddle record boundaries unevenly
val raws = List(3) { payload(10 + it) }
val glued = java.io.ByteArrayOutputStream()
raws.forEachIndexed { i, r -> glued.write(RemoteContract.encodeFramePacket(r, i)) }
val bytes = glued.toByteArray()
val reader = RemoteContract.FramePacketReader()
- val got = ArrayList()
+ val got = ArrayList()
var pos = 0
- val chunkPattern = intArrayOf(5, 38411, 1, 20000, 100, 40000)
+ val chunkPattern = intArrayOf(5, 38423, 1, 20000, 100, 40000)
var ci = 0
while (pos < bytes.size) {
val n = minOf(chunkPattern[ci % chunkPattern.size], bytes.size - pos)
@@ -83,17 +109,16 @@ class RemoteContractTest {
ci++
}
assertEquals(3, got.size)
- got.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) }
+ got.forEachIndexed { i, f -> assertArrayEquals(raws[i], f.pixels) }
}
@Test
fun garbageBeforeMagicResynchronises() {
val raw = payload(20)
val pkt = RemoteContract.encodeFramePacket(raw, 3)
- val reader = RemoteContract.FramePacketReader()
- val frames = reader.feed(byteArrayOf(1, 2, 3, 4, 5) + pkt)
+ val frames = RemoteContract.FramePacketReader().feed(byteArrayOf(1, 2, 3, 4, 5) + pkt)
assertEquals(1, frames.size)
- assertArrayEquals(raw, frames[0])
+ assertArrayEquals(raw, frames[0].pixels)
}
@Test
@@ -102,10 +127,9 @@ class RemoteContractTest {
val pkt = RemoteContract.encodeFramePacket(raw, 4)
val bad = RemoteContract.encodeFramePacket(payload(22), 5).copyOf()
RemoteContract.putU32(bad, 8, 12345) // wrong payload length
- val reader = RemoteContract.FramePacketReader()
- val frames = reader.feed(bad + pkt)
+ val frames = RemoteContract.FramePacketReader().feed(bad + pkt)
assertEquals("only the valid frame is delivered", 1, frames.size)
- assertArrayEquals(raw, frames[0])
+ assertArrayEquals(raw, frames[0].pixels)
}
@Test
@@ -135,12 +159,20 @@ class RemoteContractTest {
assertEquals("ffc", RemoteContract.commandOf(RemoteContract.cmd("ffc")))
assertEquals("welcome", RemoteContract.typeOf(RemoteContract.welcomeLine(160, 120, 15, 1)))
assertTrue(RemoteContract.isKeepalive(RemoteContract.pingLine()))
- assertTrue(!RemoteContract.isKeepalive(RemoteContract.okLine()))
- assertEquals("type", RemoteContract.typeOf(RemoteContract.streamStartLine()).let { "type" })
+ assertFalse(RemoteContract.isKeepalive(RemoteContract.okLine()))
assertEquals("stream-start", RemoteContract.typeOf(RemoteContract.streamStartLine()))
assertEquals("stream-stop", RemoteContract.typeOf(RemoteContract.streamStopLine()))
}
+ @Test
+ fun busyLineIsRecognised() {
+ // the host rejects a second client with this line; the client must
+ // surface it instead of silently waiting forever
+ assertTrue(RemoteContract.isBusyLine(RemoteContract.busyLine()))
+ assertFalse(RemoteContract.isBusyLine(RemoteContract.okLine()))
+ assertFalse(RemoteContract.isBusyLine(RemoteContract.pingLine()))
+ }
+
@Test
fun welcomeParsing() {
val w = RemoteContract.parseWelcome(RemoteContract.welcomeLine(160, 120, 15, 160043865L))
@@ -163,6 +195,6 @@ class RemoteContractTest {
val raw = payload(31)
val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 2))
assertEquals(1, frames.size)
- assertArrayEquals(raw, frames[0])
+ assertArrayEquals(raw, frames[0].pixels)
}
}
diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt
index 03ab9cf..d5a2823 100644
--- a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt
+++ b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt
@@ -50,7 +50,7 @@ class RemoteLoopbackTest {
assertEquals(160, parsed!!.w)
// start -> stream-start, then frames
- val received = ArrayList()
+ val received = ArrayList()
val collector = scope.launch {
session.frames.collect { received.add(it) }
}
@@ -80,7 +80,7 @@ class RemoteLoopbackTest {
sent.forEachIndexed { i, expect ->
assertTrue(
"frame $i must round-trip byte-exactly",
- expect.contentEquals(received[i]),
+ expect.contentEquals(received[i].pixels),
)
}
@@ -131,4 +131,88 @@ class RemoteLoopbackTest {
assertEquals(RemoteContract.CONTROL_PORT, info.tcpPort)
assertEquals(160043865L, info.serial)
}
+
+ /**
+ * Regression for the command-ordering defect: commands must reach the host in
+ * call order. The first implementation launched one writer coroutine per
+ * command, so `hello` immediately followed by `start` could arrive swapped and
+ * the client lost the `welcome` line (~6.7% of runs).
+ */
+ @Test
+ fun commandOrderIsPreservedUnderRapidSends() = runBlocking {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ val host = RemoteHost("jvm-order", 7L)
+ try {
+ host.start()
+ delay(300)
+ val session = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
+ assertNotNull(session)
+ session!!
+
+ val seen = java.util.Collections.synchronizedList(ArrayList())
+ val collector = scope.launch { session.lines.collect { seen.add(RemoteContract.typeOf(it)) } }
+ delay(100)
+
+ // a) hello immediately followed by start must yield welcome first
+ session.hello("order-test")
+ session.startStream()
+ withTimeout(5000) {
+ while (!seen.contains("welcome") || !seen.contains("stream-start")) delay(20)
+ }
+ val iWelcome = seen.indexOf("welcome")
+ val iStart = seen.indexOf("stream-start")
+ assertTrue("welcome ($iWelcome) must precede stream-start ($iStart)", iWelcome < iStart)
+
+ // b) many rapid commands must all reach the host, in order, and none
+ // may garble the frame stream. Replies are intentionally not sent
+ // while streaming (a JSON line between frame records would be read
+ // as stray bytes), so the host callback is the observable.
+ val ffcSeen = java.util.concurrent.atomic.AtomicInteger(0)
+ host.onFfcRequest = { ffcSeen.incrementAndGet() }
+ repeat(200) { session.requestFfc() }
+ withTimeout(8000) {
+ while (ffcSeen.get() < 200) delay(20)
+ }
+ assertEquals("all 200 ffc requests reached the host", 200, ffcSeen.get())
+ collector.cancel()
+ session.close()
+ } finally {
+ host.stop()
+ scope.cancel()
+ }
+ }
+
+ /**
+ * A second client must be told the host is busy instead of being left to
+ * hang in the kernel backlog (plan F2).
+ */
+ @Test
+ fun secondClientIsRejectedWithBusy(): Unit = runBlocking {
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ val host = RemoteHost("jvm-busy", 8L)
+ try {
+ host.start()
+ delay(300)
+ val first = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
+ assertNotNull("first client connects", first)
+ // start streaming so the host is definitely occupied
+ first!!.startStream()
+ withTimeout(5000) { first.lines.first { RemoteContract.typeOf(it) == "stream-start" } }
+
+ val second = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope)
+ assertNotNull("second client still connects at TCP level", second)
+ val busy = withTimeout(5000) {
+ second!!.lines.first { RemoteContract.isBusyLine(it) }
+ }
+ assertTrue(RemoteContract.isBusyLine(busy))
+
+ // the first client must be unaffected
+ assertEquals(RemoteHost.State.STREAMING, host.state)
+ second?.close()
+ first?.close()
+ } finally {
+ host.stop()
+ scope.cancel()
+ }
+ }
}
diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt
new file mode 100644
index 0000000..c61b19f
--- /dev/null
+++ b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt
@@ -0,0 +1,90 @@
+package com.mag160c.thermal.ui.live
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The orientation rule must reproduce the official app's on-screen result.
+ *
+ * Official mapping (MainActivity windowOrientationListener -> DeviceController.
+ * setPreviewOrientation, applied as matrix.postRotate in ImageViewer.drawImage):
+ * Display.rotation 0 -> image 90
+ * Display.rotation 1 -> image 0
+ * Display.rotation 2 -> image 270
+ * Display.rotation 3 -> image 180
+ * and the official WINDOW auto-rotates, so the visible result is
+ * image_rot - display_rotation*90 (mod 360) = 90 in all four cases.
+ *
+ * The display-rotation index -> degrees convention is taken from the official
+ * app's own camera code (VisibleCameraHelper.setPreviewOrientation):
+ * case 0 -> 0, case 1 -> 90, case 2 -> 180, case 3 -> 270
+ * i.e. index N means an N*90 CLOCKWISE device rotation, the same sign our grip
+ * angle uses. That makes the locked-window compensation rot = 90 - grip.
+ *
+ * Our Activity is portrait-LOCKED, so the window never rotates and the image
+ * must supply the whole difference: rotating the IMAGE by (90 - grip) gives the
+ * same constant 90 relative to the user.
+ */
+class ImageTransformOrientationTest {
+
+ /** The official app's image rotation for a given display rotation. */
+ private fun officialImageRot(displayRotation: Int): Int = when (displayRotation) {
+ 0 -> 90
+ 1 -> 0
+ 2 -> 270
+ else -> 180
+ }
+
+ @Test
+ fun officialMappingIsReproducedByTheGripRule() {
+ // Display.rotation index N corresponds to a CLOCKWISE device rotation of
+ // N*90 (the standard camera2 convention, where deviceOrientation is
+ // displayRotation*90). Our grip angle uses the same sign, so grip = N*90.
+ for (dr in 0..3) {
+ val grip = dr * 90
+ val official = officialImageRot(dr)
+ val ours = ImageTransform.params(grip).rotDeg
+ assertEquals(
+ "displayRotation=$dr (grip $grip) must match the official image rotation",
+ official, ours,
+ )
+ }
+ }
+
+ @Test
+ fun visibleOrientationIsGripIndependent() {
+ // On the official app the window rotates with the grip, so the visible
+ // image rotation is constant: image_rot - gripWindowContribution.
+ // For our locked window the visible rotation IS the image rotation
+ // measured against the world, and the rule keeps it at 90 for every grip.
+ for (grip in intArrayOf(0, 90, 180, 270)) {
+ val rot = ImageTransform.params(grip).rotDeg
+ val visible = ((rot + grip) % 360 + 360) % 360
+ assertEquals("grip=$grip keeps the world-aligned result", 90, visible)
+ }
+ }
+
+ @Test
+ fun turningThePhoneTurnsTheImageTheOppositeWay() {
+ // The reported defect: turning the phone right made the picture go the
+ // other way. The compensation must be OPPOSITE in sign to the grip.
+ val upright = ImageTransform.params(0).rotDeg // 90
+ val turnedRight = ImageTransform.params(90).rotDeg // 0
+ val turnedLeft = ImageTransform.params(270).rotDeg // 180
+ assertEquals(upright, (turnedRight + 90) % 360)
+ assertEquals(upright, (turnedLeft + 270) % 360)
+ assertTrue("rot must decrease as the grip increases", turnedRight < upright)
+ }
+
+ @Test
+ fun landscapeUsesTheWideFootprint() {
+ // holds for a landscape grip: the image is drawn 4:3 (not 3:4), which is
+ // the "must be rotated 180 in landscape" complaint
+ val landscape = ImageTransform.params(90).rotDeg
+ assertEquals(0, landscape)
+ assertEquals(4f / 3f, ImageTransform.screenAspect(landscape), 1e-4f)
+ val portrait = ImageTransform.params(0).rotDeg
+ assertEquals(3f / 4f, ImageTransform.screenAspect(portrait), 1e-4f)
+ }
+}
diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt
new file mode 100644
index 0000000..8a2a05d
--- /dev/null
+++ b/android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt
@@ -0,0 +1,138 @@
+package com.mag160c.thermal.ui.live
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * Geometry shared by the renderer and the tap handler. These tests exist because
+ * a mismatch between "what is drawn" and "where a tap maps" is invisible in unit
+ * tests but immediately wrong on a device — the sensor<->screen pair must be
+ * exact inverses for every grip angle and flip combination.
+ */
+class ImageTransformTest {
+ private val fit = ImageTransform.fit(
+ availLeft = 0f, availTop = 100f, availW = 1080f, availH = 1900f, rotDeg = 90,
+ )
+
+ @Test
+ fun rotationFollowsTheGripCompensationRule() {
+ // rot = 90 - grip: upright portrait draws the sensor 90 deg CW (sideways
+ // 3:4 image); turning the phone a quarter turn draws it upright 4:3
+ assertEquals(90, ImageTransform.params(0).rotDeg)
+ assertEquals(0, ImageTransform.params(90).rotDeg)
+ assertEquals(270, ImageTransform.params(180).rotDeg)
+ assertEquals(180, ImageTransform.params(270).rotDeg)
+ // the manual correction adds on top and stays normalised
+ assertEquals(180, ImageTransform.params(0, userRotateDeg = 90).rotDeg)
+ assertEquals(0, ImageTransform.params(0, userRotateDeg = 270).rotDeg)
+ }
+
+ @Test
+ fun fitUsesTheCorrectAspectForEachRotation() {
+ // 90/270 -> 3:4 portrait footprint; 0/180 -> 4:3 landscape footprint
+ assertTrue(ImageTransform.swapped(90))
+ assertTrue(ImageTransform.swapped(270))
+ assertFalse(ImageTransform.swapped(0))
+ assertFalse(ImageTransform.swapped(180))
+
+ // 1080x1900 area: the 3:4 footprint is 1080x1440, width-limited
+ val portrait = ImageTransform.fit(0f, 0f, 1080f, 1900f, 90)
+ assertEquals(1080f, portrait.width, 1f)
+ assertEquals(1440f, portrait.height, 1f)
+
+ // a short wide area makes the same rotation height-limited
+ val shortArea = ImageTransform.fit(0f, 0f, 1080f, 800f, 90)
+ assertEquals(800f, shortArea.height, 1f)
+ assertEquals(600f, shortArea.width, 1f)
+
+ // unrotated the footprint is 4:3 landscape
+ val wide = ImageTransform.fit(0f, 0f, 1080f, 400f, 0)
+ assertEquals(400f * 4f / 3f, wide.width, 1f) // height-limited
+ assertEquals(400f, wide.height, 1f)
+ }
+
+ @Test
+ fun sensorToScreenAndBackAreInversesForEveryGrip() {
+ val crop = ImageTransform.cropForZoom(1)
+ for (grip in intArrayOf(0, 90, 180, 270)) {
+ for (flipH in booleanArrayOf(false, true)) {
+ for (flipV in booleanArrayOf(false, true)) {
+ val p = ImageTransform.params(grip, 0, flipH, flipV)
+ val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
+ for (sx in intArrayOf(0, 37, 80, 159)) {
+ for (sy in intArrayOf(0, 22, 60, 119)) {
+ val scr = ImageTransform.sensorToScreen(sx.toFloat(), sy.toFloat(), p, f, crop)
+ val back = ImageTransform.screenToSensor(scr[0], scr[1], p, f, crop)
+ assertNotNull(
+ "grip=$grip flipH=$flipH flipV=$flipV pixel=($sx,$sy)",
+ back,
+ )
+ assertEquals(
+ "grip=$grip flipH=$flipH flipV=$flipV sx",
+ sx, back!!.first,
+ )
+ assertEquals(
+ "grip=$grip flipH=$flipH flipV=$flipV sy",
+ sy, back.second,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun screenPointsOutsideTheImageMapToNull() {
+ val p = ImageTransform.params(0)
+ val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
+ val crop = ImageTransform.cropForZoom(1)
+ // above the image and below it
+ assertNull(ImageTransform.screenToSensor(f.cx, 10f, p, f, crop))
+ assertNull(ImageTransform.screenToSensor(f.cx, 2000f, p, f, crop))
+ // far left of the image
+ assertNull(ImageTransform.screenToSensor(-500f, f.cy, p, f, crop))
+ }
+
+ @Test
+ fun zoomConfinesTheMappingToTheCroppedArea() {
+ val p = ImageTransform.params(0)
+ val f = ImageTransform.fit(0f, 100f, 1080f, 1900f, p.rotDeg)
+ val crop = ImageTransform.cropForZoom(2)
+ // the crop insets by 1/4 on each side for 2x zoom
+ assertEquals(0.25f, crop[0], 1e-4f)
+ assertEquals(0.75f, crop[2], 1e-4f)
+ // the centre still maps to the centre
+ val centre = ImageTransform.screenToSensor(f.cx, f.cy, p, f, crop)
+ assertNotNull(centre)
+ // the corner of the drawn rect maps to the CROP edge — that is what
+ // "zoomed in" means. Which sensor pixel depends on the rotation, so just
+ // require it to be inside the cropped quarter rather than at the border.
+ val corner = ImageTransform.screenToSensor(f.left + 1f, f.top + 1f, p, f, crop)
+ assertNotNull(corner)
+ val c = corner!!
+ assertTrue("corner lands inside the crop: ($c)", c.first in 40..119 || c.second in 30..89)
+ // and a point outside the drawn rect is rejected
+ assertNull(ImageTransform.screenToSensor(f.left - 50f, f.cy, p, f, crop))
+ }
+
+ @Test
+ fun rotatedTextBoxExtentsFollowTheRotation() {
+ // unrotated: half extents are half the box
+ val flat = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 0f)
+ assertEquals(50f, flat[0], 1e-3f)
+ assertEquals(10f, flat[1], 1e-3f)
+ // rotated 90 deg: the extents swap
+ val turned = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 90f)
+ assertEquals(10f, turned[0], 1e-3f)
+ assertEquals(50f, turned[1], 1e-3f)
+ // an unrotated and a 180-rotated box occupy the same footprint
+ val flip = ImageTransform.rotatedBoxHalfExtents(100f, 20f, 180f)
+ assertEquals(50f, flip[0], 1e-3f)
+ assertEquals(10f, flip[1], 1e-3f)
+ }
+}
diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk
index 176f3c5..3f88049 100644
--- a/build-artifacts/mag160c-app-debug.apk
+++ b/build-artifacts/mag160c-app-debug.apk
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
-oid sha256:3bfb57b3eae53e72dcb4a1416d2acd53157a9d84ff8ccdf0b11e069c1aa10cf1
+oid sha256:e7caf9b358fd69edc1c0c27a3c1aa61986ef3a8fda96713655f68ec6330ad814
size 12663188
diff --git a/docs/android_app/HANDOFF_DEVELOPMENT.md b/docs/android_app/HANDOFF_DEVELOPMENT.md
index 2160f83..4206f77 100644
--- a/docs/android_app/HANDOFF_DEVELOPMENT.md
+++ b/docs/android_app/HANDOFF_DEVELOPMENT.md
@@ -66,8 +66,9 @@ android/app/src/main/kotlin/com/mag160c/thermal/
│ ├─ LiveScreen.kt 稳定单分支相机布局:顶栏5控制项(FFC/变倍/追踪/调色板/画中画)
│ │ +底部快门区(相册/拍照/录像)+SurfaceView+PIP浮层
│ ├─ PipCameraView.kt 可见光PIP相机引擎(Camera2最小实现,异常只记日志)
-│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)钉在竖屏框架固定区域,
-│ 文字恒屏幕水平;色标条/圆圈标记/中心温OSD
+│ ├─ ImageTransform.kt 方向/翻转/letterbox/缩放几何 + 传感器↔屏幕互逆映射(纯 Kotlin,单测覆盖)
+│ └─ LiveRenderer.kt SurfaceView 软件渲染:图像按握持角补偿旋转(rot=90-φ)、
+│ letterbox+变倍、色标条/圆环标记/中心温OSD(标签按旋转包围盒定位)
├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限)
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/温度条+点击测温/备注回写/PDF报告)
├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/云同步/远程预览两行/关于)
@@ -129,25 +130,39 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
- 旧 analysis/protocol_spec.md 的 66f/670 描述("prepare/version query")不准,
以 magcx_official_flow.md 为准。
-### 4.3 实时画面渲染(最终约定:构图钉死竖屏框架)
+### 4.3 实时画面渲染(2026-09-11 修订:图像内容随握持朝向补偿)
+
- **Activity 锁定竖屏**(manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
- 中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
- 与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
-- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
- 加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
- Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
- 原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
- 几何仍钉死在图像上)。对话框与其他页签暂不补偿。
+ 中间、物理底边;手机怎么物理旋转,构图都不动。**不要改回 fullSensor。**
+- **图像内容补偿旋转(本轮修订,勿回退)**:构图不动,但**画面内容**必须随握持朝向
+ 反向补偿,否则转身后场景跟着转、与镜头实际指向脱节。规则:
+ `rot = 90 - φ`(φ = DeviceOrientation 握持角 0/90/180/270)。
+ 依据官方实现:`MainActivity.windowOrientationListener` 把 Display.rotation
+ (0/1/2/3) 映射成图像 90/0/270/180,再经 `ImageViewer.drawImage` 的
+ `matrix.postRotate` 应用;官方**窗口会随传感器旋转**,故可见结果恒为 90。
+ 我们锁了窗口,图像就要承担全部差值 → `90 - φ`。
+ 显示旋转索引→角度约定取自官方自家相机代码
+ (`VisibleCameraHelper.setPreviewOrientation`:case 0→0/1→90/2→180/3→270,
+ 即索引 N = 顺时针 N×90)。
+- **手工修正项**(官方"旋转USB画面/水平翻转/竖直翻转"同款):设置页三项,
+ 叠加在自动补偿之上,持久化于 `AppSettings.imageRotateDeg/imageFlipH/imageFlipV`,
+ 用于传感器安装方向特殊的机器。翻转在像素拷贝阶段完成,保证几何映射可测。
+- **图标/文字按握持角补偿**:`graphicsLayer rotationZ=-φ`(Compose)/
+ `canvas.rotate(-φ)`(Canvas)。**标签一律按旋转后包围盒定位**(
+ `ImageTransform.rotatedBoxHalfExtents` + `drawGripText`),不要再拿基线锚点
+ 摆位——色标条两端数字曾因此与色条错位。
+- **几何单一来源**:`ui/live/ImageTransform.kt`(纯 Kotlin,可单测)同时供
+ 渲染器与点击/探针映射使用(`sensorToScreen` / `screenToSensor` 互为逆映射,
+ 已对 0/90/180/270 × 翻转组合做往返测试)。渲染与取温映射**必须**用同一套参数,
+ 否则标记会落到错误像素上。
- **加速度计符号约定(第七轮教训,勿改回)**:真机 TYPE_ACCELEROMETER 静止读数
指向世界上方(竖屏正持 y=+9.81);模拟器虚拟传感器是反的重力约定(y=-9.81)。
DeviceOrientation 映射按**真机约定**写,模拟器测试须用反号值驱动
(φ=0→`adb emu sensor set acceleration 0:9.81:0`)。
-- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
-- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
- `fx=1-sy/120, fy=sx/160`)。
-- 顶栏=4 个相机控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称),恒在顶部并带小字
- 标签,`safeDrawing` 顶部inset 适配挖孔屏。
+- **追踪开关控制最高+最低两个标记**(此前只关最高,最低永远画着)。
+- 顶栏=5 个控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称 / 画中画),恒在顶部并带
+ 小字标签,`safeDrawing` 顶部 inset 适配挖孔屏。
- 底部(仅实时页)导航栏上方为**相机快门区**:相册快捷入口 / 大快门拍照 /
录像-停止;快门区高度并入 `vm.uiBottomPx`(= 导航高+快门区高)。
- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`(**必须挂在 safeDrawing inset 之前**,
@@ -158,6 +173,18 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
`connect()` 置 `status="no_device"`,LiveScreen 显示占位文案"未检测到热像仪,
请插入MAG160C",渲染器无帧黑底;startDemo/合成帧代码已删除。
+### 4.3.1 温度显示(2026-09-11 修复三个真机缺陷)
+
+- **中心温度**:`probeTemp()` 返回的**已经是毫度**,界面只许除以 1000 一次。
+ 旧代码又调了一次 `countsToTempMc()`,真机显示 108.7℃(实际约 24℃)。
+- **FFC 期间的温度跳变**:FFC 参考帧过去被解码进 `nuc`(OSD 采样的同一缓冲区),
+ 未补偿的原始 counts 直接参与显示 → 最高/最低短暂跳到 ~150℃。
+ 现在参考帧走独立 `refScratch` 缓冲。
+- **上电/未就绪**:首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0) = -161.0℃`
+ (看着像合法读数)。`RenderPipeline.tempsReady()` / `IrSession.tempsReady()`
+ 门控 UI 取温;远程页同样门控。
+- 回归测试:`core/PipelineTemperatureStateTest.kt`。
+
### 4.4 温度
- 温度 = 毫度 int(÷1000 = ℃)。`counts_to_temp_mc`(NUC域→T2E逆映射)用于探针/OSD,
在真机上需按 DDT 标定核对绝对值(待办)。
diff --git a/docs/android_app/execution_plan.md b/docs/android_app/execution_plan.md
index 60c91ce..47c2ffd 100644
--- a/docs/android_app/execution_plan.md
+++ b/docs/android_app/execution_plan.md
@@ -324,12 +324,23 @@ class PipCameraEngine(val context, val textureView) :
`{"type":"welcome","w":160,"h":120,"fps":15,"serial":...}`
- `{"cmd":"start"}` → `{"type":"stream-start"}` 后开始二进制帧
- `{"cmd":"stop"}` → `{"type":"stream-stop"}`
- - `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`
+ - `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`(**仅未出流时回复**;出流后
+ 客户端处于帧模式,主机只执行不回复,见下)
- **图像帧**(stream-start 之后,TCP 二进制流):
- `[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400][38400B 原始 u16LE 像素]`
- 共 38412B/帧。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
+ `[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400]`
+ `[u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400B 原始 u16LE 像素]`
+ 头部 24B,共 **38424B/帧**。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
(调色板/变倍全在客户端本地,无需回传)。
+ - `flags` bit0 = 主机本帧是否渲染成功;`ffcPhase` = 主机管线 FFC 阶段
+ (0 正常 / 1 快门关闭 / 2 参考帧采集);`shutter` = 本帧相机温度(原始单位)。
+ - **为什么必须带这几项**(2026-09-11 修订):NUC 表按快门温度插值,缺了它
+ counts 会饱和(真机表现为满屏噪声 + 读数 -161℃);参考帧还必须在客户端
+ 按同一规则平均。早期版本只传像素、客户端独立跑 FFC 状态机,画面不正确。
+ - 客户端相应入口:`RenderPipeline.frameRemote(frame, phase, shutter, out)`。
- **保活**:主机 3s 无帧发 `{"type":"ping"}`;客户端 10s 无任何数据判死重连。
+- **单客户端**:已有客户端时,第二连接立即收到 `{"type":"busy"}` 并被关闭。
+- **流中不写控制回复**:stream-start 之后客户端处于帧模式,此时主机不得再写
+ JSON 行(会被当作帧内杂散字节);`ffc` 在流中执行但不回复。
### F2. 文件与职责
1. `net/RemoteContract.kt`:常量(端口/魔数)、JSON data class、
diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md
index e9b9025..97affd1 100644
--- a/docs/android_app/real_device_checklist.md
+++ b/docs/android_app/real_device_checklist.md
@@ -39,7 +39,9 @@
| 17 | A:设置页 → "远程预览服务端"(点一下) | `[remote] host started (name=<机型> serial=0)` | 该行文案变为"已开启" |
| 18 | A:若第 17 步弹"先连接热像仪" | (无日志) | 说明 USB 会话不活跃:先回到实时页确认出流 |
| 19 | B:设置页 → "远程预览客户端" → "查找主机" | (B 端)列表出现 A 的主机名 | 10 秒内列出 A(卡片显示 `<主机名>` 与 `:47511`);未列出可用"手动添加"填 A 的 IP |
-| 20 | B:点该卡片(或手动 IP 后点"连接") | A 端:`[remote] client connected from `;B 端:`[remote] client connected to :47511` → `[remote] line: {"type":"welcome",...}` → `[remote] line: {"type":"stream-start"}` → `[remote] first remote frame` | B 显示 A 的热像实时画面(同一构图:竖屏 3:4、顶栏 4 项、右侧色标条) |
+| 20 | B:点该卡片(或手动 IP 后点"连接") | A 端:`[remote] client connected from `;B 端:`[remote] client connected to :47511` → `[remote] line: {"type":"welcome",...}` → `[remote] line: {"type":"stream-start"}` → `[remote] first remote frame` | B 显示 A 的热像实时画面,**温度读数与 A 端一致**(同一场景下中心温/最高最低相差应在 1℃ 内);画面朝向与 A 端相同(两机握持姿态不同时,各自按自己的姿态补偿) |
+| 20b | B 端确认温度不再是 -161℃、画面不是满屏噪声 | B 端 `[remote] first remote frame` 之后读数正常 | 这是 2026-09-11 修复项:协议改为随帧传 FFC 阶段+相机温度(`[flags][ffcPhase][shutter]`,帧记录 38424B)。若仍异常,检查两端 APK 是否同版本(新旧协议不兼容) |
+| 20c | 第三台设备(或 A 本机再连一次)尝试连接同一主机 | A 端:`[remote] rejecting : already serving a client` | 后到者立即收到 `{"type":"busy"}`,B 端弹"主机正忙(已有客户端连接)"并返回列表;**已在流的那台不受影响** |
| 21 | B:顶栏点变倍/追踪/调色板 | (无日志,全部本地) | 立即生效、无卡顿(调色板/变倍不下发到 A) |
| 22 | B:顶栏点 FFC | A 端出现一次快门校正;B 画面随之更新 | FFC 经 A 的热像仪执行 |
| 23 | B:点底部红色"断开"圆钮 | A 端:`[remote] client disconnected (frames=)` | B 返回主机列表并弹出"已断开"提示;A 服务端保持"已开启"待重连 |
@@ -49,6 +51,19 @@
中途断网/主机退出时 B 显示"连接已断开";A 端相机与本地画面**始终不受网络影响**;
日志中 `frames=…` 每 300 帧记一次。
+## 方向与温度(2026-09-11 修复项,重点验证)
+
+| # | 操作 | 预期 | 通过标准 |
+|---|------|------|----------|
+| 25 | 竖屏正持,观察画面 | (无日志) | 画面正常;中心温读数与手摸/环境常识一致(**不是 -161℃、不是 108℃ 这类离谱值**) |
+| 26 | 手持手机**顺时针转 90°**(横过来),观察画面内容 | (无日志) | **场景方向不变**(画面内容跟着手反向补偿),顶栏/底栏文字仍可读;不是整个场景跟着转 |
+| 27 | 点"追踪"关闭 | (无日志) | **最高温和最低温两个标记同时消失**;再点开→两个都出现(此前最低温标记关不掉) |
+| 28 | 点 FFC,紧盯最高/最低温读数 | `[cmd] FFC(0) write=8/8` | 读数**不出现 ~150℃ 的瞬时跳变**(可短暂保持不变,但不得跳到离谱值) |
+| 29 | 设置页"旋转USB画面"依次选 0/90/180/270° | (无日志) | 画面按所选角度整体旋转,用于修正传感器安装方向 |
+| 30 | 设置页"水平翻转"/"竖直翻转"开关 | (无日志) | 画面镜像;与官方 app 的同名设置表现一致 |
+| 31 | 横屏持机时观察色标条 | (无日志) | 色标条**两端**的最高/最低温数字紧贴色条两端,**不与色条重叠**、不偏移 |
+| 32 | 分析页打开一张 MDT,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 |
+
## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md
index 29af566..5bb9c8e 100644
--- a/docs/android_app/session_state.md
+++ b/docs/android_app/session_state.md
@@ -451,6 +451,58 @@
(CAMERA 权限原本会让相机变必需,已显式声明为可选)。
**未 push**(按用户指令)。
+## 用户反馈修复 第十八轮(2026-09-11,真机实测:温度/朝向/远程三类缺陷)
+
+用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理:
+
+- [x] **中心温度测量错误**:`probeTemp()` 返回的**已是毫度**,`updateTemps` 又调了
+ 一次 `countsToTempMc()` → 真机显示 108.7℃(实际约 24℃)。
+ 远程页同一 bug(显示 -161℃)。两处都改为只除 1000 一次,并在参数注释里
+ 写明单位契约。
+- [x] **FFC 时最高/最低温跳到 ~150℃**:FFC 参考帧被解码进 `nuc`(OSD 采样的同一
+ 缓冲区),未补偿的原始 counts 被当成温度。改为独立 `refScratch` 缓冲;
+ 另加 `tempsReady()` 门控(首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0)`
+ = **-161.0℃**,正是远程截图显示的读数)。
+ 新增回归测试 `PipelineTemperatureStateTest`(3 项)。
+- [x] **追踪开关只关最高温**:最低温标记未受开关控制(`LiveRenderer.drawOsd`)。
+ 现在开=最高+最低都画("高"/"低"),关=都不画。
+- [x] **画面转向反了 / 横屏应翻 180°**:原先"图像恒 90°CW 不动"的约定在真机上
+ 表现为转身后场景跟着转。改为**图像内容按握持角补偿**:`rot = 90 - φ`。
+ 依据官方实现(`MainActivity.windowOrientationListener` 把 Display.rotation
+ 映射成图像 90/0/270/180,经 `ImageViewer.drawImage` 的 `matrix.postRotate`
+ 应用;官方窗口随传感器转,可见结果恒 90);显示旋转索引→角度取官方自家
+ 相机代码 `VisibleCameraHelper.setPreviewOrientation`(N→N×90)。
+ 构图(条栏/图像区绝对位置)仍钉死竖屏框架不变。
+- [x] **新增官方同款方向设置**:设置页"旋转USB画面"(0/90/180/270)、
+ "水平翻转"、"竖直翻转",叠加在自动补偿之上并持久化。
+- [x] **色标条两端最高/最低温与色条错位**:标签原先按未旋转的基线锚点定位。
+ 改为**按旋转后包围盒**定位(`ImageTransform.rotatedBoxHalfExtents` +
+ `drawGripText`),任意握持角都贴着色条两端。
+- [x] **几何单一来源**:新增 `ui/live/ImageTransform.kt`(纯 Kotlin),渲染器与
+ 点击/探针映射共用同一套参数与互逆映射;新增 `ImageTransformTest`(6 项,
+ 覆盖 0/90/180/270 × 两种翻转的往返一致性)+ `ImageTransformOrientationTest`
+ (4 项,锁官方映射与"转身时图像反向")。
+- [x] **远程预览画面不正确**(用户截图:-161℃、满屏噪声):根因是协议只传像素,
+ 客户端独立跑 FFC 状态机且**拿不到帧的相机温度**,而 NUC 表要按快门温度插值
+ → counts 饱和、参考帧缺失。协议改为传元数据:
+ `[magic][counter][38400][flags][ffcPhase][shutter][像素]`(头 24B,记录 38424B),
+ 客户端用 `RenderPipeline.frameRemote(frame, phase, shutter, out)` 复刻主机状态。
+ (用户建议"传原始数据本地渲染"正是此方向;此前的错误在于只传了数据的一半。)
+- [x] **顺带修核查报告的两项证伪**:`RemoteSession.send()` 改为**单写协程 + 队列**
+ (原先每条命令各起协程写同一 socket,`hello`/`start` 可能乱序,~6.7% 丢
+ `welcome`;新增 `commandOrderIsPreservedUnderRapidSends` 回归);
+ 主机实现"后来者写 busy"(accept 循环不再阻塞在 serve 上,第二客户端立即
+ 收到 `{"type":"busy"}`;新增 `secondClientIsRejectedWithBusy`)。
+ 另删除未使用的 `ACCESS_NETWORK_STATE` 权限。
+- [x] 单测 44 → **61 项全绿**;debug + release(R8) 双构建通过;APK 已更新
+ (12.66MB)。`screenOrientation=portrait` 复核保持、camera 系列仍
+ not-required、多余权限已消失。
+
+⚠️ **规格偏离(已告知用户)**:本轮推翻了第四/五轮"图像恒 90°CW 不动"的约定
+(用户实测该约定导致转向错误)。构图绝对位置不变,仅图像**内容**随握持角补偿。
+
+
+
## 本轮(2026-09-10 执行计划 A→F)总结
七个阶段全部落地,每阶段一次 commit:
diff --git a/docs/android_app/verification_report.md b/docs/android_app/verification_report.md
new file mode 100644
index 0000000..7cbdbdc
--- /dev/null
+++ b/docs/android_app/verification_report.md
@@ -0,0 +1,454 @@
+# Phase A→F 独立核查报告(可交给修复模型)
+
+> **核查者立场**:本报告由独立核查 AI 撰写,不采信执行者(下称"执行者")的
+> 总结/session_state/checklist 叙述,全部结论来自 ① git 提交内容
+> ② 仓库内官方逆向产物 ③ 核查者亲自重跑的命令与亲自编写的独立工具
+> ④ 明确标注"无法判定"的真机项。
+>
+> **核查范围**:`06c1f30..8e1312a`(7 个提交),基线 `087e15c`;
+> 核查时 HEAD = `0919f5e`(含核查指南提交)。
+> **本轮核查未修改任何源码、未提交、未 push**;本报告文件是唯一新增物
+> (未提交,`git status` 会显示为 untracked)。
+>
+> **总体结论**:第一关硬门槛(git 完整性 / 禁改清单 / debug+release 构建 /
+> 44 项单测)**全部通过**。逐阶段核查发现 **2 项证伪**(Phase F 命令写入
+> 未串行化、Phase F busy 未实现)、**1 项 Phase Z 越界**(Manifest 改动)、
+> **2 项低风险偏差**,其余声明证实;需真机/双设备的项一律"无法判定"。
+> Phase C 最高风险项(索引 4/8/9 映射)经独立复核**证实**,且本次找到了比
+> 执行者更强的决定性证据。
+
+---
+
+## 0. 修复任务清单(给修复模型)
+
+按优先级排列。每条含:位置、现象、根因、建议改法、改完怎么验、不修的风险。
+**修复时仍须遵守 `execution_plan.md` §0 的禁改清单**(竖屏锁定、命令字节序、
+握手序列、LiveRenderer 构图、analysis/ 只读)。
+
+### FIX-1(高)`RemoteSession.send()` 并发写同一 socket 导致命令乱序
+
+- **位置**:`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt:256-269`
+- **现象(已复现)**:`hello()` 紧接 `startStream()` 时,主机可能先收到 `start`;
+ 主机随即回 `stream-start`,客户端 reader 切到帧模式
+ (`RemoteClient.kt:194`),后到的 `welcome` 文本被 `FramePacketReader` 当垃圾丢弃。
+- **实测数据**:裸 socket 抓字节,5 次运行中 1 次观测到 `hello`/`start` 顺序颠倒;
+ 面向真实 `RemoteHost` 的生产序列压测 **120 次中 8 次丢失 `welcome` 行(≈6.7%)**;
+ 帧本身始终正常(30/30 各轮均收到)。
+- **根因**:`send()` 每条命令 `scope.launch(Dispatchers.IO)` 新协程写同一个
+ `socket.getOutputStream()`。多协程竞争同一流,**顺序无法保证**。
+- **建议改法**:**不要用 Mutex**(Mutex 只保证互斥,不保证"launch 顺序=写入顺序",
+ 本缺陷是乱序而非字节交错,加锁修不掉)。改为单写协程 + 队列:
+
+ ```kotlin
+ // RemoteSession 内新增
+ private val outQueue = kotlinx.coroutines.channels.Channel(
+ kotlinx.coroutines.channels.Channel.UNLIMITED,
+ )
+ private val writerStarted = java.util.concurrent.atomic.AtomicBoolean(false)
+
+ private fun ensureWriter() {
+ if (!writerStarted.compareAndSet(false, true)) return
+ scope.launch(Dispatchers.IO) {
+ val out = socket.getOutputStream()
+ try {
+ for (line in outQueue) {
+ out.write((line + "\n").toByteArray(Charsets.UTF_8))
+ out.flush()
+ }
+ } catch (e: Exception) {
+ if (!closed.get()) DebugLog.log("remote", "writer ended: ${e.javaClass.simpleName}")
+ }
+ }
+ }
+
+ private fun send(cmd: String) {
+ if (closed.get()) return
+ ensureWriter()
+ outQueue.trySend(cmd) // 调用方(UI 协程)顺序入队 → 顺序落盘
+ }
+
+ fun close() {
+ if (!closed.getAndSet(true)) {
+ runCatching { outQueue.close() }
+ runCatching { socket.close() }
+ }
+ }
+ ```
+
+ 要点:`trySend` 在**调用方线程**顺序入队,写 socket 只发生在唯一协程里。
+- **可选补充(不替代上面的修复)**:客户端在帧模式下遇到非帧数据时,
+ 可先尝试按 JSON 行解析再丢弃,作为对旧主机的兼容容错。属于加固,不解决根因。
+- **验证方法**:复跑"高频命令"用例——`hello()`+`startStream()` 连续 3 对,
+ 再接 400 次 `requestFfc()`,断言收到的行数、内容与**顺序**完全一致、
+ 无嵌套花括号行。(核查者的独立 harness 已实现该用例,见 §6。)
+- **不修的风险**:目前 `welcome` 只被打印(`RemoteViewerViewModel.kt:103`),
+ 用户可见影响小;但 `hello→welcome` 契约已被破坏。同类乱序也可能发生在
+ 快速 stop→start、连续 FFC 等序列上,属真实缺陷。**建议修**。
+
+### FIX-2(中)"后来者拒绝写 busy" 未实现
+
+- **位置**:`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt:130-151`(acceptLoop/serve)
+- **现象**:`serve(socket)` 在 accept 循环内**同步**执行,第二个客户端只会在内核
+ backlog 里排队等待第一个断开;主机**从不写 `busy`**。
+ `RemoteContract.isBusyLine()`(`RemoteContract.kt:147`)全仓库**无调用点**,是死代码。
+- **与计划冲突**:`execution_plan.md` F2 明确要求"单客户端,后来者拒绝写 busy"。
+- **建议改法(二选一)**:
+ - **(a) 实现**:accept 循环不阻塞在 serve 上,用标志位拒绝后来者:
+
+ ```kotlin
+ private val serving = java.util.concurrent.atomic.AtomicBoolean(false)
+ // acceptLoop 的 while(running) 内:
+ val socket = server.accept()
+ if (!serving.compareAndSet(false, true)) {
+ runCatching {
+ val o = socket.getOutputStream()
+ o.write((RemoteContract.busyLine() + "\n").toByteArray(Charsets.UTF_8))
+ o.flush()
+ }
+ runCatching { socket.close() }
+ continue
+ }
+ scope?.launch { try { serve(socket) } finally { serving.set(false) } }
+ ```
+
+ 需在 `RemoteContract` 补 `fun busyLine(): String = "{\"type\":\"busy\"}"`;
+ 客户端侧建议在 `lines` 收集处对 `busy` 记一条日志(否则用户只看到连不上)。
+ - **(b) 如不实现**:同步修改 `execution_plan.md` F2 与相关文档,如实写明
+ "第二客户端排队等待,不写 busy",并把 `isBusyLine()` 删除或标注保留用途。
+- **验证方法**:单测/回环:client1 连上后 client2 连接,断言 client2 在 1s 内
+ 收到 `{"type":"busy"}` 或被立即关闭,且 client1 的帧流不受影响。
+- **不修的风险**:单客户端约束事实成立,风险低;但"计划声明了、代码没有、
+ 解析器留了死代码"三者不一致,会误导后续维护者。
+
+### FIX-3(低)lifetime 负载接受两种布局(超出官方规格的对冲)
+
+- **位置**:`android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt:226-231`
+- **现象**:实现同时接受 ①`[magic][magic][ms]`(12B,计划代码片段的字面写法,
+ 与计划自己的"共 8 字节"描述矛盾)②`[magic][ms]`(8B,官方 Java 唯一实现)。
+- **官方权威**:`jadx_magcx/.../UsbCommunication.java:190-205` —
+ `bb.getInt()==1538635105` 后再 `getInt()`,即 **8 字节 `[magic][ms]`**。
+ 另注意 `IrSession.readResp` 已剥掉前 4 字节 magic(`r.third` 是**去掉** magic 的负载),
+ 故官方布局下 `r.first==8`、`r.third.size==4`、ms = `u32(r.third,0)`。
+- **建议改法**(收紧为官方唯一布局):
+
+ ```kotlin
+ readResp(conn, epResp, "GetLifeTime")?.let { r ->
+ // readResp 已剥离 4B magic;官方帧共 8B → 余 4B 即 i32 ms
+ if (r.second == MagProtocol.RSP_SEND_LIFETIME && r.third.size >= 4) {
+ deviceLifetimeMs = MagProtocol.u32(r.third, 0).toLong() and 0xFFFFFFFFL
+ }
+ }
+ ```
+
+- **验证方法**:真机清单第 3 步出现 `[session] device lifetime=ms` 且非 -1。
+- **不修的风险**:不会误判为连接失败,仅接受一个官方不发送的布局;低。
+
+### FIX-4(低)多余权限 `ACCESS_NETWORK_STATE`
+
+- **位置**:`android/app/src/main/AndroidManifest.xml:15`
+- **现象**:全仓库 grep 无 `ConnectivityManager`/`NetworkCapabilities`/权限检查,
+ 该权限从未被使用(计划 F9 只要求 INTERNET)。
+- **建议改法**:删除该行;若保留,需在提交信息/文档中说明用途。
+- **验证方法**:`aapt2 dump badging build-artifacts/mag160c-app-debug.apk` 不再列出。
+
+### FIX-5(低)Phase Z 越界:`8e1312a` 含 Manifest 功能性改动
+
+- **位置**:`8e1312a` 修改了 `android/app/src/main/AndroidManifest.xml`(+3 条
+ `uses-feature`:`camera`、`camera.autofocus` 显式 `required=false`,并改写注释)。
+- **性质**:改动本身**合理**(防止 CAMERA 权限隐式要求相机硬件,保证无相机
+ 设备仍可运行热像主功能),执行者已主动交底;但违反"Z 内容须仅为文档/清单/APK"。
+- **建议**:不需要回滚功能,**只需在 `session_state.md` / `execution_plan.md`
+ 如实记录该改动发生在 Z 提交**(承认越界),避免后续核查再次对不上账。
+
+### FIX-6(低)文档口径修正
+
+1. `docs/android_app/real_device_checklist.md:29`(第 15 步):预期"返回后 PIP 画面
+ 重新出现"是**推断而非实测**。代码里 `PipCameraEngine.released` 置位后不复位,
+ 只能依赖 `onSurfaceTextureAvailable` 再次触发。**先按真机实测结果决定改文档
+ 还是改代码**(指南 §3E 明确:若真机不符,应判定为清单预期有误)。
+2. `analysis/sdk_re/android_app/palette_extraction_findings.md:71`:红热占位图的
+ 分母表述混用了两类像素。实测(ImageIO 逐像素):
+ **全部像素 11777/11914 相同**;**不透明像素 8536/8673 相同**(meanAbsDiff 2.18/765)。
+ 原文"11914 个**不透明**像素中 11777 个相同"应改为上述两种口径之一。
+3. `session_state.md` / `execution_plan.md`:补记本报告 §3-F 的两项证伪
+ (FIX-1 乱序、FIX-2 busy 未实现),使文档不再高于实现。
+
+### FIX-7(可选,测试加固)
+
+1. `MdtTest`:补一条**含 EXIF 缩略图(内部含 `FF D9`)的 JPEG** 往返用例。
+ 核查者已独立验证当前实现能正确取主图 EOI(不会被缩略图干扰),
+ 但仓库测试缺失该场景,属回归风险。
+2. `VendorPalettes`:`PalettesTest.vendorTablesCoverIndicesZeroThroughTen`
+ (`PalettesTest.kt:47-63`)只证明 `Palettes.buildAll()` 用了 `VendorPalettes`
+ 的对应槽位,**不能**证明 `VendorPalettes` 里 case N 的内容确实来自 case N
+ (全库仅 case 2 有官方锚点)。建议为 11 张表各加一条"金标"断言
+ (如 256 项 CRC32/采样点),把本次已外部验证过的映射冻结住。
+3. `RemoteLoopbackTest`:在 FIX-1 完成后,补"高频命令顺序"用例(见 FIX-1 验证方法)。
+4. 清理死代码:`Palettes.kt:77/89/98/110` 的 `rainbow()`/`highContrast()`/
+ `hotMetal()`/`jet()` 已无调用点(Phase C 接入精确表后遗留)。
+
+### 判定为"可接受、无需修改"的项
+
+- **Phase B 温度条固定未缩放 fitRect**:与计划字面一致(`imageRect(size,1f,Offset.Zero)`),
+ 取舍合理。
+- **Phase B 探针标签随缩放/平移**(`AnalyzeViewer.kt:266` 用 `imageRect(size,zoom,pan)`):
+ 与计划"标签随 fitRect 走"字面不符,但**实现明显更正确**(标签须跟随像素),
+ 判定为实现优于字面规格,建议保留(可在文档注明)。
+- **Phase B 分析页直接映射(无 90° 旋转)**:`AnalyzeViewer` 画布 `aspectRatio(4f/3f)`
+ 且 `drawImage` 无 `canvas.rotate`,位图 `Bitmap.createBitmap(160,120)` 为原始朝向,
+ 直接映射正确。
+
+---
+
+## 1. 第一关:硬门槛(全部通过,故继续逐阶段核查)
+
+| 声明 | 核查方法 | 结论 |
+|---|---|---|
+| 工作树干净 | `git status --short` 无输出 | 证实 |
+| 范围内恰 7 个提交、消息与计划逐字一致 | `git log --oneline 087e15c..HEAD`,逐条比对计划"提交信息" | 证实 |
+| 未 push | `git log --oneline -1 origin/main` = `087e15c` | 证实 |
+| 竖屏锁定未改 | 源码 diff 无 `orientation` 删改;`aapt2 dump xmltree` 得 `screenOrientation(0x0101001e)=1` | 证实 |
+| `MagProtocolTest` 未变 | `git diff --stat` 为空 | 证实 |
+| `MagProtocol.kt` 仅新增常量 | diff 仅 `RSP_SEND_LIFETIME` 三行 | 证实 |
+| `LiveRenderer.kt` 未变 | `git diff` 为空 | 证实 |
+| `analysis/` 既有文件未被改 | `--diff-filter=M` 为空;仅 8 个新增文件 | 证实 |
+| `IrSession` 握手序列未改 | 人工读 diff:66b→66c→66f→[670]→673、800ms 超时、前半段失败即 abort 全部保留;删除行仅两处(cache 读后加 MD5、stats 行追加 lifetime) | 证实 |
+| `assembleDebug + assembleRelease + test` 全绿 | 亲自重跑:`BUILD SUCCESSFUL`(release 走完 R8) | 证实 |
+| 44 个单测、逐类分布 3/3/5/8/6/4/12/3 | `--rerun-tasks` 强制重跑,逐 XML 汇总 = 44/0/0;每类用例名与指南表格一一对应 | 证实 |
+| `RenderPipelineTest.matchesCReferencePixelExact` 仍通过 | 重跑后该用例在列且失败数为 0 | 证实 |
+
+---
+
+## 2. 逐阶段核查明细
+
+### Phase A(`06c1f30`)
+
+| 声明 | 方法 | 结论 |
+|---|---|---|
+| `RSP_SEND_LIFETIME = 0x5BB5B561` = 官方 `D2P_SendLifeTime` | `jadx_magcx/.../D2PCmd.java:10` = 1538635105;手算 `0x5BB5B561` = 1538635105 | 证实 |
+| `CMD_GET_LIFETIME` 未改 = 官方 `P2D_GetLifeTime` | `P2DCmd.java:8` = 1807136373 = `0x6BB6B675` | 证实 |
+| lifetime 失败不阻断连接 | `IrSession.kt:219-235` 失败仅 log,无 return/abort;官方 `getDevLifeTime` 亦仅 `Logging.error` | 证实(仅静态审查,无运行时证据) |
+| cali 缓存 MD5 对照不改变返回值 | cache-hit 分支先算 `cached`,log `identical/differ` 后仍 `return cached` | 证实 |
+| 心跳 stats 追加 lifetime | `IrSession.kt:559` `" lifetime=${deviceLifetimeMs}"` | 证实 |
+| checklist 日志串与代码一致 | 独立重跑 `CheckStrings.java`:exact=16/skeleton=30/review=7;并逐条人工追溯 7 条到模板与调用点(`"$name write=$n/${packet.size}"` 418 行、`"$name resp=0x%08X len=%d head=%s"` 433 行、`"BasePara1: serial=... @${fps}fps"` 292 行、`"ep 0x%02X fail#..."` 408 行;调用点 `"GetParameter1"` 185、`"GetParameter2"` 201)。另抽查 `hb: state=`、`first reads`、`first rendered frame`、`first run on this host`、`requesting permission`、`permission result`、`[crash]`(DebugLog 崩溃钩子)均存在 | 证实 |
+| 唯一偏差 | lifetime 负载接受两种布局(见 FIX-3) | 偏差(低) |
+
+### Phase B(`656d419`)
+
+核查者用 Gradle 缓存内的 Kotlin 编译器(`kotlin-compiler-embeddable-2.2.0.jar`)
+在仓库外搭建独立 harness,直接编译并驱动**仓库真实源码**
+(`Mdt.kt`/`TempMath.kt`/`OfficialTables.kt`),不依赖执行者的任何测试代码。
+
+| 声明 | 方法 | 结论 |
+|---|---|---|
+| `Mdt.parse` 与 `compose` 互逆 | 独立构造**含 EXIF 缩略图(内部含 `FF D9`)**、长度 1081(非 4 对齐)的 JPEG,往返比对各段 | 证实:jpg/info0/info1/frame/text 全部字节一致,裁剪点为主图 EOI |
+| 坏尾/截断返回 null | 翻转尾部 1 字节、截断至 100B | 证实 |
+| text 去 NUL 填充 | `Mdt.kt:123` `trimEnd('\u0000')`;独立用例短文本往返 | 证实 |
+| 温度图 = u16LE → `countsToTempMc` | **独立复算**:从 `csdk/src/mag160c_official_t2e.h` 解析 646 项真表(非 Kotlin 表),照 `mag160c_render.c:64` 重写 C 版算法,对 19200 像素全量比对 | 证实:19200/19200 完全一致 |
+| 探针为"直接映射" | `AnalyzeViewer.kt`:`aspectRatio(4f/3f)`、`drawImage` 无 `canvas.rotate`、`Bitmap.createBitmap(160,120)` | 证实(静态) |
+| 温度条固定未缩放 fitRect | `drawTemperatureOsd` 传 `imageRect(size,1f,Offset.Zero)` | 证实 |
+| 探针标签 | 用 `imageRect(size,zoom,pan)`,与计划字面"随 fitRect"不同 | 偏差(实现更优,建议保留,见 §0) |
+
+### Phase C(`b7a928e`)—— 本轮最需深挖的一相
+
+| 声明 | 方法 | 结论 |
+|---|---|---|
+| `libcxsdk.so` 无静态调色板表 | 独立重跑 `PalScan.java`:三种编码(`B,G,R,0`/`B,G,R,0xFF`/忽略第 4 字节)**全部 not found**;[A] 段自校准证明扫描非盲 | 证实 |
+| 索引↔case 映射 0..10(case 标签即调色板序号) | ① `libcxsdk_decomp.txt:456-1180`:`switch(param_2)`,case 0..10 各一份生成体(另有 `case 0xc`/`case 0xd`);② **核查者新找到的决定性证据**:`jadx_magcx/.../DialogFragmentPalette.java` 的 `mapIndex2Id_` 把 UI 索引 0..11 依次对应白热…红热,点击时 `DeviceController.setColorPalette(index)` 把 **UI 索引原样传给 native** → case 标签 = UI 索引;③ case 2 → `OfficialTables.PALETTE256_ARGB` 256/256(重跑生成链复现) | 证实 |
+| 索引 4/8/9 映射成立(执行者交底的不一致项) | **自写独立工具**(JDK `ImageIO` 解码官方预览图,不用执行者的手写 PNG 解码器),从 `PalBody` 重算全部 case 颜色集合,对 12 张预览图做完整覆盖矩阵 | 证实:case 0..10 **各自都是自身预览图的最佳解释者**;红饱和 case9 26.7% vs case0 22.1%,其 meanChroma=9.7 解释了"像素加权最近色距离"为何误判 |
+| 索引 11 未解决、`SOURCE_CASE[11]==-1`、保留近似曲线 | `VendorPalettes.kt:24`、`Palettes.kt:47` | 证实 |
+| 铁虹仍是官方表 | `officialIronbow()` 直接返回 `OfficialTables.PALETTE256_ARGB`;`PalettesTest.ironbowMatchesOfficialTables` 通过 | 证实 |
+| 生成物可复现 | 两文件一起编译 → 重新生成:锚点 `iron_bow vs OfficialTables anchor: 256/256`;`VendorPalettes.kt`、`palette_candidates.json`、`palette_match_report.txt` **diff 全空** | 证实 |
+| 红热预览图为占位副本 | ImageIO 逐像素:全像素 11777/11914 相同;不透明像素 8536/8673 | 证实(文档分母表述需修正,见 FIX-6) |
+| `extract_palettes.py` | 本机无 python3,无法运行 | 无法判定(弱旁证) |
+
+### Phase D(`c34940e`)
+
+| 声明 | 方法 | 结论 |
+|---|---|---|
+| Retrofit 2.11.0 已接入 | `libs.versions.toml:9,24,25`;`app/build.gradle.kts:52,53` | 证实 |
+| 默认关闭 | `AppSettings.kt:29-38`;`CloudClientTest` 4 项全绿 | 证实 |
+| 无生产路径静默联网 | `grep -rn CloudClient android/app/src/main` 仅命中声明与 `AppSettings.setEnabled`;main 无 `api()` 调用点;`api()` 未开启即 `check()` 抛异常 | 证实 |
+| release 混淆不破 | 亲自跑 `assembleRelease`(R8)成功 | 证实 |
+| PROGUARD 规则 | `proguard-rules.pro:5` `-keep class com.mag160c.thermal.cloud.** { *; }` | 证实 |
+
+### Phase E(`f8b3200`)
+
+| 声明 | 方法 | 结论 |
+|---|---|---|
+| CAMERA 权限 + 相机可选;usb.host 仍 required | `aapt2 dump badging`:camera/camera.any/autofocus 均 not-required,`usb.host`/`screen.portrait` required | 证实 |
+| 顶栏第 5 项 + 图标 | `LiveScreen.kt` 5 个 `weight(1f)` 项,第 5 项 `ic_pip`+`画中画`;`ic_pip.xml` 双弧圆描边 + 右下实心矩形 | 证实 |
+| 三档 96/128/160dp、高=宽×3/4 | `PIP_WIDTHS_DP`;`hDp = wDp * 3 / 4`(72/96/120,整除无误差) | 证实 |
+| 右侧留色标条位 | `PIP_RIGHT_MARGIN_DP = 32`,初始 `pipXf=1f,pipYf=0f` | 证实 |
+| 异常不崩溃 | `PipCameraView.kt` 全部回调 try/catch,失败路径收敛到 `release()` | 证实(静态) |
+| 相机释放三处 | `PipOverlay` 的 `DisposableEffect onDispose`、离页同路径、`LiveScreen` 的 `LifecycleEventObserver(ON_STOP)` | 证实(静态) |
+| ON_STOP 后能否自动重开 | 代码侧 `released` 置位后不复位,重开依赖 `onSurfaceTextureAvailable`;本机无设备 | **无法判定** |
+| 双 `pointerInput` 手势并存 | Compose 运行时行为无法在本机验证 | 无法判定 |
+
+### Phase F(`512508e`)—— 2 项证伪
+
+核查者用独立 harness 在 JVM 上驱动**真实的** `RemoteHost`/`RemoteSession`
+(仅对 `DebugLog` 做最小桩),不打桩网络层。
+
+| 声明 | 方法 | 结论 |
+|---|---|---|
+| 端口/魔数/帧长/超时与计划一致 | `RemoteContract`:47510/47511/`0x1BB1B11B`/38400/38412/3000ms/10000ms 逐项比对 | 证实 |
+| 封包格式小端 | 独立断言字段偏移、字节序(`pkt[0..3]={1B,B1,B1,1B}`)、payload 位于 12 | 证实 |
+| 粘包/截断/坏长度/重同步 | 3 帧一次读入→3 payload;按 1000B 任意切分→恰好重组 1 次;截断 20000B→0 输出且 `pending()==20000`;坏 length→下一 magic 恢复 | 证实 |
+| 端到端可用 | 真实回环:connect→welcome→stream-start→5 帧(payload 逐字节)→ffc 到达主机回调→stream-stop;`RemoteLoopbackTest` 3 项亦全绿 | 证实 |
+| 渲染在客户端本地 | `RemoteViewerViewModel`:本地 `RenderPipeline(160,120)`+assets `mag160c.ddt`;`setPalette` 只调本地管线;`setZoom` 只改状态 | 证实 |
+| 主机侧不拖慢相机 | `Channel(capacity=8, DROP_OLDEST)`+`trySend`(USB 读线程非阻塞)+单一 `serve()` 协程写 socket | 证实 |
+| 默认不启动;开启需活跃 USB 会话 | `setRemoteHostEnabled` 先 `if (!session.isStreaming()) return false`,UI 弹"先连接热像仪" | 证实 |
+| INTERNET 权限 | badging 已声明 | 证实 |
+| 单客户端,后来者拒绝写 busy | `acceptLoop` 内同步 `serve()`,第二连接排队等待,从不写 busy;`isBusyLine` 死代码 | **证伪**(见 FIX-2) |
+| 命令写入不交错 | 见 FIX-1:120 次生产序列中 8 次丢 `welcome`;裸 socket 抓到 `hello`/`start` 颠倒 | **证伪**(见 FIX-1) |
+| UDP 广播真机可达性 | 仅回环证据 | **无法判定** |
+| `ACCESS_NETWORK_STATE` | 全代码无使用点 | 偏差(见 FIX-4) |
+
+### Phase Z(`8e1312a`)
+
+| 声明 | 方法 | 结论 |
+|---|---|---|
+| 消息无强制格式 | `git log -1 --format=%B` | 证实 |
+| 内容仅为文档/清单/APK | `git show --stat`:3 份文档 + APK,**外加 Manifest(+3 条 uses-feature)** | **证伪**(见 FIX-5) |
+| 文档更新到位 | HANDOFF §3 含 `ui/remote/`、`net/`、`cloud/CloudApi.kt`;session_state 勾选 A–Z 并追加总结;execution_plan 顶部加执行状态 | 证实 |
+| 全量构建 + 提交 APK,且不 push | 重跑 debug+release+test 成功;`origin/main` 仍 `087e15c` | 证实 |
+| 提交的 APK 与 HEAD 一致 | 对已提交 APK 核验:`screenOrientation=1`、camera 系列 not-required、INTERNET/CAMERA 已声明、dex 内含 `画中画`/`正在扫描局域网主机`/`云同步`(E/D/F 代码确在包内)。APK 字节不可复现(时间戳),故以内容核验替代字节比对 | 证实(以内容为准) |
+
+---
+
+## 3. 核查边界:本机无法判定(须真机裁决,未默认通过)
+
+本机(Windows)无模拟器、无连接设备、`adb devices` 为空。以下必须真机判定:
+
+1. USB 出流与温度绝对值标定(清单 1–12 步)。
+2. 可见光 PIP 全部运行时行为(13–15 步):**含"返回后画面是否重新出现"**、
+ 单击换档/双击关闭/拖拽三种手势是否互不干扰。
+3. 局域网远程预览双机行为(16–24 步):**含 `255.255.255.255` 广播在真实
+ Wi-Fi 上是否可达**(部分路由/AP 会过滤广播;息屏策略也可能影响收包)。
+4. 分析页温度条/探针的实际显示与取温正确性(11 步)。
+5. 任何"画面/像素"层面的确认。
+
+清单本身已按指南机械核对:16 条字面命中、30 条骨架命中、7 条人工追溯全部
+落实到真实模板与调用点,未发现凭空编造。清单可继续作为真机验证脚本。
+
+---
+
+## 4. 真机测试时请重点确认(与修复决策挂钩)
+
+| 要确认的事 | 怎么看 | 影响哪个修复 |
+|---|---|---|
+| 远程预览:连上后是否每次都看到"welcome"日志 | B 端日志 `[remote] line: {"type":"welcome"...}` 是否出现;连续重连 10 次统计 | FIX-1(当前约 6.7% 丢失) |
+| 快速 stop→start、连点 FFC 是否偶发失灵 | 反复操作,看 B 端画面是否与按钮状态不一致 | FIX-1 |
+| PIP:Home 返回后小窗是否重新出现 | 清单 15 步 | FIX-6.1(决定改文档还是改代码) |
+| PIP:单击换档 / 双击关闭 / 拖拽是否互不干扰 | 清单 14 步,各做 5 次 | 若互相干扰则需改手势实现(本机无法判定) |
+| 分析页:点已知温度位置,读数是否合理 | 清单 11 步 | 探针直接映射的运行时确认 |
+| 远程预览:局域网是否能在 10s 内发现主机 | 清单 19 步 | UDP 广播可达性 |
+| lifetime 是否非 -1 | 清单第 3 步 `[session] device lifetime=ms` | FIX-3 |
+
+---
+
+## 5. 核查者使用的判据来源(可独立复核)
+
+| 判据 | 路径 |
+|---|---|
+| 协议权威(官方 Java) | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/sdk/{D2PCmd,P2DCmd,UsbCommunication}.java` |
+| 官方调色板 UI 顺序与 native 调用点(**本次新用**) | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/DialogFragmentPalette.java` |
+| native 伪代码(12+2 个生成体) | `analysis/sdk_re/android_app/libcxsdk_decomp.txt`(`SetColorPalette @00026c70`,case 0..10、0xc、0xd) |
+| 铁虹真表(真实内存布局 `B,G,R,0`) | `csdk/src/mag160c_official_palette256.h` |
+| 温度 C 参考实现 + T2E 真表 | `csdk/src/mag160c_render.c:64`、`csdk/src/mag160c_official_t2e.h` |
+| 帧布局权威 | `csdk/src/mag160c_frame.c:11-13`(像素自 `+0x1c`,总长 `0x38+len`) |
+| 官方预览图 | `app/【普通版】MAG-Cx.apk` → `res/mipmap-hdpi-v4/palette_*.png`(12 张) |
+
+---
+
+## 6. 复现命令(修复模型可直接照跑)
+
+### 6.1 构建 + 全量单测(Windows Git Bash)
+
+```bash
+cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \
+ cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug :app:assembleRelease test --no-daemon"
+# 强制重跑(否则 UP-TO-DATE 不产生新结果)
+cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:testDebugUnitTest --rerun-tasks --no-daemon"
+# 单测计数
+grep -h -o 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*" errors="[0-9]*"' \
+ /c/Project/MAG160C/android/app/build/test-results/testDebugUnitTest/*.xml
+```
+
+### 6.2 Phase C 否证复核 + 生成物可复现
+
+```bash
+cd /c/Project/MAG160C
+"C:\Tools\jdk-21\bin\java" analysis/tools/PalScan.java \
+ analysis/sdk_re/android_app/bin/libcxsdk.so csdk/src/mag160c_official_palette256.h
+# 期望:三种编码全部 not found;[A] 段自校准 detected 1 candidate
+
+mkdir -p /c/Users/zxc/AppData/Local/Temp/regen_build && \
+cd /c/Users/zxc/AppData/Local/Temp/regen_build && \
+cp "C:\Project\MAG160C\analysis\tools\PalIdentify.java" . && \
+cp "C:\Project\MAG160C\analysis\tools\PalExport2.java" . && \
+"C:\Tools\jdk-21\bin\javac" -d out PalIdentify.java PalExport2.java && \
+"C:\Tools\jdk-21\bin\java" -cp out PalExport2 \
+ "C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt" \
+ "C:\Users\zxc\AppData\Local\Temp\cxres\res\mipmap-hdpi-v4" \
+ "C:\Users\zxc\AppData\Local\Temp\regen_core" \
+ "C:\Users\zxc\AppData\Local\Temp\regen_out"
+# 期望末行:iron_bow vs OfficialTables anchor: 256/256
+diff "C:\Users\zxc\AppData\Local\Temp\regen_core\VendorPalettes.kt" \
+ "C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\VendorPalettes.kt"
+diff "C:\Users\zxc\AppData\Local\Temp\regen_out\palette_candidates.json" \
+ "C:\Project\MAG160C\analysis\sdk_re\android_app\palette_candidates.json"
+# 期望:两条 diff 均为空
+```
+
+### 6.3 独立 Kotlin harness(仓库外编译仓库真实源码)
+
+无 gcc / 无 kotlinc,用 Gradle 缓存内的编译器:
+
+```bash
+KC=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin
+KX=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx
+STDLIB=$KC/kotlin-stdlib/2.2.0/fdfc65fbc42fda253a26f61dac3c0aca335fae96/kotlin-stdlib-2.2.0.jar
+COMPILER=$KC/kotlin-compiler-embeddable/2.2.0/8cfa2b049a4006d94474296df4abd9b50f288821/kotlin-compiler-embeddable-2.2.0.jar
+SCRIPT=$KC/kotlin-script-runtime/2.2.0/87c92e866fcd68680966a3005a2992e1ab8ec6ad/kotlin-script-runtime-2.2.0.jar
+CORO=$KX/kotlinx-coroutines-core-jvm/1.8.0/ac1dc37a30a93150b704022f8d895ee1bd3a36b3/kotlinx-coroutines-core-jvm-1.8.0.jar
+ANNOT=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/23.0.0/8cc20c07506ec18e0834947b84a864bfc094484e/annotations-23.0.0.jar
+
+# 编译(示例:Phase F;Phase B 同理,只换成 core/media 源码 + 自己的断言)
+java -cp "$COMPILER;$STDLIB;$SCRIPT;$CORO;$ANNOT" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \
+ -no-stdlib -cp "$STDLIB;$CORO" -d out \
+ .kt \
+ android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt \
+ android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt \
+ android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt \
+ <自己的断言>.kt
+java -cp "out;$STDLIB;$CORO" <主类>Kt
+```
+
+要点:
+- `DebugLog` 依赖 Android 类型,需自写同包同名的 JVM 桩
+ (`package com.mag160c.thermal.media; object DebugLog { fun log(t: String, m: String) {} }`),
+ 网络类即可在纯 JVM 上运行。
+- Windows 版 java **不认** Git Bash 的 `/tmp`,路径必须写 `C:\...`。
+
+### 6.4 其它
+
+```bash
+# 清单日志串核对(期望 exact=16 skeleton=30 review=7)
+"C:\Tools\jdk-21\bin\java" analysis/tools/CheckStrings.java \
+ docs/android_app/real_device_checklist.md android/app/src/main
+
+# APK 清单核验
+"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump badging build-artifacts/mag160c-app-debug.apk
+"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump xmltree build-artifacts/mag160c-app-debug.apk --file AndroidManifest.xml
+```
+
+---
+
+## 7. 结论一句话
+
+**Phase A/B/C/D/E 的声明全部证实(C 的索引 4/8/9 映射经独立复核成立;
+B 的温度换算经独立 C 移植全量比对一致);Phase F 有两处真实缺陷
+(命令乱序导致约 6.7% 丢 welcome、busy 未实现),Phase Z 有一处越界
+(Manifest 改动);其余为低风险偏差。真机相关项无法判定,等实测。**