@@ -2,7 +2,6 @@ package org.operatorfoundation.codex
22
33import org.operatorfoundation.codex.symbols.Number
44import 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 */
3445class 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