Welcome to C15-OBD

Before you get started, please read this important notice.

AI Diagnostic — No Reader Needed

AI Diagnostic doesn't need an OBD-II reader: enter your fault codes or describe your symptoms and get step-by-step repair guidance instantly. It's part of C15-OBD Premium, which starts with a 7-day free trial.

No Liability

We accept no liability for any damage caused to your vehicle or any other property while using this app. The app is used entirely at your own risk.

Wi-Fi BridgeConnect

Secure Wi-Fi bridge

Give your Wi-Fi reader a secure wss:// address that works from any network — including this app on https.

Why you need this

Browsers can't talk to a Wi-Fi reader directly, and on a secure page they block local ws:// addresses. The bridge is a small helper you run on a laptop, PC or Raspberry Pi on the reader's Wi-Fi. It forwards the connection and, through a free secure tunnel, gives you a wss:// address the app can use from anywhere.

Let AI set this up

Your pairing details are created and saved, one command is written for you, and the app then finds your bridge by itself and remembers it.

Sign in and the assistant will set the bridge up, store your details on your account, and explain anything that goes wrong.

Sign in

Or set it up yourself

1

Install the two tools

Node.js 18 or newer — from nodejs.org.

cloudflared — the free tunnel tool. On macOS run this in Terminal; on Windows use winget install --id Cloudflare.cloudflared; otherwise grab it from the Cloudflare downloads page.

Terminal (macOS)
brew install cloudflared
2

Save the bridge script

Download it — or copy it into a new file called obd-bridge.mjs in any folder.

obd-bridge.mjs
#!/usr/bin/env node
/**
 * C15-OBD secure bridge — WebSocket ⇄ raw TCP for Wi-Fi ELM327 adapters.
 *
 * Browsers can't open a raw TCP socket to a Wi-Fi reader, and an https page also
 * blocks local ws:// addresses. This bridge runs on a laptop / Raspberry Pi on the
 * same Wi-Fi as the reader, listens for a WebSocket, and forwards it to the reader's
 * TCP port. It starts its own secure tunnel, so the app gets a wss:// address that
 * works from any network.
 *
 *   npm install ws
 *   node obd-bridge.mjs --pair ABC123XYZ0 --token 1a2b3c4d5e6f   # paired with the app
 *   node obd-bridge.mjs                                          # manual: prints the token
 *   node obd-bridge.mjs --target 192.168.0.11:35000              # a different reader address
 *   node obd-bridge.mjs --no-tunnel                              # skip the tunnel
 *   node obd-bridge.mjs --cert cert.pem --key key.pem            # serve wss:// directly
 *
 * With --pair, the bridge finds the reader, opens a tunnel, and reports the address
 * to the app, which then connects by itself and remembers the setup.
 * Get the pairing code and token from the app's Secure Bridge page.
 */
import { createServer as createHttpServer } from 'node:http';
import { createServer as createHttpsServer } from 'node:https';
import { connect as tcpConnect } from 'node:net';
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
import * as wsModule from 'ws';

// ws exposes its server class as an ESM named export, and also as a property on the
// CommonJS default export — accept either so the script runs on any Node version.
const WebSocketServer =
  wsModule.WebSocketServer ||
  (wsModule.default && (wsModule.default.WebSocketServer || wsModule.default.Server));

if (!WebSocketServer) {
  console.error('Could not load the "ws" package. Run "npm install ws" in this folder, then try again.');
  process.exit(1);
}

const argv = process.argv.slice(2);
const arg = (name, fallback) => {
  const i = argv.indexOf('--' + name);
  return i >= 0 && argv[i + 1] ? argv[i + 1] : fallback;
};
const hasFlag = name => argv.includes('--' + name);
const stamp = () => new Date().toISOString().slice(11, 19);
const log = (...parts) => console.log('[' + stamp() + ']', ...parts);

// Addresses Wi-Fi ELM327 readers commonly listen on — tried in order when no --target is given.
const READER_CANDIDATES = [
  '192.168.0.10:35000',
  '192.168.0.10:23',
  '192.168.0.11:35000',
  '192.168.0.11:23',
  '192.168.1.5:35000',
  '192.168.4.1:35000',
  '192.168.4.1:23',
  '10.0.0.10:35000',
];

const DEFAULT_API = 'https://c15-obd.base44.app';

const api = (process.env.OBD_API || arg('api', DEFAULT_API)).replace(/\/+$/, '');
const pairCode = (process.env.OBD_PAIR || arg('pair', '')).trim().toUpperCase();
const port = Number(process.env.OBD_PORT || arg('port', 8080));
const token = process.env.OBD_TOKEN || arg('token', randomBytes(6).toString('hex'));
const certPath = process.env.OBD_CERT || arg('cert', '');
const keyPath = process.env.OBD_KEY || arg('key', '');
const useTls = Boolean(certPath && keyPath);
const noTunnel = hasFlag('no-tunnel') || process.env.OBD_NO_TUNNEL === '1';

