Login

SIM7670E

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

/*
 * Driver for the SIMCom A7670E/SIM7670E cellular modem (LTE Cat-1 + GSM/EDGE, onboard GNSS)
 * on the LilyGo T-A7670 board, running under Espruino on an ESP32.
 *
 * The modem is spoken to over a UART with AT commands; power and reset are plain GPIOs
 * on the board (no AT command can power the modem back on).
 *
 * Espruino notes (https://www.espruino.com/Reference):
 * - UART data arrives asynchronously in arbitrary-sized chunks, so all replies go through
 *   a line-buffered receiver with a single-command-in-flight queue.
 * - Written for the ES2015-targeted bundler: no async/await (Espruino has Promises but
 *   no generators, which esbuild would lower async functions into).
 *
 * AT command references:
 * - SIMCom "A76XX Series AT Command Manual" (the A7670 and SIM7670 families overlap but
 *   are NOT identical — every reply format marked VERIFY below should be checked against
 *   real hardware output).
 * - 3GPP TS 27.007 for the standard commands (+CSQ, +COPS, +CMGS, ...).
 * - LilyGo T-A76XX examples for the power/reset sequencing:
 *   https://github.com/Xinyuan-LilyGO/LilyGO-T-A76XX
 */

export interface IGpsPosition {
	latitude: number;
	longitude: number;
	/** Satellites used across constellations (GPS+GLONASS+…), when reported. */
	satellites?: number;
	/** MSL altitude in metres, when reported. */
	altitude?: number;
	/** Speed over ground in km/h (the modem reports knots), when reported. */
	speedKmh?: number;
	/** Course over ground in degrees (0–360, true north), when reported. */
	courseDeg?: number;
	/** Dilution-of-precision values — lower is a tighter fix (≈1 is excellent). */
	pdop?: number;
	hdop?: number;
	vdop?: number;
}

export interface ISmsMessage {
	/** Storage index, used to re-read or delete the message. */
	index: number;
	/** Status, e.g. 'REC UNREAD', 'REC READ', 'STO SENT'. */
	status: string;
	/** Originating address (sender number). */
	sender: string;
	/** Service-centre timestamp as the modem reports it (e.g. '24/06/23,12:34:56+08'). */
	timestamp: string;
	/** The message body. */
	text: string;
}

export interface IBatteryStatus {
	/** Battery voltage in volts, e.g. 3.82. */
	volts: number;
	/** Battery voltage in millivolts, e.g. 3820. */
	millivolts: number;
}

export interface ISignalStrength {
	/** Raw RSSI index 0..31, or 99 when unknown/not detectable. */
	rssi: number;
	/** Raw bit error rate index 0..7, or 99 when unknown. */
	ber: number;
	/** Signal strength in dBm, or null when unknown (rssi === 99). */
	dbm: number | null;
}

export type TNetworkBearer = 'no_service' | 'gsm' | 'edge' | 'lte';

interface IExecOptions {
	timeoutMs?: number;

	/** Written to the UART once the modem prompts for a body ('> ' after AT+CMGS, 'DOWNLOAD' after AT+HTTPDATA) */
	payload?: string;

	/** Appended to the payload (CTRL_Z terminates an SMS body; AT+HTTPDATA needs no terminator) */
	payloadTerminator?: string;
}

interface IPendingCommand {
	command: string;
	timeoutMs: number;
	lines: string[];
	resolve: (lines: string[]) => void;
	reject: (error: Error) => void;
	timer: any;
	payload?: string;
	payloadTerminator?: string;
	promptHandled: boolean;
}

interface IUrcWaiter {
	prefix: string;
	resolve: (line: string) => void;
	reject: (error: Error) => void;
	timer: any;
}

interface IHttpReadSession {
	data: string;
	resolve: (data: string) => void;
	reject: (error: Error) => void;
	timer: any;
}

/** Factory-default rate the modem boots at (AT+IPR is not persisted across modem reboots) */
const BAUD_RATE_DEFAULT: number = 115_200;

/**
 * Rate the driver (re)negotiates once connected (AT+IPR).
 *
 * STOCK FIRMWARE (≤2v29) WARNING: Espruino's ESP32 port silently drops everything past
 * ~64 bytes of a continuous reply burst (flat-string append bug in the serial event
 * assembly — https://github.com/espruino/Espruino/issues/2718), so at 115200 every
 * multi-line reply (AT+SIMCOMATI/AT+CMGL/…) times out. On stock firmware set this to
 * 19200 AND keep WiFi off (`require('Wifi').stopAP()`) — the fastest hardware-verified
 * loss-free combination. This project's board runs 2v29 with the fix applied, where
 * 115200 with WiFi on is verified loss-free.
 */
const BAUD_RATE_RUNTIME: number = 115_200;

// Built at runtime instead of a '\x1a' literal: bundlers emit the escape as a RAW
// 0x1A control byte, which clipboards/editors silently mangle (observed becoming a
// space after copy-pasting a bundle into the Web IDE — breaking sendSMS).
const CTRL_Z: string = String.fromCharCode(26);

const DEFAULT_COMMAND_TIMEOUT_MS: number = 10_000;

/**
 * Consecutive command timeouts after which the modem is declared mute and the
 * init ladder (probe → RESET → PWRKEY) is re-armed. A mute modem is real:
 * observed on hardware 2026-07-10 (suspected GNSS power-on brown-out on
 * USB-only power) — without this the tracker stayed dead until manual recovery.
 */
const CONSECUTIVE_TIMEOUTS_BEFORE_REINIT: number = 5;
const MODEM_BOOT_PROBE_ATTEMPTS: number = 40;
const SMS_SEND_TIMEOUT_MS: number = 60_000;
const HTTP_ACTION_TIMEOUT_MS: number = 75_000;
const HTTP_READ_TIMEOUT_MS: number = 30_000;
const HTTP_MAX_READ_BYTES_DEFAULT: number = 4_096;
const NETWORK_POLL_INTERVAL_MS: number = 15_000;
const GNSS_POLL_INTERVAL_MS: number = 5_000;
const GNSS_FIX_TIMEOUT_MS: number = 180_000; // cold starts can take minutes

