elm327
Version:
Node.js/TypeScript library for ELM327 OBD2 adapters over USB, Bluetooth and WiFi
765 lines • 31.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MultiframeMessage = exports.OBD2Connection = void 0;
const events_1 = require("events");
const errors_1 = require("./errors");
const response_matcher_1 = require("./response-matcher");
/**
* Abstract base class for all OBD2 connection types.
* Provides common initialization, validation, and response cleaning logic.
*
* Inspired by OpenXC's controller implementation with improved
* response matching and multi-frame support.
*/
class OBD2Connection extends events_1.EventEmitter {
config;
isConnected = false;
isInitialized = false;
timeout;
responseMatcher;
multiframeMessages = new Map();
commandLock = Promise.resolve();
monitorMode = false;
constructor(config) {
super();
this.config = config;
this.timeout = config.timeout || 5000;
this.responseMatcher = new response_matcher_1.ResponseMatcher();
// Forward unsolicited data events
this.responseMatcher.on('unsolicited', (data) => {
this.emit('unsolicited', data);
});
}
/**
* Validates an adapter response and throws on known error patterns.
*/
validateResponse(response) {
const clean = response.trim().toUpperCase();
// SEARCHING... means the adapter is still trying to detect protocol
// Don't throw - just return and let the command wait for a real response
if (clean.includes('SEARCHING')) {
return; // Don't throw - adapter is still working
}
if (clean.includes('UNABLE TO CONNECT')) {
throw new errors_1.ConnectionError(`Unable to connect to vehicle: ${clean}`);
}
if (clean.includes('NO DATA')) {
throw new errors_1.ProtocolError(`No data received from vehicle: ${clean}`);
}
if (clean.includes('BUS INIT')) {
throw new errors_1.ProtocolError(`Bus initialization error: ${clean}`);
}
if (clean.includes('BUS INIT...ERROR') || clean.includes('BUS INIT...ERROR')) {
throw new errors_1.ConnectionError(`Vehicle not responding (BUS INIT failed): ${clean}`);
}
if (clean.includes('FB ERROR')) {
throw new errors_1.ProtocolError(`Feedback error from adapter: ${clean}`);
}
if (clean === '?') {
throw new errors_1.ProtocolError(`Unknown command or invalid response: ${clean}`);
}
if (clean.includes('CAN ERROR')) {
throw new errors_1.ProtocolError(`CAN bus error: ${clean}`);
}
if (clean.includes('STOPPED')) {
throw new errors_1.ProtocolError(`Communication stopped: ${clean}`);
}
if (clean.includes('BUFFER FULL')) {
throw new errors_1.ProtocolError(`ELM327 buffer full: ${clean}`);
}
if (clean.includes('ERROR')) {
throw new errors_1.ProtocolError(`ELM327 error: ${clean}`);
}
}
/**
* Sends a command and waits for response using the ResponseMatcher.
* Similar to OpenXC's complex_request pattern.
* Uses a mutex to prevent parallel commands from corrupting responses.
*/
async sendCommand(command, customTimeout) {
if (!this.isConnectionOpen()) {
throw new errors_1.ConnectionError('Not connected to adapter');
}
// Acquire mutex to prevent parallel commands
const previousLock = this.commandLock;
let resolveLock;
this.commandLock = new Promise((resolve) => {
resolveLock = resolve;
});
try {
// Wait for previous command to complete
await previousLock;
// Clear buffer before sending new command to avoid residual data
this.responseMatcher.clearBuffer();
const timeout = customTimeout || this.timeout;
const { promise } = this.responseMatcher.addRequest(command, timeout);
try {
await this.sendRaw(command);
const response = await promise;
this.validateResponse(response);
return this.cleanResponse(response);
}
catch (error) {
// Re-throw with better context
if (error instanceof errors_1.ProtocolError || error instanceof errors_1.TimeoutError) {
throw error;
}
throw new errors_1.ConnectionError(`Failed to send command: ${error instanceof Error ? error.message : String(error)}`);
}
}
finally {
// Release mutex
resolveLock();
}
}
/**
* Sends a diagnostic request with matching support.
* Similar to OpenXC's create_diagnostic_request.
*/
async sendDiagnosticRequest(request, waitForResponse = true) {
const command = this.buildDiagnosticCommand(request);
if (!waitForResponse) {
await this.sendRaw(command);
return null;
}
const rawResponse = await this.sendCommand(command);
return this.parseDiagnosticResponse(rawResponse, request);
}
/**
* Builds a diagnostic command string from config.
*/
buildDiagnosticCommand(request) {
const modeHex = request.mode.toString(16).padStart(2, '0').toUpperCase();
const pidHex = request.pid !== undefined ? request.pid.toString(16).padStart(2, '0').toUpperCase() : '';
return modeHex + pidHex;
}
/**
* Parses a raw response into a DiagnosticResponse.
*/
parseDiagnosticResponse(rawResponse, request) {
const cleanResponse = rawResponse.replace(/[\r\n>]/g, '').trim();
const bytes = cleanResponse.split(/\s+/).filter((b) => b.length > 0);
const response = {
bus: request.bus || 1,
id: request.id || 0x7df,
mode: request.mode,
success: !cleanResponse.includes('NO DATA') && !cleanResponse.includes('ERROR'),
timestamp: new Date(),
};
if (request.pid !== undefined) {
response.pid = request.pid;
}
// Parse response bytes
if (bytes.length > 0) {
const responseMode = parseInt(bytes[0], 16);
response.mode = responseMode - 0x40;
if (bytes.length > 1 && request.pid !== undefined) {
response.pid = parseInt(bytes[1], 16);
}
if (bytes.length > 2) {
response.payload = bytes.slice(2).join('');
}
}
return response;
}
/**
* Handles incoming data and routes to ResponseMatcher.
* Should be called by subclasses when data is received.
* Handles multi-frame ISO-TP messages (like VIN).
* In monitor mode, emits 'canData' events for all received frames.
*/
handleIncomingData(data) {
// In monitor mode, emit all data as canData events
if (this.monitorMode) {
const lines = data.split(/[\r\n]+/).filter((l) => l.trim().length > 0);
for (const line of lines) {
const clean = line.trim();
if (clean && clean !== '>' && !clean.includes('ATMA')) {
this.emit('canData', clean);
}
}
return;
}
// Normal mode - check for ISO-TP multi-frame messages
const lines = data.split(/[\r\n]+/).filter((l) => l.trim().length > 0);
for (const line of lines) {
const clean = line.trim().toUpperCase();
if (!clean)
continue;
// Discard SEARCHING... - adapter is still detecting protocol
// Don't pass to ResponseMatcher - just wait for real response
if (clean.includes('SEARCHING')) {
this.emit('debug', { message: 'Protocol detection in progress...' });
continue;
}
// Parse bytes to check for ISO-TP frame types
const bytes = clean.split(/\s+/).filter((b) => b.length > 0);
if (bytes.length === 0)
continue;
// Determine if headers are present (ATH1) - first token might be CAN ID (3-4 hex chars)
let headerOffset = 0;
const canId = 0x7e8; // Default OBD-II response ID
// Check if first token looks like a CAN ID (not a PCI byte)
if (bytes.length > 0) {
const first = bytes[0];
if (first.length >= 3 && /^[0-9A-F]{3,4}$/.test(first)) {
// This is likely a CAN ID, skip it for PCI parsing
headerOffset = 1;
}
}
const pciByte = parseInt(bytes[headerOffset] || '0', 16);
// ISO-TP First Frame (0x10-0x1F) or Consecutive Frame (0x20-0x2F)
if ((pciByte & 0xf0) === 0x10 || (pciByte & 0xf0) === 0x20) {
if (!this.multiframeMessages.has(canId)) {
// Create message without hardcoded mode/PID; will be extracted from data
this.multiframeMessages.set(canId, new MultiframeMessage(canId));
}
const mfMsg = this.multiframeMessages.get(canId);
mfMsg.addFrame(clean);
// If this is the first frame, try to extract mode and PID from the data
if ((pciByte & 0xf0) === 0x10 && headerOffset + 4 < bytes.length) {
// Data starts after PCI and length bytes: bytes[headerOffset+2] is first data byte
const modeByte = parseInt(bytes[headerOffset + 2], 16);
const pidByte = parseInt(bytes[headerOffset + 3], 16);
if (!isNaN(modeByte)) {
mfMsg.mode = modeByte - 0x40; // Response mode = request mode + 0x40
}
if (!isNaN(pidByte)) {
mfMsg.pid = pidByte;
}
}
if (mfMsg.isComplete) {
// Multi-frame message complete, create combined response
const combinedPayload = mfMsg.getCombinedPayload();
// Pass the combined data to response matcher
this.responseMatcher.handleData(combinedPayload);
this.multiframeMessages.delete(canId);
continue;
}
// Don't pass partial frames to response matcher
continue;
}
// Single frame or non-ISO-TP data, pass through normally
this.responseMatcher.handleData(clean);
}
}
/**
* Rejects all pending requests (useful on disconnect/error).
*/
rejectAllPending(error) {
this.responseMatcher.rejectAll(error);
}
/**
* Cleans a response by removing common ELM327 artifacts.
* Uses fast string operations instead of regex for better performance.
* Removes \r, \n, >, and normalizes spaces (no double spaces).
*/
cleanResponse(response) {
// Fast removal of trailing '>' without regex
let cleaned = response;
const lastGT = cleaned.lastIndexOf('>');
if (lastGT !== -1) {
cleaned = cleaned.substring(0, lastGT);
}
// Remove SEARCHING... and BUS INIT... artifacts (adapter protocol detection)
// These can appear in the middle of responses
cleaned = cleaned.replace(/SEARCHING\.+\s*/gi, '').replace(/BUS INIT\.+\s*/gi, '');
// Fast removal of \r and \n, normalize spaces (no double spaces)
let result = '';
let prevChar = '';
for (let i = 0; i < cleaned.length; i++) {
const ch = cleaned[i];
if (ch === '\r' || ch === '\n')
continue;
// Skip if current is space and previous was also space (normalize)
if (ch === ' ' && prevChar === ' ')
continue;
result += ch;
prevChar = ch;
}
return result.trim();
}
/**
* Initializes the ELM327 adapter with standard AT commands.
* Must be called after a successful connection.
* Includes retry logic for ATZ (up to 3 attempts).
*/
async initialize() {
if (!this.isConnected) {
throw new errors_1.ConnectionError('Not connected to adapter');
}
const compatMode = this.config.cloneCompatibility || 'auto';
const isMinimal = compatMode === 'minimal';
const isLenient = compatMode === 'lenient' || compatMode === 'minimal';
const isStrict = compatMode === 'strict';
// Auto-detect: try to identify clone version
let detectedVersion = 'Unknown';
if (compatMode === 'auto') {
try {
const versionResponse = await this.sendCommand('ATI');
detectedVersion = this.cleanResponse(versionResponse);
// Check for old clone patterns
if (detectedVersion.includes('v1.5') || detectedVersion.includes('V1.5')) {
this.emit('debug', { message: 'Detected ELM327 v1.5 clone - using lenient mode' });
// Switch to lenient mode for old clones
this.config.cloneCompatibility = 'lenient';
}
}
catch {
// Ignore version detection errors
}
}
try {
// Retry ATZ up to 3 times (some adapters need time to reset)
let atzSuccess = false;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await this.sendCommand('ATZ');
atzSuccess = true;
break;
}
catch (error) {
if (attempt === 3)
throw error;
await this.delay(2000);
}
}
// Emit debug info (can be captured by the 'debug' event)
this.emit('debug', { atzSuccess, message: `ATZ ${atzSuccess ? 'succeeded' : 'failed'}` });
await this.delay(isLenient ? 2000 : 1500); // Longer delay for old clones
this.clearBuffer();
// Initialize with individual error handling for each command
// ATE0 - Echo off (essential for most adapters)
try {
await this.sendCommand('ATE0');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATE0 failed:', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
// Skip non-essential commands in minimal mode
if (!isMinimal) {
// ATL0 - Linefeeds off (may fail on old clones)
try {
await this.sendCommand('ATL0');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATL0 failed (some clones do not support it):', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
// ATS1 - Spaces on (may fail on old clones)
try {
await this.sendCommand('ATS1');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATS1 failed (some clones do not support it):', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
// ATST96 - Set timeout (use shorter timeout for old clones)
try {
await this.sendCommand(isLenient ? 'ATST32' : 'ATST96');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATST failed:', e instanceof Error ? e.message : e);
// Try default timeout
try {
await this.sendCommand('ATST0');
}
catch {
// Ignore
}
}
await this.delay(isLenient ? 200 : 100);
// ATAT1 - Adaptive timing (may fail on old clones)
try {
await this.sendCommand('ATAT1');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATAT1 failed (some clones do not support it):', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
// ATCS0 - Disable checksums (for legacy protocols ISO 9141/KWP)
// Only in non-minimal mode (essential for proper data in legacy protocols)
try {
await this.sendCommand('ATCS0');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATCS0 failed (legacy protocol only):', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
// ATH1 - Headers on (needed for ISO-TP, may fail on old clones)
try {
await this.sendCommand('ATH1');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATH1 failed (some clones do not support headers):', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
}
const version = await this.sendCommand('ATI');
let device = 'Unknown';
try {
const rawDevice = await this.sendCommand('AT@1');
device = this.cleanResponse(rawDevice);
}
catch {
// AT@1 is not supported by most cheap ELM327 clones — silently ignored
}
// ATSP0 - Set protocol to automatic (essential)
try {
await this.sendCommand('ATSP0');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATSP0 failed:', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
// ATCS0 - Disable checksums (for legacy protocols ISO 9141/KWP)
// Only send if NOT using CAN protocol (CAN doesn't use checksums)
// This prevents checksum bytes from appearing in the payload
try {
const protocol = await this.sendCommand('ATDP');
const protocolClean = this.cleanResponse(protocol).toUpperCase();
// If NOT a CAN protocol (no "CAN" in name), disable checksums
if (!protocolClean.includes('CAN')) {
await this.sendCommand('ATCS0');
}
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATCS0 failed (legacy protocol only):', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
// Configure Flow Control for ISO-TP multiframe (only if not in minimal mode)
if (!isMinimal && this.config.flowControl) {
const fc = this.config.flowControl;
// Enable/disable flow control (AT CFC0 = off, AT CFC1 = on)
if (fc.enabled !== undefined) {
try {
await this.sendCommand(fc.enabled ? 'ATCFC1' : 'ATCFC0');
}
catch (e) {
if (isStrict)
throw e;
console.warn('ATCFC failed:', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
}
// Set Flow Control Header (AT FC SH)
if (fc.header) {
try {
await this.sendCommand(`AT FC SH ${fc.header}`);
}
catch (e) {
if (isStrict)
throw e;
console.warn('AT FC SH failed:', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
}
// Set Flow Control Data (AT FC SD)
if (fc.data) {
try {
await this.sendCommand(`AT FC SD ${fc.data}`);
}
catch (e) {
if (isStrict)
throw e;
console.warn('AT FC SD failed:', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
}
// Set Flow Control Mode (AT FC SM)
if (fc.mode !== undefined) {
try {
await this.sendCommand(`AT FC SM ${fc.mode.toString(16).toUpperCase()}`);
}
catch (e) {
if (isStrict)
throw e;
console.warn('AT FC SM failed:', e instanceof Error ? e.message : e);
}
await this.delay(isLenient ? 200 : 100);
}
}
const protocol = await this.sendCommand('ATDP');
this.isInitialized = true;
return {
version: this.cleanResponse(version),
device,
protocol: this.cleanResponse(protocol),
};
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new errors_1.ProtocolError(`Failed to initialize adapter: ${message}`);
}
}
clearBuffer() { }
delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
getConnectionStatus() {
return this.isConnected && this.isConnectionOpen();
}
/**
* Resets the adapter using ATZ command without disconnecting/reconnecting.
* Useful for recovering from communication errors or changing protocols.
* This is an independent reset that doesn't recreate the socket/connection.
*/
async reset() {
if (!this.isConnected) {
throw new errors_1.ConnectionError('Not connected to adapter');
}
try {
// Send ATZ with retry logic (up to 3 attempts)
let atzSuccess = false;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await this.sendCommand('ATZ');
atzSuccess = true;
break;
}
catch (error) {
if (attempt === 3)
throw error;
await this.delay(2000);
}
}
this.emit('debug', { message: `ATZ reset ${atzSuccess ? 'succeeded' : 'failed'}` });
await this.delay(1500);
this.clearBuffer();
// Re-initialize essential settings after reset
// (but don't re-run full initialization)
try {
await this.sendCommand('ATE0'); // Echo off
}
catch {
// Ignore if fails
}
await this.delay(100);
// Re-enable headers if they were enabled
try {
await this.sendCommand('ATH1');
}
catch {
// Ignore if fails
}
this.emit('reset', { success: atzSuccess });
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new errors_1.ProtocolError(`Failed to reset adapter: ${message}`);
}
}
/**
* Starts monitoring all CAN traffic (AT MA mode).
* In this mode, the adapter forwards all CAN frames without filtering.
* Use stopMonitor() to exit this mode.
* Data is emitted via the 'canData' event.
*/
async startMonitor() {
if (!this.isInitialized) {
throw new errors_1.ConnectionError('Adapter not initialized. Call connect() first.');
}
try {
// Exit any existing monitor mode first
await this.sendRaw(String.fromCharCode(0x1b));
await this.delay(100);
// Enable headers to see CAN IDs
await this.sendCommand('ATH1');
await this.delay(100);
// Set monitor mode flag BEFORE sending ATMA
// This ensures incoming data goes to handleIncomingData correctly
this.monitorMode = true;
// Start monitoring all traffic (AT MA)
// This command doesn't return until we send an Escape
await this.sendRaw('ATMA');
}
catch (error) {
// ATMA doesn't return normally - TimeoutError is expected
if (error instanceof errors_1.TimeoutError) {
this.emit('debug', { message: 'ATMA initiated - monitoring started' });
return; // Success - monitoring is running
}
// Real errors should NOT be silenced
this.monitorMode = false; // Reset flag on real error
throw error; // Re-throw real errors
}
}
/**
* Stops CAN monitoring mode.
* Sends AT command to exit monitor mode.
*/
async stopMonitor() {
this.monitorMode = false;
try {
// Send Escape (0x1B) to exit monitor mode
await this.sendRaw(String.fromCharCode(0x1b));
await this.delay(500);
// Try to get a response to confirm we're out of monitor mode
await this.sendCommand('AT');
}
catch {
// Ignore errors when stopping monitor
}
}
/**
* Monitor mode with filter (AT CF - CAN Filter + AT CM - CAN Mask).
* Filters frames by specific CAN ID (not PID filter like AT MP).
* Use stopMonitor() to exit this mode.
*/
async startMonitorWithFilter(canId) {
if (!this.isInitialized) {
throw new errors_1.ConnectionError('Adapter not initialized. Call connect() first.');
}
try {
// Exit any existing monitor mode first
await this.sendRaw(String.fromCharCode(0x1b));
await this.delay(100);
// Set CAN Filter (AT CF) - filter by CAN ID
await this.sendCommand(`AT CF ${canId}`);
await this.delay(100);
// Set CAN Mask (AT CM) - FFF means exact match
await this.sendCommand('AT CM FFF');
await this.delay(100);
// Enable headers to see CAN IDs
await this.sendCommand('ATH1');
await this.delay(100);
// Set monitor mode flag BEFORE sending ATMA
this.monitorMode = true;
// Start monitoring (AT MA)
await this.sendRaw('ATMA');
}
catch (error) {
// ATMA doesn't return normally - TimeoutError is expected
if (error instanceof errors_1.TimeoutError) {
this.emit('debug', { message: `AT CF/CM + ATMA initiated - monitoring ${canId}` });
return; // Success - monitoring is running
}
// Real errors should NOT be silenced
this.monitorMode = false; // Reset flag on real error
throw error; // Re-throw real errors
}
}
}
exports.OBD2Connection = OBD2Connection;
/**
* Multiframe message accumulator
* Similar to OpenXC's MultiframeDiagnosticMessage
* Used for ISO-TP multi-frame responses (like VIN)
*/
class MultiframeMessage {
id;
bus;
frames = new Map();
_totalFrames = -1;
_isComplete = false;
mode;
pid;
constructor(id, mode, pid, bus) {
this.id = id;
this.bus = bus;
this.mode = mode;
this.pid = pid;
}
/**
* Adds a frame to the multiframe message
* For ISO-TP: first frame (10) has total frames, consecutive frames (21, 22, etc.)
*/
addFrame(response) {
const clean = response.replace(/[\r\n>]/g, '').trim();
// Parse ISO-TP header
const bytes = clean.split(/\s+/).filter((b) => b.length > 0);
if (bytes.length === 0)
return;
const firstByte = parseInt(bytes[0], 16);
// First frame (0x10 = 16): 10 <total_len_high> <total_len_low> <data...>
if ((firstByte & 0xf0) === 0x10) {
const totalLen = ((firstByte & 0x0f) << 8) | parseInt(bytes[1], 16);
this._totalFrames = Math.ceil(totalLen / 7); // Approximate frames needed
this.frames.set(0, bytes.slice(2).join(''));
}
// Consecutive frame (0x21 = 33 and up): 21 <data...>, 22 <data...>, etc.
else if ((firstByte & 0xf0) === 0x20) {
const frameNum = (firstByte & 0x0f) - 1; // 1->0, 2->1, etc.
if (frameNum >= 0) {
this.frames.set(frameNum, bytes.slice(1).join(''));
}
}
// Single frame (0x0X): just data
else {
this.frames.set(0, bytes.slice(1).join(''));
this._isComplete = true;
}
// Check if complete - verify ALL frames are present (contiguous)
if (this._totalFrames > 0) {
let allPresent = true;
for (let i = 0; i < this._totalFrames; i++) {
if (!this.frames.has(i)) {
allPresent = false;
break;
}
}
if (allPresent) {
this._isComplete = true;
}
}
}
/**
* Gets the combined payload from all frames in correct order
* Only returns frames that are actually present (skip missing)
*/
getCombinedPayload() {
const sortedFrames = [];
const maxFrames = this._totalFrames > 0 ? this._totalFrames : this.frames.size;
for (let i = 0; i < maxFrames; i++) {
const frame = this.frames.get(i);
if (frame)
sortedFrames.push(frame);
}
return sortedFrames.join('');
}
/**
* Checks if all frames have been received contiguously
* Verifies that frames 0, 1, 2... (totalFrames-1) are all present
*/
get isComplete() {
if (this._isComplete)
return true;
if (this._totalFrames > 0) {
// Check if ALL frames from 0 to totalFrames-1 are present
for (let i = 0; i < this._totalFrames; i++) {
if (!this.frames.has(i))
return false;
}
this._isComplete = true;
return true;
}
return false;
}
/**
* Gets the total number of frames received
*/
get frameCount() {
return this.frames.size;
}
}
exports.MultiframeMessage = MultiframeMessage;
//# sourceMappingURL=connection.js.map