---
title: Will My Device Work with Easyr? | Free Device Check
description: Check your scale, gauge or Bluetooth thermometer works with Easyr SPC in 2 minutes. Plug it in, take a reading, see it live. No account needed.
date: 2026-10-04
---

Easyr Device Check

🌐

**Use Chrome or Edge** on a Windows PC or Android tablet to check your device. iPads, iPhones, Safari and Firefox can't connect to devices from a web page.

# Will my device work with Easyr?

Let's find out. It takes about 2 minutes and you don't need an Easyr account.

Checking your browser…

## Quick check before we start

- 1 **Device switched on**Showing a reading on its own screen.
- 2 **Cable plugged into this tablet or PC**A USB cable, or an RS232 lead with a USB adapter. Tip: most scales need a "null modem" lead or adapter, not a plain straight-through one.
- 3 **Something to measure**For a scale, anything will do: a phone, a mug, a product.

Your browser will pop up a list. Pick the one that looks like a serial or USB cable.

## Now take a few readings

Scales: put something on, wait a second, then take it off. Other devices: take a reading, or press its Send or Print button. Keep going while Dot works out how your device talks.

Connecting…

# Your device is talking!

Take another reading and watch it come through live.

-

## Hmm, nothing's coming through yet

The cable is connected, but we haven't heard anything we can read from the device. Here's what usually fixes it:

- 1 **Use a "null modem" lead or adapter**The most common cause. A plain gender changer won't work for most scales and gauges.
- 2 **Turn on the device's data output**Often called RS232, COM, Interface or Print output in its settings menu.
- 3 **Take a reading while it's listening**Many scales only send a weight once it settles, and many gauges only send when you press their Send button.

## Quick check before we start

- 1 **Device switched on and nearby**Within a couple of metres of this tablet or PC.
- 2 **Not connected to another app**Close any maker's app that might be holding on to it. You don't need to pair it in your Bluetooth settings first.
- 3 **Bluetooth turned on**On this tablet or PC.

Your browser will pop up a list of nearby Bluetooth devices. Pick yours.

## Connected! Now take a reading

Use your device as normal.

Waiting for a reading…

# Your device is talking!

Take another reading and watch it come through live.

-

## We don't know this one yet

We connected to **your device**, but it talks in its own private language that Easyr hasn't learned yet. Send it to us and we'll look at adding it.

## This browser can't talk to devices

Device connections need **Chrome** or **Edge** on a Windows PC, Mac or Android tablet. iPads and iPhones can't connect to scales or Bluetooth devices from a web page yet.

`;
 }
 if (isIOS || (!wiredOk && !hasBt)) {
 chip.className = "chip warn";
 chip.textContent = `${platformText}: can't connect to devices`;
 $("pickWired").disabled = true; $("pickBt").disabled = true;
 $("welcomeErr").innerHTML = 'This browser can\'t connect to scales or Bluetooth devices. Please open this page in **Chrome** or **Edge** on a Windows PC, Mac or Android tablet.';
 $("welcomeErr").classList.add("on");
 return;
 }
 chip.textContent = `✓ ${platformText}. ${wiredUsesUsb ? "Wired devices connect over USB." : wiredOk ? "Wired devices connect over serial." : ""}`;
 if (!wiredOk) $("pickWired").disabled = true;
 if (!hasBt) $("pickBt").disabled = true;
 if (wiredUsesUsb) $("wiredPickNote").textContent = "Your browser will pop up a list of USB devices. Pick your device or its cable.";
})();

/* ---------- navigation ---------- */
const stepOf = { welcome: 0, wiredPrep: 1, btPrep: 1, wiredListen: 2, btWait: 2, wiredOk: 3, wiredTrouble: 3, btOk: 3, btUnknown: 3, unsupported: 0 };
function go(id) {
 document.querySelectorAll(".screen").forEach((s) => s.classList.remove("on"));
 $(id).classList.add("on");
 const n = stepOf[id] ?? 0;
 ["s1", "s2", "s3"].forEach((s, i) => $(s).classList.toggle("on", i < n));
 window.scrollTo(0, 0);
}
async function restart() { await closeAll(); go("welcome"); }
function showErr(id, html) { const e = $(id); e.innerHTML = html; e.classList.add("on"); }
function clearErr(id) { $(id).classList.remove("on"); }

/* ============================================================
 READING PARSER (scales, gauges and meters, not just Digi)
 ============================================================ */
