I have always been amazed by all the digital modes HAMs use, one - which is WSPR was of a particular interest to me.

WSPR uses a very complex way of encoding the bare minimum information (call-sign, locator and power) to ident a station. With error correction and a particular transmission method
this allows reception of signals way below the noise floor (down to -28dB).

Stations that receive these signals can automatically upload RX reports to an online database for everyone to see.

This felt like a good protocol for our existing CW-only 6 meter propagation-test beacon.

The WSPR Protocol
There isn't a lot of documentation on the internet on how this protocol works (or I didn't look hard enough) but one document by Andy Talbot G4JNT describes
it in all the details one would need to implement their own WSPR encoder: http://www.g4jnt.com/Coding/WSPR_Coding_Process.pdf

Armed with this paper, I began writing a simple program that would encode a WSPR message and play it out as 1.5kHz tones from the computer's speaker
to see if WSJTX would decode it.

Simplifying the above document, the WSPR encoding magic works like this:

1. Call-sign (max. 6 letters) is encoded into a 28bit number, by a sort of "Base 37" numbering system, where numbers 0-9 are encoded as numbers 0-9, and letters A-Z are given the values 10-35,
space, the only non letter/digit character allowed is encoded as number 36.

2. Locator and Power: QTH locator can only take values from AA00 to RR99, letters are encoded as 0-17 and numbers as 0-9. Power can be any value from 0 to 60 (measured in dBm).

The end result is a 50bit payload neatly packing all the information in as little amount of bits as possible.

For example: 9H1SIX becomes 67152074 (0x400A8CA in HEX) and JM75 37dBm becomes 1905381 (0x1D12E5 in HEX).

These 50 bits are bit packed together and padded with LSb zeroes into an 81bit long number.
Then, convolutional Forward Error Correction code is applied on these 81 bits, producing a payload of 162 bits with enough redundancy for error correction on the RX side.
The resulting 162 bits are then interleaved, so that burst errors can only corrupt random parts of the message and the chosen FEC code can correct them.

Then, the final 162 bits (interleaved, FEC added) payload is merged with a pseudo-random sync vector, which produces 162 numbers in 0-3 range. These can then be taken as
4FSK frequency offsets to modulate the signal.

The code below takes Call-sign, Locator and Power and produces the final 162 4FSK offset values needed for the WSPR modulation.
The code is provided under CC0, public domain license for anyone interested in building their own WSPR encoder.
#include <stdio.h>
#include <stdint.h>
#include <string.h> // strlen
#include <assert.h>

// Change these and recompile to compute new wspr code
#define CALLSIGN "9H1SIX"
#define LOC "JM75"
#define PWR 37 // dBm


// Code is based on this wonderful document by G4JNT
// http://www.g4jnt.com/Coding/WSPR_Coding_Process.pdf

uint8_t encode_char(char c, int add10) {
    assert((c>='0' && c<='9') || (c>='A' && c<='Z') || c==' ');

    if(c>='0' && c<='9')
        return c-'0';
    if(c==' ')
        return 36;

    if(add10)
    	return (c-'A')+10;
    return c-'A';
}
uint32_t encodeN(char *csign) {
    assert(strlen(csign)==6); // pad it yourself! must be 6 chars
    assert(csign[2] >='0' && csign[2] <= '9'); // 3rd char must be a number

    uint32_t N;
    N =	         encode_char(csign[0], 1);	// 1
    N = (N*36) + encode_char(csign[1], 1); 	// 2
    N = (N*10) + encode_char(csign[2], 0); 	// 3
    N = (N*27) + encode_char(csign[3], 0); 	// 4
    N = (N*27) + encode_char(csign[4], 0); 	// 5
    N = (N*27) + encode_char(csign[5], 0); 	// 6
    return N;
}