function probeTcp(hostPort, timeout = 900) {
  return new Promise(resolve => {
    const host = hostPort.replace(/:\d+$/, '');
    const tcpPort = Number(hostPort.includes(':') ? hostPort.split(':').pop() : 35000);
    const socket = tcpConnect({ host, port: tcpPort });
    const finish = ok => {
      clearTimeout(timer);
      try { socket.destroy(); } catch {}
      resolve(ok);
    };
    const timer = setTimeout(() => finish(false), timeout);
    socket.on('connect', () => finish(true));
    socket.on('error', () => finish(false));
  });
}

async function findReader() {
  for (const candidate of READER_CANDIDATES) {
    log('Looking for a reader at ' + candidate + ' …');
    if (await probeTcp(candidate)) return candidate;
  }
  return '';
}

const explicitTarget = process.env.OBD_TARGET || arg('target', '');
let target = explicitTarget;
if (!target) {
  target = await findReader();
  if (target) log('Found a reader at ' + target + ' — using it.');
  else {
    target = READER_CANDIDATES[0];
    log('Nothing answered on the usual addresses — assuming ' + target + '. Set --target if your reader uses another address.');
  }
}
const targetHost = target.replace(/:\d+$/, '');
const targetPort = Number(target.includes(':') ? target.split(':').pop() : 35000);

function requestHandler(req, res) {
  const headers = { 'content-type': 'application/json', 'access-control-allow-origin': '*' };
  if (req.url && req.url.startsWith('/healthz')) {
    res.writeHead(200, headers);
    res.end(JSON.stringify({ ok: true, bridge: 'c15-obd', target }));
    return;
  }
  res.writeHead(200, headers);
  res.end(JSON.stringify({ ok: true, bridge: 'c15-obd', message: 'Connect with a WebSocket and ?token=...' }));
}

const server = useTls
  ? createHttpsServer({ cert: readFileSync(certPath), key: readFileSync(keyPath) }, requestHandler)
  : createHttpServer(requestHandler);

const wss = new WebSocketServer({ noServer: true });

server.on('upgrade', (req, socket) => {
  let allowed = false;
  try {
    allowed = new URL(req.url || '/', 'http://localhost').searchParams.get('token') === token;
  } catch {
    allowed = false;
  }
  if (!allowed) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    log('Rejected a connection: missing or wrong token.');
    return;
  }
  wss.handleUpgrade(req, socket, Buffer.alloc(0), ws => wss.emit('connection', ws));
});

wss.on('connection', ws => {
  log('App connected — opening TCP ' + targetHost + ':' + targetPort);
  const tcp = tcpConnect({ host: targetHost, port: targetPort });
  tcp.setNoDelay(true);
  let open = false;

  const giveUp = setTimeout(() => {
    if (open) return;
    log('The reader did not answer at ' + targetHost + ':' + targetPort + '. Is the bridge device on the reader\'s Wi-Fi?');
    try { tcp.destroy(); } catch {}
    try { ws.close(); } catch {}
  }, 6000);

  tcp.on('connect', () => {
    open = true;
    clearTimeout(giveUp);
    log('Reader socket open — live data can flow now.');
  });
  tcp.on('data', chunk => { if (ws.readyState === ws.OPEN) ws.send(chunk.toString('latin1')); });
  tcp.on('error', error => {
    clearTimeout(giveUp);
    log('Reader error: ' + error.message);
    try { ws.close(); } catch {}
  });
  tcp.on('close', () => {
    log('Reader socket closed.');
    if (ws.readyState === ws.OPEN) ws.close();
  });
  ws.on('message', data => { if (open) tcp.write(data); });
  ws.on('close', () => { log('App disconnected.'); tcp.destroy(); });
  ws.on('error', () => { tcp.destroy(); });
});

