diff --git a/adapters/android/jni/js8_engine_jni.cpp b/adapters/android/jni/js8_engine_jni.cpp index 65efb138..0383c87d 100644 --- a/adapters/android/jni/js8_engine_jni.cpp +++ b/adapters/android/jni/js8_engine_jni.cpp @@ -230,7 +230,9 @@ static bool is_callsign_like(std::string const& token) { return has_digit; } -static std::string maybe_insert_callsign_prefix(std::string const& text) { +// First frame of a line only; a continuation's payload is plain text. +static std::string maybe_insert_callsign_prefix(std::string const& text, bool first_frame) { + if (!first_frame) return text; std::size_t first_sep = std::string::npos; for (std::size_t i = 0; i < text.size(); ++i) { if (std::isspace(static_cast(text[i]))) { @@ -267,15 +269,21 @@ static std::string maybe_insert_callsign_prefix(std::string const& text) { return rebuilt; } -static std::string render_decoded_text(JS8Engine_Native* native, js8core::events::Decoded const& decoded) { +// Renders the display text. *type gets the frame type, with the data flag +// added when only the payload carried it. +static std::string render_decoded_text(JS8Engine_Native* native, + js8core::events::Decoded const& decoded, + int* type) { using namespace js8core::protocol::varicode; + *type = decoded.type; auto const& frame = decoded.data; if (frame.size() < 12 || frame.find(' ') != std::string::npos) { return frame; } bool is_data_flag = (decoded.type & 0b100) == 0b100; + bool is_first_frame = (decoded.type & 0b1) == 0b1; // Try data payloads first (mirrors desktop unpack order). __android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug", "Trying data unpacker: frame='%s', decoded.type=0x%02x", @@ -284,7 +292,7 @@ static std::string render_decoded_text(JS8Engine_Native* native, js8core::events auto data = unpack_fast_data_message(frame); __android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug", "unpack_fast_data returned: '%s'", data.c_str()); - if (!data.empty()) return maybe_insert_callsign_prefix(data); + if (!data.empty()) return maybe_insert_callsign_prefix(data, is_first_frame); // Fast-data frames should not be treated as heartbeat/compound/directed. __android_log_print(ANDROID_LOG_WARN, "JS8FrameDebug", "Fast data unpack failed, returning raw frame: '%s'", frame.c_str()); @@ -293,7 +301,12 @@ static std::string render_decoded_text(JS8Engine_Native* native, js8core::events auto data = unpack_data_message(frame); __android_log_print(ANDROID_LOG_DEBUG, "JS8FrameDebug", "unpack_data returned: '%s'", data.c_str()); - if (!data.empty()) return maybe_insert_callsign_prefix(data); + if (!data.empty()) { + // Normal and Slow carry the data flag in the payload, not in the + // frame type; set it so Kotlin sees one convention for all modes. + *type |= 0b100; + return maybe_insert_callsign_prefix(data, is_first_frame); + } } // Heartbeat (most common for status beacons) @@ -429,19 +442,21 @@ static void event_callback(JS8Engine_Native* native, js8core::events::Variant co // Handle different event types if (auto* decoded = std::get_if(&event)) { - auto rendered = render_decoded_text(native, *decoded); - - auto emit_decoded = [&](js8core::events::Decoded const& d, std::string const& text_str) { - jmethodID method = env->GetMethodID(handler_class, "onDecoded", "(IIFFLjava/lang/String;IFII)V"); - if (method) { - jstring text = env->NewStringUTF(text_str.c_str()); - env->CallVoidMethod(native->callback_handler, method, - d.utc, d.snr, d.xdt, d.frequency, - text, d.type, d.quality, d.mode, d.drift_ms); - env->DeleteLocalRef(text); - } - }; - emit_decoded(*decoded, rendered); + int type = 0; + auto rendered = render_decoded_text(native, *decoded, &type); + __android_log_print(ANDROID_LOG_INFO, "JS8Engine_Native", + "DECODED: SNR=%d dB, freq=%.1f Hz, text='%s', raw='%s', type=%d, mode=%d", + decoded->snr, decoded->frequency, rendered.c_str(), + decoded->data.c_str(), type, decoded->mode); + + jmethodID method = env->GetMethodID(handler_class, "onDecoded", "(IIFFLjava/lang/String;IFII)V"); + if (method) { + jstring text = env->NewStringUTF(rendered.c_str()); + env->CallVoidMethod(native->callback_handler, method, + decoded->utc, decoded->snr, decoded->xdt, decoded->frequency, + text, type, decoded->quality, decoded->mode, decoded->drift_ms); + env->DeleteLocalRef(text); + } } else if (auto* spectrum = std::get_if(&event)) { // Call onSpectrum(float[] bins, float binHz, float powerDb, float peakDb) jmethodID method = env->GetMethodID(handler_class, "onSpectrum", "([FFFF)V"); @@ -591,12 +606,6 @@ JS8Engine_Native* js8_engine_create(JNIEnv* env, jobject callback_handler, auto const& e = std::get(event); __android_log_print(ANDROID_LOG_DEBUG, "JS8Engine_Native", "DecodeFinished: count=%zu", e.decoded); - } else if (std::holds_alternative(event)) { - auto const& e = std::get(event); - auto rendered = render_decoded_text(native, e); - __android_log_print(ANDROID_LOG_INFO, "JS8Engine_Native", - "DECODED: SNR=%d dB, freq=%.1f Hz, text='%s', raw='%s', type=%d, mode=%d", - e.snr, e.frequency, rendered.c_str(), e.data.c_str(), e.type, e.mode); } event_callback(native, event); }; diff --git a/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt b/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt index a2f7ca8f..3588d60b 100644 --- a/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt +++ b/android/app/src/main/java/com/js8call/example/model/DecodedMessage.kt @@ -36,6 +36,12 @@ data class DecodedMessage( */ fun isSingleFrame(): Boolean = isFirstFrame() && isLastFrame() + /** + * Check if this frame carries a data payload. + * JS8CallData flag is bit 2 (type & 4). + */ + fun isDataFrame(): Boolean = isDataFrame(type) + /** * Get color resource ID based on SNR level. */ @@ -82,6 +88,11 @@ data class DecodedMessage( localCal.get(java.util.Calendar.SECOND) ) } + + companion object { + /** The same data-flag test for callers holding only the type bits. */ + fun isDataFrame(type: Int): Boolean = (type and 4) != 0 + } } /** 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 620afd93..d56ee9bc 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 @@ -31,6 +31,7 @@ import com.js8call.core.TruSdxDirectSerial import com.js8call.core.UsbSerialBridge import com.js8call.core.UsbSerialPortCatalog import com.js8call.example.MainActivity +import com.js8call.example.model.DecodedMessage import com.js8call.example.ui.AudioDevices import com.js8call.example.MessageLogWriter import com.js8call.example.R @@ -536,7 +537,7 @@ class JS8EngineService : Service() { updateHeardCallsign(text) broadcastDecode(utc, snr, dt, freq, text, type, quality, mode, driftMs) handleRelayFrame(text, snr, mode, freq, type) - maybeHandleIncomingMessage(text, snr, freq, type) + maybeHandleIncomingMessage(text, snr, freq, type, mode) maybeHandleAutoReply(text, snr, mode) maybeReportToPskReporter(utc, snr, freq, text) } @@ -2369,7 +2370,7 @@ class JS8EngineService : Service() { // Drifted timeline, so slots match the engine's cycle boundaries. val now = System.currentTimeMillis() + (engine?.timeDriftMs() ?: 0L) - val frameDuration = getFrameDurationMs() + val frameDuration = framePeriodMs(getPreferredTxSubmode()) // Base delay var delay = if (first) { @@ -2414,8 +2415,8 @@ class JS8EngineService : Service() { heartbeatHandler.postDelayed(heartbeatRunnable, waitMs) } - private fun getFrameDurationMs(): Long { - return when (getPreferredTxSubmode()) { + private fun framePeriodMs(submode: Int): Long { + return when (submode) { SUBMODE_SLOW -> 30000L SUBMODE_NORMAL -> 15000L SUBMODE_FAST -> 10000L @@ -2918,7 +2919,7 @@ class JS8EngineService : Service() { * FROM: TO MSG (multi-frame: command frame) * payload... (multi-frame: data frames follow) */ - private fun maybeHandleIncomingMessage(text: String, snr: Int, freq: Float, type: Int) { + private fun maybeHandleIncomingMessage(text: String, snr: Int, freq: Float, type: Int, submode: Int) { val callsign = getConfiguredCallsign() Log.d(TAG, "maybeHandleIncomingMessage: text='$text' type=$type callsign=$callsign") if (callsign == null) { @@ -2973,6 +2974,8 @@ class JS8EngineService : Service() { snr = snr, frequency = freq, lastUpdated = now, + // Rides out three lost frames in a row before giving up. + timeoutMs = 4 * framePeriodMs(submode), parts = if (initialPayload.isNotBlank()) mutableListOf(initialPayload) else mutableListOf() ) synchronized(msgLock) { @@ -2991,7 +2994,7 @@ class JS8EngineService : Service() { } // Not a directed command - check if it's a data frame for a buffered MSG - if (!isDataFrame(type)) { + if (!DecodedMessage.isDataFrame(type)) { return } @@ -3020,17 +3023,17 @@ class JS8EngineService : Service() { val snr: Int, val frequency: Float, var lastUpdated: Long, + val timeoutMs: Long, val parts: MutableList = mutableListOf() ) private val msgBuffers = mutableMapOf() private val msgLock = Any() - private val MSG_BUFFER_TIMEOUT_MS = 60_000L private fun cleanupMsgBuffers(now: Long) { synchronized(msgLock) { msgBuffers.entries.removeIf { (_, buffer) -> - now - buffer.lastUpdated > MSG_BUFFER_TIMEOUT_MS + now - buffer.lastUpdated > buffer.timeoutMs } } } @@ -3069,7 +3072,6 @@ class JS8EngineService : Service() { } } - private fun isDataFrame(type: Int): Boolean = (type and 0x4) != 0 private fun isSubscribedGroup(target: String): Boolean { if (!target.startsWith("@")) return false @@ -3194,7 +3196,7 @@ class JS8EngineService : Service() { return } - if (!isRelayDataFrame(type)) return + if (!DecodedMessage.isDataFrame(type)) return val result = synchronized(relayLock) { val key = findMatchingRelayBufferKey(freq) ?: return@synchronized null @@ -3441,7 +3443,6 @@ class JS8EngineService : Service() { return target.contains("@") } - private fun isRelayDataFrame(type: Int): Boolean = (type and 0x4) != 0 private fun isLastFrame(type: Int): Boolean = (type and 0x2) != 0 diff --git a/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt b/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt index 1c8bf36e..93c4deae 100644 --- a/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt +++ b/android/app/src/main/java/com/js8call/example/ui/DecodeViewModel.kt @@ -14,51 +14,21 @@ import kotlin.math.roundToInt import org.json.JSONArray import org.json.JSONObject -internal fun assembleMultipartDecodeText(frameTexts: List): String { +internal fun assembleMultipartDecodeText(frames: List): String { + val parts = frames.filter { it.text.isNotEmpty() } return buildString { - frameTexts.forEachIndexed { index, frameText -> - if (index > 0 && shouldInsertMultipartSpace(this, frameText)) { - append(' ') - } - append(frameText) + parts.forEachIndexed { index, frame -> + if (index > 0 && needsSpaceBefore(parts[index - 1], frame)) append(' ') + append(frame.text) } } } -internal fun shouldInsertMultipartSpace(builder: StringBuilder, nextText: String): Boolean { - if (builder.isEmpty()) return false - if (nextText.isEmpty()) return false - var prevIndex = builder.length - 1 - while (prevIndex >= 0 && builder[prevIndex].isWhitespace()) { - prevIndex-- - } - if (prevIndex < 0) return false - - var nextIndex = 0 - while (nextIndex < nextText.length && nextText[nextIndex].isWhitespace()) { - nextIndex++ - } - if (nextIndex >= nextText.length) return false - - val prevChar = builder[prevIndex] - val nextChar = nextText[nextIndex] - if ((!prevChar.isLetterOrDigit() && prevChar != ':') || !nextChar.isLetterOrDigit()) { - return false - } - - var tokenStart = prevIndex - while (tokenStart >= 0 && !builder[tokenStart].isWhitespace()) { - tokenStart-- - } - val prevToken = builder.substring(tokenStart + 1, prevIndex + 1) - val hasDigit = prevToken.any { it.isDigit() } - return hasDigit || prevChar == ':' || isGroupToken(prevToken) -} - -private fun isGroupToken(token: String): Boolean { - if (!token.startsWith("@") || token.length < 2) return false - return token.drop(1).all { it.isLetterOrDigit() || it == '/' } -} +// Data frames split mid-word and carry their own spaces. A directed header +// can meet its payload flush: a buffered command strips the separator +// before packing, so the boundary needs the space put back. +private fun needsSpaceBefore(prev: DecodedMessage, next: DecodedMessage): Boolean = + !prev.isDataFrame() && !prev.text.last().isWhitespace() && !next.text.first().isWhitespace() /** * ViewModel for the Decodes screen. @@ -248,10 +218,9 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) * Assemble a complete message from buffered frames. */ private fun assembleMessage(buffer: MessageBuffer): DecodedMessage { - val frameTexts = buffer.frames.map { it.text }.toMutableList() - normalizeCompoundDirectedHelpers(buffer.frames, frameTexts) - - val assembledText = assembleMultipartDecodeText(frameTexts) + val frames = buffer.frames.toMutableList() + normalizeCompoundDirectedHelpers(frames) + val assembledText = assembleMultipartDecodeText(frames) // Use the last frame's metadata (most recent) val lastFrame = buffer.frames.last() @@ -270,20 +239,17 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) ) } - private fun normalizeCompoundDirectedHelpers( - frames: List, - frameTexts: MutableList - ) { - if (frameTexts.size < 2 || frames.size < 2) return + private fun normalizeCompoundDirectedHelpers(frames: MutableList) { + if (frames.size < 2) return val firstFrame = frames[0] val secondFrame = frames[1] - if (isDataFrame(firstFrame.type) || isDataFrame(secondFrame.type)) return + if (firstFrame.isDataFrame() || secondFrame.isDataFrame()) return if (!firstFrame.isFirstFrame() || firstFrame.isLastFrame()) return if (secondFrame.isFirstFrame()) return - val first = frameTexts[0].trim() - val second = frameTexts[1].trim() + val first = firstFrame.text.trim() + val second = secondFrame.text.trim() if (!isCompoundDeHelperFrame(first)) return val fromCall = first.substringBefore(' ').trim() @@ -291,14 +257,14 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) val rewrittenDirected = rewriteDirectedPlaceholder(second, fromCall) if (rewrittenDirected != null) { - frameTexts[0] = "" - frameTexts[1] = rewrittenDirected + frames[0] = firstFrame.copy(text = "") + frames[1] = secondFrame.copy(text = rewrittenDirected) return } if (isDirectedCompoundHeader(second)) { - frameTexts[0] = "" - frameTexts[1] = "$fromCall: $second" + frames[0] = firstFrame.copy(text = "") + frames[1] = secondFrame.copy(text = "$fromCall: $second") } } @@ -335,8 +301,6 @@ class DecodeViewModel(application: Application) : AndroidViewModel(application) return directedCommandTailRegex.matches(tail) } - private fun isDataFrame(type: Int): Boolean = (type and 0x4) != 0 - /** * Find a buffer key that matches the given frequency within tolerance. * Returns null if no matching buffer is found. diff --git a/android/app/src/test/java/com/js8call/example/ui/DecodeViewModelMultipartAssemblyTest.kt b/android/app/src/test/java/com/js8call/example/ui/DecodeViewModelMultipartAssemblyTest.kt index 5dc74051..773bb834 100644 --- a/android/app/src/test/java/com/js8call/example/ui/DecodeViewModelMultipartAssemblyTest.kt +++ b/android/app/src/test/java/com/js8call/example/ui/DecodeViewModelMultipartAssemblyTest.kt @@ -1,45 +1,83 @@ package com.js8call.example.ui +import com.js8call.example.model.DecodedMessage import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue import org.junit.Test class DecodeViewModelMultipartAssemblyTest { + // JS8 frame type bits: 0x1 first, 0x2 last, 0x4 data. + private fun frame(text: String, type: Int) = DecodedMessage( + utc = 0, snr = 0, dt = 0f, frequency = 0f, text = text, + type = type, quality = 0f, mode = 0 + ) + + private fun directed(text: String) = frame(text, 0x1) + private fun data(text: String) = frame(text, 0x4) + @Test - fun insertsSpaceAfterGroupCallsignAcrossFrames() { - val assembled = assembleMultipartDecodeText( - listOf("2W0OXE: @RAYNET", "TEST") + fun dataFramesConcatenateWithoutASpace() { + // On air: freetext "N5EKS GRID EM10SM87MJ" split mid-token across two frames. + assertEquals( + "N5EKS GRID EM10SM87MJ", + assembleMultipartDecodeText(listOf(data("N5EKS GRID EM10"), data("SM87MJ"))) ) - - assertEquals("2W0OXE: @RAYNET TEST", assembled) } @Test - fun insertsSpaceAfterAllcallAcrossFrames() { - val assembled = assembleMultipartDecodeText( - listOf("@ALLCALL", "QUERY") + fun bufferedCommandArgumentGetsASpace() { + // GRID strips the separator before packing its argument, so the frames meet flush. + assertEquals( + "NT5DF: N5EKS GRID EM13TE", + assembleMultipartDecodeText(listOf(directed("NT5DF: N5EKS GRID"), data("EM13TE"))) ) + } - assertEquals("@ALLCALL QUERY", assembled) + @Test + fun unbufferedCommandKeepsItsOwnSpace() { + // STATUS is not buffered, so its data frame arrives with a leading space. + assertEquals( + "NT5DF: N5EKS STATUS IDLE AND MONITORING", + assembleMultipartDecodeText(listOf(directed("NT5DF: N5EKS STATUS"), data(" IDLE AND MONITORING"))) + ) } @Test - fun stillInsertsSpaceAfterDirectedCallsign() { - val assembled = assembleMultipartDecodeText( - listOf("2W0OXE", "SNR?") + fun groupTargetHeaderGetsASpace() { + assertEquals( + "2W0OXE: @RAYNET TEST", + assembleMultipartDecodeText(listOf(directed("2W0OXE: @RAYNET"), data("TEST"))) ) + } - assertEquals("2W0OXE SNR?", assembled) + @Test + fun directedHeaderThenManyDataFramesReassembles() { + assertEquals( + "NT5DF: N5EKS MSG HELLO WORLD HOW ARE YOU", + assembleMultipartDecodeText( + listOf( + directed("NT5DF: N5EKS MSG"), + data("HELLO WORL"), + data("D HOW ARE Y"), + data("OU") + ) + ) + ) } @Test - fun doesNotInsertSpaceAfterNonAddressWord() { - assertFalse(shouldInsertMultipartSpace(StringBuilder("HELLO"), "WORLD")) + fun aBlankedHelperFrameLeavesNoLeadingSpace() { + // normalizeCompoundDirectedHelpers blanks a helper frame but leaves it in the list. + assertEquals( + "NT5DF: N5EKS GRID EM13", + assembleMultipartDecodeText(listOf(directed(""), data("NT5DF: N5EKS GRID EM13"))) + ) } @Test - fun detectsGroupTokensAsMultipartBoundaries() { - assertTrue(shouldInsertMultipartSpace(StringBuilder("@RAYNET"), "TEST")) + fun singleFramePassesThrough() { + assertEquals( + "K0OG: KN4CRD SNR +2", + assembleMultipartDecodeText(listOf(directed("K0OG: KN4CRD SNR +2"))) + ) } } diff --git a/android/js8core-lib/src/androidTest/java/com/js8call/core/JS8EngineLoopbackTest.kt b/android/js8core-lib/src/androidTest/java/com/js8call/core/JS8EngineLoopbackTest.kt index e9c3bae4..554a8c6c 100644 --- a/android/js8core-lib/src/androidTest/java/com/js8call/core/JS8EngineLoopbackTest.kt +++ b/android/js8core-lib/src/androidTest/java/com/js8call/core/JS8EngineLoopbackTest.kt @@ -21,7 +21,27 @@ class JS8EngineLoopbackTest { @Test fun ownTransmissionDecodes() { - val tx = TestEngine().use { it.transmitFromMidPeriod("CQ CQ CQ") } + val decoded = loopback("CQ CQ CQ") + assertTrue("own transmission did not decode", decoded.any { "CQ" in it.text }) + } + + @Test + fun dataFrameReportsTheDataType() { + val decoded = loopback("HELLO", forceData = true) + val frame = requireNotNull(decoded.firstOrNull { "HELLO" in it.text }) { + "data frame did not decode" + } + + // Normal mode leaves the data flag out of the transmitted frame type; + // the JNI proves it by unpacking and must report it set, or every + // consumer keyed on the flag drops the frame. + assertTrue("type 0x${"%X".format(frame.type)} lacks the data flag", + frame.type and 0x4 != 0) + } + + /** Transmits [text], places the capture in a noisy period and decodes it. */ + private fun loopback(text: String, forceData: Boolean = false): List { + val tx = TestEngine().use { it.transmitFromMidPeriod(text, forceData) } // A whole frame is 12.6 s. Short means the modulator joined one in // progress instead of waiting for the boundary. @@ -34,7 +54,7 @@ class JS8EngineLoopbackTest { val decoded = TestEngine().use { it.decode(period) } Log.i(TAG, "decoded ${decoded.size}: $decoded") - assertTrue("own transmission did not decode", decoded.any { "CQ" in it.text }) + return decoded } private companion object { diff --git a/android/js8core-lib/src/androidTest/java/com/js8call/core/TestEngine.kt b/android/js8core-lib/src/androidTest/java/com/js8call/core/TestEngine.kt index c0892e27..7cf38852 100644 --- a/android/js8core-lib/src/androidTest/java/com/js8call/core/TestEngine.kt +++ b/android/js8core-lib/src/androidTest/java/com/js8call/core/TestEngine.kt @@ -5,8 +5,8 @@ import org.junit.Assert.assertTrue import java.util.concurrent.CopyOnWriteArrayList /** One decode as the engine reported it. */ -internal data class Decode(val text: String, val snr: Int, val dt: Float, val freq: Float) { - override fun toString() = String.format("'%s' dt=%+.2f f=%.0f snr=%d", text, dt, freq, snr) +internal data class Decode(val text: String, val snr: Int, val dt: Float, val freq: Float, val type: Int) { + override fun toString() = String.format("'%s' dt=%+.2f f=%.0f snr=%d type=0x%X", text, dt, freq, snr, type) } /** @@ -29,7 +29,7 @@ internal class TestEngine : AutoCloseable { utc: Int, snr: Int, dt: Float, freq: Float, text: String, type: Int, quality: Float, mode: Int, driftMs: Int ) { - decodes.add(Decode(text, snr, dt, freq)) + decodes.add(Decode(text, snr, dt, freq, type)) } override fun onDecodeFinished(count: Int) { @@ -85,7 +85,7 @@ internal class TestEngine : AutoCloseable { * to defer to the next boundary on its own, and returns what came off the * TX tap: at the engine rate, leading silence trimmed. */ - fun transmitFromMidPeriod(text: String): ShortArray { + fun transmitFromMidPeriod(text: String, forceData: Boolean = false): ShortArray { engine.setTransmitReady(true) val offset = System.currentTimeMillis() % PERIOD_MS engine.setTimeDriftMs((ASK_AT_MS - offset + PERIOD_MS) % PERIOD_MS) @@ -95,7 +95,8 @@ internal class TestEngine : AutoCloseable { myGrid = "EM12", submode = 0, audioFrequencyHz = 1500.0, - txDelaySec = 0.0 + txDelaySec = 0.0, + forceData = forceData ) assertTrue("transmit refused", accepted)