function parseWeight(line) {
 const t = line.trim();
 if (!t || t.length > 60) return null;
 const printable = [...t].filter((c) => c >= " " && c <= "~" || c === "°" || c === "µ").length / t.length;
 if (printable < 0.95) return null;
 if (/\b(OF|UF|OL|ERR|ERROR)\b/i.test(t)) return null;
 const UNITS = "kg|g|lbs?|oz|mm|cm|m|in|um|µm|°c|°f|degc|degf|c|f|%|ph|bar|mbar|psi|kpa|mpa|pa|n|nm|kn|ml|l|rpm|s|ms|v|mv|a|ma";
 let m = t.match(new RegExp("([-+]?\\s*\\d+(?:[.,]\\d+)?)\\s*(" + UNITS + ")(?![a-z])", "i"));
 let unit = "";
 if (m) unit = m[2];
 else {
 // A line that is essentially just a number (some gauges send no unit)
 m = t.match(/^[A-Za-z]{0,4}[:,=\s]*([-+]?\s*\d+(?:[.,]\d+)?)\s*$/);
 if (!m || (m[1].match(/\d/g) || []).length < 2) return null;
 }
 const num = parseFloat(m[1].replace(/\s/g, "").replace(",", "."));
 if (!Number.isFinite(num)) return null;
 unit = unit.toLowerCase().replace(/^lbs$/, "lb").replace(/^(deg)?c$/, "°c").replace(/^(deg)?f$/, "°f").replace("um", "µm");
 const decimals = (m[1].split(/[.,]/)[1] || "").length;
 return { value: num, unit: unit.replace("°c", "°C").replace("°f", "°F").replace("ph", "pH"), text: num.toFixed(decimals) };
}
// What the Easyr app reads today (Digi-style output at 9600 8N1)
function easyrReadyFormat(line, cfg) {
 return cfg.baudRate === 9600 && cfg.dataBits === 8 && cfg.parity === "none" && /^(-?[\d.]+)\s*(KG|G|LB)$/i.test(line.trim());
}

/* ============================================================
 TRANSPORTS
 ============================================================ */
const CONFIGS = [
 { baudRate: 9600, dataBits: 8, parity: "none" },
 { baudRate: 9600, dataBits: 7, parity: "even" },
 { baudRate: 4800, dataBits: 8, parity: "none" },
 { baudRate: 2400, dataBits: 8, parity: "none" },
 { baudRate: 19200, dataBits: 8, parity: "none" },
 { baudRate: 1200, dataBits: 8, parity: "none" },
 { baudRate: 4800, dataBits: 7, parity: "even" },
 { baudRate: 2400, dataBits: 7, parity: "even" },
 { baudRate: 1200, dataBits: 7, parity: "even" },
 { baudRate: 19200, dataBits: 7, parity: "even" },
 { baudRate: 9600, dataBits: 7, parity: "odd" },
 { baudRate: 38400, dataBits: 8, parity: "none" },
];
const cfgText = (c) => `${c.baudRate} · ${c.dataBits} data bits · ${c.parity === "none" ? "no parity" : c.parity + " parity"}`;
const CHIP_NAMES = { 0x067B: "Prolific (PL2303)", 0x0403: "FTDI", 0x1A86: "WCH (CH340)", 0x10C4: "Silicon Labs (CP210x)" };

/* --- Web Serial (Windows/Mac/Linux, and Bluetooth serial) --- */
class SerialTransport {
 constructor(port) { this.port = port; this.reader = null; this.loop = null; this.onData = () => {}; this.kind = "Web Serial"; }
 async setConfig(cfg) {
 await this.stopRead();
 try { if (this.port.readable || this.port.writable) await this.port.close(); } catch (e) { log("close: " + errText(e)); }
 await this.port.open({ baudRate: cfg.baudRate, dataBits: cfg.dataBits, parity: cfg.parity, stopBits: 1, flowControl: "none" });
 this.loop = this.readLoop();
 }
 async readLoop() {
 while (this.port.readable && !this.stopping) {
 this.reader = this.port.readable.getReader();
 try {
 for (;;) { const { value, done } = await this.reader.read(); if (done) break; if (value && value.length) this.onData(value); }
 } catch (e) { log("read: " + errText(e)); break; }
 finally { try { this.reader.releaseLock(); } catch (_) {} this.reader = null; }
 }
 }
 async stopRead() {
 this.stopping = true;
 if (this.reader) { try { await this.reader.cancel(); } catch (_) {} }
 if (this.loop) { try { await this.loop; } catch (_) {} }
 this.loop = null; this.stopping = false;
 }
 async write(bytes) {
 if (!this.port.writable) return;
 const w = this.port.writable.getWriter();
 try { await w.write(bytes); } finally { w.releaseLock(); }
 }
 info() { const i = this.port.getInfo ? this.port.getInfo() : {}; return i; }
 async close() { await this.stopRead(); try { await this.port.close(); } catch (_) {} }
}

