Skip to content

Commit 1a63f98

Browse files
committed
WSPRCodex full implementation
1 parent d7882b6 commit 1a63f98

File tree

1 file changed

+162
-3
lines changed
  • Codex/src/main/java/org/operatorfoundation/codex

1 file changed

+162
-3
lines changed

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

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,162 @@ class WSPRCodex
9696
private val encoder = Encoder(WSPR_SYMBOLS)
9797
private val decoder = Decoder(WSPR_SYMBOLS)
9898

99+
/**
100+
* Encodes a byte array as a WSPR message.
101+
*
102+
* The data is converted to a large integer and distributed across the WSPR
103+
* message fields according to the symbol configuration. The encoding is
104+
* deterministic and reversible.
105+
*
106+
* @param data Binary data to encode (e.g., encrypted message bytes)
107+
* @return WSPRDataMessage containing callsign, grid square, and power level
108+
* @throws WSPRCodexException if data exceeds maximum capacity
109+
*/
99110
fun encode(data: ByteArray): WSPRDataMessage
100111
{
101-
// TODO: Not implemented
112+
// Validate payload size
113+
if (data.size > getMaxPayloadBytes())
114+
{
115+
throw WSPRCodexException(
116+
"Data size ${data.size} bytes exceeds maximum capacity of ${getMaxPayloadBytes()} bytes. " +
117+
"Consider splitting into multiple messages or reducing payload size."
118+
)
119+
}
120+
121+
// Convert byte array to BigInt for encoding
122+
// Uses big-endian byte order
123+
val dataAsInteger = BigInteger(1, data) // 1 = positive
124+
125+
// Encode integer across WSPR symbols
126+
val encodedSymbols = encoder.encode(dataAsInteger)
127+
128+
// Parse encoded symbols into WSPR message fields
129+
return parseEncodedSymbolsToMessage(encodedSymbols)
102130
}
103131

132+
133+
/**
134+
* Decodes a WSPR message back to the original byte array.
135+
*
136+
* Reverses the encoding process by converting the WSPR message fields
137+
* back to symbol representations, then decoding to the original integer
138+
* and finally to the byte array.
139+
*
140+
* @param message WSPR message to decode
141+
* @return Original byte array that was encoded
142+
* @throws WSPRCodexException if message format is invalid
143+
*/
104144
fun decode(message: WSPRDataMessage): ByteArray
105145
{
106-
// TODO: Not implemented
146+
// Convert message fields back to encoded symbol format
147+
val encodedSymbols = convertMessageToEncodedSymbols(message)
148+
149+
// Decode symbols back to int
150+
val decodedInteger = decoder.decode(encodedSymbols)
151+
152+
// Convert integer back to byte array
153+
return decodedInteger.toByteArray().let { bytes ->
154+
// BigInteger.toByteArray() may include leading zero byte for sign
155+
// Remove it if present to get original data
156+
if (bytes.isNotEmpty() && bytes[0] == 0.toByte())
157+
{
158+
bytes.copyOfRange(1, bytes.size)
159+
}
160+
else
161+
{
162+
bytes
163+
}
164+
}
165+
}
166+
167+
// ========== Private Helper Methods ==========
168+
169+
/**
170+
* Parses encoded symbols into a structured WSPR message.
171+
*
172+
* Symbol mapping:
173+
* - encodedSymbols[0]: Required 'Q' (ignored)
174+
* - encodedSymbols[1-6]: Callsign (6 characters)
175+
* - encodedSymbols[7-10]: Grid square (4 characters)
176+
* - encodedSymbols[11]: Power level (2-character representation)
177+
*
178+
* @param encodedSymbols List of ByteArrays from encoder
179+
* @return Structured WSPR message
180+
*/
181+
private fun parseEncodedSymbolsToMessage(encodedSymbols: List<ByteArray>): WSPRDataMessage
182+
{
183+
require(encodedSymbols.size == WSPR_SYMBOLS.size)
184+
{
185+
"Invalid encoded symbol count: ${encodedSymbols.size}, expected: ${WSPR_SYMBOLS.size}"
186+
}
187+
188+
// Extract callsign (symbols 1-6)
189+
val callsign = buildString {
190+
for (i in 1..6)
191+
{
192+
append(encodedSymbols[i].decodeToString())
193+
}
194+
}
195+
196+
// Extract grid square (symbols 7 - 10)
197+
val gridSquare = buildString {
198+
for (i in 7..10)
199+
{
200+
append(encodedSymbols[i].decodeToString())
201+
}
202+
}
203+
204+
// Extract power level (Symbol 11)
205+
val powerDbm = encodedSymbols[11].decodeToString().toInt()
206+
207+
return WSPRDataMessage(
208+
callsign,
209+
gridSquare,
210+
powerDbm
211+
)
212+
}
213+
214+
/**
215+
* Converts a WSPR message back to encoded symbol format for decoding.
216+
*
217+
* This reverses the parseEncodedSymbolsToMessage operation, reconstructing
218+
* the ByteArray list that the encoder originally produced.
219+
*
220+
* @param message WSPR message to convert
221+
* @return List of ByteArrays suitable for decoder
222+
*/
223+
private fun convertMessageToEncodedSymbols(message: WSPRDataMessage): List<ByteArray>
224+
{
225+
val symbols = mutableListOf<ByteArray>()
226+
227+
// Add the required prefix
228+
symbols.add("Q".toByteArray())
229+
230+
// Add callsign characters (exactly 6 characters)
231+
val paddedCallsign = message.callsign.padEnd(6, ' ')
232+
require(paddedCallsign.length == 6)
233+
{
234+
"Callsign must be 6 characters or less: ${message.callsign}"
235+
}
236+
237+
paddedCallsign.forEach { char ->
238+
symbols.add(char.toString().toByteArray())
239+
}
240+
241+
// Add grid square characters (exactly 4 characters)
242+
require(message.gridSquare.length == 4)
243+
{
244+
"Grid square must be exactly 4 characters: ${message.gridSquare}"
245+
}
246+
247+
message.gridSquare.forEach { char ->
248+
symbols.add(char.toString().toByteArray())
249+
}
250+
251+
// Add power level
252+
symbols.add(message.powerDbm.toString().toByteArray())
253+
254+
return symbols
107255
}
108256
}
109257

@@ -143,4 +291,15 @@ data class WSPRDataMessage(
143291
gridSquare.length == 4 &&
144292
powerDbm in 0..60
145293
}
146-
}
294+
}
295+
296+
/**
297+
* Exception thrown by WSPRCodex for encoding/decoding errors.
298+
*
299+
* @property message Descriptive error message
300+
* @property cause Optional underlying cause of the error
301+
*/
302+
class WSPRCodexException(
303+
message: String,
304+
cause: Throwable? = null
305+
) : Exception(message, cause)

0 commit comments

Comments
 (0)