// ******************************************************************************
// *** Shared utilities
// ******************************************************************************
const delay = (
	ms: number,
): Promise<void> =>
	new Promise((resolve) => {
		setTimeout(resolve, ms);
	});

const noop = (): void => {
	// intentionally empty
};

/**
 * Returns everything after the first ':' of an AT reply line, trimmed
 * ('+CSQ: 24,99' -> '24,99')
 */
const afterColon = (
	line: string,
): string => {
	const colon: number = line.indexOf(':');

	return (colon === -1 ? line : line.slice(colon + 1)).trim();
};

/**
 * Returns the first line starting with the given prefix, or null
 */
const findLine = (
	lines: string[],
	prefix: string,
): string | null => {
	for (let i: number = 0; i < lines.length; i++) {
		const line: string = lines[i] || '';

		if (line.indexOf(prefix) === 0) {
			return line;
		}
	}

	return null;
};

/**
 * Splits an AT reply argument list on commas that are outside double quotes,
 * stripping the quotes ('1,"REC READ","+45...",,"24/07/01"' -> ['1', 'REC READ', '+45...', '', '24/07/01'])
 */
const splitQuoted = (
	value: string,
): string[] => {
	const fields: string[] = [];
	let current: string = '';
	let inQuotes: boolean = false;

	for (let i: number = 0; i < value.length; i++) {
		const char: string = value.charAt(i);

		if (char === '"') {
			inQuotes = !inQuotes;
		} else if ((char === ',') && !inQuotes) {
			fields.push(current);
			current = '';
		} else {
			current += char;
		}
	}
	fields.push(current);

	return fields;
};

/**
 * Parses '+CSQ: <rssi>,<ber>' (3GPP TS 27.007)
 * rssi: 0 -> -113 dBm ... 31 -> -51 dBm in 2 dBm steps; 99 -> unknown (dbm null)
 */
const parseSignalStrength = (
	line: string,
): ISignalStrength => {
	const fields: string[] = afterColon(line).split(',');
	const rssi: number = parseInt(fields[0] || '', 10);
	const ber: number = parseInt(fields[1] || '', 10);

	return {
		rssi: rssi,
		ber: ber,
		dbm: ((rssi >= 0) && (rssi <= 31)) ? (-113 + (2 * rssi)) : null,
	};
};

/**
 * Parses '+COPS: <mode>[,<format>,"<oper>"[,<AcT>]]' (3GPP TS 27.007)
 * No operator/AcT field -> not registered.
 * AcT: 0/1 = GSM, 3 = GSM w/EGPRS (EDGE), 7+ = E-UTRAN (LTE); the A7670E is Cat-1 + GSM/EDGE
 */
const parseNetworkBearer = (
	line: string,
): TNetworkBearer => {
	const fields: string[] = splitQuoted(afterColon(line));

	if (fields.length < 4) {
		return 'no_service';
	}

	const accessTechnology: number = parseInt(fields[3] || '', 10);

	if (isNaN(accessTechnology)) {
		return 'no_service';
	}

	if (accessTechnology >= 7) {
		return 'lte';
	}

	if (accessTechnology === 3) {
		return 'edge';
	}

	return 'gsm';
};

/**
 * Parses AT+CBC replies. VERIFY: the format differs across SIMCom families —
 * A76XX-style: '+CBC: 3.796V'
 * SIM7000-style: '+CBC: <bcs>,<bcl>,<millivolts>'
 */
const parseBatteryStatus = (
	line: string,
): IBatteryStatus => {
	const value: string = afterColon(line);

	if (value.indexOf(',') !== -1) {
		const fields: string[] = value.split(',');
		const millivolts: number = parseInt(fields[fields.length - 1] || '', 10);

		return {
			volts: millivolts / 1_000,
			millivolts: millivolts,
		};
	}

	const volts: number = parseFloat(value);

	return {
		volts: volts,
		millivolts: Math.round(volts * 1_000),
	};
};

/**
 * Converts one +CGNSSINFO coordinate field to decimal degrees.
 *
 * VERIFY on hardware: the A76XX manual documents NMEA ddmm.mmmmmm (zero-padded,
 * so latitude has 4 integer digits and longitude 5), but some SIM76XX firmwares
 * emit plain decimal degrees (at most 2/3 integer digits). The integer-digit
 * count disambiguates the two — except for NMEA fixes within 1 degree of the
 * equator/prime meridian if the module does NOT zero-pad.
 */
const nmeaToDecimalDegrees = (
	raw: string,
	direction: string,
): number | null => {
	const value: number = parseFloat(raw);

	if (isNaN(value)) {
		return null;
	}

	const dot: number = raw.indexOf('.');
	const integerDigits: number = (dot === -1) ? raw.length : dot;
	let decimal: number = value;

	if (integerDigits >= 4) {
		// NMEA ddmm.mmmmmm / dddmm.mmmmmm
		const degrees: number = Math.floor(value / 100);

		decimal = degrees + ((value - (degrees * 100)) / 60);
	}

	return ((direction === 'S') || (direction === 'W')) ? -decimal : decimal;
};

/**
 * Parses a '+CGNSSINFO:' line into a position, or null while there is no fix
 * (no fix looks like '+CGNSSINFO: ,,,,,,,,').
 *
 * VERIFY on hardware: the field layout differs between A76XX and SIM76XX firmwares
 * (varying numbers of satellite-count fields before the coordinates), so this
 * anchors on the N/S and E/W indicator fields instead of fixed positions.
 */
