Skip to content

Commit 8213826

Browse files
committed
Multi-message support + tests
1 parent a004376 commit 8213826

File tree

4 files changed

+904
-278
lines changed

4 files changed

+904
-278
lines changed

Codex/src/main/java/org/operatorfoundation/codex/WSPRCodex.kt

Lines changed: 57 additions & 213 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package org.operatorfoundation.codex
22

33
import org.operatorfoundation.codex.symbols.Number
44
import org.operatorfoundation.codex.symbols.*
5-
import java.math.BigInteger
65

76
/**
87
* WSPRCodex provides encoding and decoding of arbitrary byte data as WSPR messages.
@@ -11,39 +10,44 @@ import java.math.BigInteger
1110
* WSPR message format (callsign, grid square, power level). The encoding is reversible,
1211
* allowing data to be transmitted via WSPR and recovered on the receiving end.
1312
*
13+
* All data is encoded using the multi-message codec for consistency and robustness.
14+
* Small data (≤5 bytes) requires only 1 WSPR message. Larger data automatically
15+
* chunks across multiple messages.
16+
*
1417
* WSPR Message Format:
1518
* - Callsign: 6 characters (letters, numbers, spaces)
1619
* - Grid Square: 4 characters (letters and numbers for Maidenhead locator)
17-
* - Power: 2 characters (power level in dBm: 0, 3, 7, 10, 13... 60)
20+
* - Power: 2 digits (power level in dBm: 0, 3, 7, 10, 13... 60)
1821
*
1922
* Example Usage:
2023
* ```kotlin
2124
* val codex = WSPRCodex()
2225
* val plaintext = "Hello WSPR!"
2326
* val encrypted = encryptData(plaintext) // External encryption
2427
*
25-
* // Encode to WSPR message
26-
* val wsprMessage = codex.encode(encrypted)
27-
* println("${wsprMessage.callsign} ${wsprMessage.gridSquare} ${wsprMessage.powerDbm}")
28+
* // Encode to WSPR messages (automatically handles chunking)
29+
* val messages = codex.encode(encrypted)
30+
* println("Encoded to ${messages.size} WSPR message(s)")
31+
*
32+
* // Transmit each message via radio
33+
* messages.forEach { message ->
34+
* transmit(message)
35+
* }
2836
*
29-
* // Decode back to original data
30-
* val decoded = codex.decode(wsprMessage)
37+
* // Decode back to original data (chunks can be in any order)
38+
* val decoded = codex.decode(messages)
3139
* val decrypted = decryptData(decoded) // External decryption
3240
* ```
41+
*
42+
* Note: Trailing zeros in data may be lost during encoding/decoding.
43+
* This is typically not an issue for encrypted data.
3344
*/
3445
class WSPRCodex
3546
{
3647
companion object
3748
{
3849
/**
3950
* WSPR symbol configuration matching the standard WSPR message format.
40-
*
41-
* Symbol breakdown:
42-
* - Required('Q'): Fixed prefix (1 byte, size=1, contributes 0 bits)
43-
* - CallLetterNumber (5x): Callsign characters (A-Z, 0-9 = 36 values each)
44-
* - GridLetter (2x): First two grid characters (A-R = 18 values each)
45-
* - Number (2x): Last two grid characters (0-9 = 10 values each)
46-
* - Power: Power level (19 discrete values: 0, 3, 7, 10... 60 dBm)
4751
*/
4852
private val WSPR_SYMBOLS: List<Symbol> = listOf<Symbol>(
4953
Required('Q'.code.toByte()), // Fixed prefix
@@ -61,226 +65,77 @@ class WSPRCodex
6165
)
6266

6367
/**
64-
* Calculates the maximum number of bytes that can be encoded in a single WSPR message.
65-
*
66-
* This is determined by the product of all symbol sizes (excluding size=1 symbols):
67-
* Capacity = log2(36^6 × 18^2 × 10^2 × 19) / 8 bytes
68+
* Gets the symbol configuration used for WSPR encoding.
6869
*
69-
* @return Maximum payload size in bytes
70+
* @return List of symbols defining the WSPR message structure
7071
*/
71-
fun getMaxPayloadBytes(): Int
72-
{
73-
// The actual capacity depends on how the encoder distributes data across symbols
74-
// We need to calculate the maximum value that can flow through the encoding process
75-
// without overflow at any symbol position
76-
77-
// Start from the end and work backwards to find capacity at each position
78-
// Symbol with size=1 (Required) doesn't contribute to capacity
79-
val effectiveSymbols = WSPR_SYMBOLS.filter { it.size() > 1 }
80-
81-
if (effectiveSymbols.isEmpty())
82-
{
83-
return 0
84-
}
85-
86-
// Calculate the product of all effective symbol sizes (the total number of unique values we can encode)
87-
var totalCapacity = BigInteger.ONE
88-
effectiveSymbols.forEach { symbol ->
89-
totalCapacity *= symbol.size().toBigInteger()
90-
}
91-
92-
// The maximum value we can encode is (totalCapacity - 1)
93-
// because we count from 0
94-
val maxEncodableValue = totalCapacity - BigInteger.ONE
95-
96-
// Convert to byte array to see how many bytes this value requires
97-
val byteArray = maxEncodableValue.toByteArray()
98-
99-
// BigInteger.toByteArray() may include a leading zero byte for the sign bit
100-
// Remove it if present
101-
val actualBytes = if (byteArray.isNotEmpty() && byteArray[0] == 0.toByte())
102-
{
103-
byteArray.size - 1
104-
}
105-
else
106-
{
107-
byteArray.size
108-
}
109-
110-
return actualBytes
111-
}
72+
fun getSymbolList(): List<Symbol> = WSPR_SYMBOLS
11273

11374
/**
114-
* Gets the symbol configuration used for WSPR encoding.
115-
* Useful for testing and capacity calculations.
75+
* Generates a random message ID for multi-message encoding.
11676
*
117-
* @return List of symbols defining the WSPR message structure
77+
* @return Random byte value (0-255)
11878
*/
119-
fun getSymbolList(): List<Symbol> = WSPR_SYMBOLS
79+
fun generateRandomMessageId(): Byte = kotlin.random.Random.nextBytes(1)[0]
12080
}
12181

122-
// Encoder and decoder instances configured with WSPR symbols
123-
private val encoder = Encoder(WSPR_SYMBOLS)
124-
private val decoder = Decoder(WSPR_SYMBOLS)
82+
private val multiMessageCodec = WSPRMultiMessageCodex()
12583

12684
/**
127-
* Encodes a byte array as a WSPR message.
85+
* Encodes a byte array as one or more WSPR messages.
12886
*
129-
* The data is converted to a large integer and distributed across the WSPR
130-
* message fields according to the symbol configuration. The encoding is
131-
* deterministic and reversible.
87+
* Data capacity per message chunk:
88+
* - ≤5 bytes: 1 WSPR message
89+
* - 6-10 bytes: 2 WSPR messages
90+
* - 11-15 bytes: 3 WSPR messages
91+
* - etc. (up to 1024 bytes total)
13292
*
13393
* @param data Binary data to encode (e.g., encrypted message bytes)
134-
* @return WSPRDataMessage containing callsign, grid square, and power level
135-
* @throws WSPRCodexException if data exceeds maximum capacity
94+
* @param messageId Optional message ID for tracking (auto-generated if null)
95+
* @return List of WSPR messages containing the encoded data
96+
* @throws WSPRCodexException if data is empty or exceeds maximum capacity
13697
*/
137-
fun encode(data: ByteArray): WSPRDataMessage
98+
fun encode(data: ByteArray, messageId: Byte? = null): List<WSPRDataMessage>
13899
{
139-
// Validate payload size
140-
if (data.size > getMaxPayloadBytes())
100+
if (data.isEmpty())
141101
{
142-
throw WSPRCodexException(
143-
"Data size ${data.size} bytes exceeds maximum capacity of ${getMaxPayloadBytes()} bytes. " +
144-
"Consider splitting into multiple messages or reducing payload size."
145-
)
102+
throw WSPRCodexException("Cannot encode empty data")
146103
}
147104

148-
// Convert byte array to BigInt for encoding
149-
// Uses big-endian byte order
150-
val dataAsInteger = BigInteger(1, data) // 1 = positive
151-
152-
// Encode integer across WSPR symbols
153-
val encodedSymbols = encoder.encode(dataAsInteger)
154-
155-
// Parse encoded symbols into WSPR message fields
156-
return parseEncodedSymbolsToMessage(encodedSymbols)
105+
val actualMessageId = messageId ?: generateRandomMessageId()
106+
return multiMessageCodec.encode(data, actualMessageId)
157107
}
158108

159-
160109
/**
161-
* Decodes a WSPR message back to the original byte array.
110+
* Decodes one or more WSPR messages back to the original byte array.
162111
*
163-
* Reverses the encoding process by converting the WSPR message fields
164-
* back to symbol representations, then decoding to the original integer
165-
* and finally to the byte array.
112+
* Messages can be provided in any order - the decoder automatically
113+
* handles sequencing and reassembly. All chunks from the same message
114+
* ID must be provided for successful decoding.
166115
*
167-
* @param message WSPR message to decode
116+
* @param messages List of WSPR messages to decode
168117
* @return Original byte array that was encoded
169-
* @throws WSPRCodexException if message format is invalid
170-
*/
171-
fun decode(message: WSPRDataMessage): ByteArray
172-
{
173-
// Convert message fields back to encoded symbol format
174-
val encodedSymbols = convertMessageToEncodedSymbols(message)
175-
176-
// Decode symbols back to int
177-
val decodedInteger = decoder.decode(encodedSymbols)
178-
179-
// Convert integer back to byte array
180-
return decodedInteger.toByteArray().let { bytes ->
181-
// BigInteger.toByteArray() may include leading zero byte for sign
182-
// Remove it if present to get original data
183-
if (bytes.isNotEmpty() && bytes[0] == 0.toByte())
184-
{
185-
bytes.copyOfRange(1, bytes.size)
186-
}
187-
else
188-
{
189-
bytes
190-
}
191-
}
192-
}
193-
194-
// ========== Private Helper Methods ==========
195-
196-
/**
197-
* Parses encoded symbols into a structured WSPR message.
198-
*
199-
* Symbol mapping:
200-
* - encodedSymbols[0]: Required 'Q' (ignored)
201-
* - encodedSymbols[1-6]: Callsign (6 characters)
202-
* - encodedSymbols[7-10]: Grid square (4 characters)
203-
* - encodedSymbols[11]: Power level (2-character representation)
204-
*
205-
* @param encodedSymbols List of ByteArrays from encoder
206-
* @return Structured WSPR message
118+
* @throws WSPRCodexException if messages are invalid, empty, or incomplete
207119
*/
208-
private fun parseEncodedSymbolsToMessage(encodedSymbols: List<ByteArray>): WSPRDataMessage
120+
fun decode(messages: List<WSPRDataMessage>): ByteArray
209121
{
210-
require(encodedSymbols.size == WSPR_SYMBOLS.size)
122+
if (messages.isEmpty())
211123
{
212-
"Invalid encoded symbol count: ${encodedSymbols.size}, expected: ${WSPR_SYMBOLS.size}"
213-
}
214-
215-
// Extract callsign (symbols 1-6)
216-
val callsign = buildString {
217-
for (i in 1..6)
218-
{
219-
append(encodedSymbols[i].decodeToString())
220-
}
221-
}
222-
223-
// Extract grid square (symbols 7 - 10)
224-
val gridSquare = buildString {
225-
for (i in 7..10)
226-
{
227-
append(encodedSymbols[i].decodeToString())
228-
}
124+
throw WSPRCodexException("Cannot decode empty message list")
229125
}
230126

231-
// Extract power level (Symbol 11)
232-
val powerDbm = encodedSymbols[11].decodeToString().toInt()
233-
234-
return WSPRDataMessage(
235-
callsign,
236-
gridSquare,
237-
powerDbm
238-
)
127+
return multiMessageCodec.decode(messages)
239128
}
129+
}
240130