uint32_t encodeM(char *loc, uint8_t pwr) {
	assert(strlen(loc)==4);

	uint32_t M1, M;
	M1 = (179 - 10*encode_char(loc[0], 0)-encode_char(loc[2], 0));
	M1 = M1 * 180 + 10*encode_char(loc[1], 0)+encode_char(loc[3], 0);
	M = M1*128 + pwr + 64;
	return M;
}
// packs 50 bits into a uint64_t
uint64_t pack(uint32_t N, uint32_t M) {
    uint64_t packed;
	// N shifts to MSB then 22 bits from M
    packed = ((uint64_t)N<<22) | ((uint64_t)M&((1<<23)-1));
    return packed;
}
uint8_t xor_parity(uint32_t x) {
    uint8_t res = 0;
    for(int i=0;i<32;i++)
        res = res^((x>>i)&1);
    return res;
}
void compute_fec(uint64_t packed, uint8_t *fec) {
	uint32_t acc = 0;
    uint8_t p1, p2;
	for(int i=0; i<81; i++) {
    	int bit = 49-i;
    	acc <<= 1;
    	if(bit>=0) { // only the first 50 bits are used, the rest (81-50=31) are padded with 0
    		acc |= (packed>>bit)&1; // MSb first
    	}
		p1 = xor_parity(acc&0xF2D05351); // first
		p2 = xor_parity(acc&0xE4613C47); // second
		fec[i*2]=p1;
		fec[(i*2)+1]=p2;
	}
}
uint8_t reverse_bits(uint8_t x) {
    uint8_t res = 0;
    for(int bit=0; bit<8; bit++)
        res |= ((x>>bit)&1)<<(7-bit);
    return res;
}
void interleave_and_sync(uint8_t *src, uint8_t *dst) {
	uint8_t sync_vector[162] = {
    	1,1,0,0,0,0,0,0,1,0,0,0,1,1,1,0,0,0,1,0,0,1,0,1,1,1,1,0,0,0,0,0,0,0,1,0,0,1,0,1,0,0,
		0,0,0,0,1,0,1,1,0,0,1,1,0,1,0,0,0,1,1,0,1,0,0,0,0,1,1,0,1,0,1,0,1,0,1,0,0,1,0,0,1,0,
		1,1,0,0,0,1,1,0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,1,0,0,1,1,1,0,1,1,0,0,1,1,0,1,0,0,0,1,
		1,1,0,0,0,0,0,1,0,1,0,0,1,1,0,0,0,0,0,0,0,1,1,0,1,0,1,1,0,0,0,1,1,0,0,0,
	};
    uint8_t rev;
    uint8_t fwd = 0;
    uint8_t idx = 0;
    while(idx<162) {
    	rev = reverse_bits(fwd++);
    	if(rev>161) continue;
    	// dst[rev]=src[idx] for interleaving,
    	// dst[i]=sync[i]+(2*src[i]) for sync
    	dst[rev] = sync_vector[rev]+(2*src[idx]);
    	idx++;
    }
}

int main(int argc, char **argv) {
    uint32_t N, M;
    uint8_t fec[162], sym[162];

    N = encodeN(CALLSIGN);
    M = encodeM(LOC, PWR);
    compute_fec(pack(N,M), fec);
	interleave_and_sync(fec, sym);

   	printf("[N] Callsign encoded: %lu 0x%X\n", N, N);
   	printf("[M] Pwr and loc encoded: %lu 0x%X\n", M, M);
   	printf("FEC: ");
   	for(int i=0; i<162;i++) printf("%d,", fec[i]);
   	printf("\n\nWSPR Code: ");
   	for(int i=0; i<162;i++) printf("%d,", sym[i]);
   	printf("\n");

    return 0;
}
Putting my WSPR transmissions on the air
Once I got my program to encode the data correctly and WSJTX was able to decode my messages, I began constructing a "Proof Of Concept" board that would 
actually transmit WSPR messages on the air.

I've long been playing with Chinese STC microcontrollers, they are very cheap and they still produce them in hobby-friendly DIP packages.
The one chosen was an STC8G1K08A of which I have many. Architecturally it's a 1T Intel8051 with some improvements, doesn't require a programmer (can be programmed via UART) and costs around 25c a piece.
There's a free and open source C compiler for it (SDCC) and a flash programmer (stcgal), as a nice bonus it also comes with an English datasheet :)
It's an 8 pin chip and had just enough GPIOs for our purpose, although I had to be a bit creative to drive both status LEDs separately from a single pin.