const parseGnssInfo = (
	line: string,
): IGpsPosition | null => {
	const fields: string[] = afterColon(line).split(',');
	let northSouthIndex: number = -1;

	for (let i: number = 0; i < fields.length; i++) {
		if ((fields[i] === 'N') || (fields[i] === 'S')) {
			northSouthIndex = i;
			break;
		}
	}

	if (northSouthIndex < 1) {
		return null;
	}

	const eastWest: string = fields[northSouthIndex + 2] || '';

	if ((eastWest !== 'E') && (eastWest !== 'W')) {
		return null;
	}

	const latitude: number | null = nmeaToDecimalDegrees(
		fields[northSouthIndex - 1] || '',
		fields[northSouthIndex] || '',
	);
	const longitude: number | null = nmeaToDecimalDegrees(
		fields[northSouthIndex + 1] || '',
		eastWest,
	);

	if ((latitude === null) || (longitude === null)) {
		return null;
	}

	// Null Island guard: a warming-up/degraded GNSS engine can report literal
	// zero coordinates with an otherwise plausible-looking reply. (0, 0) is open
	// ocean, so treat it as "no fix yet" rather than a position (observed on
	// hardware 2026-07-10: one such fix reached the live map).
	if ((Math.abs(latitude) < 0.000_1) && (Math.abs(longitude) < 0.000_1)) {
		return null;
	}

	const position: IGpsPosition = {
		latitude: latitude,
		longitude: longitude,
	};

	// The fields between <mode> and <lat> are per-constellation satellite-in-use
	// counts (GPS, GLONASS, GALILEO, BEIDOU — how many varies by firmware, hence
	// the anchor-based parsing). Sum whichever are present.
	let satellites: number = 0;
	let sawSatelliteField: boolean = false;

	for (let i: number = 1; i < northSouthIndex - 1; i++) {
		const count: number = parseInt(fields[i] || '', 10);

		if (!isNaN(count)) {
			satellites += count;
			sawSatelliteField = true;
		}
	}

	if (sawSatelliteField) {
		position.satellites = satellites;
	}

	// After <E/W> the A76XX manual documents: <date>,<UTC-time>,<alt>,<speed>,
	// <course>,<PDOP>,<HDOP>,<VDOP>. Speed is in knots.
	// VERIFY on hardware: confirmed present on this firmware up to <time>; the
	// trailing fields are parsed defensively (skipped when empty/non-numeric).
	const numberAt = (offset: number): number | null => {
		const value: number = parseFloat(fields[northSouthIndex + offset] || '');

		return isNaN(value) ? null : value;
	};

	const altitude: number | null = numberAt(5);
	const speedKnots: number | null = numberAt(6);
	const course: number | null = numberAt(7);
	const pdop: number | null = numberAt(8);
	const hdop: number | null = numberAt(9);
	const vdop: number | null = numberAt(10);

	if (altitude !== null) {
		position.altitude = altitude;
	}

	if (speedKnots !== null) {
		// knots → km/h
		position.speedKmh = Math.round(speedKnots * 1.852 * 10) / 10;
	}

	if (course !== null) {
		position.courseDeg = course;
	}

	if (pdop !== null) {
		position.pdop = pdop;
	}

	if (hdop !== null) {
		position.hdop = hdop;
	}

	if (vdop !== null) {
		position.vdop = vdop;
	}

	return position;
};

/**
 * Parses the lines of an AT+CMGL="ALL" reply into messages. Header lines look like
 * '+CMGL: <index>,"<stat>","<oa>",<alpha>,"<scts>"' and are each followed by the
 * message text (which may span multiple lines).
 */
const parseSmsList = (
	lines: string[],
): ISmsMessage[] => {
	const messages: ISmsMessage[] = [];
	let current: ISmsMessage | null = null;

	for (let i: number = 0; i < lines.length; i++) {
		const line: string = lines[i] || '';

		if (line.indexOf('+CMGL:') === 0) {
			const fields: string[] = splitQuoted(afterColon(line));

			current = {
				index: parseInt(fields[0] || '', 10),
				status: fields[1] || '',
				sender: fields[2] || '',
				timestamp: fields[4] || '',
				text: '',
			};
			messages.push(current);
		} else if (current !== null) {
			current.text += (current.text.length > 0 ? '\n' : '') + line;
		}
	}

	return messages;
};

// ******************************************************************************
// *** Driver
// ******************************************************************************
export class SIM7670E {

	private serial: Serial;
	private configuration: {
		PIN_MODEM_TX: Pin;
		PIN_MODEM_RX: Pin;
		PIN_MODEM_PWR: Pin;
		PIN_MODEM_RST: Pin;
	};

	/**
	 * Resolves once the modem answers AT and echo is disabled; every command waits on it.
	 * null until initialization starts — a failed initialization resets it to null so
	 * the next command retries instead of failing forever.
	 */
	private ready: Promise<void> | null = null;

	/** True once the init ladder has completed (false while it runs or after a re-arm). */
	private initialized: boolean = false;

	/** Command timeouts since the modem last replied (see CONSECUTIVE_TIMEOUTS_BEFORE_REINIT). */
	private consecutiveTimeouts: number = 0;

	// UART command engine
	private rxBuffer: string = '';
	private queue: IPendingCommand[] = [];
	private pending: IPendingCommand | null = null;
	private urcWaiters: IUrcWaiter[] = [];
	private httpRead: IHttpReadSession | null = null;
	private rawBytesRemaining: number = 0;

	/**
	 * Serializes HTTP transactions. The '+HTTPACTION:' report is an uncorrelated URC,
	 * so two in-flight requests would cross their results (observed on hardware:
	 * a background loop's 403 got attributed to a manual neverssl.com GET).
	 */
	private httpChain: Promise<unknown> = Promise.resolve();

	// Cached modem state
	private smsTextModeEnabled: boolean = false;
	private gnssPowered: boolean = false;

	// Watchers
	private networkWatchers: ((bearer: TNetworkBearer) => void)[] = [];
	private networkPollTimer: any = null;
	private networkPollBusy: boolean = false;
	private lastBearer: TNetworkBearer | null = null;
	private gpsWatchers: ((position: IGpsPosition) => void)[] = [];
	private gpsPollTimer: any = null;
	private gpsPollBusy: boolean = false;

	constructor(
		serial: Serial,
		configuration: {
			PIN_MODEM_TX: Pin;
			PIN_MODEM_RX: Pin;
			PIN_MODEM_PWR: Pin;
			PIN_MODEM_RST: Pin;
		},
	) {
		this.serial = serial;
		this.configuration = configuration;

		this.serial.setup(BAUD_RATE_DEFAULT, {
			tx: configuration.PIN_MODEM_TX,
			rx: configuration.PIN_MODEM_RX,
		});
		this.serial.on('data', (chunk: string): void => {
			this.onUartData(chunk);
		});

		this.ensureReady().catch(noop); // kick initialization off; a failure is retried by the next command
	}