/* --- WebUSB (Android): Prolific PL2303 driver + generic CDC-ACM --- */
class UsbTransport {
 constructor(dev) { this.d = dev; this.onData = () => {}; this.running = false; this.kind = "WebUSB"; this.ctrlIface = 0; }
 async open() {
 const d = this.d;
 if (!d.opened) await d.open();
 if (!d.configuration) await d.selectConfiguration(1);
 const ifaces = d.configuration.interfaces;
 if (d.vendorId === 0x067B) {
 this.type = "pl2303";
 await d.claimInterface(0);
 this.claimed = [0];
 const eps = ifaces.find((i) => i.interfaceNumber === 0).alternate.endpoints;
 this.inEp = eps.find((e) => e.type === "bulk" && e.direction === "in")?.endpointNumber;
 this.outEp = eps.find((e) => e.type === "bulk" && e.direction === "out")?.endpointNumber;
 await this.pl2303Init();
 } else {
 const comm = ifaces.find((i) => i.alternate.interfaceClass === 0x02);
 const data = ifaces.find((i) => i.alternate.interfaceClass === 0x0A);
 if (!data) throw new Error("UNSUPPORTED_CHIP");
 this.type = "cdc";
 this.claimed = [];
 if (comm) { await d.claimInterface(comm.interfaceNumber); this.claimed.push(comm.interfaceNumber); this.ctrlIface = comm.interfaceNumber; }
 await d.claimInterface(data.interfaceNumber); this.claimed.push(data.interfaceNumber);
 const eps = data.alternate.endpoints;
 this.inEp = eps.find((e) => e.type === "bulk" && e.direction === "in")?.endpointNumber;
 this.outEp = eps.find((e) => e.type === "bulk" && e.direction === "out")?.endpointNumber;
 }
 if (this.inEp == null) throw new Error("No bulk IN endpoint found");
 log(`USB ${this.type}: in=${this.inEp} out=${this.outEp}`);
 }
 async vin(req, val, idx) { try { const r = await this.d.controlTransferIn({ requestType: "vendor", recipient: "device", request: req, value: val, index: idx }, 1); return r.status === "ok"; } catch (_) { return false; } }
 async vout(req, val, idx) { try { await this.d.controlTransferOut({ requestType: "vendor", recipient: "device", request: req, value: val, index: idx }); } catch (_) {} }
 async pl2303Init() {
 const hxn = await this.vin(0x81, 0x8080, 0);
 this.hxn = hxn;
 log(`PL2303 type: ${hxn ? "HXN (GC/GS/GT/GL/GE)" : "legacy (HX etc.)"}`);
 if (hxn) { await this.vout(0x80, 0x07, 0x03); }
 else {
 await this.vin(1, 0x8484, 0); await this.vout(1, 0x0404, 0); await this.vin(1, 0x8484, 0); await this.vin(1, 0x8383, 0);
 await this.vin(1, 0x8484, 0); await this.vout(1, 0x0404, 1); await this.vin(1, 0x8484, 0); await this.vin(1, 0x8383, 0);
 await this.vout(1, 0, 1); await this.vout(1, 1, 0); await this.vout(1, 2, 0x44);
 }
 }
 async setConfig(cfg) {
 const lc = new Uint8Array(7);
 new DataView(lc.buffer).setUint32(0, cfg.baudRate, true);
 lc[4] = 0; lc[5] = cfg.parity === "odd" ? 1 : cfg.parity === "even" ? 2 : 0; lc[6] = cfg.dataBits;
 const setup = (request, value) => ({ requestType: "class", recipient: "interface", request, value, index: this.ctrlIface });
 try { await this.d.controlTransferOut(setup(0x20, 0), lc); } catch (e) { log("SET_LINE_CODING: " + errText(e)); }
 try { await this.d.controlTransferOut(setup(0x22, 0x03)); } catch (e) { log("SET_CONTROL_LINE_STATE: " + errText(e)); }
 if (this.type === "pl2303" && this.hxn) await this.vout(0x80, 0x07, 0x03);
 if (!this.running) this.readLoop();
 }
 async readLoop() {
 this.running = true;
 while (this.running) {
 try {
 const r = await this.d.transferIn(this.inEp, 64);
 if (r.data && r.data.byteLength) this.onData(new Uint8Array(r.data.buffer, r.data.byteOffset, r.data.byteLength));
 } catch (e) { if (this.running) log("USB read stopped: " + errText(e)); break; }
 }
 this.running = false;
 }
 async write(bytes) { if (this.outEp != null) { try { await this.d.transferOut(this.outEp, bytes); } catch (e) { log("USB write: " + errText(e)); } } }
 info() { return { usbVendorId: this.d.vendorId, usbProductId: this.d.productId, product: this.d.productName, maker: this.d.manufacturerName, serial: this.d.serialNumber }; }
 async close() {
 this.running = false;
 for (const n of this.claimed || []) { try { await this.d.releaseInterface(n); } catch (_) {} }
 try { await this.d.close(); } catch (_) {}
 }
}

