There's a standard profile for USB test and measurement devices, called USBTMC. If your instrument supports it, it becomes a plug-and-play device for the likes of LabVIEW. The Raspberry Pico examples come with a set of TinyUSB profile examples, and USBTMC is one of them. In this post, developed an USBTMC and SCPI compliant instrument on a Pico. At the end of the blog you'll find the VSCode project with source and binaries, of a working USBTMC of my previous USB-Serial lab programmable instrument. |
Turn the TinyUSB USBTMC demo code into a real SCPI parser
In my original project, a few months ago (link in the blog header), I made a programmable lab switch. SCPI traffic was over serial, either via UART or the USB as COM port. That works well, and test applications like LabVIEW and pyvisa support it.
But there's a standard profile for measurement & test devices: USBTMC, so let's try & learn.
This project offers the same functionality as my original serial/usb firmware: control a number of output pins via SCPI. The only things that changes in the firmware is the interaction between TinyUSB and the SCPI parser.
I stayed as close as possible to the TinyUSB USBTMC example, so that it's easy to see how simple the combination SPI-Parser / USBTMC can be.
There's one other change: my original project uses FreeRTOS, this one is bare metal. I did that to keep the similarity to the TinyUSB example intact.
The TinyUSB USBTMC example
The TinyUSB example supports one SCPI command: *IDN?. When it detects that this command is received, the example sends a predefined reply back. Here is where this happens:
static const char idn[] = "TinyUSB,ModelNumber,SerialNumber,FirmwareVer123456\r\n"; static volatile uint8_t status; // 0=not query, 1=queried, 2=delay,set(MAV), 3=delay 4=ready? // (to simulate delay) static volatile uint16_t queryState = 0; static volatile uint32_t queryDelayStart; static volatile uint32_t bulkInStarted; static volatile uint32_t idnQuery; bool tud_usbtmc_msg_data_cb(void *data, size_t len, bool transfer_complete) { // ... if(transfer_complete && (len >=4) && !strncasecmp("*idn?",data,4)) { idnQuery = 1; } // ... } void usbtmc_app_task_iter(void) { switch(queryState) { // ... case 4: // time to transmit; if(bulkInStarted && (buffer_tx_ix == 0)) { if(idnQuery) { tud_usbtmc_transmit_dev_msg_data(idn, tu_min32(sizeof(idn)-1,msgReqLen),true,false); queryState = 0; bulkInStarted = 0; } else { buffer_tx_ix = tu_min32(buffer_len,msgReqLen); tud_usbtmc_transmit_dev_msg_data(buffer, buffer_tx_ix, buffer_tx_ix == buffer_len, false); } // MAV is cleared in the transfer complete callback. } break; // ... } }
In this example, the firmware sets a status when it receives a recognised request. It only knows *IDN?, the SCPI "hello, world!"). When the example receives this command, and it's ready to reply (input handling complete), it sends the string "TinyUSB,ModelNumber,SerialNumber,FirmwareVer123456\r\n" back. An ideal example to check if the Pico enumerates as a test device, and to validate that pyvisa or LabVIEW can perform a real query and response scenario.
The SCPI-Parser example
I'm going to plug into the exact same parts of code. But I'll need some infrastructure first. The example has a fixed reply, I need a buffer where I can retrieve the SCPI parser's actual reply:
char reply[256]; size_t reply_len; bool query_received;
I also renamed the idnQuery flag. The TinyUSB example only reacts when it receives the *IDN? command, but I accept everything. The parser will have to see if it's a valid request. Our receiving code does not validate or care, we've delegated it to the SCPI parser.
bool tud_usbtmc_msg_data_cb(void *data, size_t len, bool transfer_complete) { // ... queryState = transfer_complete; reply_len = 0; query_received = false; // ... if(transfer_complete && (len >=1) /* && !strncasecmp("*idn?",data,4) */) // we received a query { query_received = true; scpi_instrument_input(data, len); } // ... }
If needed (not always: a command does not answer) , the SCPI parser will set the reply. I adapted the code to only send a reply if the parser generated one. That's exactly what a SCPI data exchange expects.
void usbtmc_app_task_iter(void) { switch(queryState) { // ... case 4: // time to transmit; if(/* TODO check if I can just ignore this*/ bulkInStarted && (buffer_tx_ix == 0)) { if(reply_len) { tud_usbtmc_transmit_dev_msg_data(reply, tu_min32(reply_len,msgReqLen),true,false); queryState = 0; bulkInStarted = 0; reply_len = 0; } else { buffer_tx_ix = tu_min32(buffer_len,msgReqLen); tud_usbtmc_transmit_dev_msg_data(buffer, buffer_tx_ix, buffer_tx_ix == buffer_len, false); } // MAV is cleared in the transfer complete callback. } break; // ... } }
So as long as the parser does not generate an answer (reply_len == 0), the USB state machine sends nothing. Only if the parser is finished gebnerating data (reply_len > 0) , case 4 will send the data.
The SCPI parser does not know how to write data. It's transport layer agnostic. We have to provide the write callbak. The lib will call it when it needs to write. The library doesn't write the answer in a single run. We have to deal with that.
Here's the function that knows how to make the USBTMC state machine write the data:
void setReply (const char *data, size_t len) { // attach replies to the buffer until the SCPI engine is finished. // no one should run away with the data, because only one core has focus // on scpi engine and USB state machine // TODO verify memcpy(reply + (reply_len * sizeof reply[0]), data, len); reply_len += len; }
And here's how we tell the parser how to use it:
size_t SCPI_Write(scpi_t * context, const char * data, size_t len) { (void) context; setReply(data, len); return len; }
Warning: concurrency |
Test the instrument
There are a few SCPI commands that you can test right away:
*IDN?
returns PICO-PI,LABSWITCH,0,01.00
DIGI:OUTP0 1
drives GIO22 high
DIGI:OUTP1 0
drives GIO14 low
DIGI:OUTP2 1
drives GIO15 high
SYST:ERR:COUNT?
returns number of parser errors
SYST:ERR?
returns last parser error on the stack
The DIGI:OUTP# commands are my work. All the rest (+ SCPI compliancy), you inherit for free when using the SCPI-parser lib. IEEE 488.2 compliancy you get from the parser and TinyUSB's USBTMC combined. Incredible that you can build a standards compliant instrument with understandable code and doable effort.
image: the switch mounted on shabaz ' Pico Eurocard. The SCPI command DIGI:OUTP0 {0|1} switches the LED on the card
VSCode project, sources and binaries
Here's the VSCode project archive. It contains the binary uf2 file. You can drag this on your Pico to program it without building from source.
Don't forget to add these two variables to your VSCode user environment: