') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - macarracedo/FastBadge: Sistema web self-hosted para el registro de asistentes en eventos e impresión instantánea de acreditaciones. Permite escanear códigos QR desde el móvil, actualizar la asistencianvi en tiempo real en Google Sheets y ear la orden de impresión automáticamente a impresoras térmicas portátiles mediante Web Bluetooth API. · GitHub
Skip to content

Repository files navigation

FastBadge

Real-time event badge printing & check-in system for staff working registration desks at conferences and events.

FastBadge scans an attendee's QR code, verifies and updates their status in Google Sheets, and instantly prints a personalized badge to a generic Chinese BLE thermal label printer (Marklife / Niimbot / Deluxe style) — all from a browser running on a tablet or phone, with no native app to install.


👤 Autor

Manuel Alonso Carracedo


Table of contents

  1. Architecture overview
  2. Why a backend is required
  3. Project structure
  4. Local installation
  5. Google Sheets integration guide
  6. Using the application
  7. Badge template format
  8. The BLE printing pipeline (math & binary)
  9. Browser & hardware compatibility
  10. Troubleshooting

Architecture overview

FastBadge is a client-heavy application. Almost all real work (canvas rendering, bitmap conversion, BLE transmission, QR scanning) happens in the browser using native Web APIs. The Node.js/Express backend is intentionally thin and exists for two reasons only:

  • Secure context providernavigator.bluetooth and getUserMedia (camera) are only exposed by browsers on a secure context: https:// or http://localhost. Serving the front-end from a local Express server on http://localhost:3000 satisfies this requirement without TLS certificates.
  • Google Sheets proxy — API keys / service-account credentials must never ship to the browser. The backend holds the credentials and exposes a tiny REST surface (/api/attendee/:id, /api/checkin) that the front-end calls.
