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" } }