/* ============================================================
 WIRED FLOW
 ============================================================ */
let transport = null, wiredCancelled = false, found = null, liveCount = 0, cableInfo = {};

function startWired() {
 if (!wiredOk) return go("unsupported");
 clearErr("wiredErr"); go("wiredPrep");
}

async function findScale() {
 clearErr("wiredErr");
 await closeAll();
 techLog = techLog.filter((l) => l.includes("Platform"));
 try {
 if (wiredUsesUsb) {
 const dev = await navigator.usb.requestDevice({ filters: [
 { vendorId: 0x067B }, { vendorId: 0x0403 }, { vendorId: 0x1A86 }, { vendorId: 0x10C4 }, { classCode: 0x02 },
 ] });
 cableInfo = { vid: dev.vendorId, pid: dev.productId, product: dev.productName || "", maker: dev.manufacturerName || "", serial: dev.serialNumber || "" };
 log(`Picked USB ${hex(dev.vendorId)}:${hex(dev.productId)} ${cableInfo.maker} ${cableInfo.product} SN ${cableInfo.serial}`);
 const t = new UsbTransport(dev);
 try { await t.open(); }
 catch (e) {
 await t.close();
 if (e.message === "UNSUPPORTED_CHIP") return showUnsupportedChip(dev.vendorId);
 throw e;
 }
 transport = t;
 } else {
 const port = await navigator.serial.requestPort();
 const i = port.getInfo ? port.getInfo() : {};
 cableInfo = { vid: i.usbVendorId, pid: i.usbProductId, bt: i.bluetoothServiceClassId };
 log(`Picked serial port ${i.usbVendorId != null ? hex(i.usbVendorId) + ":" + hex(i.usbProductId) : i.bluetoothServiceClassId ? "Bluetooth " + i.bluetoothServiceClassId : "(no USB info)"}`);
 transport = new SerialTransport(port);
 }
 } catch (e) {
 if (e && e.name === "NotFoundError") return; // picker cancelled
 log("Open failed: " + errText(e));
 const msg = /permission policy/i.test(errText(e))
 ? "This website is blocking device access. Please let the Easyr team know (technical: Permissions-Policy blocks usb/serial)."
 : /Access denied|in use|Failed to open/i.test(errText(e))
 ? "We couldn't open the cable because another app or browser tab is using it. Close any Easyr SPC tabs, PuTTY or test tools, unplug the cable, plug it back in and try again."
 : "We couldn't open the cable. Unplug it, plug it back in and try again.";
 return showErr("wiredErr", esc(msg));
 }
 runDetection();
}

function showUnsupportedChip(vid) {
 const chip = CHIP_NAMES[vid] || `an unknown chip (${hex(vid)})`;
 $("troubleTitle").textContent = "We found your cable, but can't drive it here yet";
 $("troubleLead").innerHTML = `Your USB adapter uses a **${esc(chip)}** chip. On Android, Easyr can currently talk to **Prolific (PL2303)** adapters. Send us the details and we'll look at adding yours, or try a Prolific-based adapter.`;
 $("troubleList").style.display = "none";
 mountSend("troubleSend", "trouble", "Ask Easyr to help");
 go("wiredTrouble");
}

async function runDetection() {
 wiredCancelled = false; found = null; liveCount = 0;
 $("troubleTitle").textContent = "Hmm, nothing's coming through yet";
 $("troubleLead").textContent = "The cable is connected, but we haven't heard anything we can read from the device. Here's what usually fixes it:";
 $("troubleList").style.display = "";
 go("wiredListen");
 const prog = $("wiredProg"), tryEl = $("wiredTry");
 let buf = "", rawSeen = 0, current = null, lastLine = "";
 transport.onData = (bytes) => {
 rawSeen += bytes.length;
 log(`RX ${bytes.length}B @${current ? current.baudRate : "?"} ${bytesHex(bytes)} "${bytesAscii(bytes)}"`);
 for (const b of bytes) buf += String.fromCharCode(b & (current && current.dataBits === 7 ? 0x7f : 0xff));
 const parts = buf.split(/\r\n|\r|\n/); buf = parts.pop();
 if (buf.length > 200) buf = buf.slice(-200);
 for (const line of parts) {
 const w = parseWeight(line);
 if (w && !found) { found = { cfg: current, line, w }; lastLine = line; }
 else if (w && found) onLiveWeight(w, line);
 }
 };
 for (let i = 0; i < CONFIGS.length && !found && !wiredCancelled; i++) {
 current = CONFIGS[i];
 prog.style.width = Math.round((i / CONFIGS.length) * 100) + "%";
 tryEl.textContent = i === 0 ? "Listening… take a reading on your device" : `Trying another setting (${i + 1} of ${CONFIGS.length})…`;
 log(`Trying ${cfgText(current)}`);
 try { await transport.setConfig(current); }
 catch (e) { log("setConfig failed: " + errText(e)); continue; }
 buf = "";
 await sleep(300);
 transport.write(new Uint8Array([0x05])); // ENQ: many scales answer this
 for (let t = 0; t < 70 && !found && !wiredCancelled; t++) {
 await sleep(100);
 if (t === 35) transport.write(new Uint8Array([0x05]));
 }
 }
 if (wiredCancelled) return;
 prog.style.width = "100%";
 if (found) { log(`MATCH ${cfgText(found.cfg)}: "${found.line}"`); showWiredSuccess(); }
 else {
 log(`No readable weight found. Raw bytes seen: ${rawSeen}`);
 mountSend("troubleSend", "trouble", "Ask Easyr to help");
 go("wiredTrouble");
 }
}