┌─────────────────────────────────────────────┐ ┌──────────────────────┐
│ Browser (tablet / phone @ http://localhost) │ │ Node.js + Express │
│ │ │ │
│ app.js ──── tab router / QR lifecycle │ HTTP │ server.js │
│ printer-driver.js ─ Web Bluetooth + bitmap │◄───────►│ /api/attendee/:id │
│ <canvas> ─ badge rendering │ JSON │ /api/checkin │
│ │ │ │ │
└───────────────┬───────────────────────────────┘ └────────┼─────────────┘
│ Web Bluetooth (GATT) │ googleapis
▼ ▼
BLE Thermal Printer Google Sheets API

Why a backend is required

ConcernSolved by
Web Bluetooth needs a secure contextExpress serves the app on http://localhost
Camera (getUserMedia) needs a secure contextsame
Google credentials must stay secretExpress keeps them server-side, exposes a proxy
CORS / Google API quirksnormalized through the proxy

If Google credentials are not configured, the server transparently falls back to an in-memory mock dataset so the whole app remains testable end-to-end (scanning, printing, status updates) without any cloud setup.


Project structure

FastBadge/
├── package.json # dependencies + npm scripts
├── server.js # Express: static hosting + Google Sheets proxy
├── .env.example # environment variable template
├── README.md # this file
├── templates/
│ └── default-badge.json # sample structured badge layout (mm + dpi metadata)
└── public/
├── index.html # app shell, 3 tabs (Scanner / Designer / Config)
├── css/
│ └── styles.css # modern responsive UI
└── js/
├── app.js # router, QR scanner lifecycle, check-in flow
└── printer-driver.js # Web Bluetooth + canvas→monochrome + chunking

Local installation

Prerequisites: Node.js ≥ 18, and a Chromium-based browser (Chrome / Edge) — Web Bluetooth is not available in Firefox or Safari.

# 1. Install dependencies
npm install
# 2. (Optional) configure Google Sheets — see next section.
cp .env.example .env
# ...edit .env... (skip to run against mock data)# 3. Start the server
npm start
# → FastBadge running at http://localhost:3000 (mock-data mode)# 4. Open Chrome / Edge at:
http://localhost:3000

Tablet / phone usage: Web Bluetooth requires a secure context. On a real device either (a) run the laptop server and reach it over https:// behind a reverse proxy, or (b) for same-device testing use Chrome on Android with a USB-forwarded localhost (chrome://inspect → Port forwarding). Plain http://<LAN-IP> will not unlock Bluetooth.


Google Sheets integration guide

The proxy supports two auth modes. Configure one in .env (copy from .env.example):

Option A — API key (read-only, simplest)

Good for verifying attendees, but the public Sheets API key cannot write unless the sheet is public-editable (not recommended). Use Option B for real check-in writes.

GOOGLE_SHEETS_ID=1AbC...your_spreadsheet_id...XyZGOOGLE_API_KEY=AIza...your_api_key...SHEET_RANGE=Attendees!A:E

Option B — Service account (read + write, recommended)

  1. Create a Google Cloud project → enable the Google Sheets API.
  2. Create a Service Account, generate a JSON key.
  3. Share your spreadsheet with the service-account email (e.g. fastbadge@project.iam.gserviceaccount.com) as Editor.
  4. Configure .env:
GOOGLE_SHEETS_ID=1AbC...your_spreadsheet_id...XyZGOOGLE_SERVICE_ACCOUNT_EMAIL=fastbadge@project.iam.gserviceaccount.comGOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n"SHEET_RANGE=Attendees!A:E

Keep the \n escapes in GOOGLE_PRIVATE_KEY; the server converts them back to real newlines.

Expected sheet columns

The first row is treated as a header. Default expected columns (configurable via SHEET_RANGE):

idnamecompanyrolestatus
A-001Ada LovelaceAnalytical EnginesSpeakerPending
A-002Alan TuringBletchley LtdAttendeePending

The QR code printed for / carried by each attendee must encode the value in the id column.


Using the application

Config tab

  • Connect printer — pairs a BLE printer via the browser chooser. Status panel shows device name, service & characteristic UUIDs, and live connection state.
  • Label size — separate Width / Height inputs in millimeters. Changing either instantly resizes the design canvas using the configured DPI (default 203 DPI → 8 dots/mm).
  • Google Sheets — shows the Spreadsheet ID and whether the backend is in live or mock mode.

Designer tab

  • Live <canvas> preview of the badge, rendered from the structured JSON template.
  • Controls for margins, logo visibility, and global font scaling.
  • Test print sends the current canvas straight to the printer.

Scanner tab

  • Starts the camera (html5-qrcode) and scans continuously.
  • Automated flow: valid QR → extract id → POST /api/checkin → on success render badge → stream to printer — fully unattended. A cooldown prevents double-scanning the same id.

Badge template format

templates/default-badge.json describes a label declaratively. Mandatory metadata lets the UI auto-adapt on load:

{
"name": "Default Conference Badge",
"width_mm": 50, // physical label width"height_mm": 30, // physical label height"dpi": 203, // printer resolution → pixels = mm / 25.4 * dpi"margin_mm": 2,
"logo": { "visible": true, "x_mm": 2, "y_mm": 2, "w_mm": 10, "h_mm": 10 },
"elements": [
{
"type": "text",
"binding": "{{name}}", // placeholder resolved from attendee record"x_mm": 2, "y_mm": 14,
"font_size_pt": 14,
"font_weight": "bold",
"align": "left"
}
]
}

Supported placeholders: {{id}}, {{name}}, {{company}}, {{role}}. Coordinates and sizes are authored in millimeters / points and converted to device pixels at render time, so the same template prints identically on any DPI.


The BLE printing pipeline (math & binary)

This is the core reverse-engineering work. Turning a visual canvas into bytes a generic thermal printer accepts happens in four stages, all in printer-driver.js.

1. Millimeters → dots (pixels)

Thermal printers address the head in dots. At 203 DPI:

dots_per_mm = DPI / 25.4 = 203 / 25.4 ≈ 8 dots/mm
width_px = round(width_mm * dots_per_mm)
height_px = round(height_mm * dots_per_mm)

The print head has a fixed dot count per row, and each row must be byte-aligned (8 dots = 1 byte). So the byte width is:

bytes_per_row = ceil(width_px / 8)

We round width_px up to a multiple of 8 to avoid partial bytes at the row edge.

2. Canvas → grayscale → 1-bit monochrome

We read the canvas pixels with ctx.getImageData(), giving an RGBA array (4 bytes/pixel). For each pixel:

luminance = 0.299*R + 0.587*G + 0.114*B // perceptual gray
ink = luminance < THRESHOLD // dark pixel = print a dot

A simple fixed threshold (default 128) is used; the code also supports optional Floyd–Steinberg dithering to render photos/logos as stippled dots, which generic printers reproduce far better than flat gray.

3. Packing bits into a bitmap (MSB-first)

Thermal printers expect 1 bit per pixel, 8 pixels per byte, most-significant-bit = leftmost pixel. For pixel at (x, y):

byteIndex = y * bytes_per_row + (x >> 3) // x >> 3 == floor(x / 8)
bitOffset = 7 - (x & 7) // MSB-first within the byte
if (ink) buffer[byteIndex] |= (1 << bitOffset)

The result is a contiguous Uint8Array of bytes_per_row * height_px bytes — the raster bitmap.

4. Command framing + MTU chunking

Generic ESC/POS-style raster printing uses the GS v 0 command to send a raster bitmap:

GS v 0 m xL xH yL yH [bitmap bytes...]
1D 76 30 00 xL xH yL yH ...

where xL xH is bytes_per_row as little-endian 16-bit, and yL yH is height_px as little-endian 16-bit. (Niimbot-class printers use a different proprietary frame; the driver exposes a pluggable protocol so the framing can be swapped — escpos is the default and the best-documented.)

The full command stream is then sliced into BLE-safe chunks. A BLE characteristic write is bounded by the negotiated ATT MTU minus 3 bytes of ATT header. Many cheap printers only support the default MTU (23 → 20 usable bytes), so the driver chunks conservatively:

CHUNK = negotiatedMTU ? (negotiatedMTU - 3) : 20
for (let i = 0; i < stream.length; i += CHUNK) {
await characteristic.writeValueWithoutResponse(stream.slice(i, i + CHUNK));
await delay(pacingMs); // small gap so the printer's RX buffer drains
}

We prefer writeValueWithoutResponse (write command) for throughput, falling back to writeValueWithResponse if the characteristic doesn't support it. A short inter-chunk delay prevents overrunning the printer's tiny receive buffer (a very common failure mode that manifests as truncated or garbled labels).


Browser & hardware compatibility

Web BluetoothCamera
Chrome (desktop, Android)
Edge (desktop)
Firefox
Safari / iOS

Because iOS/Safari lack Web Bluetooth, use Android Chrome or a desktop Chromium browser for the printing workflow. The check-in/scan flow alone works anywhere.

The default GATT service/characteristic UUIDs target common generic printers; if your printer differs, set its UUIDs in the Config tab (or printer-driver.jsKNOWN_PROFILES). When in doubt, connect with acceptAllDevices and inspect the discovered services in the status panel.


Troubleshooting

  • "Bluetooth not available" — you're not on a secure context. Use http://localhost or https://, and a Chromium browser.
  • Printer connects but prints nothing / garbage — wrong service UUID or protocol. Try the alternate profile, lower the MTU chunk, or increase inter-chunk pacing in Config.
  • Camera won't start — grant camera permission; only one app can hold the camera at a time.
  • Sheets writes fail — you're likely in API-key mode (read-only). Switch to a service account and share the sheet with it.
  • Label is shifted / cropped — verify width_mm/height_mm/dpi match the physical media; the head dot-width is fixed per model.

License

MIT — provided as a reference implementation for event tooling and BLE printer reverse-engineering education.

About

Sistema web self-hosted para el registro de asistentes en eventos e impresión instantánea de acreditaciones. Permite escanear códigos QR desde el móvil, actualizar la asistencianvi en tiempo real en Google Sheets y ear la orden de impresión automáticamente a impresoras térmicas portátiles mediante Web Bluetooth API.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages