|
|
|
@ -58,6 +58,7 @@ class StsAgent( |
|
|
|
override val coroutineContext = Dispatchers.IO + job |
|
|
|
|
|
|
|
init { |
|
|
|
Log.i(TAG, "[YIDONG] StsAgent初始化: productId=${config.productId} deviceNo=${config.deviceNo} deviceId=${config.deviceId} agentId=${config.agentId} hasSecret=${config.deviceSecret.isNotEmpty()}") |
|
|
|
fetchCredentials() |
|
|
|
} |
|
|
|
|
|
|
|
@ -89,6 +90,7 @@ class StsAgent( |
|
|
|
|
|
|
|
// ── 预缓冲(VAD 触发前 ~1.5s)──────────────────────────────────────────── |
|
|
|
private val preBuffer = ArrayDeque<ByteArray>() |
|
|
|
private val preBufferLock = Any() |
|
|
|
private val PRE_BUFFER_MAX_CHUNKS = 75 |
|
|
|
|
|
|
|
// ── 本地 VAD ────────────────────────────────────────────────────────────── |
|
|
|
@ -153,8 +155,11 @@ class StsAgent( |
|
|
|
fun fetchCredentials() { |
|
|
|
if (config.deviceSecret.isNotEmpty()) { |
|
|
|
credentialReady = true |
|
|
|
Log.i(TAG, "[YIDONG] fetchCredentials: 凭证已存在,跳过请求 deviceNo=${config.deviceNo} deviceId=${config.deviceId}") |
|
|
|
reportHeartbeat() // 凭证已有,直接上报心跳 |
|
|
|
return |
|
|
|
} |
|
|
|
Log.i(TAG, "[YIDONG] fetchCredentials: 开始请求密钥 productId=${config.productId} deviceNo=${config.deviceNo}") |
|
|
|
launch { |
|
|
|
try { |
|
|
|
val body = JSONObject().apply { |
|
|
|
@ -180,14 +185,82 @@ class StsAgent( |
|
|
|
) |
|
|
|
credentialReady = config.deviceSecret.isNotEmpty() |
|
|
|
if (credentialReady) { |
|
|
|
Log.i(TAG, "[YIDONG] fetchCredentials: 密钥获取成功 deviceId=${config.deviceId} deviceNo=${config.deviceNo}") |
|
|
|
onCredentialsFetched?.invoke(config.deviceId, config.deviceSecret, config.deviceNo) |
|
|
|
reportHeartbeat() // 凭证获取成功后立即上报心跳 |
|
|
|
} else { |
|
|
|
Log.e(TAG, "[YIDONG] fetchCredentials: 响应成功但deviceSecret为空") |
|
|
|
emit("credential_failed", mapOf("reason" to "设备未认证,deviceSecret为空")) |
|
|
|
} |
|
|
|
} else { |
|
|
|
Log.e(TAG, "凭证获取失败: ${json.optString("message")}") |
|
|
|
val msg = json.optString("message", "未知错误") |
|
|
|
Log.e(TAG, "[YIDONG] fetchCredentials: 密钥获取失败 code=${json.optString("code")} msg=$msg") |
|
|
|
emit("credential_failed", mapOf("reason" to "设备未认证:$msg")) |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e: Exception) { |
|
|
|
Log.e(TAG, "凭证获取异常: ${e.message}") |
|
|
|
Log.e(TAG, "[YIDONG] fetchCredentials: 请求异常 ${e.message}") |
|
|
|
emit("credential_failed", mapOf("reason" to "设备认证请求失败:${e.message}")) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** 向中国移动平台上报心跳 */ |
|
|
|
fun reportHeartbeat() { |
|
|
|
if (!credentialReady) { |
|
|
|
Log.w(TAG, "[YIDONG] reportHeartbeat: 凭证未就绪,跳过上报") |
|
|
|
return |
|
|
|
} |
|
|
|
Log.i(TAG, "[YIDONG] reportHeartbeat: 开始上报 deviceId=${config.deviceId} deviceNo=${config.deviceNo}") |
|
|
|
launch { |
|
|
|
try { |
|
|
|
val netType = try { |
|
|
|
val cm = context.getSystemService(android.content.Context.CONNECTIVITY_SERVICE) |
|
|
|
as android.net.ConnectivityManager |
|
|
|
val net = cm.activeNetworkInfo |
|
|
|
when (net?.type) { |
|
|
|
android.net.ConnectivityManager.TYPE_WIFI -> "WiFi" |
|
|
|
android.net.ConnectivityManager.TYPE_MOBILE -> "5G" |
|
|
|
else -> "WiFi" |
|
|
|
} |
|
|
|
} catch (_: Exception) { "WiFi" } |
|
|
|
|
|
|
|
val body = JSONObject().apply { |
|
|
|
put("deviceId", config.deviceId) |
|
|
|
put("deviceSecret", config.deviceSecret) |
|
|
|
put("productId", config.productId) |
|
|
|
put("productKey", config.productKey) |
|
|
|
put("params", JSONObject().apply { |
|
|
|
put("innerIp", JSONArray().put("127.0.0.1")) |
|
|
|
put("netSpeed", "0Mbps") |
|
|
|
put("netType", netType) |
|
|
|
put("platform", "Android${android.os.Build.VERSION.RELEASE}") |
|
|
|
put("sdkVersion", "ai_http_1.0") |
|
|
|
put("firmwareVersion", "1.0.0") |
|
|
|
put("imei", "") |
|
|
|
put("cmei", config.deviceNo) // 非蜂窝设备使用 SN 作为 cmei |
|
|
|
put("mac", "") |
|
|
|
}) |
|
|
|
}.toString().toRequestBody("application/json".toMediaType()) |
|
|
|
|
|
|
|
val req = Request.Builder() |
|
|
|
.url("https://ivs.chinamobiledevice.com:11443/v2/customer/device/report") |
|
|
|
.post(body) |
|
|
|
.build() |
|
|
|
|
|
|
|
okHttpClient.newCall(req).execute().use { resp -> |
|
|
|
val json = JSONObject(resp.body!!.string()) |
|
|
|
val code = json.optString("code") |
|
|
|
val msg = json.optString("message") |
|
|
|
if (code == "200") { |
|
|
|
Log.i(TAG, "[YIDONG] reportHeartbeat: 上报成功 code=$code msg=$msg") |
|
|
|
emit("heartbeat_reported", mapOf("time" to System.currentTimeMillis())) |
|
|
|
} else { |
|
|
|
Log.e(TAG, "[YIDONG] reportHeartbeat: 上报失败 code=$code msg=$msg") |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e: Exception) { |
|
|
|
Log.e(TAG, "[YIDONG] reportHeartbeat: 请求异常 ${e.message}") |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -202,6 +275,7 @@ class StsAgent( |
|
|
|
if (ttsPlaying) interruptTts() |
|
|
|
if (pttRecording) stopPtt(cancel = true) |
|
|
|
if (!credentialReady) { emitError("移动精灵凭证未就绪,请稍后重试"); return } |
|
|
|
Log.i(TAG, "[YIDONG] startCall: 启动通话模式 deviceNo=${config.deviceNo} agentId=${config.agentId}") |
|
|
|
openMic() |
|
|
|
startMicWatchdog() |
|
|
|
inCall = true |
|
|
|
@ -213,6 +287,7 @@ class StsAgent( |
|
|
|
if (pttRecording) return |
|
|
|
if (ttsPlaying) interruptTts() |
|
|
|
if (!credentialReady) { emitError("移动精灵凭证未就绪"); return } |
|
|
|
Log.i(TAG, "[YIDONG] startPtt: 启动PTT模式 deviceNo=${config.deviceNo} agentId=${config.agentId}") |
|
|
|
|
|
|
|
pttRecording = true |
|
|
|
openMic() |
|
|
|
@ -287,7 +362,7 @@ class StsAgent( |
|
|
|
currentPlayer = null |
|
|
|
} |
|
|
|
closeWs() |
|
|
|
preBuffer.clear() |
|
|
|
synchronized(preBufferLock) { preBuffer.clear() } |
|
|
|
clearSendBuffer() |
|
|
|
flushPendingAnswer() |
|
|
|
inCall = false |
|
|
|
@ -400,8 +475,10 @@ class StsAgent( |
|
|
|
} |
|
|
|
|
|
|
|
private fun addToPreBuffer(chunk: ByteArray) { |
|
|
|
preBuffer.addLast(chunk.copyOf()) |
|
|
|
while (preBuffer.size > PRE_BUFFER_MAX_CHUNKS) preBuffer.removeFirst() |
|
|
|
synchronized(preBufferLock) { |
|
|
|
preBuffer.addLast(chunk.copyOf()) |
|
|
|
while (preBuffer.size > PRE_BUFFER_MAX_CHUNKS) preBuffer.removeFirst() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════ |
|
|
|
@ -429,7 +506,7 @@ class StsAgent( |
|
|
|
forwardingAudio = true; wsConnecting = false |
|
|
|
emit("recognition_started", emptyMap()) |
|
|
|
|
|
|
|
val cached = preBuffer.toList(); preBuffer.clear() |
|
|
|
val cached = synchronized(preBufferLock) { preBuffer.toList().also { preBuffer.clear() } } |
|
|
|
for (c in cached) feedSendBuffer(c) |
|
|
|
} |
|
|
|
|
|
|
|
@ -445,6 +522,7 @@ class StsAgent( |
|
|
|
"?deviceNo=${config.deviceNo}&deviceId=${config.deviceId}" + |
|
|
|
"&productId=${config.productId}&productKey=${config.productKey}" + |
|
|
|
"&sign=$sign&sn=$sn&ts=$ts" |
|
|
|
Log.i(TAG, "[YIDONG] connectWs: 连接WSS deviceNo=${config.deviceNo} deviceId=${config.deviceId} sn=$sn") |
|
|
|
|
|
|
|
val req = Request.Builder().url(url) |
|
|
|
.header("Origin", "https://ivs.chinamobiledevice.com:11443") |
|
|
|
@ -462,6 +540,7 @@ class StsAgent( |
|
|
|
try { |
|
|
|
val msg = JSONObject(text) |
|
|
|
if (msg.optString("type") == "ready") { |
|
|
|
Log.i(TAG, "[YIDONG] connectWs: 收到ready,WSS连接成功") |
|
|
|
if (!resolved) { resolved = true; cont.resume(Unit) {} } |
|
|
|
} else { |
|
|
|
launch { handleMessage(msg) } |
|
|
|
@ -472,6 +551,7 @@ class StsAgent( |
|
|
|
override fun onMessage(ws: WebSocket, bytes: okio.ByteString) {} |
|
|
|
|
|
|
|
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { |
|
|
|
Log.e(TAG, "[YIDONG] connectWs: WSS连接失败 ${t.message}") |
|
|
|
if (webSocket === ws) webSocket = null // 只清除自己的引用,避免覆盖新 WS |
|
|
|
if (!resolved) { resolved = true; cont.resumeWithException(t) } |
|
|
|
else launch { handleWsDone() } |
|
|
|
@ -502,13 +582,21 @@ class StsAgent( |
|
|
|
forwardingAudio = false; wsConnecting = false |
|
|
|
when { |
|
|
|
sessionEnded && inCall -> if (!ttsPlaying) { resetVad(); startIdleTimer() } |
|
|
|
sessionEnded -> { /* PTT 结束,等 TTS 播完 */ } |
|
|
|
inCall -> resetVad() |
|
|
|
sessionEnded -> { |
|
|
|
// PTT 结束;若无 TTS 排队或播放(如无语音输入场景),立即补发 tts_completed |
|
|
|
// 避免 Flutter 侧 _pttResultPending 永久无法清除导致按钮锁死 |
|
|
|
if (!ttsPlaying && ttsQueue.isEmpty()) emit("tts_completed", emptyMap()) |
|
|
|
} |
|
|
|
inCall -> resetVad() |
|
|
|
// WS 意外关闭(session 未正常结束,如极速松手音频未发出),同样补发保底 |
|
|
|
else -> if (!ttsPlaying && ttsQueue.isEmpty() && !ttsInterrupted) emit("tts_completed", emptyMap()) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private fun resetVad() { |
|
|
|
speechDetected = false; vadOnsetCount = 0; preBuffer.clear(); openMic() |
|
|
|
speechDetected = false; vadOnsetCount = 0 |
|
|
|
synchronized(preBufferLock) { preBuffer.clear() } |
|
|
|
openMic() |
|
|
|
} |
|
|
|
|
|
|
|
private fun closeWs() { |
|
|
|
|