My junk drawer had an SI5351 board which was quickly soldered together with the microcontroller on a proto-board.

The SI chip was not the easiest to understand and program for, but is very popular in the Arduino space, so finding some working code examples and adapting them wasn't a problem.

Once everything was put together, I made the mcu listen for the start command from my laptop so that it would start transmitting WSPR at the correct time.
The prototype was then brought to the club to demonstrate the capabilities and to float the idea of replacing the old 6M beacon with this new digital mode.
CW as a second mode of operation
The old beacon was CW only and of course I did not want to completely replace it with WSPR only, so a compromise was reached where the beacon would transmit WSPR for 2 minutes,
then would switch the frequency to the old CW freq. and transmit it's message in CW for 2 minutes.

Having previously played with CW encoding I just reused my existing encoding method, which just packs all Morse letters and digits into an 8 bit code.
The right-most 3 LSb bits encode the length, the rest 5 bits encode the letter, where 1 is a dash and 0 is a dot. The only downside
of this method is that only 5 dit-dash long characters are allowed (letters and numbers fit, but no other characters). But this was enough for our needs.

__code const unsigned char morse[] = {
	// LETTERS 0-25
	0b0010010, // A .-   len=2
	0b0001100, // B -... len=4
	0b0101100, // C -.-. len=4
	0b0001011, // D -..  len=3
	0b0000001, // E .    len=1
	0b0100100, // F ..-. len=4
	0b0011011, // G --.  len=3
	0b0000100, // H .... len=4
	0b0000010, // I ..   len=2
	0b1110100, // J .--- len=4
	0b0101011, // K -.-  len=3
	0b0010100, // L .-.. len=4
	0b0011010, // M --   len=2
	0b0001010, // N -.   len=2
	0b0111011, // O ---  len=3
	0b0110100, // P .--. len=4
	0b1011100, // Q --.- len=4
	0b0010011, // R .-.  len=3
	0b0000011, // S ...  len=3
	0b0001001, // T -    len=1
	0b0100011, // U ..-  len=3
	0b1000100, // V ...- len=4
	0b0110011, // W .--  len=3
 	0b1001100, // X -..- len=4
 	0b1101100, // Y -.-- len=4
 	0b0011100, // Z --.. len=4

 	// DIGITS 26-36
 	0b11111101, // 0 ----- len=5
 	0b11110101, // 1 .---- len=5
 	0b11100101, // 2 ..--- len=5
 	0b11000101, // 3 ...-- len=5
 	0b10000101, // 4 ....- len=5
 	0b00000101, // 5 ..... len=5
 	0b00001101, // 6 -.... len=5
 	0b00011101, // 7 --... len=5
 	0b00111101, // 8 ---.. len=5
 	0b01111101, // 9 ----. len=5
}
With this, the CW message can be encoded on-the-fly on the MCU itself and no precomputed payload is required.
void tx_morse_msg(char *msg) {
    uint8_t idx = 0;
    uint8_t c = 0;
    for(;*msg!=0;*msg++){
        if(*msg>='0' && *msg<='9') {
            idx = ((*msg)-'0') + 26;
        } else if(*msg>='A' && *msg<='Z') {
            idx = ((*msg)-'A');
        } else if(*msg==' ') {
            morse_wait_unit(7);
            continue;
        }
        c = morse[idx];
        for(uint8_t i=3;i<3+(c&0b111);i++) {
        	if((c>>i)&1) { // 1 is dash
                carrier(MORSE_FREQ);
                morse_wait_unit(3);
                carrier(0);
        	} else { // 0 is dot
                carrier(MORSE_FREQ);
                morse_wait_unit(1);
                carrier(0);
        	}
        	morse_wait_unit(1);
        }
        morse_wait_unit(3);
    }
}
Timing and GPS
My junk drawer certainly can't compete with Stephen's (9H1SV) so no GPS modules were found in mine, but Steve actually had a uBlox NEO-6M board which he generously donated for the project.

These modules have a PPS signal and NMEA messaging output. The PPS can be configured to output a wide variety of frequencies, not just 1Hz.
A frequency of 1kHz was chosen as the clock signal for the MCU, which gave me 1ms precision for timing the transmissions. The clock triggers an interrupt which increments
a time counter on the MCU.

