A simple WiFi scope (Part 1 – STM32 firmware)

Have you ever thought about designing an oscilloscope? What about a WiFi scope? In this series of articles I show how to design a very simple low-frequency WiFi “oscilloscope” using an Onion Omega2+ and a STM32 microcontroller.

Introduction

Let’s start with a brief introduction: in our last semester at Conestoga College (April-July 2018) we had to work on a capstone project. The goal is to demonstrate knowledge and maybe learn something new. Since IoT is a trending topic, our team (Fabio PereiraCharlie Park and Divesh Dutt) opted for a project on remote data acquisition and what can be nicer than a real-time scope showing waveforms on a computer screen via WiFi? Furthermore, the knowledge used here is valuable and can be applied to almost any IoT project!

The idea at first looked very simple: we would use the ADC on the STM32 to capture samples, store them into a buffer and send them to the Onion Omega 2+ using a serial link. The Omega runs a serial/TCP bridge which relays information between a TCP socket and the STM32. A graphic frontend running on a computer connects to that TCP server, collects the data and displays the waveforms!

Figure 1 – Block diagram

We chose to use the STM32F303RE since it was the platform we were using in the program and we already had a Nucleo-64 development board for it. The STM32F303RE is a very nice microcontroller built around a Cortex-M4F core (which includes FPU and DSP instructions), 512kb of flash, 64+16kb of RAM, a fast 5.4MSPS 12-bit ADC, USB, CAN and a lot more! As the Nucleo-64 includes a builtin ST-link debugger and it features a Virtual Comm Port (VCP) over USB, we decided that the connection between the STM32 and Onion Omega would be over USB (instead of plain TTL serial), making it easier and more reliable.

Figure 2 – Nucleo-64

We chose the Onion Omega 2+ because it is cheap, easy to use and you can program it in virtually any language! And as I already had one (which I introduced in this article) I knew it was also very powerful (580MHz MIPS24k MT7688 processor, 128Mb of RAM, 32Mb of Flash, WiFi and several other interfaces and it runs OpenWRT Linux) and this would be a perfect way to put it to use!

Onion Omega 2+
Figure 3 – Onion Omega 2+

Back to our plan, once we started working on it we realized that despite not being really wrong, we were too optimistic! There is a lot more complexity in a WiFi scope than simply “sampling analog data, storing it into a buffer and relaying it to a display”:

  1. Analog frontend: a real scope needs to condition the analog signal before sending it to the sample/hold and ADC. This frontend must be very fast, must have high impedance (so it won’t disturb the source), mustn’t introduce any distortions or noise and must bring the input voltage level to match the ADC input range (either by amplifying low-voltage signals or by attenuating higher voltage levels). As our goal was to focus on the IoT side of things, we decided that it would be too time consuming designing some sort of analog frontend for our project. So we decided that for demonstration purposes, a signal directly connected to the ADC input would suffice;
  2. Fast sample-and-hold and ADC: real digital scopes use very fast sample-and-hold circuitry as well as very fast ADCs. I believe they also use interleaved S/H and ADCs so that each device samples and converts a slightly different part (in terms of time) of the input signal. Again, for the sake of simplicity and feasibility we opted out for using the internal ADC which has a simple S/H built-in;
  3. Buffering: a real scope will have a large and very fast memory buffer, able to store all the converted data in real-time. Our implementation uses DMA to transfer data from the ADC to the internal RAM (STM32);
  4. Triggering: this was a section that we really under-estimated. Even though we knew how a scope trigger works (and why you need it), the actual implementation needs a deeper understanding of the inner of how a scope really works.

Divide and Conquer

Our approach to work on the project was to split it into the three obvious sections and assign one to each team member. Since Charlie (a former Samsung senior engineer) had a lot of experience on Linux and C programming, he was assigned to the TCP server. Since Divesh didn’t have experience with any desktop programming language, he was assigned to the analog frontend and I worked on the graphic frontend.

Before anything else we needed to create a protocol for our WiFi scope and since I’ve done a lot of data acquisition on previous projects, I’ve decided to create a small protocol with a very simple structure which could also work on a serial terminal as text (so it would also be Web/HTTP friendly):

Figure 4 – Scope protocol

The reader might notice the absence of an error checking field. The team discussed about that but since we were using a USB connection between the STM32 and Omega and TCP between Omega and graphic frontend and both protocols already include error checking, we decided to skip it and keep the system as simple as possible. On a real application it would be a good idea to have a CRC error checking.

With that simple transport protocol in mind we designed a simple command set for our scope:

Figure 5 – Scope commands

After a couple weeks of work, Charlie had most of the TCP server code done but needed STM32 code for testing serial (USB) communication. He ended up writing code able to capture data from ADC, store into a buffer and send it over a VCP link to the Onion Omega. Charlie opted for using the built-in analog watchdog module to act as a triggering mechanism.

