diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 167afab..87ac74d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,9 @@ + + + diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt new file mode 100644 index 0000000..7ed61af --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt @@ -0,0 +1,276 @@ +package com.mag160c.thermal.net + +import com.mag160c.thermal.media.DebugLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketTimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Client side of the LAN remote preview (Phase F). + * + * [discover] listens for host beacons on UDP 47510 and emits each host once per + * 3 s window. [connect] opens the TCP control channel and returns a session. + * + * Protocol phasing (keeps one TCP stream unambiguous): + * - BEFORE start: the stream carries newline-delimited JSON lines only + * (welcome, ok, errors); + * - AFTER start: the stream carries fixed 38412-byte frame records only. + * The host's keepalive pings sent between frames are harmless because the + * frame reader resynchronises on the magic; the client's liveness timer is + * driven by received bytes, so a ping also counts as "link alive". + */ +object RemoteClient { + /** Hosts seen on the LAN, de-duplicated within a 3 s window. */ + fun discover(scope: CoroutineScope): Flow { + val out = MutableSharedFlow( + extraBufferCapacity = 16, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + scope.launch(Dispatchers.IO) { + val socket = try { + DatagramSocket(null).apply { + reuseAddress = true + broadcast = true + soTimeout = 1000 + bind(InetSocketAddress(RemoteContract.DISCOVERY_PORT)) + } + } catch (e: Exception) { + DebugLog.log("remote", "discovery bind failed: $e") + return@launch + } + val buf = ByteArray(RemoteContract.MAX_LINE) + val lastSeen = HashMap() + try { + while (isActive) { + val packet = DatagramPacket(buf, buf.size) + try { + socket.receive(packet) + } catch (e: SocketTimeoutException) { + continue + } catch (e: Exception) { + if (!isActive) break + continue + } + val text = String(buf, 0, packet.length, Charsets.UTF_8) + val from = packet.address?.hostAddress ?: continue + val info = RemoteContract.parseBeacon(text, from) ?: continue + val now = System.currentTimeMillis() + val key = "${info.address}:${info.tcpPort}" + val prev = lastSeen[key] + if (prev == null || now - prev >= 3000) { + lastSeen[key] = now + out.tryEmit(info) + } + } + } finally { + runCatching { socket.close() } + } + } + return out.asSharedFlow() + } + + suspend fun connect( + host: String, + port: Int = RemoteContract.CONTROL_PORT, + scope: CoroutineScope, + ): RemoteSession? = withContext(Dispatchers.IO) { + try { + val socket = Socket() + socket.connect(InetSocketAddress(host, port), 4000) + socket.tcpNoDelay = true + socket.soTimeout = 100 + val session = RemoteSession(host, port, socket, scope) + session.startReader() + DebugLog.log("remote", "connected to $host:$port") + session + } catch (e: Exception) { + DebugLog.log( + "remote", + "connect $host:$port failed: ${e.javaClass.simpleName}: ${e.message}", + ) + null + } + } +} + +/** One live connection to a remote host. */ +class RemoteSession( + val host: String, + val port: Int, + private val socket: Socket, + private val scope: CoroutineScope, +) { + // Control lines are few and the consumer may subscribe a moment late + // (connect() returns before the UI starts collecting), so keep a small + // replay window instead of dropping welcome / stream-start. + private val _lines = MutableSharedFlow( + replay = 8, + extraBufferCapacity = 32, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Control lines from the host; only meaningful before the stream starts. */ + val lines: Flow = _lines.asSharedFlow() + + private val _frames = MutableSharedFlow( + extraBufferCapacity = 8, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** Raw 38400-byte sensor payloads, ready for the local RenderPipeline. */ + val frames: Flow = _frames.asSharedFlow() + + private val closed = AtomicBoolean(false) + + @Volatile + private var streaming = false + + @Volatile + var frameCount: Int = 0 + private set + + @Volatile + var lastDataMs: Long = System.currentTimeMillis() + private set + + /** Set when the reader exits (link lost); the UI shows a snackbar and leaves. */ + private val _closedFlow = MutableSharedFlow(extraBufferCapacity = 1) + val closedFlow: Flow = _closedFlow.asSharedFlow() + + val isOpen: Boolean get() = !closed.get() && !socket.isClosed + + internal fun startReader() { + scope.launch(Dispatchers.IO) { + val reader = socket.getInputStream() + val framer = RemoteContract.FramePacketReader() + val chunk = ByteArray(64 * 1024) + val lineBuf = StringBuilder() + try { + while (!closed.get() && isActive) { + val n = try { + reader.read(chunk) + } catch (e: SocketTimeoutException) { + 0 // no data this tick (soTimeout) — NOT end of stream + } + if (n < 0) { + DebugLog.log("remote", "host closed the connection") + break + } + if (n > 0) { + lastDataMs = System.currentTimeMillis() + if (streaming) { + // frame records; keepalive pings in between are skipped + // by the framer's magic resynchronisation + for (frame in framer.feed(chunk.copyOfRange(0, n))) { + frameCount++ + _frames.tryEmit(frame) + } + } else { + // line mode: scan for newline-terminated JSON. The host + // sends stream-start and then switches to raw frames, so a + // chunk can contain the line AND the first frame: the + // remainder after the newline must go to the framer. + var i = 0 + while (i < n) { + val c = chunk[i].toInt().toChar() + if (c == '\n') { + val text = lineBuf.toString() + lineBuf.setLength(0) + if (text.isNotBlank()) { + if (RemoteContract.typeOf(text) == "stream-start") { + streaming = true + _lines.tryEmit(text) + for (frame in framer.feed(chunk.copyOfRange(i + 1, n))) { + frameCount++ + _frames.tryEmit(frame) + } + break + } + _lines.tryEmit(text) + } + } else if (c != '\r') { + if (lineBuf.length < RemoteContract.MAX_LINE) lineBuf.append(c) + } + i++ + } + } + continue + } + if (System.currentTimeMillis() - lastDataMs > RemoteContract.DEAD_AFTER_MS) { + DebugLog.log( + "remote", + "no data for ${RemoteContract.DEAD_AFTER_MS} ms, closing", + ) + break + } + delay(10) + } + } catch (e: Exception) { + if (!closed.get()) { + DebugLog.log( + "remote", + "session error: ${e.javaClass.simpleName}: ${e.message}", + ) + } + } finally { + val wasOpen = !closed.get() + close() + if (wasOpen) _closedFlow.tryEmit(Unit) + } + } + } + + /** + * Ask the host to start streaming. The reader stays in line mode until the + * host's stream-start line arrives, then switches to frame mode (so the + * confirmation is observable and no frame byte is ever parsed as text). + */ + fun startStream() { + if (closed.get()) return + send(RemoteContract.cmd("start")) + } + + fun stopStream() { + streaming = false + send(RemoteContract.cmd("stop")) + } + + fun requestFfc() = send(RemoteContract.cmd("ffc")) + + fun hello(name: String) = send(RemoteContract.hello(name)) + + private fun send(cmd: String) { + if (closed.get()) return + scope.launch(Dispatchers.IO) { + try { + val out = socket.getOutputStream() + out.write((cmd + "\n").toByteArray(Charsets.UTF_8)) + out.flush() + } catch (e: Exception) { + if (!closed.get()) { + DebugLog.log("remote", "send failed: ${e.javaClass.simpleName}: ${e.message}") + } + } + } + } + + fun close() { + if (!closed.getAndSet(true)) { + runCatching { socket.close() } + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt new file mode 100644 index 0000000..9a8e0e5 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt @@ -0,0 +1,266 @@ +package com.mag160c.thermal.net + +/** + * Wire contract for the LAN remote preview (Phase F). + * + * Design (frozen — do not change the wire parameters): + * - discovery: UDP broadcast on port 47510, one UTF-8 JSON line per second: + * {"app":"mag160c-remote","role":"host","name":"","tcp":47511,"serial":...} + * - control: TCP 47511, newline-delimited UTF-8 JSON, one line <= 4 KB + * - frames: after {"cmd":"start"} the host writes fixed 38412-byte binary + * records: [u32 LE 0x1BB1B11B][u32 LE counter][u32 LE 38400][38400 B u16 LE] + * - 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 + * client treats 10 s of silence as a dead link + * + * Deliberately FREE OF ANDROID TYPES (including org.json, which is a stub in + * JVM unit tests): the JSON used here is a flat object of strings and numbers, + * handled by the tiny helpers below so the whole contract is unit-testable. + */ +object RemoteContract { + const val DISCOVERY_PORT = 47510 + const val CONTROL_PORT = 47511 + 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 PING_AFTER_MS = 3000L + const val DEAD_AFTER_MS = 10000L + const val MAX_LINE = 4096 + + /** One discovery beacon / host descriptor. */ + data class HostInfo( + val name: String, + val address: String, + val tcpPort: Int = CONTROL_PORT, + val serial: Long = 0L, + ) + + /** Stream-start banner from the host. */ + data class Welcome(val w: Int, val h: Int, val fps: Int, val serial: Long) + + // ---- flat JSON helpers (string + number values only, no nesting) ---- + + /** Extract a raw value token for `"key"`; null when absent. */ + private fun rawValue(json: String, key: String): String? { + val needle = "\"$key\"" + var from = 0 + while (true) { + val at = json.indexOf(needle, from) + if (at < 0) return null + var i = at + needle.length + while (i < json.length && json[i].isWhitespace()) i++ + if (i >= json.length || json[i] != ':') { from = at + 1; continue } + i++ + while (i < json.length && json[i].isWhitespace()) i++ + if (i >= json.length) return null + if (json[i] == '"') { + val sb = StringBuilder() + var j = i + 1 + while (j < json.length) { + val c = json[j] + when { + c == '\\' && j + 1 < json.length -> { + when (val e = json[j + 1]) { + '"' -> sb.append('"') + '\\' -> sb.append('\\') + 'n' -> sb.append('\n') + 'r' -> sb.append('\r') + 't' -> sb.append('\t') + 'u' -> { + if (j + 5 < json.length) { + val cp = json.substring(j + 2, j + 6).toIntOrNull(16) + if (cp != null) sb.append(cp.toChar()) + j += 4 + } + } + else -> sb.append(e) + } + j += 2 + } + c == '"' -> return sb.toString() + else -> { + sb.append(c) + j++ + } + } + } + return null + } + var j = i + while (j < json.length && json[j] != ',' && json[j] != '}' && !json[j].isWhitespace()) j++ + return json.substring(i, j) + } + } + + private fun str(json: String, key: String, def: String = ""): String = + rawValue(json, key) ?: def + + private fun num(json: String, key: String, def: Long): Long = + rawValue(json, key)?.toLongOrNull() ?: def + + private fun escape(s: String): String { + val sb = StringBuilder(s.length + 8) + for (c in s) { + when (c) { + '"' -> sb.append("\\\"") + '\\' -> sb.append("\\\\") + '\n' -> sb.append("\\n") + '\r' -> sb.append("\\r") + '\t' -> sb.append("\\t") + else -> if (c < ' ') sb.append(String.format("\\u%04x", c.code)) else sb.append(c) + } + } + return sb.toString() + } + + fun hostBeacon(name: String, serial: Long): String = + "{\"app\":\"$APP_TAG\",\"role\":\"host\",\"name\":\"${escape(name)}\"," + + "\"tcp\":$CONTROL_PORT,\"serial\":$serial}" + + fun hello(name: String): String = "{\"cmd\":\"hello\",\"name\":\"${escape(name)}\"}" + + fun cmd(name: String): String = "{\"cmd\":\"$name\"}" + + fun okLine(): String = "{\"type\":\"ok\"}" + + fun errorLine(message: String): String = "{\"type\":\"error\",\"message\":\"${escape(message)}\"}" + + fun welcomeLine(w: Int, h: Int, fps: Int, serial: Long): String = + "{\"type\":\"welcome\",\"w\":$w,\"h\":$h,\"fps\":$fps,\"serial\":$serial}" + + fun streamStartLine(): String = "{\"type\":\"stream-start\"}" + + fun streamStopLine(): String = "{\"type\":\"stream-stop\"}" + + fun pingLine(): String = "{\"type\":\"ping\"}" + + /** "cmd" field of a client->host control line ("" when absent). */ + fun commandOf(text: String): String = str(text.trim(), "cmd") + + /** "type" field of a host->client line ("" when absent). */ + fun typeOf(text: String): String = str(text.trim(), "type") + + fun nameOf(text: String): String = str(text.trim(), "name") + + fun isBusyLine(text: String): Boolean = typeOf(text) == "busy" + + /** Parse a discovery datagram; null when it is not one of ours. */ + fun parseBeacon(text: String, fromAddress: String): HostInfo? { + val t = text.trim() + if (str(t, "app") != APP_TAG || str(t, "role") != "host") return null + return HostInfo( + name = str(t, "name", fromAddress), + address = fromAddress, + tcpPort = num(t, "tcp", CONTROL_PORT.toLong()).toInt(), + serial = num(t, "serial", 0L), + ) + } + + fun parseWelcome(text: String): Welcome? { + val t = text.trim() + if (typeOf(t) != "welcome") return null + return Welcome( + w = num(t, "w", 160).toInt(), + h = num(t, "h", 120).toInt(), + fps = num(t, "fps", 15).toInt(), + serial = num(t, "serial", 0L), + ) + } + + fun isKeepalive(text: String): Boolean = typeOf(text) == "ping" + + /** Build one frame record for the wire. */ + fun encodeFramePacket(raw: ByteArray, counter: Int): ByteArray { + require(raw.size == FRAME_PIXELS) { + "frame payload must be $FRAME_PIXELS bytes, got ${raw.size}" + } + val out = ByteArray(FRAME_TOTAL) + putU32(out, 0, FRAME_MAGIC) + putU32(out, 4, counter) + putU32(out, 8, FRAME_PIXELS) + System.arraycopy(raw, 0, out, 12, FRAME_PIXELS) + return out + } + + fun u32(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or + ((b[off + 1].toInt() and 0xFF) shl 8) or + ((b[off + 2].toInt() and 0xFF) shl 16) or + ((b[off + 3].toInt() and 0xFF) shl 24) + + 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() + } + + /** + * Incremental reassembly of the TCP byte stream into frame payloads. + * 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. + */ + class FramePacketReader { + 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 { + append(bytes) + val out = ArrayList(2) + while (true) { + if (size < 12) break + var magicAt = -1 + for (i in 0..size - 4) { + if (u32(buffer, i) == FRAME_MAGIC) { + magicAt = i + break + } + } + if (magicAt < 0) { + // keep the tail in case a magic straddles this read + dropBefore(maxOf(0, size - 3)) + break + } + if (magicAt > 0) dropBefore(magicAt) + if (size < 12) break + if (u32(buffer, 8) != FRAME_PIXELS) { // bogus header + dropBefore(1) + continue + } + if (size < FRAME_TOTAL) break + out.add(buffer.copyOfRange(12, FRAME_TOTAL)) + dropBefore(FRAME_TOTAL) + } + return out + } + + /** Bytes buffered but not yet forming a complete frame. */ + fun pending(): Int = size + + private fun append(bytes: ByteArray) { + if (size + bytes.size > buffer.size) { + buffer = buffer.copyOf(maxOf(buffer.size, size + bytes.size)) + } + System.arraycopy(bytes, 0, buffer, size, bytes.size) + size += bytes.size + } + + private fun dropBefore(n: Int) { + if (n <= 0) return + if (n >= size) { + size = 0 + return + } + System.arraycopy(buffer, n, buffer, 0, size - n) + size -= n + } + + fun reset() { + size = 0 + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt new file mode 100644 index 0000000..64bd6f6 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt @@ -0,0 +1,270 @@ +package com.mag160c.thermal.net + +import com.mag160c.thermal.media.DebugLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.BufferedOutputStream +import java.io.InputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketException +import java.net.SocketTimeoutException +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. + * + * Concurrency: ONE coroutine owns the client socket — it reads control lines + * 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). + * + * Any error tears the client down and returns to "waiting"; the thermal session + * is never affected by a network problem. + */ +class RemoteHost( + private val deviceName: String, + private val serial: Long, +) { + enum class State { STOPPED, WAITING, STREAMING } + + @Volatile + var state: State = State.STOPPED + private set + + @Volatile + var clientAddress: String? = null + private set + + /** Callback for {"cmd":"ffc"} — wired to the live session's triggerFfc. */ + @Volatile + var onFfcRequest: (() -> Unit)? = null + + private var scope: CoroutineScope? = null + private var serverSocket: ServerSocket? = null + private var discoverySocket: DatagramSocket? = null + private val running = AtomicBoolean(false) + + /** Bounded queue; a slow client drops the oldest frame instead of stalling. */ + private var frameQueueRef: Channel? = null + + val isRunning: Boolean get() = running.get() + + fun start() { + if (!running.compareAndSet(false, true)) return + frameQueueRef = Channel(capacity = 8, onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST) + val s = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scope = s + state = State.WAITING + s.launch { discoveryLoop() } + s.launch { acceptLoop() } + DebugLog.log("remote", "host started (name=$deviceName serial=$serial)") + } + + fun stop() { + if (!running.getAndSet(false)) return + state = State.STOPPED + clientAddress = null + runCatching { discoverySocket?.close() } + discoverySocket = null + runCatching { serverSocket?.close() } + serverSocket = null + frameQueueRef?.close() + frameQueueRef = null + scope?.cancel() + scope = null + DebugLog.log("remote", "host stopped") + } + + /** Push one raw sensor frame from the live session. */ + fun offerFrame(raw: ByteArray) { + if (!running.get()) return + if (raw.size != RemoteContract.FRAME_PIXELS) return + frameQueueRef?.trySend(raw) + } + + /** One UDP beacon per second on the broadcast address. */ + private suspend fun discoveryLoop() { + val bytes = RemoteContract.hostBeacon(deviceName, serial).toByteArray(Charsets.UTF_8) + val socket = try { + DatagramSocket().apply { + broadcast = true + soTimeout = 1000 + discoverySocket = this + } + } catch (e: Exception) { + DebugLog.log("remote", "discovery socket failed: $e") + return + } + val target = try { + InetAddress.getByName("255.255.255.255") + } catch (e: Exception) { + DebugLog.log("remote", "broadcast address failed: $e") + return + } + while (running.get()) { + try { + socket.send(DatagramPacket(bytes, bytes.size, target, RemoteContract.DISCOVERY_PORT)) + } catch (e: Exception) { + if (!running.get()) break + DebugLog.log("remote", "beacon send failed: $e") + } + kotlinx.coroutines.delay(1000) + } + runCatching { socket.close() } + } + + private suspend fun acceptLoop() { + val server = try { + ServerSocket(RemoteContract.CONTROL_PORT).apply { serverSocket = this } + } catch (e: Exception) { + DebugLog.log("remote", "cannot listen on ${RemoteContract.CONTROL_PORT}: $e") + state = State.STOPPED + running.set(false) + return + } + while (running.get()) { + val socket = try { + server.accept() + } catch (e: SocketException) { + break // closed by stop() + } catch (e: Exception) { + DebugLog.log("remote", "accept failed: $e") + break + } + serve(socket) + } + runCatching { server.close() } + } + + /** + * Serve one client to completion. Single-threaded by design: every write to + * the socket happens here, so control replies and frame records stay framed. + */ + private suspend fun serve(socket: Socket) { + val addr = socket.inetAddress?.hostAddress ?: "?" + DebugLog.log("remote", "client connected from $addr") + clientAddress = addr + try { + socket.tcpNoDelay = true + socket.soTimeout = 100 + } catch (_: Exception) { + } + val out = BufferedOutputStream(socket.getOutputStream(), 128 * 1024) + val input = socket.getInputStream() + var streaming = false + var counter = 0 + var sentFrames = 0 + var lastWrite = System.currentTimeMillis() + val lineBuf = StringBuilder() + + try { + while (running.get()) { + // 1) control lines available this tick + val lines = drainLines(input, lineBuf) + for (raw in lines) { + val text = raw.trim() + if (text.isEmpty()) continue + when (RemoteContract.commandOf(text)) { + "hello" -> + writeLine(out, RemoteContract.welcomeLine(160, 120, 15, serial)) + "start" -> if (!streaming) { + streaming = true + state = State.STREAMING + writeLine(out, RemoteContract.streamStartLine()) + DebugLog.log("remote", "streaming to $addr") + } + "stop" -> if (streaming) { + streaming = false + state = State.WAITING + writeLine(out, RemoteContract.streamStopLine()) + } + "ffc" -> { + onFfcRequest?.invoke() + writeLine(out, RemoteContract.okLine()) + } + else -> if (RemoteContract.typeOf(text).isEmpty()) { + writeLine(out, RemoteContract.errorLine("unknown command")) + } + } + } + // 2) frames (only while streaming) + if (streaming) { + var wrote = false + while (true) { + val frame = frameQueueRef?.tryReceive()?.getOrNull() ?: break + out.write(RemoteContract.encodeFramePacket(frame, counter++)) + sentFrames++ + wrote = true + } + if (wrote) { + out.flush() + lastWrite = System.currentTimeMillis() + } else if (System.currentTimeMillis() - lastWrite >= RemoteContract.PING_AFTER_MS) { + writeLine(out, RemoteContract.pingLine()) + lastWrite = System.currentTimeMillis() + } + if (wrote && sentFrames % 300 == 0) { + DebugLog.log("remote", "frames=$sentFrames to $addr") + } + } + kotlinx.coroutines.delay(5) + } + } catch (e: Exception) { + DebugLog.log("remote", "client $addr error: ${e.javaClass.simpleName}: ${e.message}") + } finally { + if (streaming) state = State.WAITING + clientAddress = null + runCatching { socket.close() } + DebugLog.log("remote", "client $addr disconnected (frames=$sentFrames)") + } + } + + /** Read whatever complete newline-terminated lines are available right now. */ + private fun drainLines(input: InputStream, buf: StringBuilder): List { + val out = ArrayList(2) + val chunk = ByteArray(1024) + while (true) { + val n = try { + input.read(chunk) + } catch (e: SocketTimeoutException) { + break + } catch (e: Exception) { + if (running.get()) throw e else break + } + if (n < 0) throw SocketException("client closed the control connection") + if (n == 0) break + var i = 0 + while (i < n) { + val b = chunk[i] + if (b == '\n'.code.toByte()) { + out.add(buf.toString()) + buf.setLength(0) + } else if (b != '\r'.code.toByte()) { + if (buf.length < RemoteContract.MAX_LINE) buf.append(b.toInt().toChar()) + } + i++ + } + } + return out + } + + private fun writeLine(out: BufferedOutputStream, line: String) { + out.write(line.toByteArray(Charsets.UTF_8)) + out.write('\n'.code) + out.flush() + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt index 25f57cf..53dc591 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/AppRoot.kt @@ -11,11 +11,13 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -25,9 +27,13 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.mag160c.thermal.R import com.mag160c.thermal.ui.gallery.GalleryScreen import com.mag160c.thermal.ui.live.LiveScreen +import com.mag160c.thermal.ui.live.LiveViewModel +import com.mag160c.thermal.ui.remote.RemoteClientListScreen +import com.mag160c.thermal.ui.remote.RemoteViewerScreen import com.mag160c.thermal.ui.settings.SettingsScreen private data class Tab(val label: String, val icon: Int) @@ -45,12 +51,22 @@ private val TABS = listOf( * position never moves, however the phone is physically held). Bar content * (icons/text) is pre-rotated by the physical device orientation so it stays * readable in any grip. Tab content lives in a stable slot. + * + * Phase F adds two full-screen destinations on top of the tabs: the remote + * host list and the remote viewer (which keeps the same bottom navigation). */ @Composable fun AppRoot() { var tab by rememberSaveable { mutableStateOf(0) } val phi by DeviceOrientation.deg.collectAsState() val ctx = LocalContext.current + val liveVm: LiveViewModel = viewModel() + + // remote preview navigation (Phase F) + var showRemoteList by rememberSaveable { mutableStateOf(false) } + var remoteHost by rememberSaveable { mutableStateOf(null) } + var remotePort by rememberSaveable { mutableStateOf(0) } + var notice by remember { mutableStateOf(null) } DisposableEffect(Unit) { DeviceOrientation.start(ctx) @@ -58,20 +74,41 @@ fun AppRoot() { } Box(modifier = Modifier.fillMaxSize()) { - when (tab) { - 0 -> LiveScreen(onOpenGallery = { tab = 1 }) - 1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) { - GalleryScreen() - } - 2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) { + when { + remoteHost != null -> RemoteViewerScreen( + host = remoteHost!!, + port = remotePort, + onDisconnected = { reason -> + notice = reason + remoteHost = null + remotePort = 0 + }, + ) + showRemoteList -> RemoteClientListScreen( + onBack = { showRemoteList = false }, + onConnect = { host, port -> + showRemoteList = false + remoteHost = host + remotePort = port + }, + ) + tab == 0 -> LiveScreen(vm = liveVm, onOpenGallery = { tab = 1 }) + tab == 1 || tab == 2 -> Column( + modifier = Modifier.fillMaxSize().padding(bottom = 84.dp), + ) { GalleryScreen() } else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) { - SettingsScreen() + SettingsScreen( + onOpenRemoteClient = { showRemoteList = true }, + remoteHostRunning = liveVm.isRemoteHostRunning(), + onToggleRemoteHost = { on -> liveVm.setRemoteHostEnabled(on) }, + ) } } - // bottom navigation overlay (glued to the portrait bottom edge) + // bottom navigation overlay (glued to the portrait bottom edge). + // The remote viewer keeps it, matching the plan's "底导航不变". Surface( color = MaterialTheme.colorScheme.surface, modifier = Modifier @@ -82,7 +119,12 @@ fun AppRoot() { TABS.forEachIndexed { i, t -> NavigationBarItem( selected = tab == i, - onClick = { tab = i }, + onClick = { + tab = i + // leaving the remote screens returns to the tabs + remoteHost = null + showRemoteList = false + }, // pre-rotate so the item is upright in the current grip modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() }, icon = { Icon(painterResource(t.icon), null) }, @@ -91,5 +133,23 @@ fun AppRoot() { } } } + + // "连接已断开" notice after returning from the remote viewer + notice?.let { msg -> + Surface( + color = MaterialTheme.colorScheme.inverseSurface, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(16.dp), + ) { + androidx.compose.foundation.layout.Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(msg, color = MaterialTheme.colorScheme.inverseOnSurface) + TextButton(onClick = { notice = null }) { Text("好") } + } + } + } } -} \ No newline at end of file +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt index 9e6c4de..c1a23f9 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/live/LiveViewModel.kt @@ -149,8 +149,37 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { rec.offerFrame(bmp) } } + // feed the LAN remote host (Phase F) with raw sensor frames + session.rawHook = { raw -> + if (raw.size >= 0x1C + 38400) { + remoteHost.offerFrame(raw.copyOfRange(0x1C, 0x1C + 38400)) + } + } } + // ---- 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() @@ -400,6 +429,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) { } override fun onCleared() { + remoteHost.stop() session.destroy() super.onCleared() } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt new file mode 100644 index 0000000..2225c1b --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteClientListScreen.kt @@ -0,0 +1,196 @@ +package com.mag160c.thermal.ui.remote + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.mag160c.thermal.R +import com.mag160c.thermal.net.RemoteClient +import com.mag160c.thermal.net.RemoteContract +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch + +/** + * Remote-preview host list (Phase F). + * + * Layout is fixed by the execution plan: 56dp title bar with a back arrow, + * 64dp host cards (24dp icon, host name 16sp, "IP:47511" 12sp grey, trailing + * "连接" button), a full-width 48dp "重新扫描" button, then a "手动添加" row with + * an IP field, and a centred empty state while scanning. + */ +@Composable +fun RemoteClientListScreen( + onBack: () -> Unit, + onConnect: (String, Int) -> Unit, +) { + val context = LocalContext.current + val scope = remember { CoroutineScope(SupervisorJob() + Dispatchers.IO) } + var hosts by remember { mutableStateOf>(emptyList()) } + var scanning by remember { mutableStateOf(true) } + var manualIp by remember { mutableStateOf("") } + var scanGeneration by remember { mutableStateOf(0) } + + DisposableEffect(Unit) { + onDispose { scope.cancel() } + } + + LaunchedEffect(scanGeneration) { + hosts = emptyList() + scanning = true + val job = scope.launch { + RemoteClient.discover(scope).collect { info -> + // newest first, de-duplicated by address + hosts = (listOf(info) + hosts.filter { it.address != info.address }).take(12) + } + } + // stop showing the empty state after the 10 s discovery window + kotlinx.coroutines.delay(10_000) + scanning = false + job.cancel() + } + + Column(modifier = Modifier.fillMaxSize()) { + // ---- title bar (56dp) ---- + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(R.drawable.ic_back_arrow), "返回", + modifier = Modifier + .size(24.dp) + .clickable { onBack() }, + ) + Spacer(Modifier.width(12.dp)) + Text("远程预览", style = MaterialTheme.typography.titleMedium) + } + HorizontalDivider() + + LazyColumn(modifier = Modifier.weight(1f)) { + items(hosts, key = { it.address }) { h -> + HostCard(h) { onConnect(h.address, h.tcpPort) } + } + item { + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { scanGeneration++ }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 16.dp), + ) { Text("重新扫描") } + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Text( + "手动添加", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp), + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = manualIp, + onValueChange = { manualIp = it }, + placeholder = { Text("例如 192.168.1.23") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + val ip = manualIp.trim() + if (ip.isNotEmpty()) onConnect(ip, RemoteContract.CONTROL_PORT) + }, + ) { Text("连接") } + } + Spacer(Modifier.height(16.dp)) + } + } + + if (hosts.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + if (scanning) "正在扫描局域网主机…" else "未发现主机", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun HostCard(info: RemoteContract.HostInfo, onConnect: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .clickable { onConnect() } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painterResource(R.drawable.ic_pip), null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + info.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + "${info.address}:${info.tcpPort}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = onConnect) { Text("连接") } + } + HorizontalDivider(color = Color(0x1F000000)) +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt new file mode 100644 index 0000000..1ab08ee --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteRendererHost.kt @@ -0,0 +1,192 @@ +package com.mag160c.thermal.ui.remote + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Typeface +import android.view.SurfaceHolder +import android.view.SurfaceView +import com.mag160c.thermal.ui.DeviceOrientation + +/** + * Software renderer for the remote preview (Phase F). + * + * Composition is intentionally IDENTICAL to LiveRenderer (which is frozen and + * must not be edited): the thermal frame is drawn rotated 90 deg CW into a + * fitted 3:4 rect inside the area between the top bar and the bottom nav, so a + * remote and a local view of the same camera look the same. Only the data + * source differs — this one reads RemoteViewerViewModel. + */ +class RemoteRendererHost( + private val surfaceView: SurfaceView, + private val vm: RemoteViewerViewModel, +) : SurfaceHolder.Callback, Runnable { + private var thread: Thread? = null + private val running = java.util.concurrent.atomic.AtomicBoolean(false) + private val density = surfaceView.resources.displayMetrics.density + private val bitmap = Bitmap.createBitmap(320, 240, Bitmap.Config.ARGB_8888) + private val paint = Paint(Paint.FILTER_BITMAP_FLAG) + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + typeface = Typeface.SANS_SERIF + textSize = 15f * density + setShadowLayer(3f * density, 0f, 0f, Color.BLACK) + } + private val viewport = android.graphics.RectF() + + private val textRot: Float + get() = -DeviceOrientation.deg.value.toFloat() + + fun attach() { + surfaceView.holder.addCallback(this) + } + + override fun surfaceCreated(holder: SurfaceHolder) { + running.set(true) + thread = Thread(this, "remote-render").also { it.start() } + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {} + + override fun surfaceDestroyed(holder: SurfaceHolder) { + running.set(false) + thread?.join(200) + thread = null + } + + override fun run() { + val holder = surfaceView.holder + while (running.get()) { + val canvas = holder.lockCanvas() ?: continue + try { + drawFrame(canvas) + } finally { + holder.unlockCanvasAndPost(canvas) + } + try { + Thread.sleep(33) + } catch (_: InterruptedException) { + } + } + } + + private fun drawFrame(canvas: Canvas) { + val w = canvas.width.toFloat() + 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() + val availW = w + 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) + + val cx = viewport.centerX() + val cy = viewport.centerY() + val zoom = vm.state.value.zoom + val srcRect = if (zoom > 1) { + val cw = 320 / zoom + val ch = 240 / zoom + android.graphics.Rect(160 - cw / 2, 120 - ch / 2, 160 + cw / 2, 120 + ch / 2) + } else null + + 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) + if (srcRect != null) canvas.drawBitmap(bitmap, srcRect, dst, paint) + else canvas.drawBitmap(bitmap, null, dst, paint) + canvas.restore() + + drawColorBar(canvas, vm.state.value) + drawOsd(canvas, vm.state.value) + } + + 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] + val barW = 20f * density + val barH = viewport.height() * 0.8f + val x = viewport.right - barW - 12f * density + val y0 = viewport.top + (viewport.height() - barH) / 2f + val seg = Paint() + val n = 96 + for (i in 0 until n) { + val c = pal[255 - i * 255 / (n - 1)] + seg.color = c + canvas.drawRect(x, y0 + barH * i / n, x + barW, y0 + barH * (i + 1) / n + 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 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() + } + + private fun drawOsd(canvas: Canvas, state: RemoteViewerViewModel.State) { + textPaint.color = Color.WHITE + 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() + } + // max-temperature marker, same geometry as the local view + 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) + } + markerPaint.style = Paint.Style.STROKE + markerPaint.strokeWidth = 2.5f * density + canvas.drawCircle(p[0], p[1], 7f * density, markerPaint) + markerPaint.style = Paint.Style.FILL + canvas.drawCircle(p[0], p[1], 3.5f * density, markerPaint) + } + } + + /** Sensor pixel -> screen position under the fixed 90 CW rotation + insets. */ + 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) + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt new file mode 100644 index 0000000..0ec4a49 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerScreen.kt @@ -0,0 +1,237 @@ +package com.mag160c.thermal.ui.remote + +import android.view.SurfaceView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mag160c.thermal.R +import com.mag160c.thermal.core.Palettes +import com.mag160c.thermal.ui.DeviceOrientation + +/** + * Remote preview viewer (Phase F): same skeleton as the live screen — surface + * renderer, 4-item control top bar, colour bar, OSD — but the frame source is + * the LAN session and the shutter row is replaced by a single red "断开" button. + * + * FFC goes back to the host; zoom / max-trace / palette are local pipeline + * operations, so no round trip is needed for them. + */ +@Composable +fun RemoteViewerScreen( + host: String, + port: Int, + onDisconnected: (String) -> Unit, + vm: RemoteViewerViewModel = viewModel(), +) { + val state by vm.state.collectAsState() + val phi by DeviceOrientation.deg.collectAsState() + val density = LocalDensity.current.density + var showPalette by remember { mutableStateOf(false) } + var navPx by remember { mutableStateOf(com.mag160c.thermal.ui.UiInsets.navPx) } + var shutterPx by remember { mutableStateOf(0) } + + LaunchedEffect(host, port) { vm.connect(host, port) } + LaunchedEffect(Unit) { + while (true) { + kotlinx.coroutines.delay(400) + navPx = com.mag160c.thermal.ui.UiInsets.navPx + vm.uiBottomPx = navPx + shutterPx + } + } + 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 "连接已断开", + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + factory = { ctx -> + SurfaceView(ctx).also { sv -> + val renderer = RemoteRendererHost(sv, vm) + renderer.attach() + } + }, + modifier = Modifier + .fillMaxSize() + .onSizeChanged { + vm.uiViewW = it.width + vm.uiViewH = it.height + }, + ) + + if (!state.connected) { + Text( + text = when (state.status) { + "connect_fail" -> "无法连接主机" + "disconnected" -> "连接已断开" + "ddt_fail" -> "本地标定文件加载失败" + else -> "连接中…" + }, + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { rotationZ = -phi.toFloat() } + .padding(16.dp), + ) + } + + // ---- control TOP bar (4 items, same as live) ---- + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .onSizeChanged { vm.uiTopPx = it.height } + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RemoteTopItem(phi, "FFC") { vm.requestFfc() } + RemoteTopItem(phi, "${state.zoom}×") { vm.setZoom(state.zoom % 4 + 1) } + RemoteTopItem( + phi, + if (state.maxTraceOn) "追踪·开" else "追踪", + icon = R.drawable.ic_target, + highlight = state.maxTraceOn, + ) { vm.toggleMaxTrace() } + RemoteTopItem( + phi, + Palettes.NAMES[state.paletteIndex], + icon = R.drawable.ic_palette, + ) { showPalette = true } + } + } + + // ---- single red disconnect button in the shutter row slot ---- + Row( + modifier = Modifier + .align(Alignment.BottomCenter) + .graphicsLayer { translationY = -navPx.toFloat() } + .onSizeChanged { + shutterPx = it.height + vm.uiBottomPx = navPx + shutterPx + } + .fillMaxWidth() + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() }, + ) { + Box( + modifier = Modifier + .size(60.dp) + .border(4.dp, Color(0xFFFF5252), CircleShape) + .clickable { + vm.disconnect() + onDisconnected("已断开") + }, + ) + Text( + "断开", color = Color.White, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + + if (showPalette) { + AlertDialog( + onDismissRequest = { showPalette = false }, + title = { Text("调色板") }, + text = { + LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = Modifier.height(320.dp)) { + items((0..11).toList()) { idx -> + Text( + Palettes.NAMES[idx], + color = if (idx == state.paletteIndex) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .clickable { vm.setPalette(idx); showPalette = false } + .padding(14.dp), + ) + } + } + }, + confirmButton = {}, + ) + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.RemoteTopItem( + phi: Int, + label: String, + icon: Int = R.drawable.ic_ffc, + highlight: Boolean = false, + onClick: () -> Unit, +) { + Box( + modifier = Modifier.weight(1f).graphicsLayer { rotationZ = -phi.toFloat() }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.clickable(onClick = onClick).padding(4.dp), + ) { + Icon( + painterResource(icon), null, + tint = if (highlight) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = if (highlight) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt new file mode 100644 index 0000000..579ea83 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/remote/RemoteViewerViewModel.kt @@ -0,0 +1,189 @@ +package com.mag160c.thermal.ui.remote + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import com.mag160c.thermal.core.RenderPipeline +import com.mag160c.thermal.media.DebugLog +import com.mag160c.thermal.net.RemoteClient +import com.mag160c.thermal.net.RemoteContract +import com.mag160c.thermal.net.RemoteSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Client-side remote preview (Phase F). + * + * The session sends raw sensor frames; this view model renders them with the + * LOCAL pipeline (bundled DDT) so palette and zoom changes are instant and + * never round-trip. Temperature readout mirrors the live screen's local + * branch (probe at sensor pixel 80,60). + */ +class RemoteViewerViewModel(app: Application) : AndroidViewModel(app) { + data class State( + val connected: Boolean = false, + val host: String = "", + val frames: Int = 0, + val centerTempC: Float? = null, + val maxTempC: Float? = null, + val minTempC: Float? = null, + val maxPos: Int = -1, + val minPos: Int = -1, + val paletteIndex: Int = 2, + val zoom: Int = 1, + val maxTraceOn: Boolean = true, + val status: String = "", + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state + + /** Latest rendered 320x240 ARGB frame for the renderer. */ + @Volatile + var latestFrame: IntArray? = null + private set + + /** UI insets (px) reported from Compose, consumed by the renderer. */ + @Volatile + var uiTopPx: Int = 0 + + @Volatile + var uiBottomPx: Int = 0 + + @Volatile + var uiViewW: Int = 1080 + + @Volatile + var uiViewH: Int = 2280 + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var session: RemoteSession? = null + private var pipeline: RenderPipeline? = null + private var renderJob: Job? = null + + /** Emits once when the link drops (UI shows a snackbar and returns). */ + private val _disconnected = MutableSharedFlow(extraBufferCapacity = 1) + val disconnected = _disconnected + + /** Connect, then immediately start the stream. */ + fun connect(host: String, port: Int) { + scope.launch { + val s = RemoteClient.connect(host, port, scope) + if (s == null) { + _state.value = _state.value.copy(connected = false, status = "connect_fail") + _disconnected.tryEmit(Unit) + return@launch + } + session = s + _state.value = _state.value.copy(connected = true, host = "$host:$port") + DebugLog.log("remote", "client connected to $host:$port") + + // local render pipeline with the bundled DDT + val ddt = runCatching { + getApplication().assets.open("mag160c.ddt").readBytes() + }.getOrDefault(ByteArray(0)) + val pipe = RenderPipeline(w = 160, h = 120) + if (!pipe.loadDdt(ddt)) { + DebugLog.log("remote", "ddt load failed -> cannot render remote frames") + _state.value = _state.value.copy(status = "ddt_fail") + return@launch + } + pipe.setPalette(_state.value.paletteIndex) + pipeline = pipe + + // control lines (welcome / stream-start) for logging + launch { + s.lines.collect { line -> DebugLog.log("remote", "line: $line") } + } + // frames -> local pipeline -> latestFrame + val out = IntArray(320 * 240) + renderJob = launch { + s.frames.collect { raw -> + 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)) { + latestFrame = out.copyOf() + } + } + } + launch { + s.closedFlow.collect { + _state.value = _state.value.copy(connected = false, status = "disconnected") + _disconnected.tryEmit(Unit) + } + } + s.hello("mag160c-client") + s.startStream() + // temperature refresh on a slow timer + while (true) { + kotlinx.coroutines.delay(400) + refreshTemps() + } + } + } + + private fun refreshTemps() { + val pipe = pipeline ?: return + // pipeline.probeTemp already returns millidegrees C (counts -> temp) + val centerMc = pipe.probeTemp(80, 60) + val nuc = IntArray(19200) + pipe.copyNuc(nuc) + var mn = Int.MAX_VALUE + var mx = -1 + var mnPos = -1 + var mxPos = -1 + for (i in nuc.indices) { + val v = nuc[i] + if (v < mn) { mn = v; mnPos = i } + if (v > mx) { mx = v; mxPos = i } + } + _state.value = _state.value.copy( + centerTempC = centerMc / 1000f, + maxTempC = if (mx >= 0) com.mag160c.thermal.core.TempMath.countsToTempMc(mx) / 1000f else null, + minTempC = if (mn <= Int.MAX_VALUE) com.mag160c.thermal.core.TempMath.countsToTempMc(mn) / 1000f else null, + maxPos = mxPos, + minPos = mnPos, + ) + } + + fun setPalette(index: Int) { + pipeline?.setPalette(index) + _state.value = _state.value.copy(paletteIndex = index) + } + + fun setZoom(z: Int) { + _state.value = _state.value.copy(zoom = z.coerceIn(1, 4)) + } + + fun toggleMaxTrace() { + _state.value = _state.value.copy(maxTraceOn = !_state.value.maxTraceOn) + } + + fun requestFfc() { + session?.requestFfc() + } + + /** Disconnect and return; also called from onCleared. */ + fun disconnect() { + session?.stopStream() + session?.close() + session = null + renderJob?.cancel() + renderJob = null + _state.value = _state.value.copy(connected = false) + } + + override fun onCleared() { + disconnect() + scope.cancel() + super.onCleared() + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt index d9171be..634d180 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt @@ -24,12 +24,19 @@ import androidx.compose.ui.unit.dp import com.mag160c.thermal.core.Palettes @Composable -fun SettingsScreen() { +fun SettingsScreen( + onOpenRemoteClient: () -> Unit = {}, + remoteHostRunning: Boolean = false, + onToggleRemoteHost: (Boolean) -> Boolean = { false }, +) { val context = androidx.compose.ui.platform.LocalContext.current val settings = remember { AppSettings(context) } var dialog by remember { mutableStateOf(null) } // mirror the persisted flag in Compose state so the row label updates var cloudEnabled by remember { mutableStateOf(settings.cloudEnabled) } + // remote-preview server toggle (Phase F); off by default, needs live USB + var remoteOn by remember { mutableStateOf(remoteHostRunning) } + var showNeedDevice by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize().padding(8.dp)) { SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" } @@ -40,6 +47,19 @@ fun SettingsScreen() { SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" } SettingRow("语言", settings.language) { dialog = "language" } SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" } + SettingRow("远程预览服务端", if (remoteOn) "已开启" else "已关闭") { + if (remoteOn) { + onToggleRemoteHost(false) + remoteOn = false + } else { + if (onToggleRemoteHost(true)) { + remoteOn = true + } else { + showNeedDevice = true + } + } + } + SettingRow("远程预览客户端", "查找主机") { onOpenRemoteClient() } SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null } } @@ -114,6 +134,17 @@ fun SettingsScreen() { ) else -> {} } + + if (showNeedDevice) { + AlertDialog( + onDismissRequest = { showNeedDevice = false }, + title = { Text("远程预览服务端") }, + text = { Text("先连接热像仪") }, + confirmButton = { + TextButton(onClick = { showNeedDevice = false }) { Text("好") } + }, + ) + } } @Composable diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt index cddaf1e..b16f00c 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt @@ -93,6 +93,10 @@ class IrSession(context: Context) { @Volatile var recorderHook: ((IntArray) -> Unit)? = null + /** Optional raw-frame hook (LAN remote preview), runs on the reader thread. */ + @Volatile + var rawHook: ((ByteArray) -> Unit)? = null + fun isStreaming(): Boolean = running.get() /** Latest raw frame (with 0x38-byte header) for MDT capture. */ @@ -535,6 +539,7 @@ class IrSession(context: Context) { ) } lastRawFrame = frameBuf.copyOf() + lastRawFrame?.let { raw -> rawHook?.invoke(raw) } val rendered = pipe.frame(frameBuf, true, out) if (rendered) { renderCount++ diff --git a/android/app/src/main/res/drawable/ic_back_arrow.xml b/android/app/src/main/res/drawable/ic_back_arrow.xml new file mode 100644 index 0000000..0ef2fcd --- /dev/null +++ b/android/app/src/main/res/drawable/ic_back_arrow.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt new file mode 100644 index 0000000..af77260 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteContractTest.kt @@ -0,0 +1,168 @@ +package com.mag160c.thermal.net + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +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. + */ +class RemoteContractTest { + private fun payload(seed: Int): ByteArray { + val out = ByteArray(RemoteContract.FRAME_PIXELS) + Random(seed.toLong()).nextBytes(out) + return out + } + + @Test + fun encodeFramePacketLayoutMatchesTheContract() { + val raw = payload(1) + val pkt = RemoteContract.encodeFramePacket(raw, counter = 7) + assertEquals(38412, 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)) + } + + @Test + fun singleFrameRoundTrip() { + val raw = payload(2) + val reader = RemoteContract.FramePacketReader() + val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 1)) + assertEquals(1, frames.size) + assertArrayEquals(raw, frames[0]) + assertEquals(0, reader.pending()) + } + + @Test + fun truncatedInputIsBufferedUntilComplete() { + val raw = payload(3) + val pkt = RemoteContract.encodeFramePacket(raw, 2) + val reader = RemoteContract.FramePacketReader() + // split at an awkward offset: mid-header, then mid-payload + assertTrue("nothing complete yet", reader.feed(pkt.copyOfRange(0, 7)).isEmpty()) + 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]) + } + + @Test + fun threeFramesGluedInOneRead() { + 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()) + assertEquals(3, frames.size) + frames.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) } + } + + @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() + var pos = 0 + val chunkPattern = intArrayOf(5, 38411, 1, 20000, 100, 40000) + var ci = 0 + while (pos < bytes.size) { + val n = minOf(chunkPattern[ci % chunkPattern.size], bytes.size - pos) + got += reader.feed(bytes.copyOfRange(pos, pos + n)) + pos += n + ci++ + } + assertEquals(3, got.size) + got.forEachIndexed { i, f -> assertArrayEquals(raws[i], f) } + } + + @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) + assertEquals(1, frames.size) + assertArrayEquals(raw, frames[0]) + } + + @Test + fun bogusLengthHeaderIsSkipped() { + val raw = payload(21) + 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) + assertEquals("only the valid frame is delivered", 1, frames.size) + assertArrayEquals(raw, frames[0]) + } + + @Test + fun beaconRoundTrip() { + val line = RemoteContract.hostBeacon("Pixel 8 \"test\"", 160043865L) + val info = RemoteContract.parseBeacon(line, "192.168.1.23") + assertNotNull(info) + info!! + assertEquals("Pixel 8 \"test\"", info.name) + assertEquals("192.168.1.23", info.address) + assertEquals(47511, info.tcpPort) + assertEquals(160043865L, info.serial) + } + + @Test + fun foreignOrBrokenBeaconsAreIgnored() { + assertNull(RemoteContract.parseBeacon("{\"app\":\"other\"}", "10.0.0.1")) + assertNull(RemoteContract.parseBeacon("not json at all", "10.0.0.1")) + assertNull(RemoteContract.parseBeacon("{}", "10.0.0.1")) + } + + @Test + fun controlLineParsing() { + assertEquals("hello", RemoteContract.commandOf(RemoteContract.hello("Client"))) + assertEquals("Client", RemoteContract.nameOf(RemoteContract.hello("Client"))) + assertEquals("start", RemoteContract.commandOf(RemoteContract.cmd("start"))) + 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" }) + assertEquals("stream-start", RemoteContract.typeOf(RemoteContract.streamStartLine())) + assertEquals("stream-stop", RemoteContract.typeOf(RemoteContract.streamStopLine())) + } + + @Test + fun welcomeParsing() { + val w = RemoteContract.parseWelcome(RemoteContract.welcomeLine(160, 120, 15, 160043865L)) + assertNotNull(w) + w!! + assertEquals(160, w.w) + assertEquals(120, w.h) + assertEquals(15, w.fps) + assertEquals(160043865L, w.serial) + assertNull(RemoteContract.parseWelcome(RemoteContract.okLine())) + } + + @Test + fun readerRecoversAfterReset() { + val reader = RemoteContract.FramePacketReader() + reader.feed(RemoteContract.encodeFramePacket(payload(30), 1).copyOfRange(0, 100)) + assertTrue(reader.pending() > 0) + reader.reset() + assertEquals(0, reader.pending()) + val raw = payload(31) + val frames = reader.feed(RemoteContract.encodeFramePacket(raw, 2)) + assertEquals(1, frames.size) + assertArrayEquals(raw, frames[0]) + } +} diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt new file mode 100644 index 0000000..03ab9cf --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/net/RemoteLoopbackTest.kt @@ -0,0 +1,134 @@ +package com.mag160c.thermal.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Random + +/** + * End-to-end loopback check of the Phase F host/client pair: a real TCP socket + * on localhost, real framing, real control handshake. Run on the JVM because + * RemoteHost/RemoteClient use only java.net types. + */ +class RemoteLoopbackTest { + private fun payload(seed: Int): ByteArray { + val out = ByteArray(RemoteContract.FRAME_PIXELS) + Random(seed.toLong()).nextBytes(out) + return out + } + + @Test + fun hostStreamsFramesToClientOverLoopback() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val host = RemoteHost("jvm-test", 160043865L) + try { + host.start() + // give the server socket a moment to bind + delay(300) + + val session = RemoteClient.connect("127.0.0.1", RemoteContract.CONTROL_PORT, scope) + assertNotNull("client must connect to the loopback host", session) + session!! + + // hello -> welcome + session.hello("jvm-client") + val welcome = withTimeout(5000) { + session.lines.first { RemoteContract.typeOf(it) == "welcome" } + } + val parsed = RemoteContract.parseWelcome(welcome) + assertNotNull("welcome must parse", parsed) + assertEquals(160, parsed!!.w) + + // start -> stream-start, then frames + val received = ArrayList() + val collector = scope.launch { + session.frames.collect { received.add(it) } + } + session.startStream() + val startLine = withTimeout(5000) { + session.lines.first { RemoteContract.typeOf(it) == "stream-start" } + } + assertEquals("stream-start", RemoteContract.typeOf(startLine)) + + // feed frames like the live session would + val sent = List(5) { payload(100 + it) } + for ((i, f) in sent.withIndex()) { + host.offerFrame(f) + delay(60) + if (host.state == RemoteHost.State.STREAMING && i == 0) { + // ensure the queue drains between frames + } + } + // wait for all frames to arrive + val ok = withTimeout(8000) { + while (received.size < sent.size) delay(50) + true + } + assertTrue(ok) + collector.cancel() + assertTrue("at least ${sent.size} frames received, got ${received.size}", received.size >= sent.size) + sent.forEachIndexed { i, expect -> + assertTrue( + "frame $i must round-trip byte-exactly", + expect.contentEquals(received[i]), + ) + } + + // ffc request reaches the host callback + var ffcSeen = false + host.onFfcRequest = { ffcSeen = true } + session.requestFfc() + withTimeout(5000) { + while (!ffcSeen) delay(50) + } + assertTrue("host must receive the ffc request", ffcSeen) + + // stop -> stream-stop + session.stopStream() + val stopLine = withTimeout(5000) { + session.lines.first { RemoteContract.typeOf(it) == "stream-stop" } + } + assertEquals("stream-stop", RemoteContract.typeOf(stopLine)) + + session.close() + } finally { + host.stop() + scope.cancel() + } + } + + @Test + fun hostRejectsNothingWhenNoClientIsConnected() = runBlocking { + val host = RemoteHost("jvm-test", 1L) + try { + host.start() + delay(200) + // offering frames with no client must not throw or block + repeat(20) { host.offerFrame(payload(it)) } + assertEquals(RemoteHost.State.WAITING, host.state) + } finally { + host.stop() + } + } + + @Test + fun beaconFormatIsWhatTheClientParses() { + val line = RemoteContract.hostBeacon("Redmi K70", 160043865L) + val info = RemoteContract.parseBeacon(line, "192.168.31.7") + assertNotNull(info) + assertEquals("Redmi K70", info!!.name) + assertEquals("192.168.31.7", info.address) + assertEquals(RemoteContract.CONTROL_PORT, info.tcpPort) + assertEquals(160043865L, info.serial) + } +} diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 76c54c8..9096868 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:82ed993ac92ba0b00542505569470020b85f75a90b561c5a5ef3d58a63d2eb46 -size 12662902 +oid sha256:8a96bbc095078b868269ae96cab8a88048383ad381c023d7008e1b484fda4257 +size 12663188 diff --git a/docs/android_app/real_device_checklist.md b/docs/android_app/real_device_checklist.md index ebc1e32..e9b9025 100644 --- a/docs/android_app/real_device_checklist.md +++ b/docs/android_app/real_device_checklist.md @@ -28,8 +28,28 @@ | 14 | 拖动 PIP 小窗到其它角落;单击小窗(循环 96/128/160dp);双击小窗关闭 | `[pip] released`(双击关闭时) | 小窗跟手移动且不出界;单击在三档宽度间循环;双击后小窗消失,顶栏恢复"画中画"未开启态;日志有 `released` | | 15 | PIP 开启时按 Home 回到桌面再返回 | `[pip] released`(进入后台时) | 返回后若 PIP 仍为开启态,画面重新出现(重新 open);无崩溃 | -## 相机(PIP)失败时的表现(设计如此,不算 bug) +## 远程预览(Phase F,需要两台设备) +两台安卓设备(或一台手机 + 一个模拟器)连**同一个 Wi-Fi**。以下称 A=插热像仪的 +主机手机,B=远程查看的手机。 + +| # | 操作 | 预期 DebugLog 行(原文) | 通过标准 | +|---|------|--------------------------|----------| +| 16 | A:插热像仪,确认实时页已出图(清单前 6 步) | `[session] state -> STREAMING` | A 本地画面正常 | +| 17 | A:设置页 → "远程预览服务端"(点一下) | `[remote] host started (name=<机型> serial=0)` | 该行文案变为"已开启" | +| 18 | A:若第 17 步弹"先连接热像仪" | (无日志) | 说明 USB 会话不活跃:先回到实时页确认出流 | +| 19 | B:设置页 → "远程预览客户端" → "查找主机" | (B 端)列表出现 A 的主机名 | 10 秒内列出 A(卡片显示 `<主机名>` 与 `:47511`);未列出可用"手动添加"填 A 的 IP | +| 20 | B:点该卡片(或手动 IP 后点"连接") | A 端:`[remote] client connected from `;B 端:`[remote] client connected to :47511` → `[remote] line: {"type":"welcome",...}` → `[remote] line: {"type":"stream-start"}` → `[remote] first remote frame` | B 显示 A 的热像实时画面(同一构图:竖屏 3:4、顶栏 4 项、右侧色标条) | +| 21 | B:顶栏点变倍/追踪/调色板 | (无日志,全部本地) | 立即生效、无卡顿(调色板/变倍不下发到 A) | +| 22 | B:顶栏点 FFC | A 端出现一次快门校正;B 画面随之更新 | FFC 经 A 的热像仪执行 | +| 23 | B:点底部红色"断开"圆钮 | A 端:`[remote] client disconnected (frames=)` | B 返回主机列表并弹出"已断开"提示;A 服务端保持"已开启"待重连 | +| 24 | A:设置页再点"远程预览服务端"关闭 | `[remote] host stopped` | 文案变回"已关闭";A 本地热像画面不受影响 | + +远程预览失败时的表现(设计如此):连不上主机时 B 显示"无法连接主机"并返回列表; +中途断网/主机退出时 B 显示"连接已断开";A 端相机与本地画面**始终不受网络影响**; +日志中 `frames=…` 每 300 帧记一次。 + +## 相机(PIP)失败时的表现(设计如此,不算 bug) PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**: - 无相机硬件 / 权限被拒 → `[pip] no camera available` / `[pip] camera permission not granted`, diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index 6843737..dd4d538 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -423,7 +423,23 @@ 拖动/单击换档/双击关闭;PIP 关闭、离页、ON_STOP 三处释放相机; 运行时权限用 rememberLauncherForActivityResult。真机项已写入 real_device_checklist.md(第 13-15 步 + 失败表现)。APK 已更新。 -- [ ] Phase F:网络互连远程预览 +- [x] Phase F(2026-09-10):网络互连远程预览。net/RemoteContract.kt(协议常量+ + 手写扁平 JSON 助手,**无 Android 依赖**便于 JVM 单测 + FramePacketReader + 流式重组);net/RemoteHost.kt(UDP 47510 每秒广播 + TCP 47511 单客户端; + **单一协程拥有 socket**,控制回复与帧记录不会交错;帧队列 DROP_OLDEST 不拖慢 + 相机);net/RemoteClient.kt(discover 去重 3s;connect 行模式→收到 + stream-start 后切帧模式,同一 chunk 内混合也能正确切分;10s 无数据判死); + IrSession 加 rawHook(与 recorderHook 并列);LiveViewModel 接 rawHook → + remoteHost.offerFrame + setRemoteHostEnabled(需活跃 USB 会话); + 设置页新增"远程预览服务端"/"远程预览客户端"两行(前置校验弹"先连接热像仪"); + ui/remote/ 三文件:RemoteClientListScreen(56dp 标题栏/64dp 卡片/重新扫描/ + 手动添加/空态)、RemoteViewerViewModel(本地 RenderPipeline + 内置 DDT + 渲染)、RemoteViewerScreen + RemoteRendererHost(与 LiveRenderer 同构图, + 未改冻结文件)、AppRoot 加两个全屏目的地。Manifest 加 INTERNET。 + 单测 44 个全绿,含 **loopback 端到端测试**(真实 TCP:hello→welcome→ + start→stream-start→5 帧逐字节往返→ffc→stop→stream-stop)与粘包/截断/ + 坏长度/重同步用例。debug+release 双构建通过。真机双机步骤写入清单第 16-24 步。 +- [ ] Phase Z:收尾(文档/全量构建/APK) ## 里程碑日志