NMEA messages contain a lot of information I was not interested in, so I configured the module to only output GPRMC messages, which contain time and location.
A simple parser was written to collect the actual UTC time every 4 minutes, backed by 1kHz PPS signal this gave the needed time precision to operate WSPR without a computer.

Interestingly, the GPS module can pick up the time from the satellites before a full GPS lock is achieved, I think it only needs data from 1 or 2 sats to get the time, but at least 4 for a full lock.
Hence, the beacon has 2 LEDs for GPS, one to tell whether the UTC time has been acquired and operation can start, the other whether full GPS lock has been acquired and the PPS signal is precise.

After reading uBlox docs and banging my head against a wall a few times, the end result was this configuration code:
#define GPS_PAYLOADS 7
__code const uint8_t gps_init_payloads[GPS_PAYLOADS][40] = {
    	{	// set pps speed 1kHz
        	0x06, 0x31, // CFG-TP5
    		0x20, 0x00, // length
    		// payload
    		0x00, // tpIdx = TIMEPULSE
    		0x00, // reserved0
    		0x00,0x00, // reserved1
    		0x00,0x00, // antCableDelay
    		0x00,0x00, // rfGroupDelay
    		0xE8,0x03,0x00,0x00, // freqPeriod (1_000Hz)
    		0xE8,0x03,0x00,0x00, // freqPeriodLock (1_000Hz)
    		0xff,0xff,0xff,0x7f, // pulseLenRatio (50% DutyCycle)
    		0xff,0xff,0xff,0x7f, // pulseLenRatioLock (50% DutyCycle)
    		0x00,0x00,0x00,0x00, // userConfigDelay
    		0x2F,0x0,0x0,0x0, // flags - falling edge at top of second
    	},
    	{   // disable GGA
        	0x06, 0x01, // CFG-MSG
    	 	0x03, 0x00, // length
    	 	// payload
    		0xF0, 0x00, // class/id
    	 	0x0, // disable
    	},
    	{   // disable GLL
        	0x06, 0x01, // CFG-MSG
    	 	0x03, 0x00, // length
    	 	// payload
    		0xF0, 0x01, // class/id
    	 	0x0, // disable
    	},
    	{   // disable GSA
        	0x06, 0x01, // CFG-MSG
    	 	0x03, 0x00, // length
    	 	// payload
    		0xF0, 0x02, // class/id
    	 	0x0, // disable
    	},
    	{   // disable GSV
        	0x06, 0x01, // CFG-MSG
    	 	0x03, 0x00, // length
    	 	// payload
    		0xF0, 0x03, // class/id
    	 	0x0, // disable
    	},
    	{   // disable GPVTG
        	0x06, 0x01, // CFG-MSG
    	 	0x03, 0x00, // length
    	 	// payload
    		0xF0, 0x05, // class/id
    	 	0x0, // disable
    	},
    	{  	// save config
    	 	0x06, 0x09, // CFG-CFG
    	 	0x0C, 0x00, // length
    	 	// payload
    		0x00, 0x00, 0x00, 0x00, // clear mask
    	 	0x1F, 0x00, 0x00, 0x00, // save mask
    		0x00, 0x00, 0x00, 0x00, // load mask
    	},
};


uint16_t gps_checksum(uint8_t *payload, uint8_t size) {
	uint8_t CK_A = 0;
	uint8_t CK_B = 0;
	for(uint8_t i=0; i<size; i++) {
    	uint8_t p = payload[i];
    	CK_A += p;
    	CK_B += CK_A;
	}
	// little endian
	return ((uint16_t)CK_B<<8) | CK_A;
}
Conclusion
Once everything was put together software-wise, the chip and the boards were given back to Stephen 9H1SV.
You can read about his (I would argue, most significant) part of the project in the previous article.

I would like to personally thank Stephen 9H1SV, Domenic 9H1M and all of MARL for supporting my radio experiments and teaching me along the way.
Special thanks goes to Andy Talbot G4JNT for his amazing write-up on the WSPR encoding.

Arty SWL.