Announcing Web Serial Support in Firefox – Mozilla Hacks - the Web developer

I got ChatGPT to whip up a Firefox Web Serial Monitor HTML web page that mimics (and expands upon) the Arduino IDE Serial Monitor. I'm using it to monitor (via USB ports) several DIY devices running sketches on a Sparkfun Nesso N1 board, an Elecrow display board, and an M5Stack NanoC6 board.

Also attached is a code snippet that is not mandatory, but helps an ESP32 sketch self-identify to the FWSM (one drawback of the Firefox embodiment is that all 5 of my attached ESP32 devices identify as "USB JTAG/serial debug unit").

EDITED to fix bug in the optional code snippet.
EDITED to provde an auto-reconnect (but not auto-disconnect) feature.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Firefox Web Serial Monitor</title>
  <style>
    :root {
      --bg: #111317;
      --panel: #1b1f27;
      --panel2: #242a35;
      --text: #e8edf2;
      --muted: #9aa8b6;
      --accent: #5eb1ff;
      --danger: #ff7777;
      --ok: #79d98c;
      --border: #384252;
      --input: #0e1117;
      --shadow: rgba(0, 0, 0, 0.35);
      --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
      --sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }

    * { box-sizing: border-box; }

    body {
      margin: 0;
      min-height: 100vh;
      background: var(--bg);
      color: var(--text);
      font-family: var(--sans);
    }

    header {
      padding: 14px 18px;
      background: linear-gradient(180deg, #202633 0%, #171b23 100%);
      border-bottom: 1px solid var(--border);
      box-shadow: 0 2px 10px var(--shadow);
    }

    h1 {
      margin: 0 0 4px 0;
      font-size: 1.2rem;
      font-weight: 650;
    }

    .subtitle {
      color: var(--muted);
      font-size: 0.9rem;
    }

    main {
      height: calc(100vh - 76px);
      display: grid;
      grid-template-rows: auto 1fr auto;
      gap: 10px;
      padding: 10px;
    }

    .toolbar, .sendbar, .statusbar {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 8px;
      padding: 10px;
      background: var(--panel);
      border: 1px solid var(--border);
      border-radius: 12px;
    }

    .toolbar label, .sendbar label {
      color: var(--muted);
      font-size: 0.9rem;
      display: inline-flex;
      align-items: center;
      gap: 6px;
      white-space: nowrap;
    }

    button, select, input[type="text"], input[type="number"], textarea {
      border-radius: 9px;
      border: 1px solid var(--border);
      background: var(--input);
      color: var(--text);
      font: inherit;
    }

    button {
      padding: 8px 12px;
      cursor: pointer;
      transition: transform 0.04s ease, border-color 0.15s ease, background 0.15s ease;
    }

    button:hover:not(:disabled) {
      border-color: var(--accent);
      background: #151b24;
    }

    button:active:not(:disabled) { transform: translateY(1px); }
    button:disabled { opacity: 0.45; cursor: not-allowed; }

    button.primary {
      border-color: #3978b6;
      background: #123353;
    }

    button.danger {
      border-color: #8d3a3a;
      background: #3a181d;
    }

    select, input[type="text"], input[type="number"], textarea {
      padding: 8px 9px;
    }

    input[type="number"] { width: 105px; }

    details.settings-details {
      display: inline-block;
      border: 1px solid var(--border);
      border-radius: 10px;
      background: var(--input);
      color: var(--text);
    }

    details.settings-details summary {
      cursor: pointer;
      list-style-position: inside;
      padding: 8px 10px;
      color: var(--muted);
      user-select: none;
      white-space: nowrap;
    }

    details.settings-details[open] {
      display: block;
      flex: 1 1 100%;
      padding-bottom: 8px;
    }

    details.settings-details[open] summary {
      border-bottom: 1px solid var(--border);
      margin-bottom: 8px;
    }

    .settings-content {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 8px;
      padding: 0 10px;
    }

    #settingsSummary {
      color: var(--text);
      font-family: var(--mono);
    }

    details.notes-details textarea {
      width: 100%;
      min-height: 90px;
      resize: vertical;
      font-family: var(--mono);
      line-height: 1.35;
    }

    .notes-content {
      align-items: stretch;
    }

    .note-help {
      flex: 1 1 100%;
      color: var(--muted);
      font-size: 0.88rem;
      line-height: 1.35;
    }

    .note-editor-row {
      display: flex;
      flex: 1 1 100%;
      gap: 8px;
      align-items: stretch;
    }

    .note-editor-row textarea {
      flex: 1 1 auto;
      min-width: 220px;
    }

    .note-buttons {
      display: flex;
      flex-direction: column;
      gap: 8px;
    }

    .sendbar input[type="text"] {
      flex: 1 1 320px;
      min-width: 180px;
      font-family: var(--mono);
    }

    .monitor {
      min-height: 0;
      background: #05070a;
      border: 1px solid var(--border);
      border-radius: 12px;
      overflow: auto;
      padding: 12px;
      font-family: var(--mono);
      font-size: 0.92rem;
      line-height: 1.45;
      white-space: pre-wrap;
      word-break: break-word;
    }

    .line { display: block; min-height: 1.45em; }
    .timestamp { color: #7dbdff; user-select: none; }
    .rx { color: var(--text); }
    .tx { color: #d7b7ff; }
    .sys { color: #a6e3a1; }
    .err { color: var(--danger); }
    .muted { color: var(--muted); }

    .statusbar {
      justify-content: space-between;
      color: var(--muted);
      font-size: 0.88rem;
    }

    .status-left, .status-right {
      display: flex;
      align-items: center;
      gap: 10px;
      flex-wrap: wrap;
    }

    .pill {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 4px 8px;
      border: 1px solid var(--border);
      border-radius: 999px;
      background: var(--panel2);
    }

    .dot {
      width: 9px;
      height: 9px;
      border-radius: 50%;
      background: var(--danger);
      display: inline-block;
    }

    .dot.connected { background: var(--ok); }

    @media (max-width: 720px) {
      main { height: auto; min-height: calc(100vh - 76px); }
      .monitor { min-height: 45vh; }
      .toolbar, .sendbar, .statusbar { align-items: stretch; }
    }
  </style>
</head>
<body>
  <header>
    <h1>Firefox Web Serial Monitor</h1>
    <div class="subtitle">Arduino-style serial monitor with dynamic date/time timestamps, line endings, send box, autoscroll, clear, copy/save log, upload release/reconnect assist, chooser-order notes, persistent VID/PID-based port labels, and optional sketch-side auto-identify using DEVICE_NAME, VERSION/FIRMWARE, and BUILD lines.</div>
  </header>

  <main>
    <section class="toolbar" aria-label="Serial controls">
      <button id="connectButton" class="primary" title="Open the browser serial-port chooser. Firefox controls the names shown in that popup.">Grant / Connect New Port</button>
      <button id="connectGrantedButton" title="Connect to a port this page already has permission to use." disabled>Connect Granted Port</button>
      <select id="grantedPortsSelect" title="Ports already granted to this page."><option value="">No granted ports yet</option></select>
      <button id="refreshPortsButton" title="Refresh the list of serial ports already granted to this page.">Refresh Granted Ports</button>
      <button id="labelPortButton" title="Label the currently connected port. When VID/PID are available, the label is saved for automatic reuse." disabled>Label Connected Port</button>
      <button id="identifyButton" title="Send __identify__ to the connected sketch. If the sketch replies DEVICE_NAME=..., DEVICE_NAME = ..., VERSION=..., or BUILD=..., this page will parse those identity fields." disabled>Identify Now</button>
      <button id="releaseForUploadButton" title="Close the serial port so Arduino IDE can upload, then periodically try to reconnect to the same granted port." disabled>Release for Upload</button>
      <button id="exportLabelsButton" title="Save VID/PID-based port labels as a small JSON settings file.">Export Port Labels</button>
      <button id="importLabelsButton" title="Read VID/PID-based port labels from a JSON settings file you select.">Import Port Labels</button>
      <input id="importLabelsFile" type="file" accept=".json,application/json,text/plain" hidden />
      <button id="disconnectButton" class="danger" disabled>Disconnect</button>

      <details id="serialSettingsDetails" class="settings-details">
        <summary>Serial settings: <span id="settingsSummary">115200 8N1 DTR:ON RTS:OFF</span></summary>
        <div class="settings-content">
          <label>Baud
            <select id="baudSelect">
              <option>300</option>
              <option>1200</option>
              <option>2400</option>
              <option>4800</option>
              <option>9600</option>
              <option>19200</option>
              <option>38400</option>
              <option>57600</option>
              <option selected>115200</option>
              <option>230400</option>
              <option>250000</option>
              <option>460800</option>
              <option>500000</option>
              <option>921600</option>
              <option value="custom">Custom...</option>
            </select>
          </label>
          <input id="customBaud" type="number" min="1" step="1" placeholder="baud" hidden />

          <label>Data bits
            <select id="dataBitsSelect">
              <option>7</option>
              <option selected>8</option>
            </select>
          </label>

          <label>Parity
            <select id="paritySelect">
              <option selected>none</option>
              <option>even</option>
              <option>odd</option>
            </select>
          </label>

          <label>Stop bits
            <select id="stopBitsSelect">
              <option selected>1</option>
              <option>2</option>
            </select>
          </label>

          <label title="Assert DTR after opening the port. Some sketches that wait for Serial need this."><input id="dtrToggle" type="checkbox" checked />DTR</label>
          <label title="Assert RTS after opening the port. RTS/DTR changes can reset some boards."><input id="rtsToggle" type="checkbox" />RTS</label>
        </div>
      </details>

      <details id="chooserNotesDetails" class="settings-details notes-details">
        <summary>Chooser order notes</summary>
        <div class="settings-content notes-content">
          <div class="note-help">Optional human notes for Firefox's serial-port chooser order. These notes are saved in this browser and included with Export/Import Port Labels. They are not used as an automatic identifier because Web Serial does not expose the chooser row number.</div>
          <div class="note-editor-row">
            <textarea id="chooserNotes" spellcheck="false" placeholder="Example:
Popup row 1 = Elecrow display
Popup row 2 = Nesso C6 air sensor
Popup row 3 = ESP32-C6 pressure sensor"></textarea>
            <div class="note-buttons">
              <button id="saveChooserNotesButton" title="Save chooser-order notes in this browser.">Save Notes</button>
              <button id="clearChooserNotesButton" title="Clear chooser-order notes from this page and browser storage.">Clear Notes</button>
            </div>
          </div>
        </div>
      </details>

      <label title="Show or hide timestamps for all retained log rows, Arduino IDE style."><input id="timestampToggle" type="checkbox" checked />Timestamp</label>
      <label><input id="autoscrollToggle" type="checkbox" checked />Autoscroll</label>
      <label><input id="localEchoToggle" type="checkbox" />Local echo</label>
      <label title="After connecting, automatically send __identify__ to sketches that support it."><input id="autoIdentifyToggle" type="checkbox" checked />Auto identify</label>

      <button id="clearButton">Clear Output</button>
      <button id="saveButton">Save Log</button>
      <button id="copyButton" title="Copy the entire internal log, not just selected or currently visible text.">Copy Log</button>
    </section>

    <section id="monitor" class="monitor" aria-live="polite" aria-label="Serial output"></section>

    <section class="sendbar" aria-label="Send controls">
      <input id="sendInput" type="text" placeholder="Type text to send to the board" disabled />
      <label>Line ending
        <select id="lineEndingSelect">
          <option value="none">No line ending</option>
          <option value="nl" selected>Newline</option>
          <option value="cr">Carriage return</option>
          <option value="both">Both NL &amp; CR</option>
        </select>
      </label>
      <button id="sendButton" disabled>Send</button>
    </section>

    <section class="statusbar" aria-label="Status">
      <div class="status-left">
        <span class="pill"><span id="statusDot" class="dot"></span><span id="statusText">Disconnected</span></span>
        <span id="supportText"></span>
      </div>
      <div class="status-right">
        <span id="portInfo">No port selected</span>
        <span id="byteCount">RX 0 bytes / TX 0 bytes</span>
      </div>
    </section>
  </main>

  <script>
    "use strict";

    const $ = (id) => document.getElementById(id);

    const connectButton = $("connectButton");
    const connectGrantedButton = $("connectGrantedButton");
    const grantedPortsSelect = $("grantedPortsSelect");
    const refreshPortsButton = $("refreshPortsButton");
    const labelPortButton = $("labelPortButton");
    const identifyButton = $("identifyButton");
    const releaseForUploadButton = $("releaseForUploadButton");
    const exportLabelsButton = $("exportLabelsButton");
    const importLabelsButton = $("importLabelsButton");
    const importLabelsFile = $("importLabelsFile");
    const chooserNotes = $("chooserNotes");
    const saveChooserNotesButton = $("saveChooserNotesButton");
    const clearChooserNotesButton = $("clearChooserNotesButton");
    const disconnectButton = $("disconnectButton");
    const settingsSummary = $("settingsSummary");
    const baudSelect = $("baudSelect");
    const customBaud = $("customBaud");
    const dataBitsSelect = $("dataBitsSelect");
    const paritySelect = $("paritySelect");
    const stopBitsSelect = $("stopBitsSelect");
    const timestampToggle = $("timestampToggle");
    const autoscrollToggle = $("autoscrollToggle");
    const localEchoToggle = $("localEchoToggle");
    const autoIdentifyToggle = $("autoIdentifyToggle");
    const dtrToggle = $("dtrToggle");
    const rtsToggle = $("rtsToggle");
    const clearButton = $("clearButton");
    const saveButton = $("saveButton");
    const copyButton = $("copyButton");
    const monitor = $("monitor");
    const sendInput = $("sendInput");
    const lineEndingSelect = $("lineEndingSelect");
    const sendButton = $("sendButton");
    const statusDot = $("statusDot");
    const statusText = $("statusText");
    const supportText = $("supportText");
    const portInfo = $("portInfo");
    const byteCount = $("byteCount");

    let port = null;
    let reader = null;
    let keepReading = false;
    let rxBytes = 0;
    let txBytes = 0;
    let currentRxEntry = null;
    let currentTxEntry = null;
    let logEntries = [];
    let nextLogEntryId = 1;
    let lastWasCR = false;
    let grantedPorts = [];
    let portAliases = new WeakMap();
    let portSessionIds = new WeakMap();
    let portIdentities = new WeakMap();
    let nextPortSessionId = 1;
    let uploadAssistActive = false;
    let uploadAssistTimer = null;
    let uploadReconnectPort = null;
    let uploadReconnectKey = "";
    let uploadReconnectLabel = "";
    let uploadReconnectAttempts = 0;

    const LABEL_STORAGE_KEY = "firefox-web-serial-monitor-port-labels-v1";
    const CHOOSER_NOTES_STORAGE_KEY = "firefox-web-serial-monitor-chooser-notes-v1";
    const IDENTIFY_COMMAND = "__identify__\n";
    const AUTO_IDENTIFY_DELAY_MS = 1200;
    const UPLOAD_RECONNECT_INITIAL_DELAY_MS = 2500;
    const UPLOAD_RECONNECT_INTERVAL_MS = 1500;
    let savedPortLabelSettings = { version: 1, labels: {} };
    let persistentLabelStorageAvailable = false;

    function pad(value, width = 2) {
      return String(value).padStart(width, "0");
    }

    function timestamp() {
      const d = new Date();
      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} @ ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
    }

    function linePrefixFor(entry, forceTimestamps = timestampToggle.checked) {
      return forceTimestamps ? `${entry.ts}  ` : "";
    }

    function textForEntry(entry, forceTimestamps = timestampToggle.checked) {
      return `${linePrefixFor(entry, forceTimestamps)}${entry.text}`;
    }

    function createNodeForEntry(entry) {
      const line = document.createElement("span");
      line.className = `line ${entry.kind}`;
      line.dataset.logEntryId = String(entry.id);
      line.textContent = textForEntry(entry);
      entry.node = line;
      return line;
    }

    function appendLogEntry(kind, text = "") {
      const entry = {
        id: nextLogEntryId++,
        ts: timestamp(),
        kind,
        text,
        node: null
      };
      logEntries.push(entry);
      monitor.appendChild(createNodeForEntry(entry));
      scrollToBottom();
      return entry;
    }

    function updateEntryNode(entry) {
      if (entry && entry.node) entry.node.textContent = textForEntry(entry);
    }

    function renderLog() {
      monitor.textContent = "";
      for (const entry of logEntries) {
        monitor.appendChild(createNodeForEntry(entry));
      }
      scrollToBottom();
    }

    function serializedLog(forceTimestamps = timestampToggle.checked) {
      return logEntries.map((entry) => `${textForEntry(entry, forceTimestamps)}\n`).join("");
    }

    function hexId(value) {
      return value === undefined ? "----" : `0x${Number(value).toString(16).toUpperCase().padStart(4, "0")}`;
    }

    function portInfoText(serialPort) {
      if (!serialPort || !serialPort.getInfo) return "No USB VID/PID available";
      const info = serialPort.getInfo();
      const parts = [];
      if (info.usbVendorId !== undefined) parts.push(`VID ${hexId(info.usbVendorId)}`);
      if (info.usbProductId !== undefined) parts.push(`PID ${hexId(info.usbProductId)}`);
      if (info.bluetoothServiceClassId !== undefined) parts.push(`Bluetooth ${info.bluetoothServiceClassId}`);
      return parts.length ? parts.join(" / ") : "No USB VID/PID available";
    }

    function portKeyForInfo(info) {
      if (!info) return "";
      if (info.usbVendorId !== undefined && info.usbProductId !== undefined) {
        return `usb:${hexId(info.usbVendorId)}:${hexId(info.usbProductId)}`;
      }
      if (info.bluetoothServiceClassId !== undefined) {
        return `bluetooth:${String(info.bluetoothServiceClassId)}`;
      }
      return "";
    }

    function portKeyForPort(serialPort) {
      if (!serialPort || !serialPort.getInfo) return "";
      try {
        return portKeyForInfo(serialPort.getInfo());
      } catch (_) {
        return "";
      }
    }

    function normalizeImportedLabelSettings(value) {
      const normalized = { version: 1, labels: {} };
      const labels = value && typeof value === "object" && value.labels && typeof value.labels === "object"
        ? value.labels
        : value;

      if (!labels || typeof labels !== "object") return normalized;

      for (const [key, record] of Object.entries(labels)) {
        if (typeof record === "string") {
          normalized.labels[key] = { key, label: record, updatedAt: new Date().toISOString() };
        } else if (record && typeof record === "object" && typeof record.label === "string") {
          normalized.labels[key] = { ...record, key, label: record.label.trim() };
        }
      }
      return normalized;
    }

    function loadSavedPortLabelSettings() {
      try {
        const testKey = `${LABEL_STORAGE_KEY}-test`;
        localStorage.setItem(testKey, "1");
        localStorage.removeItem(testKey);
        persistentLabelStorageAvailable = true;

        const raw = localStorage.getItem(LABEL_STORAGE_KEY);
        if (raw) savedPortLabelSettings = normalizeImportedLabelSettings(JSON.parse(raw));
      } catch (_) {
        persistentLabelStorageAvailable = false;
        savedPortLabelSettings = { version: 1, labels: {} };
      }
    }

    function saveSavedPortLabelSettings() {
      try {
        localStorage.setItem(LABEL_STORAGE_KEY, JSON.stringify(savedPortLabelSettings));
        persistentLabelStorageAvailable = true;
        return true;
      } catch (err) {
        persistentLabelStorageAvailable = false;
        appendSystem(`Could not save labels to browser storage: ${err.message}`, "err");
        return false;
      }
    }

    function loadChooserNotes() {
      try {
        const raw = localStorage.getItem(CHOOSER_NOTES_STORAGE_KEY);
        chooserNotes.value = raw || "";
      } catch (_) {
        chooserNotes.value = "";
      }
    }

    function saveChooserNotes(showMessage = true) {
      try {
        localStorage.setItem(CHOOSER_NOTES_STORAGE_KEY, chooserNotes.value || "");
        if (showMessage) appendSystem("Chooser-order notes saved.", "muted");
      } catch (err) {
        appendSystem(`Could not save chooser-order notes: ${err.message}`, "err");
      }
    }

    function clearChooserNotes() {
      chooserNotes.value = "";
      try {
        localStorage.removeItem(CHOOSER_NOTES_STORAGE_KEY);
        appendSystem("Chooser-order notes cleared.", "muted");
      } catch (err) {
        appendSystem(`Could not clear chooser-order notes: ${err.message}`, "err");
      }
    }

    function savedLabelRecordForPort(serialPort) {
      const key = portKeyForPort(serialPort);
      return key ? savedPortLabelSettings.labels[key] : null;
    }

    function savedLabelForPort(serialPort) {
      const record = savedLabelRecordForPort(serialPort);
      return record && record.label ? record.label : "";
    }

    function labelForPort(serialPort) {
      const identity = serialPort ? portIdentities.get(serialPort) : null;
      return (identity && identity.deviceName) || portAliases.get(serialPort) || savedLabelForPort(serialPort);
    }

    function identitySummaryForPort(serialPort) {
      const identity = serialPort ? portIdentities.get(serialPort) : null;
      if (!identity) return "";
      const extras = [];
      if (identity.version) extras.push(`Version ${identity.version}`);
      if (identity.firmware) extras.push(`Firmware ${identity.firmware}`);
      if (identity.build) extras.push(`Build ${identity.build}`);
      return extras.length ? ` / ${extras.join(" / ")}` : "";
    }

    function setPortIdentityField(field, value) {
      if (!port || !value) return;
      const identity = portIdentities.get(port) || {};
      identity[field] = value.trim();
      portIdentities.set(port, identity);

      if (field === "deviceName") {
        appendSystem(`Identified connected device as "${identity.deviceName}" from sketch response.`, "muted");
      }

      showCurrentPortInfo();
      refreshGrantedPorts();
    }

    function processReceivedLine(line) {
      const trimmed = line.trim();
      if (!trimmed) return;

      const match = trimmed.match(/^([A-Z_][A-Z0-9_]*)\s*(?:=|:)\s*(.+)$/i);
      if (!match) return;

      const key = match[1].toUpperCase();
      const value = match[2].trim();
      if (!value) return;

      if (key === "DEVICE_NAME" || key === "DEVICE_LABEL" || key === "DEVICE_ID") {
        setPortIdentityField("deviceName", value);
      } else if (key === "VERSION" || key === "DEVICE_VERSION" || key === "SKETCH_VERSION") {
        setPortIdentityField("version", value);
      } else if (key === "FIRMWARE" || key === "FIRMWARE_VERSION") {
        setPortIdentityField("firmware", value);
      } else if (key === "BUILD" || key === "BUILD_TIMESTAMP") {
        setPortIdentityField("build", value);
      }
    }

    function duplicateGrantedPortCountForKey(key) {
      if (!key) return 0;
      return grantedPorts.filter((serialPort) => portKeyForPort(serialPort) === key).length;
    }

    function savedLabelCount() {
      return Object.keys(savedPortLabelSettings.labels || {}).length;
    }

    function sessionIdForPort(serialPort) {
      if (!portSessionIds.has(serialPort)) {
        portSessionIds.set(serialPort, nextPortSessionId++);
      }
      return portSessionIds.get(serialPort);
    }

    function displayNameForPort(serialPort, index) {
      const alias = labelForPort(serialPort);
      const base = alias || `Granted port ${index + 1}`;
      return `${base} β€” ${portInfoText(serialPort)}${identitySummaryForPort(serialPort)}`;
    }

    function showCurrentPortInfo() {
      if (!port) {
        portInfo.textContent = "No port selected";
        return;
      }
      const alias = labelForPort(port);
      const prefix = alias ? `${alias}: ` : `Session port ${sessionIdForPort(port)}: `;
      portInfo.textContent = `${prefix}${portInfoText(port)}${identitySummaryForPort(port)}`;
    }

    async function refreshGrantedPorts() {
      if (!("serial" in navigator)) return;
      try {
        grantedPorts = await navigator.serial.getPorts();
        grantedPortsSelect.textContent = "";
        if (!grantedPorts.length) {
          const option = document.createElement("option");
          option.value = "";
          option.textContent = "No granted ports yet";
          grantedPortsSelect.appendChild(option);
        } else {
          grantedPorts.forEach((serialPort, index) => {
            sessionIdForPort(serialPort);
            const option = document.createElement("option");
            option.value = String(index);
            option.textContent = displayNameForPort(serialPort, index);
            grantedPortsSelect.appendChild(option);
          });
        }
        connectGrantedButton.disabled = !!port || grantedPorts.length === 0;
      } catch (err) {
        appendSystem(`Could not refresh granted ports: ${err.message}`, "err");
      }
    }

    async function connectToPort(selectedPort, sourceLabel = "serial port") {
      port = selectedPort;
      await port.open({
        baudRate: selectedBaudRate(),
        dataBits: Number(dataBitsSelect.value),
        stopBits: Number(stopBitsSelect.value),
        parity: paritySelect.value,
        bufferSize: 255
      });

      await applySignals();
      setConnected(true);
      showCurrentPortInfo();
      appendSystem(`Opened ${sourceLabel} at ${selectedBaudRate()} baud, ${dataBitsSelect.value}${paritySelect.value[0].toUpperCase()}${stopBitsSelect.value}. ${portInfoText(port)}.`);

      keepReading = true;
      readLoop();

      if (autoIdentifyToggle.checked) {
        window.setTimeout(() => {
          if (port === selectedPort) sendIdentifyProbe("auto");
        }, AUTO_IDENTIFY_DELAY_MS);
      }
    }

    function setConnected(isConnected) {
      statusDot.classList.toggle("connected", isConnected);
      statusText.textContent = isConnected ? "Connected" : "Disconnected";
      connectButton.disabled = isConnected;
      connectGrantedButton.disabled = isConnected || grantedPorts.length === 0;
      refreshPortsButton.disabled = isConnected;
      grantedPortsSelect.disabled = isConnected || grantedPorts.length === 0;
      labelPortButton.disabled = !isConnected;
      identifyButton.disabled = !isConnected;
      releaseForUploadButton.disabled = !isConnected && !uploadAssistActive;
      releaseForUploadButton.textContent = uploadAssistActive ? "Cancel Reconnect" : "Release for Upload";
      disconnectButton.disabled = !isConnected;
      sendInput.disabled = !isConnected;
      sendButton.disabled = !isConnected;
      baudSelect.disabled = isConnected;
      customBaud.disabled = isConnected;
      dataBitsSelect.disabled = isConnected;
      paritySelect.disabled = isConnected;
      stopBitsSelect.disabled = isConnected;
      dtrToggle.disabled = false;
      rtsToggle.disabled = false;
    }

    function updateByteCount() {
      byteCount.textContent = `RX ${rxBytes} bytes / TX ${txBytes} bytes`;
    }

    function scrollToBottom() {
      if (autoscrollToggle.checked) {
        monitor.scrollTop = monitor.scrollHeight;
      }
    }

    function appendSystem(message, className = "sys") {
      appendLogEntry(className, message);
    }

    function ensureLine(kind) {
      const current = kind === "tx" ? currentTxEntry : currentRxEntry;
      if (current) return current;

      const entry = appendLogEntry(kind, "");
      if (kind === "tx") currentTxEntry = entry;
      else currentRxEntry = entry;
      return entry;
    }

    function finishLine(kind) {
      if (kind === "tx") currentTxEntry = null;
      else currentRxEntry = null;
      scrollToBottom();
    }

    function appendSerialText(text, kind = "rx") {
      for (const ch of text) {
        if (ch === "\r") {
          const entry = ensureLine(kind);
          if (kind === "rx") processReceivedLine(entry.text);
          finishLine(kind);
          lastWasCR = kind === "rx";
          continue;
        }

        if (ch === "\n") {
          if (kind === "rx" && lastWasCR) {
            lastWasCR = false;
            continue;
          }
          const entry = ensureLine(kind);
          if (kind === "rx") processReceivedLine(entry.text);
          finishLine(kind);
          continue;
        }

        lastWasCR = false;
        const entry = ensureLine(kind);
        entry.text += ch;
        updateEntryNode(entry);
      }
      scrollToBottom();
    }

    function selectedBaudRate() {
      if (baudSelect.value === "custom") {
        const n = Number(customBaud.value);
        if (!Number.isFinite(n) || n <= 0) throw new Error("Enter a valid custom baud rate.");
        return Math.floor(n);
      }
      return Number(baudSelect.value);
    }

    function selectedBaudRateLabel() {
      if (baudSelect.value === "custom") {
        const n = Number(customBaud.value);
        return Number.isFinite(n) && n > 0 ? String(Math.floor(n)) : "custom";
      }
      return baudSelect.value;
    }

    function serialFrameLabel() {
      const parityLetter = paritySelect.value === "none" ? "N" : paritySelect.value[0].toUpperCase();
      return `${dataBitsSelect.value}${parityLetter}${stopBitsSelect.value}`;
    }

    function updateSettingsSummary() {
      settingsSummary.textContent = `${selectedBaudRateLabel()} ${serialFrameLabel()} DTR:${dtrToggle.checked ? "ON" : "OFF"} RTS:${rtsToggle.checked ? "ON" : "OFF"}`;
    }

    function lineEnding() {
      switch (lineEndingSelect.value) {
        case "nl": return "\n";
        case "cr": return "\r";
        case "both": return "\r\n";
        default: return "";
      }
    }

    async function applySignals() {
      if (!port || !port.setSignals) return;
      try {
        await port.setSignals({ dataTerminalReady: dtrToggle.checked, requestToSend: rtsToggle.checked });
      } catch (err) {
        appendSystem(`Could not set DTR/RTS: ${err.message}`, "err");
      }
    }

    async function connect() {
      if (!("serial" in navigator)) {
        appendSystem("Web Serial is not available in this browser/context. Use Firefox 151+ Desktop, Chrome, or Edge in a secure context such as localhost.", "err");
        return;
      }

      try {
        const selectedPort = await navigator.serial.requestPort();
        await connectToPort(selectedPort, "newly granted serial port");
        await refreshGrantedPorts();
      } catch (err) {
        appendSystem(`Connect failed: ${err.message}`, "err");
        try { if (port) await port.close(); } catch (_) {}
        port = null;
        showCurrentPortInfo();
        setConnected(false);
      }
    }

    async function connectGranted() {
      if (!("serial" in navigator)) return;
      const index = Number(grantedPortsSelect.value);
      if (!Number.isInteger(index) || !grantedPorts[index]) {
        appendSystem("No granted port is selected.", "err");
        return;
      }
      try {
        await connectToPort(grantedPorts[index], displayNameForPort(grantedPorts[index], index));
      } catch (err) {
        appendSystem(`Connect failed: ${err.message}`, "err");
        try { if (port) await port.close(); } catch (_) {}
        port = null;
        showCurrentPortInfo();
        setConnected(false);
      }
    }

    function labelConnectedPort() {
      if (!port) return;
      const key = portKeyForPort(port);
      const info = port.getInfo ? port.getInfo() : {};
      const current = labelForPort(port) || "";
      const duplicateCount = duplicateGrantedPortCountForKey(key);
      const duplicateNote = duplicateCount > 1
        ? `\n\nNote: ${duplicateCount} granted ports currently share this same VID/PID, so a saved VID/PID label will apply to all of them.`
        : "";
      const label = prompt(
        `Label for the currently connected port. Example: Elecrow display, Nesso air sensor, BME688 test board.\n\nWhen VID/PID are available, this label is saved and automatically reused for matching ports.${duplicateNote}`,
        current
      );
      if (label === null) return;

      const trimmed = label.trim();
      if (trimmed) portAliases.set(port, trimmed);
      else portAliases.delete(port);

      if (key) {
        if (trimmed) {
          savedPortLabelSettings.labels[key] = {
            key,
            label: trimmed,
            usbVendorId: info.usbVendorId,
            usbProductId: info.usbProductId,
            bluetoothServiceClassId: info.bluetoothServiceClassId,
            updatedAt: new Date().toISOString(),
            updatedLocal: timestamp()
          };
        } else {
          delete savedPortLabelSettings.labels[key];
        }

        const stored = saveSavedPortLabelSettings();
        const scopeNote = duplicateCount > 1 ? ` (${duplicateCount} granted ports share ${key})` : "";
        appendSystem(
          trimmed
            ? `Saved label "${trimmed}" for ${key}${scopeNote}.${stored ? "" : " Use Export Port Labels if you want to keep a copy."}`
            : `Cleared saved label for ${key}${scopeNote}.`,
          "muted"
        );
      } else {
        appendSystem(trimmed ? `Labeled current port for this page session only: ${trimmed}` : "Cleared current session label.", "muted");
      }

      showCurrentPortInfo();
      refreshGrantedPorts();
    }

    async function readLoop() {
      const decoder = new TextDecoder();
      while (port && port.readable && keepReading) {
        reader = port.readable.getReader();
        try {
          while (keepReading) {
            const { value, done } = await reader.read();
            if (done) break;
            if (value) {
              rxBytes += value.byteLength;
              updateByteCount();
              appendSerialText(decoder.decode(value, { stream: true }), "rx");
            }
          }
        } catch (err) {
          appendSystem(`Read error: ${err.message}`, "err");
        } finally {
          try { reader.releaseLock(); } catch (_) {}
          reader = null;
        }
      }
    }

    function cancelUploadReconnect(showMessage = true) {
      uploadAssistActive = false;
      uploadReconnectPort = null;
      uploadReconnectKey = "";
      uploadReconnectLabel = "";
      uploadReconnectAttempts = 0;
      if (uploadAssistTimer) {
        window.clearTimeout(uploadAssistTimer);
        uploadAssistTimer = null;
      }
      setConnected(!!port);
      if (showMessage) appendSystem("Upload reconnect assist canceled.", "muted");
    }

    async function findUploadReconnectCandidate() {
      if (uploadReconnectPort) return uploadReconnectPort;
      if (!("serial" in navigator)) return null;

      const ports = await navigator.serial.getPorts();
      if (uploadReconnectKey) {
        const matches = ports.filter((candidate) => portKeyForPort(candidate) === uploadReconnectKey);
        if (matches.length === 1) return matches[0];
      }
      return null;
    }

    async function tryUploadReconnect() {
      if (!uploadAssistActive || port) return;
      uploadReconnectAttempts++;

      try {
        const candidate = await findUploadReconnectCandidate();
        if (!candidate) throw new Error("previously granted port is not currently available");

        await connectToPort(candidate, uploadReconnectLabel || "released upload port");
        uploadAssistActive = false;
        uploadReconnectPort = null;
        uploadReconnectKey = "";
        uploadReconnectLabel = "";
        uploadReconnectAttempts = 0;
        if (uploadAssistTimer) {
          window.clearTimeout(uploadAssistTimer);
          uploadAssistTimer = null;
        }
        appendSystem("Upload reconnect assist reconnected successfully.", "muted");
        setConnected(true);
      } catch (err) {
        port = null;
        setConnected(false);
        showCurrentPortInfo();
        if (uploadAssistActive) {
          statusText.textContent = `Waiting for upload to finish… retry ${uploadReconnectAttempts}`;
          uploadAssistTimer = window.setTimeout(tryUploadReconnect, UPLOAD_RECONNECT_INTERVAL_MS);
        }
      }
    }

    async function releaseForUpload() {
      if (uploadAssistActive) {
        cancelUploadReconnect(true);
        return;
      }

      if (!port) {
        appendSystem("No connected serial port to release.", "err");
        return;
      }

      uploadReconnectPort = port;
      uploadReconnectKey = portKeyForPort(port);
      uploadReconnectLabel = labelForPort(port) || `Session port ${sessionIdForPort(port)}`;
      uploadReconnectAttempts = 0;
      uploadAssistActive = true;
      setConnected(true);

      appendSystem(`Released ${uploadReconnectLabel} for Arduino upload. Start the upload now; this page will retry reconnecting every ${UPLOAD_RECONNECT_INTERVAL_MS / 1000} seconds.`, "muted");
      await disconnect({ preserveUploadAssist: true, quiet: true });
      statusText.textContent = "Released for upload; waiting…";
      releaseForUploadButton.disabled = false;
      releaseForUploadButton.textContent = "Cancel Reconnect";
      uploadAssistTimer = window.setTimeout(tryUploadReconnect, UPLOAD_RECONNECT_INITIAL_DELAY_MS);
    }

    async function disconnect(options = {}) {
      keepReading = false;

      try {
        if (reader) {
          await reader.cancel();
        }
      } catch (_) {}

      try {
        if (port) {
          await port.close();
        }
        if (!options.quiet) appendSystem("Serial port closed.");
      } catch (err) {
        appendSystem(`Disconnect error: ${err.message}`, "err");
      } finally {
        port = null;
        currentRxEntry = null;
        currentTxEntry = null;
        showCurrentPortInfo();
        if (!options.preserveUploadAssist) cancelUploadReconnect(false);
        setConnected(false);
        refreshGrantedPorts();
      }
    }

    async function writeToPort(text) {
      if (!port || !port.writable) return false;
      const encoder = new TextEncoder();
      const data = encoder.encode(text);
      const writer = port.writable.getWriter();
      try {
        await writer.write(data);
        txBytes += data.byteLength;
        updateByteCount();
        return true;
      } finally {
        writer.releaseLock();
      }
    }

    async function sendIdentifyProbe(source = "manual") {
      if (!port || !port.writable) return;
      try {
        const ok = await writeToPort(IDENTIFY_COMMAND);
        if (ok) appendSystem(`${source === "auto" ? "Auto-identify" : "Identify"} probe sent: ${IDENTIFY_COMMAND.trim()}`, "muted");
      } catch (err) {
        appendSystem(`Identify probe failed: ${err.message}`, "err");
      }
    }

    async function sendText() {
      if (!port || !port.writable) return;
      const text = sendInput.value + lineEnding();
      if (text.length === 0) return;

      try {
        const ok = await writeToPort(text);

        if (ok && localEchoToggle.checked) {
          appendSerialText(`> ${text}`, "tx");
        }
        sendInput.value = "";
        sendInput.focus();
      } catch (err) {
        appendSystem(`Send failed: ${err.message}`, "err");
      }
    }

    function clearOutput() {
      monitor.textContent = "";
      logEntries = [];
      currentRxEntry = null;
      currentTxEntry = null;
      appendSystem("Output cleared.", "muted");
    }

    function saveLog() {
      const text = serializedLog();
      const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `serial-log-${timestamp().replace(/[:@ ]/g, "-")}.txt`;
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
    }

    function fallbackCopyText(text) {
      const textarea = document.createElement("textarea");
      textarea.value = text;
      textarea.setAttribute("readonly", "");
      textarea.style.position = "fixed";
      textarea.style.left = "-9999px";
      textarea.style.top = "0";
      document.body.appendChild(textarea);
      textarea.focus();
      textarea.select();
      const ok = document.execCommand("copy");
      textarea.remove();
      if (!ok) throw new Error("Browser refused clipboard copy.");
    }

    async function copyLog() {
      const rowsCopied = logEntries.length;
      const text = serializedLog();
      try {
        if (navigator.clipboard && window.isSecureContext) {
          await navigator.clipboard.writeText(text);
        } else {
          fallbackCopyText(text);
        }
        appendSystem(`Copied ${rowsCopied} log row${rowsCopied === 1 ? "" : "s"} to the clipboard.`, "muted");
      } catch (err) {
        appendSystem(`Copy failed: ${err.message}`, "err");
      }
    }

    function exportPortLabels() {
      const payload = {
        ...savedPortLabelSettings,
        chooserOrderNotes: chooserNotes.value || "",
        exportedAt: new Date().toISOString(),
        exportedLocal: timestamp()
      };
      const text = JSON.stringify(payload, null, 2);
      const blob = new Blob([text], { type: "application/json;charset=utf-8" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = `serial-port-label-settings-${timestamp().replace(/[:@ ]/g, "-")}.json`;
      document.body.appendChild(a);
      a.click();
      a.remove();
      URL.revokeObjectURL(url);
      appendSystem(`Exported ${savedLabelCount()} saved port label${savedLabelCount() === 1 ? "" : "s"}.`, "muted");
    }

    async function importPortLabelsFromFile(event) {
      const file = event.target.files && event.target.files[0];
      event.target.value = "";
      if (!file) return;

      try {
        const text = await file.text();
        const parsed = JSON.parse(text);
        const imported = normalizeImportedLabelSettings(parsed);
        const importedCount = Object.keys(imported.labels || {}).length;
        savedPortLabelSettings = {
          version: 1,
          labels: {
            ...savedPortLabelSettings.labels,
            ...imported.labels
          }
        };
        saveSavedPortLabelSettings();

        let importedNotes = false;
        if (parsed && typeof parsed === "object" && typeof parsed.chooserOrderNotes === "string") {
          chooserNotes.value = parsed.chooserOrderNotes;
          saveChooserNotes(false);
          importedNotes = true;
        }

        await refreshGrantedPorts();
        showCurrentPortInfo();
        appendSystem(`Imported ${importedCount} port label${importedCount === 1 ? "" : "s"}${importedNotes ? " and chooser-order notes" : ""} from ${file.name}.`, "muted");
      } catch (err) {
        appendSystem(`Import failed: ${err.message}`, "err");
      }
    }

    baudSelect.addEventListener("change", () => {
      customBaud.hidden = baudSelect.value !== "custom";
      if (!customBaud.hidden) customBaud.focus();
      updateSettingsSummary();
    });
    customBaud.addEventListener("input", updateSettingsSummary);
    dataBitsSelect.addEventListener("change", updateSettingsSummary);
    paritySelect.addEventListener("change", updateSettingsSummary);
    stopBitsSelect.addEventListener("change", updateSettingsSummary);

    connectButton.addEventListener("click", connect);
    connectGrantedButton.addEventListener("click", connectGranted);
    refreshPortsButton.addEventListener("click", refreshGrantedPorts);
    labelPortButton.addEventListener("click", labelConnectedPort);
    identifyButton.addEventListener("click", () => sendIdentifyProbe("manual"));
    disconnectButton.addEventListener("click", () => disconnect());
    releaseForUploadButton.addEventListener("click", releaseForUpload);
    sendButton.addEventListener("click", sendText);
    clearButton.addEventListener("click", clearOutput);
    saveButton.addEventListener("click", saveLog);
    copyButton.addEventListener("click", copyLog);
    exportLabelsButton.addEventListener("click", exportPortLabels);
    importLabelsButton.addEventListener("click", () => importLabelsFile.click());
    importLabelsFile.addEventListener("change", importPortLabelsFromFile);
    saveChooserNotesButton.addEventListener("click", () => saveChooserNotes(true));
    clearChooserNotesButton.addEventListener("click", clearChooserNotes);
    chooserNotes.addEventListener("input", () => saveChooserNotes(false));
    timestampToggle.addEventListener("change", renderLog);
    dtrToggle.addEventListener("change", () => { updateSettingsSummary(); applySignals(); });
    rtsToggle.addEventListener("change", () => { updateSettingsSummary(); applySignals(); });

    sendInput.addEventListener("keydown", (event) => {
      if (event.key === "Enter" && !event.shiftKey) {
        event.preventDefault();
        sendText();
      }
    });

    window.addEventListener("beforeunload", () => {
      if (port) disconnect();
      if (uploadAssistTimer) window.clearTimeout(uploadAssistTimer);
    });

    loadSavedPortLabelSettings();
    loadChooserNotes();

    if ("serial" in navigator) {
      supportText.textContent = "Web Serial API detected.";
      navigator.serial.addEventListener("connect", refreshGrantedPorts);
      navigator.serial.addEventListener("disconnect", refreshGrantedPorts);
      appendSystem("Ready. Click Grant / Connect New Port, choose the ESP32 serial port, and match the sketch baud rate. Browser chooser names cannot be customized by this page; use Chooser order notes for human port-picker hints, sketches can optionally respond to __identify__ with DEVICE_NAME=..., VERSION=..., and BUILD=..., and Release for Upload can temporarily close the port while Arduino IDE uploads.", "muted");
      if (persistentLabelStorageAvailable) {
        appendSystem(`Loaded ${savedLabelCount()} saved VID/PID port label${savedLabelCount() === 1 ? "" : "s"} from browser storage.`, "muted");
      } else {
        appendSystem("Browser storage for automatic port labels is unavailable in this context. Import/Export Port Labels can still be used manually.", "muted");
      }
      refreshGrantedPorts();
    } else {
      supportText.textContent = "Web Serial API not detected.";
      appendSystem("This browser/context does not expose navigator.serial. Try Firefox 151+ Desktop and open this page from file:// or http://localhost.", "err");
      connectButton.disabled = true;
      connectGrantedButton.disabled = true;
      refreshPortsButton.disabled = true;
      labelPortButton.disabled = true;
      identifyButton.disabled = true;
      releaseForUploadButton.disabled = true;
    }

    setConnected(false);
    showCurrentPortInfo();
    updateSettingsSummary();
    updateByteCount();
  </script>
</body>
</html>

ESP32 helper code:

String webSerialCmdLine;

void sendWebSerialIdentity() {
  Serial.print("DEVICE_NAME = ");
  Serial.println(getDeviceName());

  Serial.print("VERSION = ");
  Serial.println(zbModelString);

  Serial.print("BUILD = ");
  Serial.println(Timestamp);
}

void checkSerialCommands() {
  // No active host terminal: clear any partial command and do nothing.
  // On native USB CDC ESP32 boards, !Serial usually tracks the host/DTR state.
  // On USB-to-UART boards, Serial may always evaluate true; Serial.available()
  // still keeps this function effectively idle unless a command arrives.
  if (!Serial) {
    webSerialCmdLine = "";
    return;
  }

  while (Serial.available()) {
    char c = (char)Serial.read();

    if (c == '\r') continue;

    if (c == '\n') {
      webSerialCmdLine.trim();

      if (webSerialCmdLine == "__identify__") {
        sendWebSerialIdentity();
      }

      webSerialCmdLine = "";
      return;
    }

    if (webSerialCmdLine.length() < 80) {
      webSerialCmdLine += c;
    } else {
      webSerialCmdLine = ""; // discard overlong junk
    }
  }

  /* Alternative identity labels recognized by the Firefox Web Serial Monitor:
    DEVICE_NAME = ...
    DEVICE_LABEL = ...
    DEVICE_ID = ...

    VERSION = ...
    DEVICE_VERSION = ...
    SKETCH_VERSION = ...

    FIRMWARE = ...
    FIRMWARE_VERSION = ...

    BUILD = ...
    BUILD_TIMESTAMP = ...
  */
}

The User Guide is in the next post (space limitation exceeded here).

Firefox Web Serial Monitor User Guide

Overview

The Firefox Web Serial Monitor is a local, single-file HTML serial monitor for boards and devices that expose a serial port to the browser through the Web Serial API. It is intended to provide Arduino IDE Serial Monitor-style functionality, plus conveniences such as full-log copy/save, dynamic timestamps, port notes, optional sketch-side identification, and an upload-assist mode that temporarily releases the serial port for Arduino IDE uploads.

The monitor is especially useful for ESP32-S3, ESP32-C6, and similar boards that print diagnostic messages with Serial.print() / Serial.println().

Requirements

  • Firefox Desktop with Web Serial support.

  • A board or USB serial adapter visible to the operating system as a serial port.

  • A sketch that initializes serial output, for example:


Serial.begin(115200);

Serial.println("Starting...");

For normal viewing of serial output, no sketch changes are required.

Opening the Monitor

The monitor is a local HTML file. In many cases, it can be opened directly in Firefox by double-clicking the file.

If the browser does not allow Web Serial from a directly opened file, serve the file from localhost instead. From the folder containing the HTML file, run:


python -m http.server 8000

Then open:


http://localhost:8000/firefox_web_serial_monitor_v9.html

Basic Connection Workflow

  1. Connect the board to USB.

  2. Close any other program that is using the same serial port, such as Arduino IDE Serial Monitor.

  3. Open the Web Serial Monitor page.

  4. Confirm or adjust the serial settings.

  5. Click Grant / Connect New Port.

  6. Select the appropriate serial device in the Firefox port chooser.

  7. Watch serial output appear in the terminal area.

Only one program can usually open a given serial port at a time. If Firefox cannot connect, check whether Arduino Serial Monitor, PlatformIO, PuTTY, another browser tab, or another tool already has the port open.

When uploading a new sketch from Arduino IDE while the Web Serial Monitor is connected, use Release for Upload before starting the upload. The monitor will close the port, wait while Arduino IDE uses it, and then periodically try to reconnect.

Main Controls

Grant / Connect New Port

Opens Firefox's browser-controlled serial port chooser. Select the device you want to monitor.

Firefox controls the chooser dialog. The page cannot rename the devices shown there. Many ESP32 native USB devices may appear with the same name, such as USB JTAG/serial debug unit.

Disconnect

Closes the current serial connection.

Release for Upload / Cancel Reconnect

Temporarily closes the current serial connection so another tool, such as Arduino IDE, can upload a new sketch to the same board.

Use this when the Web Serial Monitor is connected to a board and you want to upload from Arduino IDE without manually disconnecting and reconnecting the monitor.

Recommended upload workflow:

  1. Confirm the Web Serial Monitor is connected to the target board.

  2. Click Release for Upload.

  3. Start the upload in Arduino IDE.

  4. Leave the Web Serial Monitor page open.

  5. After the upload finishes and Arduino IDE releases the port, the Web Serial Monitor periodically retries and reconnects to the previously granted port.

While this mode is active, the button changes to Cancel Reconnect. Click it if you do not want the page to keep retrying.

This is an assisted workflow, not true Arduino-IDE-style automatic contention detection. A browser page cannot detect that Arduino IDE is about to use the port. You must click Release for Upload before starting the upload.

If the upload causes the board to disappear and re-enumerate in a way that changes the browser's granted port object, automatic reconnect may fail. In that case, use Grant / Connect New Port again.

Send Box

Type text into the send box and press Send to transmit it to the connected device.

The selected line-ending mode controls what is appended to the outgoing text.

Line Ending

Controls what is appended when sending text:

  • None: sends exactly the typed text.

  • Newline: appends \n.

  • Carriage return: appends \r.

  • Both NL + CR or equivalent option: appends both line-ending characters.

For Arduino-style command parsing using readStringUntil('\n') or a line-oriented parser, use a newline-ending mode.

Timestamp

Toggles timestamp display for the entire retained log.

Timestamps are dynamic. Turning timestamps off hides them for all retained rows. Turning timestamps back on re-displays them using the original receive times.

Timestamp format:


2026-05-21 @ 15:18:36.004

Autoscroll

When enabled, the terminal view scrolls to the newest output automatically.

Disable autoscroll when you want to inspect earlier output without the view jumping as new data arrives.

Local Echo

When enabled, text sent from the send box is also shown in the terminal immediately.

This is useful when the sketch accepts commands but does not echo them back.

If the sketch already echoes received commands, local echo may make sent commands appear twice.

Clear

Clears the retained log and the visible terminal.

Copy Log

Copies the full retained internal log to the clipboard, not merely the text currently visible on screen.

The copied format follows the current timestamp setting. If timestamps are on, copied log lines include timestamps. If timestamps are off, copied log lines omit timestamps.

Save Log

Downloads the full retained internal log as a text file.

Like Copy Log, the saved format follows the current timestamp setting.

Serial Settings

The Serial settings section is collapsible. When collapsed, it shows a compact summary such as:


Serial settings: 115200 8N1 DTR:ON RTS:OFF

Baud

The baud rate must match the sketch's Serial.begin(...) value.

Common values:

  • 9600

  • 57600

  • 115200

  • 230400

  • 460800

  • 921600

Most of these ESP32 diagnostic sketches use 115200 unless changed.

Data Bits, Parity, and Stop Bits

For Arduino/ESP32 serial output, the usual setting is:


8N1

That means:

  • 8 data bits

  • No parity

  • 1 stop bit

DTR and RTS

DTR means Data Terminal Ready.

RTS means Request To Send.

They are serial control signals. On many microcontroller boards, especially boards with USB-to-UART auto-reset circuitry or native USB CDC support, DTR and RTS can affect reset or boot behavior.

Recommended default for ordinary monitoring:


DTR: ON

RTS: OFF

If connecting causes unexpected resets or bootloader behavior, try changing DTR and RTS.

DTR and RTS do not transmit a unique device identity. They are control lines/signals, not an identification protocol.

Uploading New Sketches While Using the Monitor

Arduino IDE can coordinate its own Serial Monitor and uploader because both are part of the same application. The Firefox Web Serial Monitor is a browser page, so it cannot automatically know that Arduino IDE is about to upload a binary or that Arduino IDE is contending for the same COM port.

The Web Serial Monitor therefore provides an assisted Release for Upload workflow:

  • Release for Upload closes the active serial port so Arduino IDE can use it.

  • The page remembers the previously granted port object.

  • The page periodically tries to reopen that port.

  • When Arduino IDE finishes and releases the port, the monitor reconnects automatically if the granted port is still valid.

This avoids the common manual cycle of disconnecting the monitor, uploading, then reconnecting the monitor.

Limitations:

  • The page cannot detect an upload attempt before it happens. Click Release for Upload first.

  • If the board disappears and re-enumerates as a different browser-visible port, reconnect may fail. Use Grant / Connect New Port again.

  • If multiple identical ESP32 devices are connected, confirm that you released and reconnected to the intended device.

Port Labels and Duplicate ESP32 Ports

Many ESP32-S3 and ESP32-C6 boards expose the same USB identity, for example:


VID 0x303A / PID 0x1001

When several boards share the same VID/PID, the browser cannot distinguish them by VID/PID alone. Firefox may show several entries with the same name, such as:


USB JTAG/serial debug unit

The monitor therefore provides several practical tools for working around this limitation.

Session Label

After connecting, you can assign a temporary label to the current port, such as:


Elecrow display

Nesso air sensor

ESP32-C6 pressure sensor

This label helps keep the active session readable, but it does not rename Firefox's chooser entries.

Saved VID/PID Labels

The monitor can save labels associated with VID/PID pairs. This works well for distinguishing different USB serial adapter types, such as ESP32 native USB vs. CH340 vs. CP210x.

It does not distinguish five identical ESP32 native USB devices if they all report the same VID/PID.

Export Port Labels

Downloads a JSON settings file containing saved port-label information and chooser-order notes.

Use this to back up or move your monitor settings.

Import Port Labels

Reads a previously exported JSON settings file and restores saved label information and chooser-order notes.

A local web page cannot silently read a settings file by path. The browser requires you to select the file manually.

Chooser Order Notes

The Chooser order notes section is a browser-side replacement for a physical note or Post-it.

Use it to record observations such as:


Popup row 1 = Elecrow display

Popup row 2 = Nesso C6 air sensor

Popup row 3 = ESP32-C6 pressure sensor

These notes are saved locally and included in Export/Import settings.

The notes are human guidance only. The page does not automate selection based on chooser row order, because the browser does not expose the chooser row number to JavaScript and does not guarantee that the order will remain stable.

Auto Identify

The monitor includes an optional sketch-identification mechanism.

When Auto identify is enabled, the page sends this command shortly after connecting:


__identify__

The same command is also sent when you click Identify Now. The sketch-side handler should therefore treat __identify__ as a repeatable command. This allows the sketch to identify itself on first connection, after a browser disconnect/reconnect, and after repeated manual Identify Now requests.

The sketch can respond with identity lines. The monitor recognizes keys such as:


DEVICE_NAME = ...

DEVICE_LABEL = ...

DEVICE_ID = ...

VERSION = ...

DEVICE_VERSION = ...

SKETCH_VERSION = ...

FIRMWARE = ...

FIRMWARE_VERSION = ...

BUILD = ...

BUILD_TIMESTAMP = ...

Both = and : are accepted, so these are equivalent:


VERSION = v2.8 N1 Dual Sensor

VERSION: v2.8 N1 Dual Sensor

When a recognized device-name line is received, the page uses it as the connected port's displayed session label.

Identify Now

Sends the same __identify__ command manually.

Use this if the board reset when the serial port opened and the automatic identify command was sent before the sketch was ready.

Optional Sketch-Side Identify Support

Normal serial monitoring does not require any sketch changes. To support Auto Identify, add a small command handler to the sketch.

Recommended reconnect-safe non-blocking command handler:


String webSerialCmdLine;

void sendWebSerialIdentity() {

Serial.print("DEVICE_NAME = ");

Serial.println(getDeviceName());

Serial.print("VERSION = ");

Serial.println(zbModelString);

Serial.print("BUILD = ");

Serial.println(Timestamp);

}

void checkSerialCommands() {

if (!Serial) {

webSerialCmdLine = "";

return;

}

while (Serial.available()) {

char c = (char)Serial.read();

if (c == '\r') continue;

if (c == '\n') {

webSerialCmdLine.trim();

if (webSerialCmdLine == "__identify__") {

sendWebSerialIdentity();

}

webSerialCmdLine = "";

return;

}

if (webSerialCmdLine.length() < 80) {

webSerialCmdLine += c;

} else {

webSerialCmdLine = ""; // discard overlong junk

}

}

}

Call it from loop():


void loop() {

checkSerialCommands();

// existing loop code...

}

Notes on the Identify Handler

The handler returns immediately unless serial input is actually available.

The recommended handler is intentionally repeatable. It does not use a webSerialIdentified one-shot gate. That matters because a browser disconnect/reconnect does not necessarily reboot the ESP32. If the sketch answers once and then permanently stops checking for __identify__, later reconnect probes and Identify Now requests will be ignored.

The if (!Serial) check is useful on native USB CDC boards where Serial can indicate whether a host is connected. On some USB-to-UART boards, it may always behave as connected. Clearing webSerialCmdLine when !Serial avoids carrying a partial command across a disconnect/reconnect.

What the Browser Can and Cannot Know

What it can know

The page can know:

  • that a serial port was granted by the user;

  • the current read/write stream;

  • limited USB identity such as VID/PID;

  • text printed by the sketch;

  • identity fields that the sketch explicitly prints in response to __identify__.

What it usually cannot know

The page usually cannot know:

  • the Windows COM port name;

  • the friendly name shown by Device Manager;

  • the exact row number selected in the browser chooser;

  • a unique identity for several identical ESP32 native USB devices;

  • the sketch name unless the sketch prints or returns it.

Serial.println() sends characters only. There is no hidden per-line metadata that identifies the sketch.

DTR and RTS do not exchange unique identity information.

Troubleshooting

The Connect button does not show any ports

Check that the board is plugged in and visible to the operating system.

Try closing Arduino IDE Serial Monitor or any other serial tool.

Try using localhost instead of opening the file directly.

The port opens but no text appears

Check the baud rate.

Confirm the sketch calls Serial.begin(...).

Press the board reset button after connecting.

Check whether the sketch waits for serial connection with a statement such as:


while (!Serial);

A timeout pattern is usually safer:


unsigned long serialStart = millis();

while (!Serial && millis() - serialStart < 3000) {

delay(10);

}

Text appears garbled

The baud rate is probably wrong. Match the web page's baud rate to Serial.begin(...) in the sketch.

Arduino IDE cannot upload while the Web Serial Monitor is connected

Only one program can usually open a serial port at a time. If the Web Serial Monitor is connected, Arduino IDE may not be able to open the same port for upload.

Use this workflow:

  1. Click Release for Upload in the Web Serial Monitor.

  2. Start the upload in Arduino IDE.

  3. Wait for the upload to finish.

  4. The Web Serial Monitor will periodically retry and reconnect to the previously granted port.

If automatic reconnect does not succeed after the upload, click Cancel Reconnect, then use Grant / Connect New Port again. This can happen if the board resets or re-enumerates in a way that changes the browser's granted port object.

Auto Identify does not work

Click Identify Now after the sketch's normal startup output appears.

Confirm that the sketch command parser is called from loop().

Confirm that the web page sends a newline line ending and that the sketch parser looks for \n.

Confirm that the sketch prints one of the recognized keys, such as:


DEVICE_NAME = My Board

If Auto Identify works once but stops working after disconnecting and reconnecting the browser, check the sketch for a one-shot gate such as:


if (webSerialIdentified) return;

or:


webSerialIdentified = true;

Remove that gate or reset it on disconnect. The recommended handler in this guide is repeatable, so the sketch can answer future reconnect probes and Identify Now requests without requiring an ESP32 reboot.

The wrong duplicate ESP32 port was selected

Use the chooser-order notes to track the popup order.

Add distinctive startup banners to each sketch.

Add sketch-side Auto Identify support.

Disconnect and try the next duplicate-looking port.

Recommended ESP32 Sketch Startup Banner

Even with Auto Identify, it is useful for each sketch to print a clear startup banner:


Serial.println();

Serial.println("=== Zigbee Air Sensor ===");

Serial.println("Version: v2.8 N1 Dual Sensor");

Serial.print("Build: ");

Serial.println(Timestamp);

Serial.println();

Startup banners help in Arduino Serial Monitor, Web Serial Monitor, saved logs, copied logs, and troubleshooting notes.

Practical Recommended Setup

For day-to-day ESP32 debugging:

  • Baud: 115200

  • Format: 8N1

  • DTR: ON

  • RTS: OFF

  • Timestamp: ON

  • Autoscroll: ON

  • Local echo: OFF

  • Auto identify: ON if the sketch supports it

  • Use Release for Upload before uploading a new sketch from Arduino IDE

For multiple identical ESP32 native USB boards:

  • Keep chooser-order notes.

  • Use distinctive sketch startup banners.

  • Add __identify__ support to sketches where convenient.

  • Use Identify Now after connecting if the automatic probe was sent too early.