From 0aade7eef00390be69fcb714a1c2d8da2f0094d3 Mon Sep 17 00:00:00 2001 From: tekstrand Date: Fri, 28 Aug 2026 08:54:08 -0500 Subject: [PATCH] Taught the directed-command parser the full JS8 command vocabulary. The parser took the command as a single whitespace token, so MSG TO: arrived as MSG with the destination glued to the payload, and QUERY MSGS arrived as QUERY. The new Js8Commands table mirrors kDirectedCmds from the native varicode source and matches longest name first, so the two-word names parse whole. Unrecognized text falls back to the old single-token shape, so free-text frames reach callers unchanged. A unit test reads the native table out of varicode.cpp and fails when the Kotlin copy drifts from it. Removed the MSG handler's inline-payload extraction rather than teaching it about MSG TO:. It existed to handle a command token with the payload stuck to it, a shape the engine cannot produce: a directed frame unpacks its command from the table by index, and the JNI joins the parts with explicit separators. It was dead code until now, but MSG TO: parsing as a whole command would have set it off and put a stray TO: into the delivered text. The MSG branch now matches the two commands it actually handles. --- .../example/service/JS8EngineService.kt | 44 +++---- .../com/js8call/example/util/Js8Commands.kt | 108 ++++++++++++++++ .../js8call/example/util/Js8CommandsTest.kt | 116 ++++++++++++++++++ 3 files changed, 242 insertions(+), 26 deletions(-) create mode 100644 android/app/src/main/java/com/js8call/example/util/Js8Commands.kt create mode 100644 android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt diff --git a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt index 7c408764..bca14a87 100644 --- a/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt +++ b/android/app/src/main/java/com/js8call/example/service/JS8EngineService.kt @@ -36,6 +36,7 @@ import com.js8call.example.R import com.js8call.example.BuildConfig import com.js8call.example.network.PskReporterClient import com.js8call.example.util.CallsignValidator +import com.js8call.example.util.Js8Commands import com.js8call.example.util.TxMessageClassifier import java.util.Calendar import java.util.Locale @@ -2909,7 +2910,7 @@ class JS8EngineService : Service() { // Try parsing as a directed command (MSG header frame) val directed = parseDirectedCommand(text) - if (directed != null && (directed.command.uppercase() == "MSG" || directed.command.uppercase().startsWith("MSG"))) { + if (directed != null && (directed.command.uppercase() == Js8Commands.CMD_MSG || directed.command.uppercase() == Js8Commands.CMD_MSG_TO)) { // This is a MSG command frame val isForMe = isSelfCallsign(callsign, directed.to) val isForMyGroup = isSubscribedGroup(directed.to) @@ -2919,23 +2920,9 @@ class JS8EngineService : Service() { return } - // Extract any inline payload from concatenated format (MSGpayload) - val inlinePayload = if (directed.command.uppercase().startsWith("MSG") && directed.command.length > 3) { - directed.command.substring(3) - } else { - "" - } - - // Combine inline payload with any additional payload tokens - val initialPayload = if (inlinePayload.isNotBlank() && directed.payload.isNotBlank()) { - "$inlinePayload ${directed.payload}" - } else if (inlinePayload.isNotBlank()) { - inlinePayload - } else { - directed.payload - } + val initialPayload = directed.payload - Log.d(TAG, "maybeHandleIncomingMessage: MSG command from=${directed.from} to=${directed.to} inlinePayload='$inlinePayload' initialPayload='$initialPayload' isLastFrame=${isLastFrame(type)}") + Log.d(TAG, "maybeHandleIncomingMessage: MSG command from=${directed.from} to=${directed.to} initialPayload='$initialPayload' isLastFrame=${isLastFrame(type)}") // If this is the last frame and we have payload, deliver immediately if (isLastFrame(type) && initialPayload.isNotBlank()) { @@ -3239,25 +3226,30 @@ class JS8EngineService : Service() { } var to = toToken - var command: String - var payloadStart = index + 1 + val command: String + val payload: String if (toToken.endsWith(">")) { to = toToken.trimEnd('>') command = ">" + payload = tokens.drop(index + 1).joinToString(" ") } else { if (index + 1 >= tokens.size) return null - command = tokens[index + 1] - payloadStart = index + 2 + val remainder = tokens.drop(index + 1).joinToString(" ") + val match = Js8Commands.matchAt(remainder) + if (match != null) { + command = match.command + payload = match.payload + } else { + // Unknown text keeps the single-token shape, so freetext frames + // reach callers unchanged. + command = tokens[index + 1] + payload = tokens.drop(index + 2).joinToString(" ") + } } if (to.isBlank() || command.isBlank()) return null if (from.isBlank() && command != ">") return null - val payload = if (payloadStart < tokens.size) { - tokens.subList(payloadStart, tokens.size).joinToString(" ") - } else { - "" - } return DirectedCommand(from, to, command, payload) } diff --git a/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt b/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt new file mode 100644 index 00000000..503dd825 --- /dev/null +++ b/android/app/src/main/java/com/js8call/example/util/Js8Commands.kt @@ -0,0 +1,108 @@ +package com.js8call.example.util + +/** + * The JS8 directed command vocabulary, mirroring kDirectedCmds in + * core/src/protocol/varicode.cpp. Js8CommandsTest fails when the two drift. + * Names are stored without the leading space the native table carries. + */ +object Js8Commands { + + const val CMD_MSG = "MSG" + const val CMD_MSG_TO = "MSG TO:" + const val CMD_QUERY = "QUERY" + const val CMD_QUERY_MSGS = "QUERY MSGS" + const val CMD_QUERY_CALL = "QUERY CALL" + const val CMD_ACK = "ACK" + const val CMD_NACK = "NACK" + const val CMD_YES = "YES" + const val CMD_NO = "NO" + + /** Command name to the number the protocol packs it as. */ + val COMMANDS: Map = mapOf( + "HEARTBEAT" to -1, + "HB" to -1, + "CQ" to -1, + "SNR?" to 0, + "?" to 0, + "DIT DIT" to 1, + "NACK" to 2, + "HEARING?" to 3, + "GRID?" to 4, + ">" to 5, + "STATUS?" to 6, + "STATUS" to 7, + "HEARING" to 8, + "MSG" to 9, + "MSG TO:" to 10, + "QUERY" to 11, + "QUERY MSGS" to 12, + "QUERY MSGS?" to 12, + "QUERY CALL" to 13, + "ACK" to 14, + "GRID" to 15, + "INFO?" to 16, + "INFO" to 17, + "FB" to 18, + "HW CPY?" to 19, + "SK" to 20, + "RR" to 21, + "QSL?" to 22, + "QSL" to 23, + "CMD" to 24, + "SNR" to 25, + "NO" to 26, + "YES" to 27, + "73" to 28, + "HEARTBEAT SNR" to 29, + "AGN?" to 30 + ) + + /** + * Commands whose payload spans data frames, so the receiver has to buffer + * until the last-frame bit before the text is complete. + */ + val BUFFERED: Set = setOf(5, 9, 10, 11, 12, 13, 15, 24) + + /** Commands that carry a trailing checksum, and its width in bits. */ + val CHECKSUMMED: Map = mapOf( + 5 to 16, 9 to 16, 10 to 16, 11 to 16, 12 to 16, 13 to 16, 15 to 0, 24 to 16 + ) + + /** Longest name first, so QUERY MSGS wins over QUERY and MSG TO: over MSG. */ + private val byLongestName: List = + COMMANDS.keys.sortedByDescending { it.length } + + data class Match(val command: String, val payload: String) + + /** + * Match a command at the head of [remainder], everything after the addressed + * callsign. Runs on the raw string rather than tokens: several names span + * two words, and a name ending in ':' can have its argument glued to it, + * as in MSG TO:KN4CRD. + */ + fun matchAt(remainder: String): Match? { + val text = remainder.trimStart() + if (text.isEmpty()) return null + + for (name in byLongestName) { + if (!text.regionMatches(0, name, 0, name.length, ignoreCase = true)) continue + + // A name ending in ':' may be followed immediately by its argument. + // Every other name has to end on a token boundary, so that MSG does + // not match the front of MSGS. + if (!name.endsWith(":")) { + val next = text.getOrNull(name.length) + if (next != null && !next.isWhitespace()) continue + } + + return Match(name, text.substring(name.length).trim()) + } + return null + } + + fun isBuffered(command: String): Boolean = + COMMANDS[command.uppercase()]?.let { BUFFERED.contains(it) } == true + + fun isChecksummed(command: String): Boolean = + COMMANDS[command.uppercase()]?.let { CHECKSUMMED.containsKey(it) } == true +} diff --git a/android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt b/android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt new file mode 100644 index 00000000..339e5425 --- /dev/null +++ b/android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt @@ -0,0 +1,116 @@ +package com.js8call.example.util + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class Js8CommandsTest { + + @Test + fun matchesMultiWordCommandsWhole() { + assertEquals(Js8Commands.Match("QUERY MSGS", ""), Js8Commands.matchAt("QUERY MSGS")) + assertEquals(Js8Commands.Match("QUERY MSGS?", ""), Js8Commands.matchAt("QUERY MSGS?")) + assertEquals(Js8Commands.Match("QUERY CALL", "KN4CRD"), Js8Commands.matchAt("QUERY CALL KN4CRD")) + assertEquals(Js8Commands.Match("HEARTBEAT SNR", "-10"), Js8Commands.matchAt("HEARTBEAT SNR -10")) + assertEquals(Js8Commands.Match("HW CPY?", ""), Js8Commands.matchAt("HW CPY?")) + assertEquals(Js8Commands.Match("DIT DIT", ""), Js8Commands.matchAt("DIT DIT")) + } + + /** + * QUERY MSG {id} is command QUERY with a MSG argument, not a command of its + * own. The desktop dispatches it the same way. + */ + @Test + fun treatsQueryMsgIdAsQueryWithArgument() { + assertEquals(Js8Commands.Match("QUERY", "MSG 3"), Js8Commands.matchAt("QUERY MSG 3")) + } + + @Test + fun acceptsMsgToWithAndWithoutSpaceAfterColon() { + assertEquals(Js8Commands.Match("MSG TO:", "KN4CRD HELLO"), Js8Commands.matchAt("MSG TO:KN4CRD HELLO")) + assertEquals(Js8Commands.Match("MSG TO:", "KN4CRD HELLO"), Js8Commands.matchAt("MSG TO: KN4CRD HELLO")) + } + + @Test + fun requiresTokenBoundaryForNamesWithoutColon() { + // MSG must not match the front of MSGS + assertEquals(Js8Commands.Match("MSG", "HELLO"), Js8Commands.matchAt("MSG HELLO")) + val msgs = Js8Commands.matchAt("MSGS HELLO") + assertTrue("MSGS should not match MSG", msgs == null || msgs.command != "MSG") + } + + @Test + fun matchesSingleWordCommandsAndTheirPayloads() { + assertEquals(Js8Commands.Match("SNR?", ""), Js8Commands.matchAt("SNR?")) + assertEquals(Js8Commands.Match("GRID?", ""), Js8Commands.matchAt("GRID?")) + assertEquals(Js8Commands.Match("INFO?", ""), Js8Commands.matchAt("INFO?")) + assertEquals(Js8Commands.Match("STATUS?", ""), Js8Commands.matchAt("STATUS?")) + assertEquals(Js8Commands.Match("HEARING?", ""), Js8Commands.matchAt("HEARING?")) + assertEquals(Js8Commands.Match("AGN?", ""), Js8Commands.matchAt("AGN?")) + assertEquals(Js8Commands.Match("ACK", ""), Js8Commands.matchAt("ACK")) + assertEquals(Js8Commands.Match("MSG", "HELLO THERE"), Js8Commands.matchAt("MSG HELLO THERE")) + } + + @Test + fun reportsBufferedAndChecksummedCommands() { + assertTrue(Js8Commands.isBuffered("MSG")) + assertTrue(Js8Commands.isBuffered("MSG TO:")) + assertTrue(Js8Commands.isBuffered("QUERY MSGS")) + assertTrue(Js8Commands.isChecksummed("MSG TO:")) + } + + @Test + fun returnsNullForUnknownText() { + assertNull(Js8Commands.matchAt("HELLO WORLD")) + assertNull(Js8Commands.matchAt("")) + } + + /** + * Drift guard. The native table is the one the protocol actually packs + * against, so a divergence here is a wire-format bug, not a style problem. + */ + @Test + fun kotlinTableMatchesNativeVaricodeTable() { + val source = findVaricodeSource() + assertNotNull( + "Could not locate core/src/protocol/varicode.cpp from ${File("").absolutePath}", + source + ) + + val body = source!!.readText() + val start = body.indexOf("kDirectedCmds") + assertTrue("kDirectedCmds not found in ${source.path}", start >= 0) + val open = body.indexOf('{', start) + val close = body.indexOf("};", open) + assertTrue("kDirectedCmds block is malformed", open in 0 until close) + + val entry = Regex("""\{\s*"([^"]*)"\s*,\s*(-?\d+)\s*\}""") + val native = mutableMapOf() + for (m in entry.findAll(body.substring(open, close))) { + val name = m.groupValues[1].trim() + // The blank names are the free-text sentinel, which is not a command + if (name.isEmpty()) continue + native[name] = m.groupValues[2].toInt() + } + + assertTrue("Parsed no entries from the native table", native.size > 20) + assertEquals( + "Kotlin and native directed-command tables disagree", + native.toSortedMap(), + Js8Commands.COMMANDS.toSortedMap() + ) + } + + private fun findVaricodeSource(): File? { + var dir: File? = File("").absoluteFile + while (dir != null) { + val candidate = File(dir, "core/src/protocol/varicode.cpp") + if (candidate.isFile) return candidate + dir = dir.parentFile + } + return null + } +}