Use a USB-C to USB-A cable â a USB-C to USB-C cable didn't work at all.
ESP-32 Espruino GPS Tracker
A GPS tracker built on the ESP-32 and programmed in JavaScript with Espruino. It reads a fix from an onboard cellular/GNSS modem and reports its position to the live map further down the page.The hardware is a LILYGO T-A7670E â an ESP-32 board with a built-in SIMCom A7670E cellular and GNSS modem, so one board covers both positioning and the data connection.

Flashing and installing Espruino
Flashing Firmware
There are multiple ways of flashing the Espruino firmware onto the LILYGO board, but the easiest one is to use the Espruino flashing tool.
When trying to flash the firmware using the Espruino online flashing tool, I encountered the error failed to execute 'open' on 'SerialPort': Failed to open serial port.

The fix is missing serial-port permissions: find your device's USB identifier, then grant access to it.
Finding your USB identifier.
On Linux, you can list the available serial ports using the following command:
ls /dev/tty* Now we can set the permissions for the serial port using the following command, replacing <USERNAME> with your actual username and /dev/ttyACM0 with the correct USB identifier for your device.
sudo setfacl -m u:<USERNAME>:rw /dev/ttyACM0 Checking that everything works
Let's open the online Espruino IDE, insert some code, and run it. We've got life! Right?

