Login

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.

LILYGO T-A7670E pin out diagram

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.

Checking that everything works

Let's open the online Espruino IDE, insert some code, and run it. We've got life! Right?

Espruino IDE running on the browser with the code editor and console output visible.

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);
espruino-uart-init.md

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'

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.

SIM7670E

Espruino driver for the SIMCom A7670E modem — AT commands, GNSS and HTTP.

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);
	});
espruino.main.ts

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);
}
espruino-report-location.ts
idle

🔒 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.

No position yet — showing the home region

Resources

Hardware & datasheets

Documentation

Code samples

Tags

  • ESP-32
  • Espruino
  • GPS
  • UART
  • Hardware
  • JavaScript