Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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()) {
Expand Down Expand Up @@ -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)
}

Expand Down
108 changes: 108 additions & 0 deletions android/app/src/main/java/com/js8call/example/util/Js8Commands.kt
Original file line number Diff line number Diff line change
@@ -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<String, Int> = 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<Int> = setOf(5, 9, 10, 11, 12, 13, 15, 24)

/** Commands that carry a trailing checksum, and its width in bits. */
val CHECKSUMMED: Map<Int, Int> = 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<String> =
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
}
116 changes: 116 additions & 0 deletions android/app/src/test/java/com/js8call/example/util/Js8CommandsTest.kt
Original file line number Diff line number Diff line change
@@ -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<String, Int>()
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
}
}
Loading