	// ******************************************************************************
	// *** Modem / status
	// ******************************************************************************
	/**
	 * Reads the modem identity/firmware string (AT+SIMCOMATI)
	 *
	 * @returns { Promise<string> } manufacturer/model/revision/IMEI, one per line
	 */
	public readVersion(): Promise<string> {
		return this.run('AT+SIMCOMATI')
			.then((lines: string[]): string => lines.join('\n'));
	}

	/**
	 * Reads the current signal strength (AT+CSQ)
	 *
	 * @returns { Promise<ISignalStrength> } rssi/ber as reported; dbm converted per 3GPP TS 27.007 (null when unknown)
	 */
	public readSignalStrength(): Promise<ISignalStrength> {
		return this.run('AT+CSQ')
			.then((lines: string[]): ISignalStrength => {
				const line: string | null = findLine(lines, '+CSQ:');

				if (line === null) {
					throw new Error('AT+CSQ: unexpected reply');
				}

				return parseSignalStrength(line);
			});
	}

	/**
	 * Reads which radio access technology the modem is currently registered on (AT+COPS?)
	 *
	 * @returns { Promise<TNetworkBearer> } the "signal icon" bearer
	 */
	public readNetworkBearer(): Promise<TNetworkBearer> {
		return this.run('AT+COPS?')
			.then((lines: string[]): TNetworkBearer => {
				const line: string | null = findLine(lines, '+COPS:');

				return (line === null) ? 'no_service' : parseNetworkBearer(line);
			});
	}

	/**
	 * Notifies on changes to the network bearer (polled via AT+COPS?).
	 * The callback also fires once with the initial bearer.
	 *
	 * @returns { () => void } unsubscribe function
	 */
	public onNetworkStatus(
		callback: (bearer: TNetworkBearer) => void,
	): () => void {
		this.networkWatchers.push(callback);

		if (this.networkWatchers.length === 1) {
			this.pollNetworkBearer();
			this.networkPollTimer = setInterval((): void => {
				this.pollNetworkBearer();
			}, NETWORK_POLL_INTERVAL_MS);
		}

		return (): void => {
			const index: number = this.networkWatchers.indexOf(callback);

			if (index !== -1) {
				this.networkWatchers.splice(index, 1);
			}

			if ((this.networkWatchers.length === 0) && (this.networkPollTimer !== null)) {
				clearInterval(this.networkPollTimer);
				this.networkPollTimer = null;
				this.lastBearer = null;
			}
		};
	}

	/**
	 * Reads the battery/supply voltage (AT+CBC)
	 *
	 * @returns { Promise<IBatteryStatus> }
	 */
	public readBatteryStatus(): Promise<IBatteryStatus> {
		return this.run('AT+CBC')
			.then((lines: string[]): IBatteryStatus => {
				const line: string | null = findLine(lines, '+CBC:');

				if (line === null) {
					throw new Error('AT+CBC: unexpected reply');
				}

				return parseBatteryStatus(line);
			});
	}

	// ******************************************************************************
	// *** Power
	// ******************************************************************************
	/**
	 * Cleanly shuts the modem down (AT+CPOF). Power-on afterwards requires the
	 * PWRKEY GPIO sequence (re-instantiate the driver).
	 *
	 * @returns { Promise<string> }
	 */
	public powerOff(): Promise<string> {
		return this.run('AT+CPOF')
			.then((): string => {
				// The modem is gone; invalidate cached state
				this.smsTextModeEnabled = false;
				this.gnssPowered = false;

				return 'OK';
			});
	}

	/**
	 * Enables/disables the modem's low-power UART sleep (AT+CSCLK).
	 * Note: whether the modem actually sleeps (and wakes) depends on the DTR
	 * line being wired through on the board.
	 *
	 * @returns { Promise<string> }
	 */
	public setSleepMode(
		enabled: boolean,
	): Promise<string> {
		return this.run('AT+CSCLK=' + (enabled ? '1' : '0'))
			.then((): string => 'OK');
	}

	// ******************************************************************************
	// *** GNSS / GPS
	// ******************************************************************************
	/**
	 * Powers the GNSS receiver on (AT+CGNSSPWR) and polls AT+CGNSSINFO until a fix
	 * is obtained, or throws after the fix window elapses (cold starts can take minutes).
	 * The receiver is powered back down unless onGPSPosition() subscribers remain.
	 *
	 * @returns { Promise<IGpsPosition> }
	 */
	public readGPSPositionOrThrow(): Promise<IGpsPosition> {
		const attempts: number = Math.ceil(GNSS_FIX_TIMEOUT_MS / GNSS_POLL_INTERVAL_MS);

		return this.setGnssPower(true)
			.then((): Promise<IGpsPosition> => this.pollGnssFix(attempts))
			.then((position: IGpsPosition): IGpsPosition => {
				this.powerDownGnssIfUnused();

				return position;
			})
			.catch((error: Error): never => {
				this.powerDownGnssIfUnused();

				throw error;
			});
	}

	/**
	 * Notifies with position updates (AT+CGNSSINFO polled while subscribed).
	 * The GNSS receiver is powered on with the first subscriber and off with the last.
	 *
	 * @returns { () => void } unsubscribe function
	 */
	public onGPSPosition(
		callback: (position: IGpsPosition) => void,
	): () => void {
		this.gpsWatchers.push(callback);

		if (this.gpsWatchers.length === 1) {
			this.setGnssPower(true).catch(noop);
			this.gpsPollTimer = setInterval((): void => {
				this.pollGpsWatchers();
			}, GNSS_POLL_INTERVAL_MS);
		}

		return (): void => {
			const index: number = this.gpsWatchers.indexOf(callback);

			if (index !== -1) {
				this.gpsWatchers.splice(index, 1);
			}

			if ((this.gpsWatchers.length === 0) && (this.gpsPollTimer !== null)) {
				clearInterval(this.gpsPollTimer);
				this.gpsPollTimer = null;
				this.setGnssPower(false).catch(noop);
			}
		};
	}