241-
/**
242-
* Converts a WSPR message back to encoded symbol format for decoding.
243-
*
244-
* This reverses the parseEncodedSymbolsToMessage operation, reconstructing
245-
* the ByteArray list that the encoder originally produced.
246-
*
247-
* @param message WSPR message to convert
248-
* @return List of ByteArrays suitable for decoder
249-
*/
250-
private fun convertMessageToEncodedSymbols(message: WSPRDataMessage): List<ByteArray>
251-
{
252-
val symbols = mutableListOf<ByteArray>()
253-
254-
// Add the required prefix
255-
symbols.add("Q".toByteArray())
256-
257-
// Add callsign characters (exactly 6 characters)
258-
val paddedCallsign = message.callsign.padEnd(6, ' ')
259-
require(paddedCallsign.length == 6)
260-
{
261-
"Callsign must be 6 characters or less: ${message.callsign}"
262-
}
263-
264-
paddedCallsign.forEach { char ->
265-
symbols.add(char.toString().toByteArray())
266-
}
267-
268-
// Add grid square characters (exactly 4 characters)
269-
require(message.gridSquare.length == 4)
270-
{
271-
"Grid square must be exactly 4 characters: ${message.gridSquare}"
272-
}
273-
274-
message.gridSquare.forEach { char ->
275-
symbols.add(char.toString().toByteArray())
276-
}
277-
278-
// Add power level
279-
symbols.add(message.powerDbm.toString().toByteArray())
131+
/**
132+
* Exception thrown by WSPRCodex for encoding/decoding errors.
133+
*/
134+
class WSPRCodexException(
135+
message: String,
136+
cause: Throwable? = null
137+
) : Exception(message, cause)
280138

281-
return symbols
282-
}
283-
}
284139

285140
/**
286141
* Data class representing a WSPR message with encoded data.
@@ -319,14 +174,3 @@ data class WSPRDataMessage(
319174
powerDbm in 0..60
320175
}
321176
}
322-
323-
/**
324-
* Exception thrown by WSPRCodex for encoding/decoding errors.
325-
*
326-
* @property message Descriptive error message
327-
* @property cause Optional underlying cause of the error
328-
*/
329-
class WSPRCodexException(
330-
message: String,
331-
cause: Throwable? = null
332-
) : Exception(message, cause)

0 commit comments

Comments
 (0)