Running Go HTTP server on Teensy 4.1
Since its first release in 2009, the Go language has been closely associated with network programming. Unfortunately, until now the Embedded Go had no networking capabilities, mainly due to the strong dependency of the net package on the network capabilities of the underlying operating system. But that has changed.
Hardware
As a hardware platform, we use the Teensy 4.1 development board. The code presented here should also run on Teensy 4.0 without any modifications. The exception, unfortunately, is the titular HTTP server, which does not fit into its smaller Flash.
As a network interface, we use the ESP-01S module. It is easily available, cheap and sufficient for our needs.
You can use any other ESP8266 or ESP32 based development board provided you flash it with the ESP-AT firmware.
The ESP-AT firmware gives us what the operating system provides for a typical Go application. Instead of using system calls, our programs will talk to the ESP-AT firmware using an UART interface and a rather weird protocol based on Hayes AT commands. All these details will be invisible to us thanks to the espat package. Moreover, the espat/espnet package will provide our application the interface compatible with the standard net package, so any code that uses net.Conn or net.Listener can work with it.
First steps with Teensy and Go
If you have the required hardware and want to test the code presented below, you need to install the latest release of Embedded Go. Detailed instruction can be found on the Getting Started page.
Let’s create a folder, say teensyApp
, go into it and create the following two files:
main.go
package main
import (
"time"
"github.com/embeddedgo/imxrt/devboard/teensy4/board/leds"
)
func main() {
for {
leds.User.Toggle()
time.Sleep(time.Second)
}
}
build.cfg
GOTARGET = imxrt1060
GOMEM = 0x20200000:992K
GOTEXT = 0x60002000:7928K
The main.go
file contains a simple Go program that blinks the onboard LED. We’ll use it to test our development environment.
The build.cfg
file contains the build options for Teensy 4.1. They also work for Teensy 4.0 but to be consistent with the true size of its Flash you should set GOTEXT
to 0x60002000:1976K
.
Run the emgo mod init
and emgo mod tidy
commands to initialize our simple “project”.
$ emgo mod init teensyApp
go: creating new go.mod: module teensyApp
go: to add module requirements and sums:
go mod tidy
$ emgo mod tidy
go: downloading github.com/embeddedgo/imxrt v0.0.5
go: finding module for package github.com/embeddedgo/fs/termfs
go: found github.com/embeddedgo/fs/termfs in github.com/embeddedgo/fs v0.1.0
These two commands create the go.mod
and go.sum
files in our folder and install dependencies.
Now we can build our test program.
$ emgo build
Go compiler is silent, so there is no any message if everything went well. You should find the result of compilation in the teensyApp.elf
file.
Teensy Loader
Teensy Loader is a program that allow you to communicate with your Teensy board over USB. You can download it from the Teensy website.
Unfortunately, the Teensy Loader can’t load ELF files. Moreover, the internal bootloader of the Teensy’s MCU requires some additional information like Image Vector Table, Boot Data and FlexSPI Configuration that our ELF file doesn’t contain. They should be programmed into the first blocks of the Teensy’s Flash memory. As they’re hardware dependent but software independent they were arranged into one binary file and for our purposes we call them MBR (aka Master Boot Record).
Let’s download the mbr.img for our Teensy board.
We also need to modify our build.cfg
file or download a ready-made one.
GOTARGET = imxrt1060
GOMEM = 0x20200000:992K
GOTEXT = 0x60002000:7928K
GOINCBIN = mbr.img:0x60000000
GOOUT = hex
Now the emgo build
command will generate the HEX file as required by Teensy Loader.
$ emgo build
objcopy: skipping section '.shstrtab' (365 bytes)
Ignore the warning about the .shstrtab section. It’s here to remind me that we’re wasting 365 bytes in the generated image. It should disappear in the future release of Embedded Go.
Programming the board
Connect your Teensy board to your PC using a USB cable, run the Teensy Loader program, select the teensyApp.hex
file and enable the Automatic mode. Now, any press of the Teensy’s button will program it with the latest version of generated HEX file.
So press the button.
During programming, Teensy blinks its red LED. After reboot, our program should start blinking the yellow LED.
Getting started with the ESP-01S module
Once we have a ready and tested development environment, we can get to the point of this tutorial article.
Let’s connect our Wi-Fi module to the Teensy board, according to the table below.
ESP-01S | Teensy 4.x |
---|---|
RX | 14 |
TX | 15 |
GND | G |
3V3 | 3V |
Because of the limited power capabilities of the Teensy 3V pin (250 mA) and rather long connections our ESP-01S module requires a slight hardware modification.
The simplest way to meet the momentary power demand of ESP8266 is to store the energy in the properly sized decoupling capacitor. A 100 µF low-ESR electrolytic capacitor between GND and 3V3 should be just right.
Alternatively you can purchase a breadboard adapter that already includes a proper decoupling capacitor and assemble everything securely on the breadboard.
First program
Our first program will display a list of the names (SSIDs) of nearby wireless networks.
package main
import (
"fmt"
"time"
"github.com/embeddedgo/espat"
"github.com/embeddedgo/imxrt/hal/lpuart"
"github.com/embeddedgo/imxrt/hal/lpuart/lpuart2"
"github.com/embeddedgo/imxrt/devboard/teensy4/board/pins"
)
func fatalErr(err error) {
for err != nil {
fmt.Println("error:", err.Error())
time.Sleep(time.Second)
}
}
func main() {
// Initialize LPUART2 on pins 14 and 15.
u := lpuart2.Driver()
u.Setup(lpuart.Word8b, 115200)
u.UsePin(pins.P14, lpuart.TXD)
u.UsePin(pins.P15, lpuart.RXD)
u.EnableRx(512)
u.EnableTx()
// Give the user time to connect to the USB console.
time.Sleep(5 * time.Second)
fmt.Print("Initializing Wi-Fi module... ")
dev := espat.NewDevice("esp0", u, u)
fatalErr(dev.Init(true))
fmt.Println("OK")
// Print the ESP-AT version information.
s, err := dev.CmdStr("+GMR")
fatalErr(err)
fmt.Println(s)
// Periodically print information about available Wi-Fi networks.
for {
fmt.Println("Nearby wireless networks:")
s, err := dev.CmdStr("+CWLAP")
fatalErr(err)
fmt.Println(s)
time.Sleep(5 * time.Second)
}
}
The above code first issues the AT+GMR command to print on the USB console the information about the ESP-AT firmware. Then, it periodically issues the AT+CWLAP command to print information about the available WI-FI networks.
Let’s build it.
$ emgo build
main.go:7:2: missing go.sum entry for module providing package github.com/embeddedgo/espat (imported by teensyApp); to add:
go get teensyApp
$ emgo get teensyApp
go: downloading github.com/embeddedgo/espat v0.2.3
$ emgo build
objcopy: skipping section '.shstrtab' (365 bytes)
Now press the button on your Teensy to program it (I’m assuming your Teensy Loader is still running in the background).
If you quickly enough connect to the Teensy USB console, you should see the following output from this program:
$ picocom -q /dev/ttyACM0
Initializing Wi-Fi module... OK
AT version:2.2.0.0(s-b097cdf - ESP8266 - Jun 17 2021 12:58:29)
SDK version:v3.4-22-g967752e2
compile time(6800286):Jul 11 2021 11:09:32
Bin version:2.2.0(ESP8266_1MB)
Nearby wireless networks:
+CWLAP:(3,"IOT",-63,"76:4d:28:21:11:83",1,-1,-1,4,4,7,1)
+CWLAP:(3,"Home",-64,"74:4d:28:21:11:83",1,-1,-1,4,4,7,1)
+CWLAP:(3,"IOT",-78,"2e:c8:1b:11:22:bb",5,-1,-1,4,4,6,0)
+CWLAP:(3,"Home",-78,"2c:c8:1b:11:22:bb",5,-1,-1,4,4,6,0)
Nearby wireless networks:
+CWLAP:(3,"IOT",-65,"76:4d:28:21:11:83",1,-1,-1,4,4,7,1)
+CWLAP:(3,"Home",-67,"74:4d:28:21:11:83",1,-1,-1,4,4,7,1)
+CWLAP:(3,"IOT",-75,"2e:c8:1b:11:22:bb",5,-1,-1,4,4,6,0)
+CWLAP:(3,"Home",-82,"2c:c8:1b:11:22:bb",5,-1,-1,4,4,6,0)
As you can see, my Teensy sees two Wi-Fi networks provided by four access points.
USB console
If you connect a programmed Teensy board to your computer, it’s seen as a composite USB device that comprises two CDC-ACM (virtual Serial) devices (ports). The first one is used as the USB console, the second one can be used by your program in any way you want. This default USB configuration is performed when you import the devboard/teensy4/board/system package or any other package from the devboard/teensy4/board directory (in our case it’s the pins package).
If you want a custom USB configuration, you cannot import anything from devboard/teensy4/board
. Instead, you have to recreate all the things you need from this directory in your application, especially the system
package.
You can interact with the USB console using a terminal emulator program like PuTTY on Windows or picocom on Linux or Mac.
Connecting to the network
Our next program will ask for the network name (SSID) and password to connect to the Wi-Fi network.
package main
import (
"fmt"
"time"
"github.com/embeddedgo/espat"
"github.com/embeddedgo/imxrt/hal/lpuart"
"github.com/embeddedgo/imxrt/hal/lpuart/lpuart2"
"github.com/embeddedgo/imxrt/devboard/teensy4/board/pins"
)
func fatalErr(err error) {
for err != nil {
fmt.Println("error:", err.Error())
time.Sleep(time.Second)
}
}
func main() {
u := lpuart2.Driver()
u.Setup(lpuart.Word8b, 115200)
u.UsePin(pins.P14, lpuart.TXD)
u.UsePin(pins.P15, lpuart.RXD)
u.EnableRx(512)
u.EnableTx()
time.Sleep(5 * time.Second)
fmt.Print("Initializing ESP-AT module... ")
dev := espat.NewDevice("esp0", u, u)
fatalErr(dev.Init(true))
fmt.Println("OK")
var ssid, passwd string
fmt.Print("SSID: ")
fmt.Scanln(&ssid)
fmt.Print("Password: ")
fmt.Scanln(&passwd)
_, err := dev.Cmd("+CWMODE=1") // Wi-Fi station
fatalErr(err)
_, err = dev.Cmd("+CWJAP=", ssid, passwd)
fatalErr(err)
// Priodically print the connection state.
for {
s, err := dev.CmdStr("+CWSTATE?")
fatalErr(err)
fmt.Print(s)
time.Sleep(5 * time.Second)
}
}
By default, if the connection is successfully established, the SSID and password will be permanently saved by our Wi-Fi module. It’ll try to connect to the network after reboot or reconnect in case of the connection loss.
Interaction with this program may look like this:
Initializing ESP-AT module... OK
SSID: Home
Password: abc123xyz
+CWSTATE:2,"Home"
+CWSTATE:2,"Home"
+CWSTATE:2,"Home"
As the Wi-Fi credentials were saved by the ESP-AT firmware, we won’t set them in the following examples.
TCP client
It took a while, but we finally got there. Let’s write a simple TCP client program.
package main
import (
"fmt"
"io"
"net"
"os"
"time"
"github.com/embeddedgo/espat"
"github.com/embeddedgo/espat/espnet"
"github.com/embeddedgo/imxrt/hal/lpuart"
"github.com/embeddedgo/imxrt/hal/lpuart/lpuart2"
"github.com/embeddedgo/imxrt/devboard/teensy4/board/pins"
)
func logErr(err error) bool {
for err == nil {
return false
}
fmt.Println("error:", err.Error())
return true
}
func fatalErr(err error) {
for logErr(err) {
time.Sleep(time.Second)
}
}
func main() {
u := lpuart2.Driver()
u.Setup(lpuart.Word8b, 115200)
u.UsePin(pins.P14, lpuart.TXD)
u.UsePin(pins.P15, lpuart.RXD)
u.EnableRx(512)
u.EnableTx()
time.Sleep(5 * time.Second)
fmt.Print("Initializing Wi-Fi module... ")
dev := espat.NewDevice("esp0", u, u)
fatalErr(dev.Init(true))
fmt.Println("OK")
fmt.Println("Waiting for an IP address...")
for msg := range dev.Async() {
fatalErr(msg.Err)
fmt.Println(msg.Str)
if msg.Str == "WIFI GOT IP" {
break
}
}
var conn net.Conn
for {
if conn != nil {
conn.Close()
}
time.Sleep(2 * time.Second) // one request every two seconds
var err error
conn, err = espnet.DialDev(dev, "tcp", "httpbin.org:80")
if logErr(err) {
continue
}
fmt.Print("\nSending HTTP request... ")
_, err = io.WriteString(
conn,
"GET /ip HTTP/1.0\r\nHost: httpbin.org\r\n\r\n",
)
if logErr(err) {
continue
}
fmt.Print("OK\nReceiving response:\n\n")
_, err = io.Copy(os.Stdout, conn)
logErr(err)
}
}
If we ignore the Wi-Fi initialization and the waiting for an IP address, the only difference from a typical code of TCP client is the use of the espnet.DialDev function instead of net.Dial.
The output may look like this:
Initializing ESP-AT module... OK
Waiting for an IP address...
WIFI CONNECTED
WIFI GOT IP
Sending HTTP request... OK
Receiving response:
HTTP/1.1 200 OK
Date: Sun, 31 Dec 2023 21:58:14 GMT
Content-Type: application/json
Content-Length: 33
Connection: close
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
{
"origin": "37.109.142.142"
}
HTTP server
Logically, the next example should be a simple TCP server. However, this article is getting too long, so we will go straight to the titular HTTP server. You can find the code of simple TCP echo server on Github.
package main
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/embeddedgo/espat"
"github.com/embeddedgo/espat/espnet"
"github.com/embeddedgo/imxrt/hal/lpuart"
"github.com/embeddedgo/imxrt/hal/lpuart/lpuart2"
"github.com/embeddedgo/imxrt/devboard/teensy4/board/pins"
)
func fatalErr(err error) {
for err != nil {
fmt.Println("error:", err.Error())
time.Sleep(time.Second)
}
}
func main() {
u := lpuart2.Driver()
u.Setup(lpuart.Word8b, 115200)
u.UsePin(pins.P14, lpuart.TXD)
u.UsePin(pins.P15, lpuart.RXD)
u.EnableRx(512)
u.EnableTx()
time.Sleep(5 * time.Second)
fmt.Print("Initializing Wi-Fi module... ")
dev := espat.NewDevice("esp0", u, u)
fatalErr(dev.Init(true))
fmt.Println("OK")
fmt.Println("Waiting for an IP address...")
for msg := range dev.Async() {
fatalErr(msg.Err)
fmt.Println(msg.Str)
if msg.Str == "WIFI GOT IP" {
break
}
}
// Print IP address.
txt, err := dev.CmdStr("+CIPSTA?")
fatalErr(err)
fmt.Println(strings.ReplaceAll(txt, "+CIPSTA:", ""))
ls, err := espnet.ListenDev(dev, "tcp", ":80")
fatalErr(err)
fmt.Println("Listen on:", ls.Addr())
fatalErr(http.Serve(ls, http.HandlerFunc(handler)))
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Println(r.RemoteAddr, r.RequestURI)
fmt.Fprintln(w, "Go HTTP server")
fmt.Fprintln(w)
fmt.Fprintln(w, "Method: ", r.Method)
fmt.Fprintln(w, "URL: ", r.URL)
fmt.Fprintln(w, "Proto: ", r.Proto)
fmt.Fprintln(w, "Host: ", r.Host)
fmt.Fprintln(w, "RemoteAddr:", r.RemoteAddr)
fmt.Fprintln(w, "RequestURI:", r.RequestURI)
}
Similarly to the TCP client, the only difference from the typical Go program is the use of the espnet.ListenDev function instead of net.Listen.
If we run this program on Teensy it should print its IP address so we’ll know where to direct our web browser.
Initializing Wi-Fi module... OK
Waiting for an IP address...
WIFI CONNECTED
WIFI GOT IP
ip:"192.168.1.11"
gateway:"192.168.1.1"
netmask:"255.255.255.0"
Listen on: :80
If we enter the appropriate URL into our web browser (e.g. http://192.168.1.11/abc) then we should receive the response as below:
Go HTTP server
Method: GET
URL: /abc
Proto: HTTP/1.1
Host: 192.168.1.11
RemoteAddr: 192.168.1.5:46674
RequestURI: /abc
and something like this on the USB console:
192.168.1.5:46674 /abc
192.168.1.5:46674 /favicon.ico
That’s all folks.
Happy New Year!
Michał Derkacz