Talking to the modem
To read GPS data, the ESP-32 talks to the A7670X modem over UART. Espruino has built-in UART support, which makes reading from the module straightforward.
// Software UART
const serial = new Serial();
serial.setup(9600, { rx: a_pin, tx: a_pin });We initialize the UART at the modem's baud rate, then power the modem and hold its reset pin low before reading.
const SERIAL_FREQ = 115200;
// * Pins
const PIN_MODEM_TX = D26; // Modem TX
const PIN_MODEM_RX = D27; // Modem RX
const PIN_MODEM_PWR = D4; // Modem power pin
const PIN_MODEM_RST = D5; // Modem reset pin
// * Modem Commands
const CMD_READ_MODEM_VERSION = 'AT+SIMCOMATI';
// Initialize UART for SIM7670E
Serial3.setup(SERIAL_FREQ, { tx: PIN_MODEM_TX, rx: PIN_MODEM_RX });
Serial3.on('data', (data) => {
console.log(`'${data}'`);
});
// Setup pins
PIN_MODEM_PWR.mode('output');
PIN_MODEM_RST.mode('output');
console.log(`Resetting modem`);
// * Power on SIM7670E (hold PWRKEY high for ~1s)
// Reset modem
PIN_MODEM_RST.reset();
// Power modem
PIN_MODEM_PWR.set();
setTimeout(() => {
console.log('Reading modem version');
Serial3.write(`${CMD_READ_MODEM_VERSION}\r\n`);
}, 1000);
We're not getting the full response yet â but it's enough to confirm the modem is talking back:
Modem says: 'AT+SIMCOMATI
Manufacturer: INCORPORATED
Model: A7670E-FASE' That cut-off reply isn't the modem's fault. On stock Espruino firmware (2v29 and earlier) the ESP32 port silently drops everything past ~64 bytes of a continuous UART burst â so any multi-line reply (AT+SIMCOMATI, AT+CMGL, GPS output, âŚ) arrives truncated, and having WiFi on makes it worse. The root cause is a flat-string append bug in the serial event assembly, tracked in espruino/Espruino#2718 â the fix is now merged upstream and verified on this very board. Until it ships in a release, the loss-free workaround on stock firmware is: renegotiate the modem to 19200 baud (AT+IPR=19200) and turn WiFi off with require('Wifi').stopAP().
Or skip the workaround: this project runs on a build with the fix applied, and you can download that firmware here (original ESP32 only, ~1 MB). It is stock Espruino 2v29 (MPL-2.0) plus only this three-line patch â unofficial and unsupported, so prefer the next official release once the fix lands. The archive contains bootloader, partition table, app image and flash instructions; if the board already runs Espruino you only need the app image:
esptool.py --chip esp32 --port /dev/ttyUSB0 --baud 460800 \
write_flash --flash_mode dio --flash_freq 40m 0x10000 espruino_esp32.bin
# sha256 of the archive:
# ce95cea03bddd34b664bc4a9b2963516f784042df974aa1bec67822007080f8eThe official web flasher restores stock firmware at any time.
Let's do something useful
Now that the modem responds, let's wrap the communication in a class to keep the rest of the code clean.
With the class in place, the program itself stays short: set the modem up, read a GPS fix, and POST it as JSON.
import { SIM7670E } from '@espruino/modules';
// * Pins
const PIN_MODEM_TX = D27; // Modem TX
const PIN_MODEM_RX = D26; // Modem RX
const PIN_MODEM_PWR = D4; // PWR
const PIN_MODEM_RST = D5; // Reset
const gpsModule = new SIM7670E(
Serial3,
{
PIN_MODEM_RX: PIN_MODEM_TX,
PIN_MODEM_TX: PIN_MODEM_RX,
PIN_MODEM_PWR,
PIN_MODEM_RST,
},
);
gpsModule
.readVersion()
.then((version) => {
console.log('Modem version:', version);
})
.catch((error) => {
console.error('Error reading modem version:', error);
});
// Read the current GPS position and POST it to a server as JSON.
gpsModule
.readGPSPositionOrThrow()
.then((position) => {
console.log('GPS position:', position);
return gpsModule.postJSON('http://example.com/positions', {
latitude: position.latitude,
longitude: position.longitude,
altitude: position.altitude,
timestamp: Date.now(),
});
})
.then((response) => {
console.log('Position posted:', response);
})
.catch((error) => {
console.error('Error reporting GPS position:', error);
});
Live position
Once the tracker has a fix, it POSTs its position â plus whatever quality info the GNSS receiver reported: precision (dilution values), altitude, speed, course and satellite count â to the backend, which keeps the last ten readings in memory and relays them to this page over an encrypted Server-Sent Events stream. The map shows the whole trail, newest fix fully opaque and older ones fading out; click any point for its details. The coordinates are AES-encrypted server-side, so the positions only become visible once you enter the shared decryption key below â the key is remembered in your browser and never sent to the server. Even with no fix the device still checks in, so the plaintext "last life-sign" timestamp above the map (visible to anyone) shows the tracker is alive.
The example below is what runs on the device: it reports as fast as it can on boot, then every 15 minutes. The reporting loop lives in onInit(), which Espruino calls automatically on every boot once the code is saved to flash â so after uploading it through the Espruino Web IDE and running save(), the tracker starts on its own with no PC attached.
import { SIM7670E } from '@espruino/modules';
// * Pins (LilyGo T-A7670E)
const PIN_MODEM_TX = D27; // Modem TX | 26
const PIN_MODEM_RX = D26; // Modem RX | 27
const PIN_MODEM_PWR = D4; // PWR | 4
const PIN_MODEM_RST = D5;
// Where each fix is POSTed. Kept OUT of this (public) source and read from the
// device's flash instead, so the URL lives only on your tracker. Set it once from
// the Espruino Web IDE console â it survives reprograms and power-offs:
//
// require('Storage').writeJSON('tracker-config', {
// locationUrl: 'https://example.com/api/location',
// });
//
// `readJSON(name, true)` returns undefined (rather than throwing) when it's unset.
const config = require('Storage').readJSON('tracker-config', true) || {};
const LOCATION_URL = config.locationUrl;
// Report every 10 minutes (the first goes out on boot).
const REPORT_INTERVAL_MS = 10 * 60 * 1000;
// On a cold power-up the A7670E needs ~10s before its UART answers AT commands;
// wait it out so the first poll doesn't fail.
const MODEM_COLD_BOOT_MS = 12 * 1000;
/**
* Espruino calls onInit() automatically on every boot, but only once the code is
* saved to flash (run save() in the Web IDE; type onInit() to start it before
* saving). Hardware setup lives here because save() restores JS state but not the
* hardware config, so it must be re-applied on each boot.
*/
function onInit(
) {
if (!LOCATION_URL) {
throw new Error(
"No locationUrl in flash. Set it once with: require('Storage').writeJSON('tracker-config', { locationUrl: 'https://âŚ' });",
);
}
// The tracker never uses WiFi, so drop the default softAP (saves power). On
// STOCK Espruino â¤2v29 this is also mandatory: the ESP32 port truncates >64-byte
// UART bursts (https://github.com/espruino/Espruino/issues/2718) and WiFi makes
// it far worse â stock firmware additionally needs the driver's baud rate set to
// 19200. This board runs a build with the fix applied, so 115200 + WiFi works.
require('Wifi').stopAP();
console.log('Started. Initializing modem ...');
const modem = new SIM7670E(
Serial3,
{
// TX/RX are crossed: the modem's TX is the ESP32's RX and vice versa.
PIN_MODEM_RX: PIN_MODEM_TX,
PIN_MODEM_TX: PIN_MODEM_RX,
PIN_MODEM_PWR,
PIN_MODEM_RST,
},
);
// Expose the modem on the global object so you can poke it live from the Web
// IDE console while it's running â e.g. test data without waiting for a cycle:
// modem.getText('http://neverssl.com').then(print).catch(print);
// modem.setApn('your.apn').then(() => modem.getText('http://neverssl.com')).then(print).catch(print);
global['modem'] = modem;
// One report cycle: read one GPS fix and POST it. With no fix we POST an empty
// body, which the backend records as a life-sign so the page shows the tracker
// is alive. Every step's error is swallowed, so one bad cycle never stops the
// schedule â the loop survives no fix, no network, or a failed POST forever.
const reportLocation = () => {
console.log('Acquiring GPS fix (up to 3 min; needs sky view) ...');
return modem
.readGPSPositionOrThrow()
.then((position) => {
console.log('GPS position:', position);
// Besides the coordinates, pass along whatever quality/motion info the
// fix carried (undefined fields simply drop out of the JSON): satellite
// count, altitude, speed, course and the DOP precision values.
return modem.postJSON(LOCATION_URL, {
lat: position.latitude,
lng: position.longitude,
sats: position.satellites,
alt: position.altitude,
speedKmh: position.speedKmh,
course: position.courseDeg,
pdop: position.pdop,
hdop: position.hdop,
vdop: position.vdop,
});
})
.catch((error) => {
// No fix is the EXPECTED outcome indoors â not an error. Send a
// position-less life-sign instead so the backend still sees a pulse.
console.log('No GPS fix (' + error.message + ') - sending life-sign only');
return modem.postJSON(LOCATION_URL, {});
})
.then((response) => {
// postJSON resolves with the HTTP response body ('' when the backend
// sends none); reaching here at all means the server answered 2xx.
console.log('Reported OK:', response.trim());
})
.catch((error) => {
// Reports genuinely failing (no network, server down/refusing) IS
// worth surfacing - but as one tidy line, not a stack of Errors.
console.log('Report failed: ' + ((error && error.message) || error));
});
};
// One self-scheduling loop, forever: the next cycle is queued only once the
// current one has fully settled (setTimeout, NOT setInterval), so cycles can
// never overlap or stack no matter how long a GPS attempt or a slow POST takes.
// reportLocation always resolves, so this runs unattended indefinitely â under
// open sky or parked in a garage for a week.
const runReportCycle = () =>
void reportLocation()
.then(() => {
setTimeout(runReportCycle, REPORT_INTERVAL_MS);
});
// After the cold-boot wait: start watching coverage, set the APN once, then run
// the report loop. onNetworkStatus polls in the background; the driver serialises
// all AT commands internally, so its polls and the GPS reads take turns on the
// shared link rather than colliding.
setTimeout(() => {
// Live coverage â no_service / gsm / edge / lte â the states a phone's
// status-bar icon shows. Handy when a report fails: was there a network?
modem.onNetworkStatus((bearer) => {
console.log('Connection:', bearer);
});
// Kick off the reporting cycle
runReportCycle();
}, MODEM_COLD_BOOT_MS);
}
đ Without a key, the map shows only approximate, scrambled positions that anyone can see. Enter the decryption key to reveal the tracker's precise trail.
Resources
- LILYGO T-A7670E
The ESP-32 + A7670E development board used for this build. - SIMCom A7670X
The cellular + GNSS module the ESP-32 talks to over UART.
- Espruino Web IDE
Browser-based IDE for flashing and running code on the board. - Espruino on Espressif â flashing guide
Steps and permissions for flashing the firmware over serial. - Espruino Serial.setup() reference
UART API used to read data from the GPS module.
- ReadBattery.ino
Reference example for reading the battery level. - GPS_NMEA_Output.ino
Reference example for reading NMEA GPS output.