Files
view-mate/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DebugHttpServer.kt
T
subochev 79a500150d Пейринг RayNeo: кнопка ручного (без QR) ре-паринга + debug-роуты + keep-screen-on
- MercuryBridge.pairClassicAuto: классический discovery → найти RayNeo по
  имени → createBond (без QR и без камеры очков) для случая «бонд слетел,
  очки в first-pairing». discoverGlasses — список найденных.
- MercuryPanel: кнопка «🔗 паринг (вручную)» рядом с QR-парингом
- DebugHttpServer: /mercury/pair, /mercury/pair/stop, /mercury/pair/classic,
  /mercury/unpair, /mercury/bonded, /mercury/discover, поля pairing в /status
- keep-screen-on, чтобы QR/состояние реально были на экране телефона
2026-09-04 03:08:38 +03:00

252 lines
12 KiB
Kotlin

package pw.binom.viewmate.phone
import pw.binom.viewmate.phone.agent.LlmModelChoice
import pw.binom.viewmate.phone.agent.LocalLlmClient
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.InetAddress
import java.net.ServerSocket
/**
* Отладочный HTTP-сервер в приложении (только для разработки).
* Позволяет дёргать ассистента и переключать модель без UI:
*
* GET /status — выбор модели, готовность движка, размер файла
* POST /chat {"text":"привет"} — отправить фразу в ассистента (та же очередь, что UI)
* POST /model {"choice":"local"} — переключить модель (local|remote), персист
*
* Запускается на 127.0.0.1:8787. Доступ: `adb forward tcp:8787 tcp:8787`.
*/
object DebugHttpServer {
private const val PORT = 8787
private const val TAG = "httpd"
@Volatile
private var running = false
fun start(app: PhoneApp) {
if (running) return
running = true
Thread {
try {
val server = ServerSocket(PORT, 8, InetAddress.getByName("127.0.0.1"))
log(TAG, "debug-http на 127.0.0.1:$PORT (adb forward tcp:8787 tcp:8787)")
while (running) {
try {
val socket = server.accept()
Thread {
try {
handle(socket, app)
} catch (e: Exception) {
log(TAG, "обработка запроса: ${e.message}")
} finally {
runCatching { socket.close() }
}
}.apply { isDaemon = true }.start()
} catch (e: Exception) {
if (running) log(TAG, "accept: ${e.message}")
}
}
} catch (e: Exception) {
log(TAG, "не удалось запустить: ${e.message}")
}
}.apply { isDaemon = true; name = "viewmate-httpd" }.start()
}
private fun handle(socket: java.net.Socket, app: PhoneApp) {
val reader = BufferedReader(InputStreamReader(socket.getInputStream(), Charsets.UTF_8))
val requestLine = reader.readLine() ?: return
val parts = requestLine.split(" ")
if (parts.size < 2) return
val method = parts[0]
val rawPath = parts[1]
val path = rawPath.substringBefore("?")
val queryParams = rawPath.substringAfter("?", "").split("&")
.filter { it.isNotEmpty() }
.associate { it.substringBefore("=") to it.substringAfter("=") }
// Тело (до Content-Length)
var contentLength = 0
while (true) {
val line = reader.readLine() ?: break
if (line.isEmpty()) break
if (line.startsWith("Content-Length:", true)) {
contentLength = line.substringAfter(':').trim().toIntOrNull() ?: 0
}
}
val body = if (contentLength > 0) {
val buf = CharArray(contentLength)
var read = 0
while (read < contentLength) {
val n = reader.read(buf, read, contentLength - read)
if (n < 0) break
read += n
}
String(buf, 0, read)
} else ""
val response: String
val code: Int
when {
method == "GET" && path.startsWith("/status") -> {
val choice = app.llmChoice.value
val local = app.localLlmClient()
val file = app.localModelFile()
response = """{"choice":"${choice.name.lowercase()}","local":{${localStatus(local, file)}}}"""
code = 200
}
method == "POST" && path.startsWith("/chat") -> {
val text = jsonField(body, "text") ?: ""
if (text.isBlank()) {
response = """{"error":"пустой text"}"""
code = 400
} else {
app.sendToAssistant(text)
response = """{"ok":true,"queued":"${text.take(60)}"}"""
code = 200
}
}
method == "POST" && path.startsWith("/model") -> {
val choice = jsonField(body, "choice")
if (choice == "local" || choice == "remote") {
app.setLlmModelChoice(if (choice == "local") LlmModelChoice.LOCAL else LlmModelChoice.REMOTE)
response = """{"ok":true,"choice":"$choice"}"""
code = 200
} else {
response = """{"error":"choice должен быть local|remote"}"""
code = 400
}
}
// --- Mercury BLE (полевой тест либы rayneo-mercury) ---
method == "GET" && path.startsWith("/mercury/status") -> {
val s = MercuryBridge.state.value
response = """{"connected":${s.connected},"glasses":${s.glassesName?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)},"error":${s.lastError?.let { jsonEscape(it) } ?: "null"},"pairing":${s.pairCode != null},"pairCode":${s.pairCode?.let { "\"$it\"" } ?: "null"},"realAddress":${s.realAddress?.let { "\"$it\"" } ?: "null"}}"""
code = 200
}
method == "GET" && path.startsWith("/mercury/bonded") -> {
val bonds = MercuryBridge.bondedAddresses()
val items = bonds.joinToString(",") { (addr, name) ->
"""{"address":"$addr","name":${name?.let { jsonEscape(it) } ?: "null"}}"""
}
response = """{"count":${bonds.size},"bonds":[$items]}"""
code = 200
}
method == "GET" && path.startsWith("/mercury/discover") -> {
val seconds = queryParams["time"]?.toIntOrNull() ?: 5
val found = MercuryBridge.discoverGlasses(seconds * 1_000L)
val items = found.joinToString(",") { (name, addr) ->
"""{"name":${name?.let { jsonEscape(it) } ?: "null"},"address":"$addr"}"""
}
val s = MercuryBridge.state.value
response = """{"count":${found.size},"found":[$items],"connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/pair/stop") -> {
MercuryBridge.cancelPairing()
val s = MercuryBridge.state.value
response = """{"ok":true,"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/pair/classic") -> {
val address = queryParams["address"]
if (address.isNullOrBlank()) {
response = """{"error":"нет параметра address"}"""
code = 400
} else {
val ok = MercuryBridge.pairClassic(address)
val s = MercuryBridge.state.value
response = """{"ok":$ok,"address":"$address","connected":${s.connected},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
}
method == "POST" && path.startsWith("/mercury/pair") -> {
val pairCode = MercuryBridge.startPairing()
val s = MercuryBridge.state.value
response = """{"ok":${pairCode != null},"pairCode":${pairCode?.let { "\"$it\"" } ?: "null"},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/unpair") -> {
val address = queryParams["address"]
if (address.isNullOrBlank()) {
response = """{"error":"нет параметра address"}"""
code = 400
} else {
val ok = MercuryBridge.unpair(address)
response = """{"ok":$ok,"address":"$address"}"""
code = 200
}
}
method == "POST" && path.startsWith("/mercury/connect") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.connectFirst() }
response = """{"ok":$ok,"connected":${MercuryBridge.state.value.connected},"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/disconnect") -> {
kotlinx.coroutines.runBlocking { MercuryBridge.disconnect() }
response = """{"ok":true}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/screen/off") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOff() }
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/screen/on") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.screenOn() }
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/autolock") -> {
val time = queryParams["time"]?.toIntOrNull() ?: 10
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.autoLockTime(time) }
response = """{"ok":$ok,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/wifi") -> {
val enable = "true".equals(queryParams["enable"], ignoreCase = true)
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiToggle(enable) }
response = """{"ok":$ok,"enable":$enable,"status":${jsonEscape(MercuryBridge.state.value.statusText)}}"""
code = 200
}
method == "POST" && path.startsWith("/mercury/wifiscan") -> {
val ok = kotlinx.coroutines.runBlocking { MercuryBridge.wifiScan() }
val s = MercuryBridge.state.value
response = """{"ok":$ok,"networks":${s.wifiNetworks.size},"status":${jsonEscape(s.statusText)}}"""
code = 200
}
else -> {
response = """{"error":"нет такого пути: $method $path"}"""
code = 404
}
}
val out = socket.getOutputStream()
val payload = response.toByteArray(Charsets.UTF_8)
out.write(("HTTP/1.1 $code ${reason(code)}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: ${payload.size}\r\nConnection: close\r\n\r\n").toByteArray(Charsets.UTF_8))
out.write(payload)
out.flush()
}
private fun localStatus(local: LocalLlmClient?, file: java.io.File): String {
if (local == null) return """"ready":false,"fileExists":${file.exists()},"fileBytes":${file.length()}"""
return """"ready":true,"modelDownloaded":${local.modelDownloaded()},"fileExists":${file.exists()},"fileBytes":${file.length()}"""
}
private fun jsonField(body: String, key: String): String? {
// {"text":"..."} — примитивный парсер, без кавычек внутри значения
val regex = Regex("\"$key\"\\s*:\\s*\"([^\"]*)\"")
return regex.find(body)?.groupValues?.get(1)
}
private fun jsonEscape(s: String): String =
s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n")
private fun reason(code: Int): String = when (code) {
200 -> "OK"
400 -> "Bad Request"
404 -> "Not Found"
else -> "Error"
}
}