On my side I was doing good progress on the graphic frontend but also needed a working TCP server that was capable of accepting connections, send/receive commands (using our scope protocol) and send back some fake waveforms, so I also ended up writing my very own TCP server (I’ve wrote the graphic frontend in Object Pascal / Lazarus but my TCP server was written in C).

The analog frontend didn’t progress very much so we decided to proceed with Charlie’s code as a starting point and I ended up writing new triggering and sampling code.

I would also like to mention that we decided to use Mike Jarebek‘s simple monitor as the base for our STM32 firmware. It is an efficient cooperative OS-like monitor which includes a serial console interface. We also used ST’s CubeMX for some peripheral configuration.

Scope’s Analog Frontend (STM32)

Our code configures the STM32F303RE to run at 72MHz, the internal ADC is configured to work in 12-bit mode and single-conversion mode. Each conversion triggers a DMA transfer which transfers data from ADC (we are using ADC2 channel 1, pin PA4/A2, as our analog input) to a 1024-entry memory buffer. The ADC sampling rate is configured to be four times higher than the desired X resolution, we will see later why this is necessary.

Figure 6 shows a block diagram with the operations performed by the STM32 firmware.

Figure 6 – STM32 firmware block diagram

There are two main buffers: a 1024-entry 16-bit buffer is used for storing raw data from the ADC. The DMA deals with those transfers without CPU intervention. Once 1024 samples are transferred, an interrupt triggers the trigger checking function which scans that buffer looking for a trigger condition.

A secondary 128-entry 16-bit buffer is used for storing samples that matched trigger condition (including a few samples before the trigger).

Upon receiving a sample buffer read command (0x22), the command processor copies data from the sample buffer and sends it through the serial interface.

Our STM32 firmware runs basically two main tasks:

  1. ADC task
  2. Command processing

ADC Task

The ADC task runs the following code:

void TaskADC(void *data)
{
	if (conversionComplete && !transmissionInProgress) {
		checkTrigger();
		conversionComplete = 0;
		HAL_ADC_Start_DMA(&AdcHandle,(uint32_t *)ADC_samples, ADC_SAMPLES_BUFFSIZE);
	}
}

We can see it is pretty straightforward: basically we check if a conversion is complete (conversionComplete flag which is set by ADC/DMA when all 1024 samples are read and transferred). If the flag is set and a transmission is not in progress, it performs the trigger checking, clears the conversion complete flag and restart the ADC/DMA.

Command Processing

The other task we run is the serial command processing. Chul hee used a very neat approach by choosing to use an array of structure which stores the possible commands and a pointer to the desired handling function:

struct {
	char type;
	void (*func)(char *buff, int len);
} pkt_handler[] = {
	{ PKT_SET_SAMPLERATE,		setSampleRate},
	{ PKT_READ_SAMPLERATE,		nullfunc},
	{ PKT_READ_SAMPLEBUFFER,	send_sample_buff },
	{ PKT_START_SAMPLING,		nullfunc},
	{ PKT_STOP_SAMPLING,		nullfunc},
	{ PKT_SET_TRIGGER_LEVEL,	setTriggerLevel},
	{ PKT_READ_TRIGGER_LEVEL,	nullfunc},
	{ PKT_SET_TRIGGER_TYPE,		setTriggerType},
	{ PKT_READ_TRIGGER_TYPE,	nullfunc},
	{ PKT_READ_TRIGGER_TYPE,	nullfunc},
	{ PKT_TEST,			testfunc},
	{ 0x00,	NULL }
};

The code that parses and handle serial commands (which is our command processing task) is pretty simple:

void TaskProcessPacket(void *data)
{
	char c;
	static unsigned int count = 0;
	int rc;
	int psize, ptype;
	char *ptr;
	/* Get the next character */
	rc = TerminalRead(INDEX_DATA, (uint8_t*)&c, 1);
	if(rc) goto ProcessCharacter;
	/* No characters were found, just return */
	return;
ProcessCharacter:
	/* We have a character to process */
	//printf("Got:'%c' %d\n",c,c);
	input[count++] = c;
	// reached at the end of incoming packet
	if (c == '\n') {
		ptr = input;
		if (!strncmp(input, SYNC_STR, SYNC_STR_SIZE)) {
			//send_log("proper packet");
			ptr += SYNC_STR_SIZE;
			sscanf(ptr, "%04x", &psize);
			//send_log("size=%04x", psize);
			ptr += 4;
			sscanf(ptr, "%02x", &ptype);
			//send_log("type=%02x", ptype);
			ptr += 2;
			// find proper packet handler function and run
			for (int i=0; pkt_handler[i].type ;i++)
				if (pkt_handler[i].type == ptype) {
					(*(pkt_handler[i].func))(ptr, psize);
				}
		} else {
			//send_log("wrong packet");
		}
		count = 0;
	}
}

The for loop will search through pkt_handler array trying to find an entry that matches its type field (the actual command). If a matching entry is found, it runs the function using its pointer, otherwise, if a zero is found (pkt_handler[i].and type == 0) it means that we reached the end of the array and no function was found for that command!

Triggering

