android: fix real-device defects (temp double-conversion, FFC temp jump, image orientation per grip, remote raw+metadata stream)
This commit is contained in:
@@ -12,7 +12,6 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- LAN remote preview (Phase F): host broadcasts on UDP 47510, streams on TCP 47511 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -126,16 +126,31 @@ class RemoteSession(
|
||||
/** Control lines from the host; only meaningful before the stream starts. */
|
||||
val lines: Flow<String> = _lines.asSharedFlow()
|
||||
|
||||
private val _frames = MutableSharedFlow<ByteArray>(
|
||||
private val _frames = MutableSharedFlow<RemoteContract.FramePacket>(
|
||||
extraBufferCapacity = 8,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
/** Raw 38400-byte sensor payloads, ready for the local RenderPipeline. */
|
||||
val frames: Flow<ByteArray> = _frames.asSharedFlow()
|
||||
/** Frame records (raw counts + the host's FFC metadata), ready to render. */
|
||||
val frames: Flow<RemoteContract.FramePacket> = _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<String>(
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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":"<model>","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<ByteArray> {
|
||||
/** Feed bytes, get back every complete frame available. */
|
||||
fun feed(bytes: ByteArray): List<FramePacket> {
|
||||
append(bytes)
|
||||
val out = ArrayList<ByteArray>(2)
|
||||
val out = ArrayList<FramePacket>(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
|
||||
|
||||
@@ -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<ByteArray>? = null
|
||||
private var frameQueueRef: Channel<RemoteHost.OutFrame>? = 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}")
|
||||
|
||||
@@ -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<Int, Int>? {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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<Unit>(extraBufferCapacity = 1)
|
||||
private val _disconnected = MutableSharedFlow<String>(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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ fun SettingsScreen(
|
||||
var dialog by remember { mutableStateOf<String?>(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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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<ByteArray>()
|
||||
val got = ArrayList<RemoteContract.FramePacket>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ class RemoteLoopbackTest {
|
||||
assertEquals(160, parsed!!.w)
|
||||
|
||||
// start -> stream-start, then frames
|
||||
val received = ArrayList<ByteArray>()
|
||||
val received = ArrayList<RemoteContract.FramePacket>()
|
||||
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<String>())
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+90
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user