	// ******************************************************************************
	// *** SMS
	// ******************************************************************************
	/**
	 * Sends a text SMS (AT+CMGF=1 text mode, AT+CMGS).
	 * The body is written after the modem's '> ' prompt and terminated with Ctrl-Z.
	 *
	 * @returns { Promise<string> } the '+CMGS: <reference>' line, or 'OK'
	 */
	public sendSMS(
		recipient: string,
		message: string,
	): Promise<string> {
		return this.ensureSmsTextMode()
			.then((): Promise<string[]> =>
				this.run('AT+CMGS="' + recipient + '"', {
					timeoutMs: SMS_SEND_TIMEOUT_MS,
					payload: message,
					payloadTerminator: CTRL_Z,
				})
			)
			.then((lines: string[]): string => findLine(lines, '+CMGS:') || 'OK');
	}

	/**
	 * Lists all stored messages (AT+CMGL="ALL")
	 *
	 * @returns { Promise<ISmsMessage[]> }
	 */
	public listSMS(): Promise<ISmsMessage[]> {
		return this.ensureSmsTextMode()
			.then((): Promise<string[]> =>
				this.run('AT+CMGL="ALL"', {
					timeoutMs: 30_000,
				})
			)
			.then(parseSmsList);
	}

	/**
	 * Reads one stored message by index (AT+CMGR)
	 *
	 * @returns { Promise<ISmsMessage | null> } null if the slot is empty
	 */
	public readSMS(
		index: number,
	): Promise<ISmsMessage | null> {
		return this.ensureSmsTextMode()
			.then((): Promise<string[]> => this.run('AT+CMGR=' + index))
			.then((lines: string[]): ISmsMessage | null => {
				const header: string | null = findLine(lines, '+CMGR:');

				if (header === null) {
					// Empty slot: the modem replies with a bare OK
					return null;
				}

				// '+CMGR: "<stat>","<oa>",<alpha>,"<scts>"' followed by the message text
				const fields: string[] = splitQuoted(afterColon(header));
				const headerIndex: number = lines.indexOf(header);

				return {
					index: index,
					status: fields[0] || '',
					sender: fields[1] || '',
					timestamp: fields[3] || '',
					text: lines.slice(headerIndex + 1).join('\n'),
				};
			});
	}

	/**
	 * Deletes a stored message by index (AT+CMGD)
	 *
	 * @returns { Promise<string> }
	 */
	public deleteSMS(
		index: number,
	): Promise<string> {
		return this.run('AT+CMGD=' + index)
			.then((): string => 'OK');
	}

	// ******************************************************************************
	// *** Data / HTTP
	// ******************************************************************************
	/**
	 * Sets the data APN on PDP context 1 (AT+CGDCONT)
	 *
	 * @returns { Promise<string> }
	 */
	public setApn(
		apn: string,
	): Promise<string> {
		return this.run('AT+CGDCONT=1,"IP","' + apn + '"')
			.then((): string => 'OK');
	}

	/**
	 * HTTP(S) GET returning the response body as text
	 * (AT+HTTPINIT/AT+HTTPPARA/AT+HTTPACTION/AT+HTTPREAD/AT+HTTPTERM)
	 *
	 * @param { string } url http:// or https://
	 * @param { number } maxBytes caps how much of the body is read (Espruino RAM is scarce)
	 *
	 * @returns { Promise<string> }
	 */
	public getText(
		url: string,
		maxBytes?: number,
	): Promise<string> {
		return this.httpRequest(
			url,
			0,
			null,
			(maxBytes === undefined) ? HTTP_MAX_READ_BYTES_DEFAULT : maxBytes,
		);
	}

	/**
	 * HTTP(S) POST of a JSON payload, returning the response body as text
	 * (same HTTP command family, body uploaded via AT+HTTPDATA)
	 *
	 * @returns { Promise<string> }
	 */
	public postJSON(
		url: string,
		payload: unknown,
	): Promise<string> {
		return this.httpRequest(url, 1, JSON.stringify(payload), HTTP_MAX_READ_BYTES_DEFAULT);
	}

	// ******************************************************************************
	// *** Initialization (private)
	// ******************************************************************************
	private ensureReady(): Promise<void> {
		if (this.ready === null) {
			this.initialized = false;
			this.consecutiveTimeouts = 0;
			this.ready = this.initialize()
				.then((): void => {
					this.initialized = true;
				})
				.catch((error: Error): never => {
					// Allow the next command to retry initialization instead of failing forever
					this.ready = null;

					throw error;
				});
		}

		return this.ready;
	}

	/**
	 * Brings the modem up regardless of its prior state (LilyGo T-A76XX sequencing —
	 * the board buffers PWRKEY and RESET through transistors, so both GPIOs are
	 * active-high pulses):
	 * 1. If the modem already answers AT, leave it alone — a PWRKEY press would
	 *    power a RUNNING modem off (verified on hardware).
	 * 2. Otherwise pulse RESET: reboots a hung-but-powered modem, no effect when off.
	 * 3. Still silent: press PWRKEY to power it on, then wait out the ~10 s boot.
	 * Finally disables echo and enables verbose errors.
	 */
	private initialize(): Promise<void> {
		digitalWrite(this.configuration.PIN_MODEM_RST, 0);
		digitalWrite(this.configuration.PIN_MODEM_PWR, 0);

		// Fastest paths first: a modem this driver already configured (still at the
		// runtime baud rate), then a running modem at the factory default rate
		return this.probeAt(BAUD_RATE_RUNTIME, 2)
			.catch((): Promise<void> => this.probeAt(BAUD_RATE_DEFAULT, 2))
			.catch((): Promise<void> =>
				this.pulsePin(this.configuration.PIN_MODEM_RST, 2_600)
					.then((): Promise<void> => this.probeAt(BAUD_RATE_DEFAULT, 8))
			)
			.catch((): Promise<void> =>
				this.pulsePin(this.configuration.PIN_MODEM_PWR, 1_200)
					.then((): Promise<void> =>
						this.probeAt(BAUD_RATE_DEFAULT, MODEM_BOOT_PROBE_ATTEMPTS)
					)
			)
			.then((): Promise<void> => this.lowerBaudRate())
			.then((): Promise<string[]> => this.exec('ATE0')) // echo off, so replies are parseable
			.then((): Promise<string[]> => this.exec('AT+CMEE=2')) // verbose error reports
			.then(noop);
	}