function cancelWired() { wiredCancelled = true; restart(); }

function onLiveWeight(w, line) {
 liveCount++;
 const el = $("wiredLive");
 if (!el) return;
 el.innerHTML = `${esc(w.text)}${esc(w.unit)}`;
 el.classList.remove("pop"); void el.offsetWidth; el.classList.add("pop");
}

function showWiredSuccess() {
 const f = found;
 onLiveWeight(f.w, f.line);
 const ready = easyrReadyFormat(f.line, f.cfg);
 const isWeight = ["kg", "g", "lb", "oz"].includes(f.w.unit);
 f.ready = ready;
 $("wiredOkTitle").textContent = ready ? "Your scale works with Easyr!" : isWeight ? "Your scale is talking!" : "Your device is talking!";
 $("wiredVerdict").className = "win " + (ready ? "green" : "teal");
 $("wiredVerdict").innerHTML = ready
 ? "

### ✓ Ready to go

Readings will fill in automatically in Easyr when the weight settles.

"
 : "

### ✓ Good news, we can read it

We worked out how your device talks. Send us this result and we'll get it set up in Easyr for you.

";
 const chip = cableInfo.vid != null ? (CHIP_NAMES[cableInfo.vid] || hex(cableInfo.vid)) : cableInfo.bt ? "Bluetooth serial" : "Serial port";
 const facts = [
 ["Cable", chip + (cableInfo.product ? ` · ${cableInfo.product}` : "")],
 ["Settings found", cfgText(f.cfg)],
 ["What it sends", `"${f.line.trim()}"`],
 ["This device", `${platformText} · ${transport.kind}`],
 ];
 $("wiredFacts").innerHTML = facts.map(([k, v]) => `

${esc(k)}**${esc(v)}**

`).join("");
 mountSend("wiredSend", "wired", ready ? "Send this result to Easyr" : "Send this to Easyr to get set up");
 go("wiredOk");
}

/* ============================================================
 BLUETOOTH FLOW
 ============================================================ */
const ETI_SVC = "45544942-4c55-4554-4845-524db87ad700";
const ETI_S1 = "45544942-4c55-4554-4845-524db87ad701";
let bt = { device: null, server: null, profile: null, name: "", info: {}, last: null, count: 0 };

async function findBt() {
 clearErr("btErr");
 if (!hasBt) return go("unsupported");
 await closeAll();
 techLog = techLog.filter((l) => l.includes("Platform"));
 bt = { device: null, server: null, profile: null, name: "", info: {}, last: null, count: 0 };
 let device;
 try {
 device = await navigator.bluetooth.requestDevice({
 acceptAllDevices: true,
 optionalServices: [ETI_SVC, "health_thermometer", "weight_scale", "battery_service", "device_information"],
 });
 } catch (e) {
 if (e && e.name === "NotFoundError") return;
 log("BT picker: " + errText(e));
 return showErr("btErr", /permission policy/i.test(errText(e))
 ? "This website is blocking Bluetooth access. Please let the Easyr team know."
 : "Bluetooth isn't available right now. Check Bluetooth is turned on and try again.");
 }
 bt.device = device; bt.name = device.name || "Unnamed device";
 log(`Picked BT device "${bt.name}" id=${device.id}`);
 device.addEventListener("gattserverdisconnected", () => { log("BT disconnected"); if ($("btWait").classList.contains("on") || $("btOk").classList.contains("on")) { $("btWaitText").textContent = "Device disconnected. It may have gone to sleep."; } });
 go("btWait");
 $("btWaitTitle").textContent = `Connecting to ${bt.name}…`;
 $("btWaitLead").textContent = "This usually takes a few seconds.";
 $("btWaitText").textContent = "Connecting…";
 try {
 bt.server = await device.gatt.connect();
 let services = [];
 try { services = await bt.server.getPrimaryServices(); } catch (e) { log("getPrimaryServices: " + errText(e)); }
 const uuids = services.map((s) => s.uuid);
 log("Services we can see: " + (uuids.join(", ") || "none"));
 await readDeviceInfo();
 if (uuids.includes(ETI_SVC)) await startEti();
 else if (uuids.some((u) => u.startsWith("00001809"))) await startStdThermo();
 else if (uuids.some((u) => u.startsWith("0000181d"))) await startStdScale();
 else return showBtUnknown();
 } catch (e) {
 log("BT connect: " + errText(e));
 go("btPrep");
 return showErr("btErr", "We found the device but couldn't connect. Make sure it's awake and not connected to another app, then try again.");
 }
}