// Opens a free Cloudflare quick tunnel and returns the public https address.
function startTunnel() {
  const tunnelArgs = ['tunnel', '--url', 'http://localhost:' + port];
  // A copy of cloudflared sitting next to this script is used if the system one is missing,
  // which is what makes phone-only setups (Termux) and portable folders work.
  const localBinary = process.platform === 'win32' ? '.\\cloudflared' : './cloudflared';
  const attempts = [
    { cmd: 'cloudflared', args: tunnelArgs },
    { cmd: localBinary, args: tunnelArgs },
    { cmd: 'npx', args: ['-y', 'cloudflared@latest', ...tunnelArgs] },
  ];
  return new Promise(resolve => {
    let index = 0;
    let done = false;
    let settled = false;
    const finish = url => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      resolve(url);
    };
    const timer = setTimeout(() => finish(''), 30000);
    const readChunk = chunk => {
      const match = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
      if (match) finish(match[0]);
    };
    const attempt = () => {
      if (settled) return;
      if (index >= attempts.length) return finish('');
      const { cmd, args } = attempts[index++];
      log('Starting a secure tunnel with "' + cmd + '" …');
      // Windows needs the shell to resolve npx/cloudflared wrappers; elsewhere spawn resolves PATH directly.
      const child = spawn(cmd, args, { shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
      let advanced = false;
      const next = () => {
        if (advanced || settled) return;
        advanced = true;
        log('"' + cmd + '" is not available on this computer.');
        attempt();
      };
      child.stdout.on('data', readChunk);
      child.stderr.on('data', readChunk);
      child.on('error', next);
      child.on('exit', next);
    };
    attempt();
  });
}

async function registerWithApp(publicUrl) {
  if (!pairCode) return false;
  const payload = {
    action: 'register',
    code: pairCode,
    url: publicUrl,
    target: targetHost + ':' + targetPort,
    local_url: (useTls ? 'wss' : 'ws') + '://localhost:' + port + '/?token=' + token,
  };
  try {
    const response = await fetch(api + '/functions/bridge-registry', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify(payload),
    });
    const data = await response.json().catch(() => ({}));
    if (!response.ok || data.error) {
      log('The app refused the bridge registration: ' + (data.error || response.status));
      return false;
    }
    return true;
  } catch (error) {
    log('Could not reach the app to register this bridge: ' + error.message);
    return false;
  }
}

function printManualAddress() {
  console.log('  Next step — make it secure from a second terminal:');
  console.log('    cloudflared tunnel --url http://localhost:' + port);
  console.log('');
  console.log('  It prints an https://<something>.trycloudflare.com address.');
  console.log('  Use that host in the app, with wss:// and your token:');
  console.log('    wss://<something>.trycloudflare.com/?token=' + token);
  console.log('');
}

server.listen(port, async () => {
  const scheme = useTls ? 'wss' : 'ws';
  console.log('');
  console.log('  C15-OBD secure bridge is running');
  console.log('  --------------------------------');
  console.log('  Reader target : ' + targetHost + ':' + targetPort);
  console.log('  Access token  : ' + token);
  console.log('  Local address : ' + scheme + '://localhost:' + port + '/?token=' + token);
  if (useTls) console.log('  TLS           : ' + certPath);
  console.log('');

  let publicUrl = '';
  if (!noTunnel) publicUrl = await startTunnel();
  const secureAddress = publicUrl ? publicUrl.replace(/^https:/i, 'wss:') + '/?token=' + token : '';

  if (secureAddress) {
    console.log('  Secure address: ' + secureAddress);
    console.log('');
  }

  if (!pairCode) {
    if (secureAddress) console.log('  Copy the secure address above into the app\'s Secure Bridge page.');
    else printManualAddress();
  } else if (secureAddress) {
    const paired = await registerWithApp(secureAddress);
    if (paired) {
      console.log('  Paired — the app will find this bridge by itself and remember it.');
      console.log('  Open C15-OBD and press connect; leave this window running.');
      setInterval(() => registerWithApp(secureAddress), 60000);
    } else {
      console.log('  Pairing did not go through, so paste the secure address above into the app.');
    }
    console.log('');
  } else {
    console.log('  No tunnel could be started, so a secure app page cannot reach this bridge.');
    console.log('  Install cloudflared:');
    console.log('    https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/');
    console.log('  Then run, in a second terminal:');
    console.log('    cloudflared tunnel --url http://localhost:' + port);
    console.log('  and paste the wss:// address it prints into the app, with your token.');
    console.log('');
  }

  console.log('  Keep this window open while diagnosing. Press Ctrl+C to stop.');
  console.log('');
});
3

Start the bridge

In the folder you saved it in. Keep this window open.

Terminal
npm install ws
node obd-bridge.mjs

It prints your access token — you'll need it in step 5. If your reader isn't at the usual address, add it: node obd-bridge.mjs --target 192.168.0.11:35000.

4

Make it secure with a tunnel

Open a second terminal window and run:

Terminal
cloudflared tunnel --url http://localhost:8080

Copy the https://something.trycloudflare.com address it prints, swap https for wss, and add your token:

Address to use in the app
wss://something.trycloudflare.com/?token=YOUR-TOKEN
5

Connect from the app

Paste the address from step 4 and connect. The app remembers it and tries it first from now on.

Currently saved: ws://192.168.0.10:35000

Nothing connecting?

Paste what the terminal window on your computer says and the assistant will tell you exactly what to change.

Your token and bridge address are hidden before anything is sent.

Keep it safe

  • The tunnel address is reachable from the internet, so your token is what protects it — don't share the full address with anyone.
  • Anyone with both can read your engine data and send diagnostic commands to your car, so stop the bridge (Ctrl+C) when you're done.
  • Turn off the tunnel when you're not diagnosing — the free tunnel address changes every time you start it.