	/** Re-opens the UART at the given rate and probes for the modem */
	private probeAt(
		baudRate: number,
		attempts: number,
	): Promise<void> {
		this.serial.setup(baudRate, {
			tx: this.configuration.PIN_MODEM_TX,
			rx: this.configuration.PIN_MODEM_RX,
		});
		this.rxBuffer = '';

		return this.probeModem(attempts);
	}

	/** (Re)negotiates the runtime baud rate (see BAUD_RATE_RUNTIME) */
	private lowerBaudRate(): Promise<void> {
		return this.exec('AT+IPR=' + BAUD_RATE_RUNTIME)
			.then((): Promise<void> => {
				this.serial.setup(BAUD_RATE_RUNTIME, {
					tx: this.configuration.PIN_MODEM_TX,
					rx: this.configuration.PIN_MODEM_RX,
				});
				this.rxBuffer = '';

				return delay(100);
			})
			.then((): Promise<void> => this.probeModem(3));
	}

	/** Drives the pin high for the given time, resolving 100 ms after release */
	private pulsePin(
		pin: Pin,
		milliseconds: number,
	): Promise<void> {
		digitalWrite(pin, 1);

		return delay(milliseconds)
			.then((): Promise<void> => {
				digitalWrite(pin, 0);

				return delay(100);
			});
	}

	private probeModem(
		attemptsLeft: number,
	): Promise<void> {
		return this.exec('AT', {
			timeoutMs: 1_000,
		})
			.then(noop)
			.catch((error: Error): Promise<void> => {
				if (attemptsLeft <= 1) {
					throw new Error(
						'SIM7670E: modem did not respond to AT (' + error.message + ')',
					);
				}

				return this.probeModem(attemptsLeft - 1);
			});
	}

	// ******************************************************************************
	// *** UART command engine (private)
	// ******************************************************************************
	/**
	 * Queues an AT command once the modem is initialized. Resolves with the
	 * information lines of the reply (final OK/ERROR stripped).
	 */
	private run(
		command: string,
		options?: IExecOptions,
	): Promise<string[]> {
		return this.ensureReady()
			.then((): Promise<string[]> => this.exec(command, options));
	}

	/**
	 * Queues an AT command without waiting for initialization (used by the
	 * initialization sequence itself)
	 */
	private exec(
		command: string,
		options?: IExecOptions,
	): Promise<string[]> {
		return new Promise((resolve, reject) => {
			this.queue.push({
				command: command,
				timeoutMs: (options && options.timeoutMs !== undefined)
					? options.timeoutMs
					: DEFAULT_COMMAND_TIMEOUT_MS,
				lines: [],
				resolve: resolve,
				reject: reject,
				timer: null,
				payload: options ? options.payload : undefined,
				payloadTerminator: options ? options.payloadTerminator : undefined,
				promptHandled: false,
			});
			this.sendNext();
		});
	}

	private sendNext(): void {
		if ((this.pending !== null) || (this.queue.length === 0)) {
			return;
		}

		const next: IPendingCommand | undefined = this.queue[0];

		if (next === undefined) {
			return;
		}

		this.queue.splice(0, 1);
		this.pending = next;
		this.armPendingTimer(next);
		this.serial.write(next.command + '\r');
	}

	/**
	 * (Re)arms the pending command's timeout. Called on every received reply line,
	 * making timeoutMs an INACTIVITY timeout — long multi-line dumps (AT+CMGL of a
	 * full inbox) stay alive while lines keep arriving, true silence still fails.
	 */
	private armPendingTimer(
		pending: IPendingCommand,
	): void {
		if (pending.timer !== null) {
			clearTimeout(pending.timer);
		}
		pending.timer = setTimeout((): void => {
			if (this.pending === pending) {
				this.pending = null;
			}

			// Enough consecutive timeouts = the modem is mute (crashed/browned-out/off).
			// Re-arm the init ladder so the NEXT command re-probes and, if needed,
			// pulses RESET/PWRKEY — instead of every command failing forever.
			// Only from the initialized state: the ladder's own probes are EXPECTED
			// to time out on a dead modem and must not re-trigger this.
			this.consecutiveTimeouts++;

			if (this.initialized && (this.consecutiveTimeouts >= CONSECUTIVE_TIMEOUTS_BEFORE_REINIT)) {
				this.initialized = false;
				this.ready = null;
				// The modem may come back rebooted; invalidate cached state
				this.smsTextModeEnabled = false;
				this.gnssPowered = false;
			}

			pending.reject(new Error(pending.command + ': timed out'));
			this.sendNext();
		}, pending.timeoutMs);
	}

	private finishPending(
		error: Error | null,
	): void {
		const pending: IPendingCommand | null = this.pending;

		if (pending === null) {
			return;
		}

		// Any reply — OK or an error report — proves the modem is talking
		this.consecutiveTimeouts = 0;

		this.pending = null;
		clearTimeout(pending.timer);

		if (error === null) {
			pending.resolve(pending.lines);
		} else {
			pending.reject(error);
		}

		this.sendNext();
	}

	/**
	 * Registers a one-shot waiter for an unsolicited result code
	 * (e.g. the '+HTTPACTION:' report that arrives long after its OK)
	 */
	private waitForUrc(
		prefix: string,
		timeoutMs: number,
	): Promise<string> {
		return new Promise((resolve, reject) => {
			const waiter: IUrcWaiter = {
				prefix: prefix,
				resolve: resolve,
				reject: reject,
				timer: null,
			};

			waiter.timer = setTimeout((): void => {
				const index: number = this.urcWaiters.indexOf(waiter);

				if (index !== -1) {
					this.urcWaiters.splice(index, 1);
				}
				reject(new Error(prefix + ' not received within ' + timeoutMs + ' ms'));
			}, timeoutMs);
			this.urcWaiters.push(waiter);
		});
	}

	private onUartData(
		chunk: string,
	): void {
		this.rxBuffer += chunk;
		this.drainRxBuffer();
	}