async function readDeviceInfo() {
 const read = async (svc, ch, fmt) => {
 try { const s = await bt.server.getPrimaryService(svc); const c = await s.getCharacteristic(ch); const v = await c.readValue();
 return fmt === "u8" ? v.getUint8(0) : new TextDecoder().decode(v).replace(/\0/g, "").trim(); } catch (_) { return null; }
 };
 bt.info.maker = await read("device_information", "manufacturer_name_string");
 bt.info.model = await read("device_information", "model_number_string");
 bt.info.serial = await read("device_information", "serial_number_string");
 bt.info.firmware = await read("device_information", "firmware_revision_string");
 bt.info.battery = await read("battery_service", "battery_level", "u8");
 log(`Device info: ${JSON.stringify(bt.info)}`);
}

function waitFor(title, lead) {
 $("btWaitTitle").textContent = title; $("btWaitLead").textContent = lead; $("btWaitText").textContent = "Waiting for a reading…";
}

async function startEti() {
 bt.profile = { key: "eti", label: "ETI Bluetooth thermometer", supported: true };
 const s = await bt.server.getPrimaryService(ETI_SVC);
 const c = await s.getCharacteristic(ETI_S1);
 c.addEventListener("characteristicvaluechanged", (ev) => {
 const v = ev.target.value;
 log(`ETI RX ${bytesHex(new Uint8Array(v.buffer))}`);
 if (v.byteLength < 4) return;
 if (v.getUint32(0, true) === 0xFFFFFFFF) { $("btWaitText").textContent = "Check the probe is touching something."; return; }
 btReading(v.getFloat32(0, true).toFixed(1), "°C");
 });
 await c.startNotifications();
 waitFor("Connected! Now take a reading", "Press the button on your thermometer, just like you normally would.");
}

async function startStdThermo() {
 bt.profile = { key: "std_thermo", label: "Standard Bluetooth thermometer", supported: false };
 const s = await bt.server.getPrimaryService("health_thermometer");
 const c = await s.getCharacteristic("temperature_measurement");
 c.addEventListener("characteristicvaluechanged", (ev) => {
 const v = ev.target.value; log(`Thermo RX ${bytesHex(new Uint8Array(v.buffer))}`);
 if (v.byteLength < 5) return;
 const flags = v.getUint8(0);
 let mant = v.getUint8(1) | (v.getUint8(2) << 8) | (v.getUint8(3) << 16);
 if (mant & 0x800000) mant -= 0x1000000;
 const exp = v.getInt8(4);
 if (mant === 0x7FFFFF) return; // NaN
 const val = mant * Math.pow(10, exp);
 btReading(val.toFixed(Math.max(0, -exp)), flags & 1 ? "°F" : "°C");
 });
 await c.startNotifications();
 waitFor("Connected! Now take a reading", "Take a temperature reading the way you normally would.");
}

async function startStdScale() {
 bt.profile = { key: "std_scale", label: "Standard Bluetooth scale", supported: false };
 const s = await bt.server.getPrimaryService("weight_scale");
 const c = await s.getCharacteristic("weight_measurement");
 c.addEventListener("characteristicvaluechanged", (ev) => {
 const v = ev.target.value; log(`Scale RX ${bytesHex(new Uint8Array(v.buffer))}`);
 if (v.byteLength < 3) return;
 const flags = v.getUint8(0), raw = v.getUint16(1, true);
 if (raw === 0xFFFF) return;
 if (flags & 1) btReading((raw * 0.01).toFixed(2), "lb"); else btReading((raw * 0.005).toFixed(3), "kg");
 });
 await c.startNotifications();
 waitFor("Connected! Now weigh something", "Put something on the scale and let it settle.");
}

