android: LAN remote preview (UDP discovery + raw-frame TCP stream, client-side rendering)
This commit is contained in:
@@ -5,6 +5,9 @@
|
|||||||
<!-- Visible-light PIP overlay (Phase E): optional, requested at runtime -->
|
<!-- Visible-light PIP overlay (Phase E): optional, requested at runtime -->
|
||||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<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_MEDIA_IMAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
|
|
||||||
|
|||||||
@@ -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<RemoteContract.HostInfo> {
|
||||||
|
val out = MutableSharedFlow<RemoteContract.HostInfo>(
|
||||||
|
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<String, Long>()
|
||||||
|
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<String>(
|
||||||
|
replay = 8,
|
||||||
|
extraBufferCapacity = 32,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Control lines from the host; only meaningful before the stream starts. */
|
||||||
|
val lines: Flow<String> = _lines.asSharedFlow()
|
||||||
|
|
||||||
|
private val _frames = MutableSharedFlow<ByteArray>(
|
||||||
|
extraBufferCapacity = 8,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Raw 38400-byte sensor payloads, ready for the local RenderPipeline. */
|
||||||
|
val frames: Flow<ByteArray> = _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<Unit>(extraBufferCapacity = 1)
|
||||||
|
val closedFlow: Flow<Unit> = _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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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":"<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]
|
||||||
|
* - 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<ByteArray> {
|
||||||
|
append(bytes)
|
||||||
|
val out = ArrayList<ByteArray>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ByteArray>? = 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<String> {
|
||||||
|
val out = ArrayList<String>(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,13 @@ import androidx.compose.material3.NavigationBar
|
|||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.mag160c.thermal.R
|
import com.mag160c.thermal.R
|
||||||
import com.mag160c.thermal.ui.gallery.GalleryScreen
|
import com.mag160c.thermal.ui.gallery.GalleryScreen
|
||||||
import com.mag160c.thermal.ui.live.LiveScreen
|
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
|
import com.mag160c.thermal.ui.settings.SettingsScreen
|
||||||
|
|
||||||
private data class Tab(val label: String, val icon: Int)
|
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
|
* position never moves, however the phone is physically held). Bar content
|
||||||
* (icons/text) is pre-rotated by the physical device orientation so it stays
|
* (icons/text) is pre-rotated by the physical device orientation so it stays
|
||||||
* readable in any grip. Tab content lives in a stable slot.
|
* 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
|
@Composable
|
||||||
fun AppRoot() {
|
fun AppRoot() {
|
||||||
var tab by rememberSaveable { mutableStateOf(0) }
|
var tab by rememberSaveable { mutableStateOf(0) }
|
||||||
val phi by DeviceOrientation.deg.collectAsState()
|
val phi by DeviceOrientation.deg.collectAsState()
|
||||||
val ctx = LocalContext.current
|
val ctx = LocalContext.current
|
||||||
|
val liveVm: LiveViewModel = viewModel()
|
||||||
|
|
||||||
|
// remote preview navigation (Phase F)
|
||||||
|
var showRemoteList by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var remoteHost by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
var remotePort by rememberSaveable { mutableStateOf(0) }
|
||||||
|
var notice by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
DeviceOrientation.start(ctx)
|
DeviceOrientation.start(ctx)
|
||||||
@@ -58,20 +74,41 @@ fun AppRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (tab) {
|
when {
|
||||||
0 -> LiveScreen(onOpenGallery = { tab = 1 })
|
remoteHost != null -> RemoteViewerScreen(
|
||||||
1 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
host = remoteHost!!,
|
||||||
GalleryScreen()
|
port = remotePort,
|
||||||
}
|
onDisconnected = { reason ->
|
||||||
2 -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
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()
|
GalleryScreen()
|
||||||
}
|
}
|
||||||
else -> Column(modifier = Modifier.fillMaxSize().padding(bottom = 84.dp)) {
|
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(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surface,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -82,7 +119,12 @@ fun AppRoot() {
|
|||||||
TABS.forEachIndexed { i, t ->
|
TABS.forEachIndexed { i, t ->
|
||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = tab == i,
|
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
|
// pre-rotate so the item is upright in the current grip
|
||||||
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
|
modifier = Modifier.graphicsLayer { rotationZ = -phi.toFloat() },
|
||||||
icon = { Icon(painterResource(t.icon), null) },
|
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("好") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,8 +149,37 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
rec.offerFrame(bmp)
|
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. */
|
/** Begin USB permission flow, then start streaming. No device -> idle notice. */
|
||||||
fun connect() {
|
fun connect() {
|
||||||
val now = android.os.SystemClock.elapsedRealtime()
|
val now = android.os.SystemClock.elapsedRealtime()
|
||||||
@@ -400,6 +429,7 @@ class LiveViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCleared() {
|
override fun onCleared() {
|
||||||
|
remoteHost.stop()
|
||||||
session.destroy()
|
session.destroy()
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<List<RemoteContract.HostInfo>>(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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> = _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<Unit>(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<Application>().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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,19 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.mag160c.thermal.core.Palettes
|
import com.mag160c.thermal.core.Palettes
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen() {
|
fun SettingsScreen(
|
||||||
|
onOpenRemoteClient: () -> Unit = {},
|
||||||
|
remoteHostRunning: Boolean = false,
|
||||||
|
onToggleRemoteHost: (Boolean) -> Boolean = { false },
|
||||||
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val settings = remember { AppSettings(context) }
|
val settings = remember { AppSettings(context) }
|
||||||
var dialog by remember { mutableStateOf<String?>(null) }
|
var dialog by remember { mutableStateOf<String?>(null) }
|
||||||
// mirror the persisted flag in Compose state so the row label updates
|
// mirror the persisted flag in Compose state so the row label updates
|
||||||
var cloudEnabled by remember { mutableStateOf(settings.cloudEnabled) }
|
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)) {
|
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||||
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
|
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
|
||||||
@@ -40,6 +47,19 @@ fun SettingsScreen() {
|
|||||||
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
|
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
|
||||||
SettingRow("语言", settings.language) { dialog = "language" }
|
SettingRow("语言", settings.language) { dialog = "language" }
|
||||||
SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" }
|
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 }
|
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,6 +134,17 @@ fun SettingsScreen() {
|
|||||||
)
|
)
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showNeedDevice) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showNeedDevice = false },
|
||||||
|
title = { Text("远程预览服务端") },
|
||||||
|
text = { Text("先连接热像仪") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { showNeedDevice = false }) { Text("好") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ class IrSession(context: Context) {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var recorderHook: ((IntArray) -> Unit)? = null
|
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()
|
fun isStreaming(): Boolean = running.get()
|
||||||
|
|
||||||
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
|
/** Latest raw frame (with 0x38-byte header) for MDT capture. */
|
||||||
@@ -535,6 +539,7 @@ class IrSession(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
lastRawFrame = frameBuf.copyOf()
|
lastRawFrame = frameBuf.copyOf()
|
||||||
|
lastRawFrame?.let { raw -> rawHook?.invoke(raw) }
|
||||||
val rendered = pipe.frame(frameBuf, true, out)
|
val rendered = pipe.frame(frameBuf, true, out)
|
||||||
if (rendered) {
|
if (rendered) {
|
||||||
renderCount++
|
renderCount++
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
||||||
|
<path android:pathData="M12,4a8,8 0 1,0 0.01,0 a8,8 0 1,0 -0.01,0z" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M11,8 L7,12 L11,16" android:strokeColor="#FF000000" android:strokeWidth="2" android:fillColor="#00000000" />
|
||||||
|
<path android:pathData="M7,12 L17,12" android:strokeColor="#FF000000" android:strokeWidth="2" />
|
||||||
|
</vector>
|
||||||
@@ -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<ByteArray>()
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ByteArray>()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -28,8 +28,28 @@
|
|||||||
| 14 | 拖动 PIP 小窗到其它角落;单击小窗(循环 96/128/160dp);双击小窗关闭 | `[pip] released`(双击关闭时) | 小窗跟手移动且不出界;单击在三档宽度间循环;双击后小窗消失,顶栏恢复"画中画"未开启态;日志有 `released` |
|
| 14 | 拖动 PIP 小窗到其它角落;单击小窗(循环 96/128/160dp);双击小窗关闭 | `[pip] released`(双击关闭时) | 小窗跟手移动且不出界;单击在三档宽度间循环;双击后小窗消失,顶栏恢复"画中画"未开启态;日志有 `released` |
|
||||||
| 15 | PIP 开启时按 Home 回到桌面再返回 | `[pip] released`(进入后台时) | 返回后若 PIP 仍为开启态,画面重新出现(重新 open);无崩溃 |
|
| 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(卡片显示 `<主机名>` 与 `<IP>:47511`);未列出可用"手动添加"填 A 的 IP |
|
||||||
|
| 20 | B:点该卡片(或手动 IP 后点"连接") | A 端:`[remote] client connected from <B的IP>`;B 端:`[remote] client connected to <A的IP>: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 <IP> disconnected (frames=<N>)` | B 返回主机列表并弹出"已断开"提示;A 服务端保持"已开启"待重连 |
|
||||||
|
| 24 | A:设置页再点"远程预览服务端"关闭 | `[remote] host stopped` | 文案变回"已关闭";A 本地热像画面不受影响 |
|
||||||
|
|
||||||
|
远程预览失败时的表现(设计如此):连不上主机时 B 显示"无法连接主机"并返回列表;
|
||||||
|
中途断网/主机退出时 B 显示"连接已断开";A 端相机与本地画面**始终不受网络影响**;
|
||||||
|
日志中 `frames=…` 每 300 帧记一次。
|
||||||
|
|
||||||
|
## 相机(PIP)失败时的表现(设计如此,不算 bug)
|
||||||
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
|
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
|
||||||
|
|
||||||
- 无相机硬件 / 权限被拒 → `[pip] no camera available` / `[pip] camera permission not granted`,
|
- 无相机硬件 / 权限被拒 → `[pip] no camera available` / `[pip] camera permission not granted`,
|
||||||
|
|||||||
@@ -423,7 +423,23 @@
|
|||||||
拖动/单击换档/双击关闭;PIP 关闭、离页、ON_STOP 三处释放相机;
|
拖动/单击换档/双击关闭;PIP 关闭、离页、ON_STOP 三处释放相机;
|
||||||
运行时权限用 rememberLauncherForActivityResult。真机项已写入
|
运行时权限用 rememberLauncherForActivityResult。真机项已写入
|
||||||
real_device_checklist.md(第 13-15 步 + 失败表现)。APK 已更新。
|
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)
|
||||||
|
|
||||||
|
|
||||||
## 里程碑日志
|
## 里程碑日志
|
||||||
|
|||||||
Reference in New Issue
Block a user