diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
index 09f3166a..acc2c633 100644
--- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
+++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt
@@ -10,6 +10,9 @@ import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.remote.await
import com.m57.hermescontrol.data.session.ActiveSessionHolder
+import com.m57.hermescontrol.data.ws.contract.RpcMethod
+import com.m57.hermescontrol.data.ws.contract.RpcMethods
+import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -821,6 +824,34 @@ object HermesWsClient {
return deferred
}
+ /**
+ * Send a typed JSON-RPC request and await its deserialized result.
+ *
+ * Params classes are strict writers, result classes are tolerant readers, and
+ * profile is never a field in params (it is injected by WsProfileParams).
+ */
+ suspend fun
call(
+ method: RpcMethod
,
+ params: P,
+ timeoutMs: Long = REQUEST_TIMEOUT_MS,
+ suppressErrorEvent: Boolean = false,
+ ): R {
+ val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
+ require(encoded is JsonObject) {
+ "RPC params for ${method.name} must serialize to a JsonObject, got ${encoded::class.simpleName}"
+ }
+ val deferred = request(method.name, encoded, timeoutMs, suppressErrorEvent)
+ val result =
+ try {
+ deferred.await()
+ } catch (e: CancellationException) {
+ deferred.cancel(e)
+ throw e
+ }
+ val element = result.toJsonElement()
+ return OkHttpProvider.json.decodeFromJsonElement(method.result, element)
+ }
+
/** Complete (or fail) a single pending call and cancel its timer. */
private fun resolvePending(
id: String,
@@ -1093,63 +1124,55 @@ object HermesWsClient {
for (sid in sessionsToReplay) {
val lastSeen = lastSeenSeq[sid] ?: continue
try {
- val deferred =
- request(
- method = WsMethods.SESSION_EVENTS_SINCE,
- params = mapOf("session_id" to sid, "last_seen" to lastSeen),
+ val res =
+ call(
+ RpcMethods.SESSION_EVENTS_SINCE,
+ SessionEventsSinceParams(sessionId = sid, lastSeen = lastSeen),
timeoutMs = 10_000L,
)
- val result = deferred.await()
- @Suppress("UNCHECKED_CAST")
- val resultMap =
- when (result) {
- is JsonElement -> result.toAny() as? Map
- is Map<*, *> -> result as? Map
- else -> null
- }
- if (resultMap != null) {
- val epoch = resultMap["epoch"] as? String
- val epochChanged =
- !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
- val truncated = resultMap["truncated"] == true
- val latestSeq = (resultMap["latest_seq"] as? Number)?.toInt()
+ val epoch = res.epoch
+ val epochChanged =
+ !epoch.isNullOrEmpty() && replayEpoch != null && replayEpoch != epoch
+ val truncated = res.truncated == true
+ val latestSeq = res.latestSeq
- if (epochChanged) replayEpoch = epoch
+ if (epochChanged) replayEpoch = epoch
- if (epochChanged || truncated) {
- // Ring buffer (512 events) could not cover the gap or epoch changed (gateway restarted).
- // Partial replay would silently hole the transcript — fast-forward watermark
- // and request full transcript resync.
- if (epochChanged) lastSeenSeq.clear()
- if (latestSeq != null && !epochChanged) lastSeenSeq[sid] = latestSeq
- parsedEvents.tryEmit(WsEvent.TranscriptResyncRequired(sid))
- continue
- }
- val eventsList = (resultMap["events"] as? List<*>)?.filterIsInstance