function btReading(text, unit) {
 bt.last = `${text} ${unit}`; bt.count++;
 log(`Reading: ${bt.last}`);
 if (!$("btOk").classList.contains("on")) showBtSuccess();
 const el = $("btLive");
 el.innerHTML = `${esc(text)}${esc(unit)}`;
 el.classList.remove("pop"); void el.offsetWidth; el.classList.add("pop");
}

function btFacts() {
 const rows = [["Device", bt.name]];
 const mm = [bt.info.maker, bt.info.model].filter(Boolean).join(" · ");
 if (mm) rows.push(["Maker / model", mm]);
 if (bt.info.serial) rows.push(["Serial number", bt.info.serial]);
 if (bt.info.battery != null) rows.push(["Battery", bt.info.battery + "%"]);
 if (bt.profile) rows.push(["Type", bt.profile.label]);
 rows.push(["This device", platformText + " · Bluetooth"]);
 return rows.map(([k, v]) => `

${esc(k)}**${esc(v)}**

`).join("");
}

function showBtSuccess() {
 const ok = bt.profile.supported;
 $("btOkTitle").textContent = ok ? "Your device works with Easyr!" : "Your device is talking!";
 $("btVerdict").className = "win " + (ok ? "green" : "teal");
 $("btVerdict").innerHTML = ok
 ? "

### ✓ Ready to go

Readings will fill in automatically in Easyr when you press the button.

"
 : "

### ✓ Good news, we can read it

It uses a standard Bluetooth format. Send us this result and we'll get it set up in Easyr for you.

";
 $("btFacts").innerHTML = btFacts();
 mountSend("btSend", "bt", ok ? "Send this result to Easyr" : "Send this to Easyr to get set up");
 go("btOk");
}

function showBtUnknown() {
 bt.profile = { key: "unknown", label: "Not recognised yet", supported: false };
 $("btUnknownName").textContent = bt.name;
 $("btUnknownFacts").innerHTML = btFacts();
 mountSend("btUnknownSend", "btUnknown", "Send it to Easyr");
 go("btUnknown");
}

/* Bluetooth devices that act like a serial port (SPP / RFCOMM) go through the wired detection */
async function startBtSerial() {
 clearErr("btErr");
 if (!hasSerial) return showErr("btErr", "This browser can't open Bluetooth serial devices. Try Chrome on a Windows PC or Mac.");
 await closeAll();
 techLog = techLog.filter((l) => l.includes("Platform"));
 try {
 const port = await navigator.serial.requestPort({ allowedBluetoothServiceClassIds: ["00001101-0000-1000-8000-00805f9b34fb"] });
 const i = port.getInfo ? port.getInfo() : {};
 cableInfo = { vid: i.usbVendorId, pid: i.usbProductId, bt: i.bluetoothServiceClassId || "Bluetooth serial" };
 log(`Picked Bluetooth serial port ${JSON.stringify(i)}`);
 transport = new SerialTransport(port);
 } catch (e) {
 if (e && e.name === "NotFoundError") return;
 log("BT serial open: " + errText(e));
 return showErr("btErr", "We couldn't open that device. Make sure it's paired in your Bluetooth settings first for this option, then try again.");
 }
 runDetection();
}

/* ============================================================
 SEND TO EASYR
 ============================================================ */
function summary(kind) {
 const lines = [`Easyr device check result`, `When: ${new Date().toString()}`, `Device used: ${platformText}`];
 if (kind === "wired" || kind === "trouble") {
 lines.push(`Type: Wired device`);
 if (cableInfo.vid != null) lines.push(`Cable: ${CHIP_NAMES[cableInfo.vid] || "Unknown chip"} ${hex(cableInfo.vid)}:${hex(cableInfo.pid ?? 0)} ${cableInfo.maker || ""} ${cableInfo.product || ""} SN ${cableInfo.serial || "-"}`);
 if (cableInfo.bt) lines.push(`Bluetooth serial: ${cableInfo.bt}`);
 if (found) lines.push(`Result: READABLE${found.ready ? " (Easyr-ready)" : " (needs setup)"}`, `Settings: ${cfgText(found.cfg)}`, `Sample: "${found.line.trim()}"`);
 else lines.push(`Result: NO READABLE DATA`);
 } else {
 lines.push(`Type: Bluetooth`, `Device: ${bt.name}`);
 for (const [k, v] of Object.entries(bt.info)) if (v != null) lines.push(`${k}: ${v}`);
 lines.push(`Profile: ${bt.profile ? bt.profile.label : "-"}`, `Last reading: ${bt.last || "none"}`);
 }
 lines.push("", "Technical log:", ...techLog.slice(-120));
 return lines.join("\n");
}

