Skip to content

Commit 83a34f9

Browse files
author
Dr. Brandon Wiley
committed
Added codecs for whole WSPRMessage and sequence of WSPRMessages
1 parent c49458e commit 83a34f9

File tree

3 files changed

+331
-0
lines changed

3 files changed

+331
-0
lines changed
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.operatorfoundation.codex.symbols
2+
3+
import java.math.BigInteger
4+
import org.operatorfoundation.codex.Encoder
5+
6+
/**
7+
* A single WSPR message symbol that encodes/decodes one complete WSPR transmission.
8+
*
9+
* Format: Q + 6-char callsign + 4-char grid + power level = 12 bytes total
10+
*/
11+
class WSPRMessage : Symbol {
12+
/**
13+
* WSPR symbol configuration matching the standard WSPR message format.
14+
*
15+
* Symbol breakdown:
16+
* - Required('Q'): Fixed prefix (1 byte, size=1, contributes 0 bits)
17+
* - CallLetterNumber (6x): Callsign characters (A-Z, 0-9 = 36 values each)
18+
* - GridLetter (2x): First two grid characters (A-R = 18 values each)
19+
* - Number (2x): Last two grid characters (0-9 = 10 values each)
20+
* - Power: Power level (19 discrete values: 0, 3, 7, 10... 60 dBm)
21+
*/
22+
private val WSPR_SYMBOLS: List<Symbol> = listOf(
23+
Required('Q'.code.toByte()), // Fixed prefix
24+
CallLetterNumber(), // Callsign char 1
25+
CallLetterNumber(), // Callsign char 2
26+
CallLetterNumber(), // Callsign char 3
27+
CallLetterNumber(), // Callsign char 4
28+
CallLetterNumber(), // Callsign char 5
29+
CallLetterNumber(), // Callsign char 6 (often space)
30+
GridLetter(), // Grid char 1
31+
GridLetter(), // Grid char 2
32+
Number(), // Grid char 3
33+
Number(), // Grid char 4
34+
Power() // Power level
35+
)
36+
37+
private val encoder = Encoder(WSPR_SYMBOLS)
38+
private val decoder = encoder.decoder()
39+
40+
override fun size(): Int = WSPR_SYMBOLS.sumOf { it.size() }
41+
42+
override fun toString(): String = "WSPRMessage"
43+
44+
override fun decode(encodedValue: ByteArray): BigInteger {
45+
require(encodedValue.size == size()) {
46+
"Encoded value must be ${size()} bytes, got ${encodedValue.size}"
47+
}
48+
49+
// Split the encoded byte array into parts for each symbol
50+
val parts = mutableListOf<ByteArray>()
51+
var offset = 0
52+
53+
for (symbol in WSPR_SYMBOLS) {
54+
val symbolSize = symbol.size()
55+
val symbolBytes = encodedValue.sliceArray(offset until offset + symbolSize)
56+
parts.add(symbolBytes)
57+
offset += symbolSize
58+
}
59+
60+
// Use the decoder to convert the parts back to an integer
61+
return decoder.decode(parts)
62+
}
63+
64+
override fun encode(numericValue: BigInteger): ByteArray {
65+
// Use the encoder to convert the integer to a list of byte arrays
66+
val parts = encoder.encode(numericValue)
67+
68+
// Concatenate all parts into a single byte array
69+
return parts.flatMap { it.toList() }.toByteArray()
70+
}
71+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.operatorfoundation.codex.symbols
2+
3+
import java.math.BigInteger
4+
import org.operatorfoundation.codex.Encoder
5+
6+
/**
7+
* A sequence of WSPR messages that can encode arbitrarily large integers
8+
* by splitting them across multiple WSPR transmissions.
9+
*
10+
* This enables the Tidbit protocol to send messages larger than a single
11+
* WSPR message can contain by chaining multiple messages together.
12+
*
13+
* @param count Number of WSPR messages in the sequence
14+
*/
15+
class WSPRMessageSequence(private val count: Int) : Symbol {
16+
init {
17+
require(count > 0) { "Sequence must contain at least one WSPR message" }
18+
}
19+
20+
private val wsrpMessages: List<Symbol> = List(count) { WSPRMessage() }
21+
private val encoder = Encoder(wsrpMessages)
22+
private val decoder = encoder.decoder()
23+
24+
override fun size(): Int = wsrpMessages.sumOf { it.size() }
25+
26+
override fun toString(): String = "WSPRMessageSequence($count)"
27+
28+
override fun decode(encodedValue: ByteArray): BigInteger {
29+
require(encodedValue.size == size()) {
30+
"Encoded value must be ${size()} bytes for $count messages, got ${encodedValue.size}"
31+
}
32+
33+
// Split the encoded byte array into parts for each WSPR message
34+
val parts = mutableListOf<ByteArray>()
35+
var offset = 0
36+
37+
for (message in wsrpMessages) {
38+
val messageSize = message.size()
39+
val messageBytes = encodedValue.sliceArray(offset until offset + messageSize)
40+
parts.add(messageBytes)
41+
offset += messageSize
42+
}
43+
44+
// Use the decoder to convert the parts back to an integer
45+
return decoder.decode(parts)
46+
}
47+
48+
override fun encode(numericValue: BigInteger): ByteArray {
49+
// Use the encoder to convert the integer to a list of byte arrays
50+
val parts = encoder.encode(numericValue)
51+
52+
require(parts.size == count) {
53+
"Encoder produced ${parts.size} parts but expected $count"
54+
}
55+
56+
// Concatenate all parts into a single byte array
57+
return parts.flatMap { it.toList() }.toByteArray()
58+
}
59+
}

Codex/src/test/java/org/operatorfoundation/codex/EncoderDecoderTest.kt

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import org.operatorfoundation.codex.symbols.CallLetterSpace
1212
import org.operatorfoundation.codex.symbols.GridLetter
1313
import org.operatorfoundation.codex.symbols.Power
1414
import org.operatorfoundation.codex.symbols.Trinary
15+
import org.operatorfoundation.codex.symbols.WSPRMessage
16+
import org.operatorfoundation.codex.symbols.WSPRMessageSequence
1517

1618
import java.math.BigInteger
1719

@@ -174,5 +176,204 @@ class EncoderDecoderTest
174176
assertEquals(bigIntValue, decoded, "Round-trip failed for value $value")
175177
}
176178
}
179+
}
180+
181+
@Test
182+
fun testWSPRMessageRoundTrip() {
183+
val wsprMessage = WSPRMessage()
184+
185+
// Test various values within WSPR message capacity
186+
val testValues = listOf(
187+
BigInteger.ZERO,
188+
BigInteger.ONE,
189+
BigInteger.valueOf(1415934836), // "Test" as integer
190+
BigInteger.valueOf(123456789),
191+
BigInteger.valueOf(999999999),
192+
BigInteger("10000000000") // Larger value
193+
)
194+
195+
testValues.forEach { value ->
196+
val encoded = wsprMessage.encode(value)
197+
198+
// Verify size
199+
assertEquals(wsprMessage.size(), encoded.size,
200+
"Encoded size mismatch for value $value")
201+
202+
// Decode and verify
203+
val decoded = wsprMessage.decode(encoded)
204+
assertEquals(value, decoded, "Round-trip failed for value $value")
205+
206+
println("WSPRMessage round-trip: $value -> ${encoded.size} bytes -> $decoded")
207+
}
208+
}
209+
210+
@Test
211+
fun testWSPRMessageFormat() {
212+
val wsprMessage = WSPRMessage()
213+
val testInteger = BigInteger.valueOf(1415934836) // "Test"
214+
215+
val encoded = wsprMessage.encode(testInteger)
216+
217+
// Verify the message starts with 'Q'
218+
assertEquals('Q'.code.toByte(), encoded[0], "WSPR message should start with 'Q'")
219+
220+
// Decode and display the message components
221+
val parts = mutableListOf<String>()
222+
parts.add(encoded[0].toInt().toChar().toString()) // Required 'Q'
223+
parts.add((1..6).map { encoded[it].toInt().toChar() }.joinToString("")) // Callsign
224+
parts.add((7..8).map { encoded[it].toInt().toChar() }.joinToString("")) // Grid letters
225+
parts.add((9..10).map { encoded[it].toInt().toChar() }.joinToString("")) // Grid numbers
226+
parts.add(encoded[11].toInt().toChar().toString()) // Power
227+
228+
println("WSPR Message components: ${parts.joinToString(" ")}")
229+
230+
// Verify decoding
231+
val decoded = wsprMessage.decode(encoded)
232+
assertEquals(testInteger, decoded)
233+
}
234+
235+
@Test
236+
fun testWSPRMessageSequenceSingleMessage() {
237+
// Test with a sequence of 1 message (should behave like WSPRMessage)
238+
val sequence = WSPRMessageSequence(1)
239+
val singleMessage = WSPRMessage()
240+
241+
val testValue = BigInteger.valueOf(1415934836)
242+
243+
val seqEncoded = sequence.encode(testValue)
244+
val msgEncoded = singleMessage.encode(testValue)
245+
246+
// Should produce identical results
247+
assertArrayEquals(msgEncoded, seqEncoded)
248+
249+
// Verify decoding
250+
val decoded = sequence.decode(seqEncoded)
251+
assertEquals(testValue, decoded)
252+
}
253+
254+
@Test
255+
fun testWSPRMessageSequenceMultipleMessages() {
256+
// Test with 3 messages
257+
val sequence = WSPRMessageSequence(3)
258+
259+
val testValues = listOf(
260+
BigInteger.ZERO,
261+
BigInteger.ONE,
262+
BigInteger.valueOf(1415934836),
263+
BigInteger("100000000000000"), // Large value requiring multiple messages
264+
BigInteger("999999999999999999")
265+
)
266+
267+
testValues.forEach { value ->
268+
val encoded = sequence.encode(value)
269+
270+
// Verify total size is 3 × single message size
271+
val singleMessageSize = WSPRMessage().size()
272+
assertEquals(singleMessageSize * 3, encoded.size,
273+
"Sequence size should be 3 × single message size")
274+
275+
// Decode and verify
276+
val decoded = sequence.decode(encoded)
277+
assertEquals(value, decoded, "Round-trip failed for value $value")
278+
279+
println("WSPRMessageSequence(3) round-trip: $value -> ${encoded.size} bytes -> $decoded")
280+
}
281+
}
282+
283+
@Test
284+
fun testWSPRMessageSequenceCapacity() {
285+
// Test that larger sequences can handle larger numbers
286+
val sequence2 = WSPRMessageSequence(2)
287+
val sequence5 = WSPRMessageSequence(5)
288+
289+
// A very large number that would overflow a single message
290+
val largeValue = BigInteger("123456789012345678901234567890")
291+
292+
try {
293+
val encoded2 = sequence2.encode(largeValue)
294+
val decoded2 = sequence2.decode(encoded2)
295+
assertEquals(largeValue, decoded2, "Sequence of 2 failed for large value")
296+
println("WSPRMessageSequence(2) handled large value successfully")
297+
} catch (e: Exception) {
298+
println("WSPRMessageSequence(2) cannot handle value (expected if too large)")
299+
}
177300

301+
try {
302+
val encoded5 = sequence5.encode(largeValue)
303+
val decoded5 = sequence5.decode(encoded5)
304+
assertEquals(largeValue, decoded5, "Sequence of 5 failed for large value")
305+
println("WSPRMessageSequence(5) handled large value: $largeValue")
306+
} catch (e: Exception) {
307+
println("WSPRMessageSequence(5) cannot handle value (too large even for 5 messages)")
308+
}
309+
}
310+
311+
@Test
312+
fun testWSPRMessageSequenceIndependence() {
313+
// Verify that each message in the sequence contributes independently
314+
val sequence = WSPRMessageSequence(2)
315+
val singleMessageSize = WSPRMessage().size()
316+
317+
val testValue = BigInteger.valueOf(1000000)
318+
val encoded = sequence.encode(testValue)
319+
320+
// Split into two messages
321+
val message1 = encoded.sliceArray(0 until singleMessageSize)
322+
val message2 = encoded.sliceArray(singleMessageSize until encoded.size)
323+
324+
println("Message 1 bytes: ${message1.map { (it.toInt() and 0xFF).toString(16) }}")
325+
println("Message 2 bytes: ${message2.map { (it.toInt() and 0xFF).toString(16) }}")
326+
327+
// Both should start with 'Q'
328+
assertEquals('Q'.code.toByte(), message1[0], "First message should start with Q")
329+
assertEquals('Q'.code.toByte(), message2[0], "Second message should start with Q")
330+
331+
// Verify full round-trip still works
332+
val decoded = sequence.decode(encoded)
333+
assertEquals(testValue, decoded)
334+
}
335+
336+
@Test
337+
fun testWSPRMessageVsSequenceComparison() {
338+
// Compare the capacity and behavior of single vs multiple messages
339+
val single = WSPRMessage()
340+
val double = WSPRMessageSequence(2)
341+
342+
// Find a value that fits in single message
343+
val smallValue = BigInteger.valueOf(1000)
344+
val singleEncoded = single.encode(smallValue)
345+
val doubleEncoded = double.encode(smallValue)
346+
347+
println("Single message size: ${singleEncoded.size} bytes")
348+
println("Double sequence size: ${doubleEncoded.size} bytes")
349+
350+
assertEquals(single.size(), singleEncoded.size)
351+
assertEquals(single.size() * 2, doubleEncoded.size)
352+
353+
// Both should decode correctly
354+
assertEquals(smallValue, single.decode(singleEncoded))
355+
assertEquals(smallValue, double.decode(doubleEncoded))
356+
}
357+
358+
@Test
359+
fun testWSPRMessageSequenceEdgeCases() {
360+
// Test edge cases
361+
val sequence = WSPRMessageSequence(1)
362+
363+
// Test zero
364+
val zeroEncoded = sequence.encode(BigInteger.ZERO)
365+
assertEquals(BigInteger.ZERO, sequence.decode(zeroEncoded))
366+
367+
// Test one
368+
val oneEncoded = sequence.encode(BigInteger.ONE)
369+
assertEquals(BigInteger.ONE, sequence.decode(oneEncoded))
370+
371+
// Test that invalid count throws
372+
assertThrows(IllegalArgumentException::class.java) {
373+
WSPRMessageSequence(0)
374+
}
375+
376+
assertThrows(IllegalArgumentException::class.java) {
377+
WSPRMessageSequence(-1)
378+
}
178379
}

0 commit comments

Comments
 (0)