	private drainRxBuffer(): void {
		for (;;) {
			// Raw HTTP body bytes (framed by '+HTTPREAD: <len>' headers) bypass line parsing,
			// as the body may itself contain newlines
			if ((this.rawBytesRemaining > 0) && (this.httpRead !== null)) {
				const take: number = Math.min(this.rawBytesRemaining, this.rxBuffer.length);

				this.httpRead.data += this.rxBuffer.slice(0, take);
				this.rxBuffer = this.rxBuffer.slice(take);
				this.rawBytesRemaining -= take;

				if (this.rawBytesRemaining > 0) {
					return;
				}
				continue;
			}

			const newline: number = this.rxBuffer.indexOf('\n');

			if (newline === -1) {
				// The SMS body prompt ('> ' after AT+CMGS) arrives without a newline
				const pending: IPendingCommand | null = this.pending;

				if (
					(pending !== null)
					&& (pending.payload !== undefined)
					&& !pending.promptHandled
					&& (this.rxBuffer.indexOf('>') !== -1)
				) {
					this.rxBuffer = '';
					this.writePayload(pending);
				}

				return;
			}

			const line: string = this.rxBuffer.slice(0, newline).trim();

			this.rxBuffer = this.rxBuffer.slice(newline + 1);

			if (line.length > 0) {
				this.onLine(line);
			}
		}
	}

	private onLine(
		line: string,
	): void {
		// Streamed AT+HTTPREAD data: '+HTTPREAD: <len>' frames <len> raw bytes,
		// '+HTTPREAD: 0' ends the stream. (Some firmwares report '+HTTPREAD: DATA,<len>'.)
		if ((this.httpRead !== null) && (line.indexOf('+HTTPREAD:') === 0)) {
			const parts: string[] = afterColon(line).split(',');
			const length: number = parseInt(parts[parts.length - 1], 10);

			if (length > 0) {
				this.rawBytesRemaining = length;
			} else {
				const session: IHttpReadSession = this.httpRead;

				this.httpRead = null;
				clearTimeout(session.timer);
				session.resolve(session.data);
			}

			return;
		}

		// One-shot URC waiters
		for (let i: number = 0; i < this.urcWaiters.length; i++) {
			const waiter: IUrcWaiter | undefined = this.urcWaiters[i];

			if ((waiter !== undefined) && (line.indexOf(waiter.prefix) === 0)) {
				this.urcWaiters.splice(i, 1);
				clearTimeout(waiter.timer);
				waiter.resolve(line);

				return;
			}
		}

		const pending: IPendingCommand | null = this.pending;

		if (pending === null) {
			// Unsolicited noise (boot banners, +CPIN: READY, ...) — ignored
			return;
		}

		if (line === pending.command) {
			// Command echo (until ATE0 takes effect)
			return;
		}

		if (line === 'OK') {
			this.finishPending(null);

			return;
		}

		if (
			(line === 'ERROR')
			|| (line.indexOf('+CME ERROR') === 0)
			|| (line.indexOf('+CMS ERROR') === 0)
		) {
			this.finishPending(new Error(pending.command + ': ' + line));

			return;
		}

		if ((line === 'DOWNLOAD') && (pending.payload !== undefined) && !pending.promptHandled) {
			// AT+HTTPDATA prompt: the modem now expects exactly the announced byte count
			this.writePayload(pending);

			return;
		}

		pending.lines.push(line);
		this.armPendingTimer(pending); // data is flowing — extend the inactivity window
	}

	private writePayload(
		pending: IPendingCommand,
	): void {
		pending.promptHandled = true;
		this.serial.write(pending.payload + (pending.payloadTerminator || ''));
	}

	// ******************************************************************************
	// *** Watcher/GNSS helpers (private)
	// ******************************************************************************
	private pollNetworkBearer(): void {
		// One poll in flight at a time (see pollGpsWatchers)
		if (this.networkPollBusy) {
			return;
		}

		this.networkPollBusy = true;
		this.readNetworkBearer()
			.then((bearer: TNetworkBearer): void => {
				this.networkPollBusy = false;

				if (bearer === this.lastBearer) {
					return;
				}

				this.lastBearer = bearer;
				this.networkWatchers.forEach((watcher: (bearer: TNetworkBearer) => void): void => {
					watcher(bearer);
				});
			})
			.catch((): void => {
				// Transient failure — retried on the next poll
				this.networkPollBusy = false;
			});
	}

	private setGnssPower(
		on: boolean,
	): Promise<void> {
		if (this.gnssPowered === on) {
			return Promise.resolve();
		}

		// The '+CGNSSPWR: READY!' URC follows later; the fix polling tolerates the warm-up
		return this.run('AT+CGNSSPWR=' + (on ? '1' : '0'), {
			timeoutMs: 15_000,
		})
			.then((): void => {
				this.gnssPowered = on;
			});
	}

	private powerDownGnssIfUnused(): void {
		if (this.gpsWatchers.length === 0) {
			this.setGnssPower(false).catch(noop);
		}
	}

	private pollGnssFix(
		attemptsLeft: number,
	): Promise<IGpsPosition> {
		return this.run('AT+CGNSSINFO')
			// The engine replies ERROR while still warming up — treat it as "no fix yet"
			.catch((): string[] => [])
			.then((lines: string[]): IGpsPosition | Promise<IGpsPosition> => {
				const line: string | null = findLine(lines, '+CGNSSINFO:');
				const position: IGpsPosition | null = (line === null) ? null : parseGnssInfo(line);

				if (position !== null) {
					return position;
				}

				if (attemptsLeft <= 1) {
					throw new Error(
						'SIM7670E: no GNSS fix within ' + (GNSS_FIX_TIMEOUT_MS / 1_000) + ' s',
					);
				}

				return delay(GNSS_POLL_INTERVAL_MS)
					.then((): Promise<IGpsPosition> => this.pollGnssFix(attemptsLeft - 1));
			});
	}

