From 464cfcb5e8b1db9d6a16f997bd84046187aa90bd Mon Sep 17 00:00:00 2001 From: ZXCLI Date: Fri, 11 Sep 2026 03:01:13 +0800 Subject: [PATCH] android: fix real-device defects (temp double-conversion, FFC temp jump, image orientation per grip, remote raw+metadata stream) --- android/app/src/main/AndroidManifest.xml | 1 - .../mag160c/thermal/core/RenderPipeline.kt | 99 +++- .../com/mag160c/thermal/net/RemoteClient.kt | 39 +- .../com/mag160c/thermal/net/RemoteContract.kt | 71 ++- .../com/mag160c/thermal/net/RemoteHost.kt | 91 +++- .../mag160c/thermal/ui/live/ImageTransform.kt | 149 ++++++ .../mag160c/thermal/ui/live/LiveRenderer.kt | 157 ++++-- .../com/mag160c/thermal/ui/live/LiveScreen.kt | 10 +- .../mag160c/thermal/ui/live/LiveViewModel.kt | 193 +++++--- .../thermal/ui/remote/RemoteRendererHost.kt | 152 +++--- .../thermal/ui/remote/RemoteViewerScreen.kt | 13 +- .../ui/remote/RemoteViewerViewModel.kt | 51 +- .../thermal/ui/settings/AppSettings.kt | 16 + .../thermal/ui/settings/SettingsScreen.kt | 38 +- .../com/mag160c/thermal/usb/IrSession.kt | 14 +- .../core/PipelineTemperatureStateTest.kt | 120 +++++ .../mag160c/thermal/net/RemoteContractTest.kt | 86 ++-- .../mag160c/thermal/net/RemoteLoopbackTest.kt | 88 +++- .../ui/live/ImageTransformOrientationTest.kt | 90 ++++ .../thermal/ui/live/ImageTransformTest.kt | 138 ++++++ build-artifacts/mag160c-app-debug.apk | 2 +- docs/android_app/HANDOFF_DEVELOPMENT.md | 57 ++- docs/android_app/execution_plan.md | 17 +- docs/android_app/real_device_checklist.md | 17 +- docs/android_app/session_state.md | 52 ++ docs/android_app/verification_report.md | 454 ++++++++++++++++++ 26 files changed, 1938 insertions(+), 277 deletions(-) create mode 100644 android/app/src/main/kotlin/com/mag160c/thermal/ui/live/ImageTransform.kt create mode 100644 android/app/src/test/kotlin/com/mag160c/thermal/core/PipelineTemperatureStateTest.kt create mode 100644 android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformOrientationTest.kt create mode 100644 android/app/src/test/kotlin/com/mag160c/thermal/ui/live/ImageTransformTest.kt create mode 100644 docs/android_app/verification_report.md 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 改动);其余为低风险偏差。真机相关项无法判定,等实测。**