function mountSend(slotId, kind, label) {
 const slot = $(slotId);
 slot.innerHTML = `

We'll send your details and this test result to the Easyr team so we can help. Nothing else on your device is shared.

`;
 const form = slot.querySelector("[data-form]"), msg = slot.querySelector("[data-msg]");
 slot.querySelector("[data-open]").onclick = (e) => { e.target.style.display = "none"; form.classList.add("on"); };
 slot.querySelector("[data-tech]").onclick = () => {
 const b = slot.querySelector("[data-techbox]"); b.textContent = summary(kind); b.style.display = b.style.display === "block" ? "none" : "block";
 };
 slot.querySelector("[data-send]").onclick = async () => {
 const v = (k) => slot.querySelector(`[data-f="${k}"]`).value.trim();
 const data = { first: v("first"), last: v("last"), company: v("company"), email: v("email"), mobile: v("mobile"), industry: v("industry"), msg: v("msg") };
 msg.className = "msg";
 const missing = [];
 if (!data.first) missing.push("first name");
 if (!data.last) missing.push("last name");
 if (!data.company) missing.push("company");
 if (!/^([A-Za-z0-9-._%'+/]+@[A-Za-z0-9.-]+\.[a-zA-Z]{2,22})$/.test(data.email)) missing.push("a valid email");
 if (!/^[0-9a-zA-Z+.()\-;\s]+$/.test(data.mobile)) missing.push("a mobile number");
 if (missing.length) { msg.textContent = `Please add your ${missing.join(", ")} so we can get back to you.`; msg.className = "msg on bad"; return; }
 const btn = slot.querySelector("[data-send]"); btn.disabled = true; btn.textContent = "Sending…";
 const body = `[Device check] ${headline(kind)}\n${data.msg ? "Message: " + data.msg + "\n" : ""}\n${summary(kind)}`;
 const ok = await sendResult(data, body);
 btn.disabled = false; btn.textContent = "Send";
 if (ok === "sent") { form.classList.remove("on"); msg.textContent = "Thanks! The Easyr team has your result and will be in touch."; msg.className = "msg on good"; }
 else { msg.textContent = "Hmm, we couldn't send that just now, so we've copied your result instead. Please paste it into an email to the Easyr team."; msg.className = "msg on bad"; }
 };
}

function headline(kind) {
 if (kind === "wired") return found ? `Wired device READABLE${found.ready ? " (Easyr-ready)" : " (needs setup)"}` : "Wired device";
 if (kind === "trouble") return "Wired device, NO DATA (needs help)";
 if (kind === "bt") return `Bluetooth ${bt.profile && bt.profile.supported ? "WORKS" : "READABLE (needs setup)"}: ${bt.name}`;
 return `Bluetooth UNKNOWN device: ${bt.name}`;
}

async function sendResult(data, body) {
 const form = document.forms[BIGIN_FORM_ID];
 const scriptReady = typeof window.validateForm118888000000452001 === "function";
 if (form && scriptReady) {
 try {
 form["First Name"].value = data.first.slice(0, 40);
 form["Last Name"].value = data.last.slice(0, 80);
 form["Mobile"].value = data.mobile.slice(0, 30);
 form["Email"].value = data.email.slice(0, 100);
 form["Accounts.Account Name"].value = data.company.slice(0, 200);
 form["Accounts.ACCOUNTCF5"].value = data.industry || "-None-";
 form["Description"].value = body.slice(0, 31000);
 document.getElementById("formsubmit").disabled = false;
 window.__biginError = null;
 const frame = document.getElementById("biginTarget");
 const loaded = new Promise((res) => { frame.onload = () => res(true); setTimeout(() => res(false), 15000); });
 if (form.requestSubmit) form.requestSubmit(); else if (validateForm118888000000452001() !== false) form.submit();
 if (window.__biginError) { log("Bigin validation: " + window.__biginError); throw new Error(window.__biginError); }
 const ok = await loaded;
 log(ok ? "Sent to Bigin" : "Bigin did not confirm within 15s");
 if (ok) return "sent";
 } catch (e) { log("Send failed: " + errText(e)); }
 } else {
 log(`Bigin form script not available (form=${!!form}, script=${scriptReady})`);
 }
 try { await navigator.clipboard.writeText(`From: ${data.first} ${data.last} (${data.company}) ${data.email} ${data.mobile}\n\n${body}`); } catch (_) {}
 return "copied";
}

/* ---------- cleanup ---------- */
async function closeAll() {
 wiredCancelled = true;
 if (transport) { try { await transport.close(); } catch (_) {} transport = null; }
 if (bt.device && bt.device.gatt && bt.device.gatt.connected) { try { bt.device.gatt.disconnect(); } catch (_) {} }
}
window.addEventListener("pagehide", closeAll);