As I said earlier, checking for a trigger condition wasn’t as simple as we first expected. The problem is due to the fact that in the quantization process there is always some data loss which is governed by two main factors regarding sampling:

  1. Speed (compared to analog signal frequency)
  2. Resolution or accuracy (the number of bits used to represent the analog value)

Sampling resolution is not our main concern here (in terms of triggering) because it will only affect trigger level and in our case, with 12 bits of resolution, this is not really noticeable.

Sampling speed on the other hand can play a really big role, specially for fast changing signals when using lower sampling frequency.

Let’s take a look at the following figure.

Figure 7 – Triggering

Can you see that at sampling sequence A, the first sample was below the trigger level and the second one was well above it? In this case (and considering a rising edge trigger slope) we would take sample 2 as the trigger one (the one located at the origin of our X axis).

Now take a look at sampling sequence B: sample 1 is right at the trigger level so that is the triggering one (the one located at the origin of X axis).

The problem is that they are not at the same voltage level! A2 is probably several levels above the trigger level and B1 is right (or very close) to the trigger level! The result is that you are going to see a “dancing” waveform on your screen! It will constantly shift around the X origin in a very annoying and ugly way! Figure 8 below shows how bad this issue is!

Triggering without oversampling
Figure 8 – Trigger without oversampling

How can we overcome that? Our answer was to use oversampling! By sampling the signal four times faster than what we are actually consuming we can significantly reduce this triggering issue. Check figure 9 to see how oversampling improves our triggering mechanism.

Trigerring with 4x oversampling
Figure 9 – Trigger with 4x oversampling

Much better isn’t it?

By oversampling the signal we now have four times more samples to work with and it makes easier to find the trigger point! But because we are oversampling we need to do the opposite operation before consuming data, that is, we need to decimate before sending it to the client. This is done within checkTrigger() function:

void checkTrigger(void) 
{
	uint16_t currentSample, previousSample, index;
	//check if we have a trigger condition within our working buffer
	for (index=10*ADC_OVERSAMPLING;index<ADC_SAMPLES_BUFFSIZE;index++) {
		currentSample = ADC_samples[index];
		previousSample = ADC_samples[index-1];
		switch (triggerMode) {
			case TRIGGER_WAITING:	// we are waiting for trigger conditions
				if (index>=SCOPE_SAMPLES_BUFFSIZE) return;
				switch (triggerType) {
					case 0:	// rising edge trigger
						if (currentSample>=triggerLevel && previousSample<triggerLevel) {
							// we have a rising edge! Copy samples to buffer and wait for trigger hold off
							for (uint16_t x=0; x<SCOPE_SAMPLES_BUFFSIZE; x++) {
								uint16_t idx = index-(10*ADC_OVERSAMPLING)+x*ADC_OVERSAMPLING;
								if (idx<ADC_SAMPLES_BUFFSIZE) samples[x] = ADC_samples[idx];
								else samples[x] = 0;
							} 
							triggerMode = TRIGGER_HOLDOFF;
						}
						break;
					case 1:	// falling edge trigger
						if (currentSample<=triggerLevel && previousSample>triggerLevel) {
							// we have a falling edge! Copy samples to buffer and wait for trigger hold off
							for (uint16_t x=0; x<SCOPE_SAMPLES_BUFFSIZE; x++) {
								uint16_t idx = index-(10*ADC_OVERSAMPLING)+x*ADC_OVERSAMPLING;
								if (idx<ADC_SAMPLES_BUFFSIZE) samples[x] = ADC_samples[idx];
								else samples[x] = 0;
							}
							triggerMode = TRIGGER_HOLDOFF;
						}
						break;					
				}
				break;			
			case TRIGGER_HOLDOFF:	// this is post-trigger event, wait for the signal to go on opposite direction of trigger
				switch (triggerType) {
					case 0:	// rising edge trigger, wait for signal to go below trigger level
						if (currentSample<triggerLevel) {
							triggerMode = TRIGGER_DONE;
							return;
						}
						break;
					case 1:	// falling edge trigger, wait for signal to go above trigger level
						if (currentSample>triggerLevel) {
							triggerMode = TRIGGER_DONE;
							return;
						}
						break;					
				}
				break;
			default:
				triggerMode = TRIGGER_WAITING;		
		}
	}
}

At this point the reader might ask: “Why don’t you use this higher resolution instead of decimating it?”

The answer is: we would have four times more data to send to the client and that could be an issue. We are currently using 128 points to draw our waveform and for now this is more than enough!

Closing

In this article we presented some background information and the firmware for our WiFi scope. Next time we will see the TCP server code that sits on Onion Omega 2+ and the graphical frontend!

You can find the whole code for this project at my Github profile: https://github.com/fabiopjve/wifiScope

For this project I’ve been using GCC 4.9.3 (arm-none-eabi-gcc 4.9.3 20150529). In order to compile and program your Nucleo-64 board just type make program from our stm32 source folder and make will compile, link and program the firmware for you!

See you soon!

Leave a Reply