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);
});
});
}
}