	private pollGpsWatchers(): void {
		// One poll in flight at a time: a stalled/mute modem must not stack a new
		// command every tick (observed on hardware: the queue grew unboundedly and
		// exhausted the interpreter's memory in ~10 min).
		if (this.gpsPollBusy) {
			return;
		}

		this.gpsPollBusy = true;
		this.run('AT+CGNSSINFO')
			.then((lines: string[]): void => {
				this.gpsPollBusy = false;

				const line: string | null = findLine(lines, '+CGNSSINFO:');
				const position: IGpsPosition | null = (line === null) ? null : parseGnssInfo(line);

				if (position === null) {
					return;
				}

				this.gpsWatchers.forEach((watcher: (position: IGpsPosition) => void): void => {
					watcher(position);
				});
			})
			.catch((): void => {
				// Transient failure — retried on the next poll
				this.gpsPollBusy = false;
			});
	}

	// ******************************************************************************
	// *** SMS/HTTP helpers (private)
	// ******************************************************************************
	private ensureSmsTextMode(): Promise<void> {
		if (this.smsTextModeEnabled) {
			return Promise.resolve();
		}

		return this.run('AT+CMGF=1')
			.then((): void => {
				this.smsTextModeEnabled = true;
			});
	}

	/**
	 * TLS 1.2 without server-certificate validation (the module ships without a CA store).
	 * Failures are tolerated: firmwares differ in which AT+CSSLCFG parameters exist,
	 * and HTTPS may still work with the firmware defaults.
	 *
	 * enableSNI is essential, not cosmetic: the firmware defaults to NO SNI, and
	 * Cloudflare (and other SNI-routed edges) answers SNI-less requests with a bare
	 * 403 that never reaches the origin — hardware-verified on sidekick.run.
	 */
	private configureTls(): Promise<void> {
		return this.run('AT+CSSLCFG="sslversion",0,4')
			.then((): Promise<string[]> => this.run('AT+CSSLCFG="authmode",0,0'))
			.then((): Promise<string[]> => this.run('AT+CSSLCFG="enableSNI",0,1'))
			.then((): Promise<string[]> => this.run('AT+HTTPPARA="SSLCFG",0'))
			.then(noop)
			.catch(noop);
	}

	/**
	 * Shared GET/POST flow, serialized so only one HTTP transaction runs at a time
	 * (see httpChain). Requires network registration and an APN
	 * (the A76XX HTTP service activates the PDP context itself).
	 *
	 * @param { 0 | 1 } action AT+HTTPACTION method: 0 = GET, 1 = POST
	 * @param { string | null } body uploaded via AT+HTTPDATA when non-null.
	 *                               Note: byte length is assumed = string length (ASCII payloads)
	 */
	private httpRequest(
		url: string,
		action: 0 | 1,
		body: string | null,
		maxBytes: number,
	): Promise<string> {
		const start = (): Promise<string> => this.httpTransaction(url, action, body, maxBytes);

		// Chain onto the previous transaction whether it succeeded or failed
		const result: Promise<string> = this.httpChain.then(start, start);
		this.httpChain = result.catch(noop);

		return result;
	}

	/** The actual GET/POST flow — only ever one in flight (call via httpRequest) */
	private httpTransaction(
		url: string,
		action: 0 | 1,
		body: string | null,
		maxBytes: number,
	): Promise<string> {
		const isHttps: boolean = url.indexOf('https:') === 0;

		// Terminate any stale session first (e.g. after a previous request threw)
		return this.run('AT+HTTPTERM').catch((): string[] => [])
			.then((): Promise<string[]> => this.run('AT+HTTPINIT'))
			.then((): Promise<void> => (isHttps ? this.configureTls() : Promise.resolve()))
			.then((): Promise<string[]> => this.run('AT+HTTPPARA="URL","' + url + '"'))
			// Follow redirects; tolerated as not every firmware supports the parameter
			.then((): Promise<string[]> =>
				this.run('AT+HTTPPARA="REDIR",1').catch((): string[] => [])
			)
			.then((): Promise<string[]> => {
				if (body === null) {
					return Promise.resolve([]);
				}

				return this.run('AT+HTTPPARA="CONTENT","application/json"')
					.then((): Promise<string[]> =>
						this.run('AT+HTTPDATA=' + body.length + ',10000', {
							timeoutMs: 15_000,
							payload: body,
							payloadTerminator: '',
						})
					);
			})
			.then((): Promise<string> => {
				// The '+HTTPACTION: <method>,<status>,<length>' report arrives as a URC,
				// potentially long after the command's OK
				const report: Promise<string> = this.waitForUrc(
					'+HTTPACTION:',
					HTTP_ACTION_TIMEOUT_MS,
				);

				return this.run('AT+HTTPACTION=' + action)
					.then((): Promise<string> => report);
			})
			.then((report: string): string | Promise<string> => {
				const fields: string[] = afterColon(report).split(',');
				const status: number = parseInt(fields[1], 10);
				const length: number = parseInt(fields[2], 10);

				if ((status < 200) || (status >= 300)) {
					throw new Error('HTTP ' + status + ' for ' + url);
				}

				if (!(length > 0)) {
					return '';
				}

				return this.readHttpBody(Math.min(length, maxBytes));
			})
			.then((responseBody: string): Promise<string> =>
				this.run('AT+HTTPTERM')
					.catch((): string[] => [])
					.then((): string => responseBody)
			)
			.catch((error: Error): Promise<never> =>
				this.run('AT+HTTPTERM')
					.catch((): string[] => [])
					.then((): never => {
						throw error;
					})
			);
	}

	private readHttpBody(
		length: number,
	): Promise<string> {
		return new Promise((resolve, reject) => {
			this.httpRead = {
				data: '',
				resolve: resolve,
				reject: reject,
				timer: setTimeout((): void => {
					this.httpRead = null;
					this.rawBytesRemaining = 0;
					reject(new Error('AT+HTTPREAD: timed out'));
				}, HTTP_READ_TIMEOUT_MS),
			};

			this.exec('AT+HTTPREAD=0,' + length)
				.catch((error: Error): void => {
					const session: IHttpReadSession | null = this.httpRead;

					if (session !== null) {
						this.httpRead = null;
						this.rawBytesRemaining = 0;
						clearTimeout(session.timer);
					}
					reject(error);
				});
		});
	}
}
sim7670e.ts