Initial commit: ESP32-C3 WiFi→BLE bridge for Phomemo M110

- BLE client (NimBLE) with auto-scan/reconnect
- ESC/POS protocol encoder (GS v 0 raster)
- Web API: /api/status, /api/connect, /api/print
- Web UI: image upload with dithering, QR codes, positioning
- Client-side QR generation (qrcode-generator)
- Supports bitmap, QR, and raw ESC/POS print types
This commit is contained in:
2026-06-13 16:38:54 +02:00
commit 42efd83bc0
11 changed files with 1330 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.pio/
.pioenvs/
.vscode/
*.o
*.elf
*.bin
private_config.ini
+146
View File
@@ -0,0 +1,146 @@
# ESP32 M110 Label Printer Bridge
WiFi → BLE Bridge für den **Phomemo M110** Bluetooth-Etikettendrucker.
Läuft auf **ESP32-C3** (PlatformIO / Arduino).
## Hardware
- **ESP32-C3** DevKit (oder kompatibel mit WiFi + BLE)
- **Phomemo M110** Etikettendrucker (203 dpi, 48mm Druckbreite)
## Quickstart
### 1. Konfiguration
WiFi-Zugangsdaten und BLE-Name des Druckers in `platformio.ini` eintragen:
```ini
build_flags = -std=gnu++17 -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1
-DWIFI_SSID='"MeinWLAN"'
-DWIFI_PASS='"MeinPasswort"'
-DPRINTER_BLE_NAME='"Q199G4180950002"'
```
Den BLE-Namen findest du auf dem Etikett auf der Drucker-Rückseite.
### 2. Build & Flash
```bash
pio run -t upload # Firmware
pio run -t uploadfs # Web-Interface (LittleFS)
```
### 3. Verwenden
Web-Interface unter `http://<esp32-ip>/` öffnen.
Oder per curl:
```bash
# Status
curl http://192.168.1.100/api/status
# QR-Code drucken (106×106px, zentriert)
curl -X POST http://192.168.1.100/api/print \
-H "Content-Type: application/json" \
-d '{"type":"qr","data":"https://techahead.de","scale":4}'
# Bild drucken (1-Bit Bitmap als Base64)
curl -X POST http://192.168.1.100/api/print \
-H "Content-Type: application/json" \
-d '{"type":"bitmap","data":"...","width":384,"height":200,"density":15,"offsetX":0}'
# BLE-Scan starten
curl -X POST http://192.168.1.100/api/connect
```
## API
| Endpoint | Methode | Beschreibung |
|----------|---------|-------------|
| `/` | GET | Web-Interface |
| `/api/status` | GET | JSON: WiFi, BLE, Drucker-Status |
| `/api/connect` | POST | BLE-Scan starten |
| `/api/print` | POST | Druckauftrag |
### POST /api/print
```json
{
"type": "qr",
"data": "https://example.com",
"scale": 4,
"offsetX": 0
}
```
```json
{
"type": "bitmap",
"data": "<base64>",
"width": 384,
"height": 200,
"density": 15,
"offsetX": 0
}
```
```json
{
"type": "raw",
"data": "<base64-ESC/POS-bytes>"
}
```
## Web-Interface
- **🖼️ Bild drucken:** Upload per Drag & Drop, Vorschau mit B/W-Dithering (Atkinson, Floyd-Steinberg, Schwellwert), Positionierung auf 48mm-Band, einstellbare Druckdichte
- **📱 QR-Code:** Text/URL eingeben, Skalierung, Positionierung. QR wird client-seitig generiert — Vorschau = Druckergebnis
- **Status:** WiFi/BLE-Verbindung, RSSI, Uptime
## M110 BLE Protokoll
- **Service UUID:** `0000FF00-0000-1000-8000-00805F9B34FB`
- **Write Characteristic:** `0000FF02-0000-1000-8000-00805F9B34FB`
- **Verbindung:** Per advertised name, kein Pairing
- **Protokoll:** ESC/POS `GS v 0` Raster-Bitmap
- **Auflösung:** 384px (48mm) @ 203dpi → 48 Bytes/Zeile
- **Esc/POS-Kommandos:**
- `ESC @` — Drucker initialisieren
- `ESC N 0x0D <speed>` — Druckgeschwindigkeit (15)
- `ESC N 0x04 <density>` — Druckdichte (115)
- `GS DC1 <type>` — Medientyp (0x0A=LabelWithGaps, 0x0B=Continuous)
- `GS v 0 <xL><xH><yL><yH> <data>` — Raster-Bitmap (max 240 Zeilen empfohlen)
- `GS F0 05 00` / `GS F0 03 00` — Footer
Das Protokoll basiert auf Reverse-Engineering von [phomemo-tools](https://github.com/vivier/phomemo-tools) und [phomemo-macos](https://github.com/jacquesg/phomemo-macos).
## Projektstruktur
```
esp32-m110-label/
├── platformio.ini # PlatformIO Config
├── private_config.ini.example # Vorlage für Credentials
├── README.md
├── src/
│ ├── main.cpp # WiFi, HTTP-API, Setup
│ ├── config.h # UUIDs, Konstanten
│ ├── printer.h # M110 ESC/POS Encoder
│ ├── ble_client.h # NimBLE Scanner + Connection Manager
│ └── qr_renderer.h # QR-Code → Bitmap Renderer
└── data/
├── index.html # Web-Interface
└── qrcode.min.js # QR-Code Generator (client-side)
```
## Libraries
- [NimBLE-Arduino](https://github.com/h2zero/NimBLE-Arduino) — BLE Stack (ESP32-C3 optimiert)
- [ESPAsyncWebServer](https://github.com/esphome/ESPAsyncWebServer) — Async HTTP Server
- [ArduinoJson](https://arduinojson.org/) — JSON Parsing
- [QRCode](https://github.com/ricmoo/QRCode) — QR-Code Generation (ESP32, optional)
- [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) — QR-Code Generation (Client/JS)
## Lizenz
MIT
+400
View File
@@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M110 Label Printer</title>
<style>
:root { --bg:#1a1a2e; --fg:#e0e0e0; --accent:#00d4aa; --card:#16213e; --input:#0f3460; --danger:#e74c3c; --ok:#2ecc71; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:system-ui,-apple-system,sans-serif; background:var(--bg); color:var(--fg); padding:1rem; max-width:800px; margin:0 auto; }
h1 { font-size:1.4rem; margin-bottom:1rem; color:var(--accent); }
h2 { font-size:1rem; margin-bottom:.5rem; color:var(--accent); }
.card { background:var(--card); border-radius:8px; padding:1rem; margin-bottom:1rem; }
.status-grid { display:grid; grid-template-columns:auto 1fr; gap:.3rem 1rem; font-size:.85rem; }
.status-grid .label { color:#888; }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:4px; }
.dot-ok { background:var(--ok); } .dot-err { background:var(--danger); }
.row { display:flex; gap:1rem; flex-wrap:wrap; }
.col { flex:1; min-width:280px; }
input, select, button { font:inherit; }
input[type=range] { width:100%; }
input[type=text] { width:100%; padding:.4rem; border:1px solid #333; border-radius:4px; background:var(--input); color:var(--fg); }
button { padding:.5rem 1rem; border:none; border-radius:4px; background:var(--accent); color:#000; font-weight:600; cursor:pointer; margin:.2rem .2rem 0 0; }
button:disabled { opacity:.5; cursor:default; }
button.danger { background:var(--danger); color:#fff; }
.preview-container { position:relative; background:#333; border-radius:4px; padding:0; margin:.5rem 0; overflow:hidden; }
.preview-container canvas { display:block; margin:0 auto; image-rendering:pixelated; }
.preview-container .ruler { position:absolute; bottom:2px; left:0; right:0; text-align:center; font-size:9px; color:#666; pointer-events:none; }
#preview-info, #qr-preview-info { font-size:.75rem; color:#888; margin-top:.3rem; }
.slider-row { display:flex; align-items:center; gap:.5rem; margin:.3rem 0; }
.slider-row label { font-size:.8rem; min-width:80px; }
.slider-row span { font-size:.8rem; min-width:30px; text-align:right; }
#log { background:#000; color:#0f0; font-family:monospace; font-size:.8rem; padding:.5rem; border-radius:4px; max-height:150px; overflow-y:auto; white-space:pre-wrap; margin-top:.5rem; }
.upload-zone { border:2px dashed #555; border-radius:8px; padding:2rem; text-align:center; cursor:pointer; transition:border-color .2s; }
.upload-zone:hover, .upload-zone.dragover { border-color:var(--accent); }
.upload-zone p { color:#888; font-size:.9rem; }
#file-input { display:none; }
.tabs { display:flex; gap:.2rem; margin-bottom:.5rem; }
.tabs button { background:#333; color:#aaa; border-radius:4px 4px 0 0; }
.tabs button.active { background:var(--accent); color:#000; }
@media (max-width:600px) { .row { flex-direction:column; } }
</style>
</head>
<body>
<h1>🖨️ Phomemo M110</h1>
<div class="card">
<h2>Status</h2>
<div class="status-grid" id="status">
<span class="label">WiFi:</span><span id="s-wifi"></span>
<span class="label">Drucker:</span><span id="s-printer"></span>
<span class="label">IP:</span><span id="s-ip"></span>
<span class="label">Signal:</span><span id="s-rssi"></span>
</div>
<button onclick="refreshStatus()">↻ Refresh</button>
<button onclick="connectPrinter()">🔍 Scan & Connect</button>
</div>
<div class="row">
<div class="col">
<div class="card">
<div class="tabs">
<button id="tab-img" class="active" onclick="switchTab('img')">🖼️ Bild drucken</button>
<button id="tab-qr" onclick="switchTab('qr')">📱 QR-Code</button>
</div>
<!-- Image tab -->
<div id="panel-img">
<div class="upload-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<p>📂 Bild hier ablegen oder klicken</p>
<input type="file" id="file-input" accept="image/*" onchange="loadImage(event)">
</div>
<div style="display:none" id="img-preview-area">
<div class="preview-container">
<canvas id="preview-full"></canvas>
<div class="ruler">48mm Druckbreite</div>
</div>
<div id="preview-info"></div>
</div>
<div class="slider-row">
<label>Dichte:</label>
<input type="range" id="density" min="1" max="15" value="15" oninput="updatePreview()">
<span id="density-val">15</span>
</div>
<div class="slider-row">
<label>Breite (px):</label>
<input type="range" id="img-width" min="100" max="384" value="384" oninput="updatePreview()">
<span id="img-width-val">384</span>
</div>
<div class="slider-row">
<label>Position:</label>
<input type="range" id="offsetX" min="-192" max="192" value="0" oninput="updatePreview()">
<span id="offsetX-val">0</span>
</div>
<div class="slider-row">
<label>Schwellwert:</label>
<input type="range" id="threshold" min="40" max="240" value="128" oninput="updatePreview()">
<span id="threshold-val">128</span>
</div>
<div class="slider-row">
<label>Modus:</label>
<select id="dither-mode" onchange="updatePreview()">
<option value="atkinson">Atkinson Dithering</option>
<option value="floyd">Floyd-Steinberg</option>
<option value="threshold">Einfacher Schwellwert</option>
</select>
</div>
<button onclick="printImage()" id="btn-print-img" disabled>🖨️ Drucken</button>
<button class="danger" onclick="invertImage()">🔄 Invertieren</button>
</div>
<!-- QR tab -->
<div id="panel-qr" style="display:none">
<input type="text" id="qr-text" placeholder="URL oder Text" value="https://techahead.de" oninput="updateQRPreview()">
<div class="slider-row">
<label>Skalierung:</label>
<input type="range" id="qr-scale" min="2" max="10" value="4" oninput="updateQRPreview()">
<span id="qr-scale-val">4</span>
</div>
<div class="slider-row">
<label>Position:</label>
<input type="range" id="qr-offsetX" min="-192" max="192" value="0" oninput="updateQRPreview()">
<span id="qr-offsetX-val">0</span>
</div>
<div class="preview-container">
<canvas id="qr-preview-full"></canvas>
<div class="ruler">48mm Druckbreite</div>
</div>
<div id="qr-preview-info"></div>
<button onclick="printQR()">📱 QR drucken</button>
</div>
</div>
</div>
</div>
<div class="card">
<h2>Log</h2>
<div id="log"></div>
</div>
<script src="/qrcode.min.js"></script>
<script>
const API = '';
// ---- Constants matching ESP32 ----
const QR_START_VERSION = 4;
const QR_ECC = 'Q'; // QUARTILE = 25%
// ---- State ----
let originalImage = null;
let processedBitmap = null;
let inverted = false;
// ---- Tab switching ----
function switchTab(tab) {
document.getElementById('panel-img').style.display = tab==='img' ? 'block':'none';
document.getElementById('panel-qr').style.display = tab==='qr' ? 'block':'none';
document.getElementById('tab-img').className = tab==='img' ? 'active':'';
document.getElementById('tab-qr').className = tab==='qr' ? 'active':'';
if (tab === 'qr') updateQRPreview();
}
// ---- Slider value displays ----
['density','img-width','threshold','offsetX','qr-scale','qr-offsetX'].forEach(id => {
document.getElementById(id).addEventListener('input', function() {
document.getElementById(id+'-val').textContent = this.value;
});
});
// ---- Image upload ----
function loadImage(e) {
const file = e.target.files[0];
if (!file) return;
const img = new Image();
img.onload = () => {
originalImage = img;
inverted = false;
document.getElementById('drop-zone').style.display = 'none';
document.getElementById('img-preview-area').style.display = 'block';
document.getElementById('btn-print-img').disabled = false;
updatePreview();
};
img.src = URL.createObjectURL(file);
}
// ---- Drag & drop ----
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
const dt = new DataTransfer(); dt.items.add(file);
document.getElementById('file-input').files = dt.files;
loadImage({target: document.getElementById('file-input')});
}
});
// ---- Invert ----
function invertImage() { inverted = !inverted; updatePreview(); }
// ---- Image processing ----
function updatePreview() {
if (!originalImage) return;
const targetW = parseInt(document.getElementById('img-width').value);
const thresh = parseInt(document.getElementById('threshold').value);
const mode = document.getElementById('dither-mode').value;
const offsetX = parseInt(document.getElementById('offsetX').value);
const scale = targetW / originalImage.width;
const targetH = Math.round(originalImage.height * scale);
const oc = document.createElement('canvas');
oc.width = targetW; oc.height = targetH;
const ctx = oc.getContext('2d');
ctx.drawImage(originalImage, 0, 0, targetW, targetH);
const imgData = ctx.getImageData(0, 0, targetW, targetH);
const gray = new Float32Array(targetW * targetH);
for (let i = 0; i < gray.length; i++) {
const r = imgData.data[i*4], g = imgData.data[i*4+1], b = imgData.data[i*4+2];
gray[i] = (0.299*r + 0.587*g + 0.114*b) / 255;
}
let bw;
if (mode === 'floyd') bw = floydSteinberg(gray, targetW, targetH, thresh/255);
else if (mode === 'atkinson') bw = atkinson(gray, targetW, targetH, thresh/255);
else bw = simpleThreshold(gray, thresh/255);
if (inverted) { for (let i = 0; i < bw.length; i++) bw[i] = bw[i] ? 0 : 1; }
const bwBPL = Math.ceil(targetW / 8);
const buf = new Uint8Array(bwBPL * targetH);
for (let y = 0; y < targetH; y++)
for (let x = 0; x < targetW; x++)
if (bw[y * targetW + x]) buf[y * bwBPL + Math.floor(x/8)] |= (1 << (7 - (x % 8)));
processedBitmap = { data: buf, width: targetW, height: targetH };
// Preview
const FULL_W = 384;
const c = document.getElementById('preview-full');
c.width = FULL_W; c.height = targetH;
const pctx = c.getContext('2d');
pctx.fillStyle = '#e8e8e8'; pctx.fillRect(0, 0, FULL_W, targetH);
const imgX = Math.round((FULL_W - targetW) / 2 + offsetX);
const outData = pctx.createImageData(FULL_W, targetH);
for (let i = 0; i < FULL_W * targetH; i++) { outData.data[i*4]=232; outData.data[i*4+1]=232; outData.data[i*4+2]=232; outData.data[i*4+3]=255; }
for (let y = 0; y < targetH; y++) {
for (let x = 0; x < targetW; x++) {
const px = imgX + x; if (px < 0 || px >= FULL_W) continue;
const v = bw[y * targetW + x] ? 0 : 255;
const i = (y * FULL_W + px) * 4;
outData.data[i]=v; outData.data[i+1]=v; outData.data[i+2]=v; outData.data[i+3]=255;
}
}
pctx.putImageData(outData, 0, 0);
document.getElementById('preview-info').textContent = `${targetW}×${targetH}px · Offset ${offsetX}px · ${buf.length}B`;
}
function floydSteinberg(gray, w, h, thresh) {
const err = new Float32Array(gray); const out = new Uint8Array(gray.length);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = y*w+x, old = err[idx];
out[idx] = old < thresh ? 1 : 0;
const qe = (old - out[idx]), d7=qe*7/16, d3=qe*3/16, d5=qe*5/16, d1=qe*1/16;
if (x+1<w) err[idx+1]+=d7;
if (x+1<w&&y+1<h) err[idx+w+1]+=d1;
if (y+1<h) err[idx+w]+=d5;
if (x-1>=0&&y+1<h) err[idx+w-1]+=d3;
}
}
return out;
}
function atkinson(gray, w, h, thresh) {
const err = new Float32Array(gray); const out = new Uint8Array(gray.length); const f=1/8;
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
const idx=y*w+x, old=err[idx]; out[idx]=old<thresh?1:0;
const e=(old-out[idx])*f;
if(x+1<w)err[idx+1]+=e; if(x+2<w)err[idx+2]+=e;
if(x-1>=0&&y+1<h)err[idx+w-1]+=e; if(y+1<h)err[idx+w]+=e;
if(x+1<w&&y+1<h)err[idx+w+1]+=e; if(y+2<h)err[idx+w*2]+=e;
}
return out;
}
function simpleThreshold(gray, thresh) { const o=new Uint8Array(gray.length);for(let i=0;i<gray.length;i++)o[i]=gray[i]<thresh?1:0;return o; }
// ---- Base64 encode ----
function toBase64(bytes) {
const chars='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; let r='';
for(let i=0;i<bytes.length;i+=3){const a=bytes[i],b=bytes[i+1]||0,c=bytes[i+2]||0;r+=chars[a>>2]+chars[((a&3)<<4)|(b>>4)]+(i+1<bytes.length?chars[((b&15)<<2)|(c>>6)]:'=')+(i+2<bytes.length?chars[c&63]:'=');}
return r;
}
// ---- Print image ----
async function printImage() {
if (!processedBitmap) return;
const density=parseInt(document.getElementById('density').value), offsetX=parseInt(document.getElementById('offsetX').value);
log(`Drucke ${processedBitmap.width}×${processedBitmap.height}px, Dichte=${density}, Offset=${offsetX}`);
const b64=toBase64(processedBitmap.data);
try {
const r = await fetch(API+'/api/print',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'bitmap',data:b64,width:processedBitmap.width,height:processedBitmap.height,density,offsetX})});
const j=await r.json(); log(j.ok?`${j.bytes}B`:`${j.error||'Fehler'}`);
} catch(e) { log('❌ '+e); }
}
// ---- QR (client-side generation, same settings as ESP32) ----
function updateQRPreview() {
const text=document.getElementById('qr-text').value||' ';
const scale=parseInt(document.getElementById('qr-scale').value);
const offX=parseInt(document.getElementById('qr-offsetX').value);
const FULL_W=384;
// Auto-select QR version so the text fits (start at v4, go up to v40)
let qr = null, version = QR_START_VERSION;
while (version <= 40) {
try {
qr = qrcode(version, QR_ECC);
qr.addData(text);
qr.make();
break; // success
} catch(e) {
version++;
if (version > 40) { console.error('QR text too long for v40'); return; }
}
}
const modCount = qr.getModuleCount();
const qrPx = modCount * scale;
const c=document.getElementById('qr-preview-full');
c.width=FULL_W; c.height=qrPx;
const ctx=c.getContext('2d');
ctx.fillStyle='#e8e8e8'; ctx.fillRect(0,0,FULL_W,qrPx);
const imgX=Math.round((FULL_W - qrPx)/2 + offX);
ctx.fillStyle='#000';
for(let my=0;my<modCount;my++)
for(let mx=0;mx<modCount;mx++)
if(qr.isDark(my,mx))
ctx.fillRect(imgX+mx*scale, my*scale, scale, scale);
document.getElementById('qr-preview-info').textContent = `QR ${qrPx}×${qrPx}px · v${version}-${QR_ECC} · Offset ${offX}px`;
}
async function printQR() {
// Generate QR bitmap client-side — identical to preview.
// This ensures the printed QR EXACTLY matches the preview.
const text=document.getElementById('qr-text').value||' ';
const scale=parseInt(document.getElementById('qr-scale').value);
const offX=parseInt(document.getElementById('qr-offsetX').value);
let qr=null, version=QR_START_VERSION;
while(version<=40){try{qr=qrcode(version,QR_ECC);qr.addData(text);qr.make();break}catch(e){version++;if(version>40){log('❌ Text zu lang');return}}}
const modCount=qr.getModuleCount();
const qrPx=modCount*scale;
const bpl=Math.ceil(qrPx/8);
const buf=new Uint8Array(bpl*qrPx);
for(let my=0;my<modCount;my++)
for(let mx=0;mx<modCount;mx++)
if(qr.isDark(my,mx))
for(let dy=0;dy<scale;dy++)
for(let dx=0;dx<scale;dx++){
const px=mx*scale+dx, py=my*scale+dy;
buf[py*bpl+Math.floor(px/8)]|=(1<<(7-(px%8)));
}
log(`QR: "${text}" v${version} ${qrPx}×${qrPx}px, offset=${offX}`);
try {
const r=await fetch(API+'/api/print',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'bitmap',data:toBase64(buf),width:qrPx,height:qrPx,offsetX:offX,density:15})});
const j=await r.json(); log(j.ok?`${j.bytes}B`:`${j.error||'Fehler'}`);
} catch(e) { log('❌ '+e); }
}
// ---- Status ----
async function refreshStatus() {
try {
const r=await fetch(API+'/api/status'); const s=await r.json();
document.getElementById('s-wifi').innerHTML=dot(s.wifi==='connected')+s.wifi;
document.getElementById('s-printer').innerHTML=dot(s.printerConnected)+(s.printerConnected?'Verbunden ('+s.printerName+')':'Getrennt');
document.getElementById('s-ip').textContent=s.ip;
document.getElementById('s-rssi').textContent=s.printerConnected?s.rssi+' dBm':'—';
if(s.printerConnected) document.getElementById('btn-print-img').disabled=!processedBitmap;
else document.getElementById('btn-print-img').disabled=true;
} catch(e) { log('Status: '+e); }
}
async function connectPrinter() { log('Suche Drucker...'); try { await fetch(API+'/api/connect',{method:'POST'}); setTimeout(refreshStatus,6000); } catch(e) { log('Fehler: '+e); } }
function dot(ok) { return ok?'<span class="dot dot-ok"></span>':'<span class="dot dot-err"></span>'; }
function log(msg) { const el=document.getElementById('log'); el.textContent+=new Date().toLocaleTimeString()+' '+msg+'\n'; el.scrollTop=el.scrollHeight; }
refreshStatus(); setInterval(refreshStatus,15000);
updateQRPreview();
</script>
</body>
</html>
+8
View File
File diff suppressed because one or more lines are too long
+20
View File
@@ -0,0 +1,20 @@
[env:esp32c3]
platform = espressif32
board = esp32-c3-devkitm-1
framework = arduino
monitor_speed = 115200
upload_speed = 460800
board_build.mcu = esp32c3
board_build.variant = esp32c3
board_build.filesystem = littlefs
build_unflags = -std=gnu++11
build_flags = -std=gnu++17 -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1
-DWIFI_SSID='"FRITZ!Box 7312"'
-DWIFI_PASS='"88767327694885648772"'
-DPRINTER_BLE_NAME='"Q199G4180950002"'
lib_deps =
h2zero/NimBLE-Arduino@^2.5.0
esphome/ESPAsyncWebServer-esphome@^3.3.2
bblanchon/ArduinoJson@^7.3.0
ricmoo/QRCode@^0.0.1
+8
View File
@@ -0,0 +1,8 @@
; Example private configuration — copy to private_config.ini and fill in
; PlatformIO will merge this automatically.
[env:esp32c3]
build_flags =
-DWIFI_SSID='"your-wifi-ssid"'
-DWIFI_PASS='"your-wifi-password"'
-DPRINTER_BLE_NAME='"Q002E0CP0670069"'
+208
View File
@@ -0,0 +1,208 @@
#pragma once
#include <NimBLEDevice.h>
#include <HWCDC.h>
#include "config.h"
/**
* BLE connection manager for Phomemo M110 printers.
*
* Scans for the printer by advertised name, connects, and exposes the
* write characteristic for sending ESC/POS data.
*
* Compatible with NimBLE-Arduino >= 2.1.0 (NimBLEScanCallbacks API).
*/
class BLEPrinterClient {
public:
struct Status {
bool wifiConnected = false;
bool bleReady = false;
bool printerFound = false;
bool printerConnected = false;
String printerName;
String printerAddress;
int rssi = 0;
String ipAddress;
};
BLEPrinterClient() = default;
/**
* Initialise BLE stack and start background scanning.
*/
void begin() {
NimBLEDevice::init("ESP32-M110-Bridge");
_pScan = NimBLEDevice::getScan();
_pScan->setScanCallbacks(new ScanCallbacks(this));
_pScan->setActiveScan(true);
_pScan->setInterval(100);
_pScan->setWindow(99);
startScan();
_bleReady = true;
}
/**
* Call regularly from loop(). Handles reconnection.
*/
void tick() {
uint32_t now = millis();
// Periodic re-scan
if (now - _lastScan > SCAN_INTERVAL_MS && !_printerConnected) {
startScan();
}
// Auto-reconnect
if (!_printerConnected && _deviceFound && now - _lastConnectAttempt > RECONNECT_MS) {
connect();
}
}
void startScan() {
if (!_bleReady) return;
_deviceFound = false;
_pScan->start(SCAN_DURATION_SEC * 1000, false); // NimBLE 2.x: time in ms
_lastScan = millis();
}
bool connect() {
_lastConnectAttempt = millis();
if (_printerConnected || !_deviceFound) return false;
if (_pClient) {
NimBLEDevice::deleteClient(_pClient);
_pClient = nullptr;
}
_pClient = NimBLEDevice::createClient(_deviceAddress);
if (!_pClient) return false;
_pClient->setClientCallbacks(new ClientCallbacks(this));
_pClient->setConnectionParams(12, 12, 0, 200);
if (!_pClient->connect(true)) {
NimBLEDevice::deleteClient(_pClient);
_pClient = nullptr;
return false;
}
// MTU exchange is automatic during connect() (exchangeMTU=true default)
auto *pService = _pClient->getService(PHOMEMO_SERVICE_UUID);
if (!pService) {
_pClient->disconnect();
return false;
}
_pWriteChar = pService->getCharacteristic(PHOMEMO_CHARACTERISTIC_UUID);
if (!_pWriteChar || !_pWriteChar->canWrite()) {
_pClient->disconnect();
return false;
}
_printerConnected = true;
Serial.printf("[BLE] Connected to %s\n", _deviceName.c_str());
return true;
}
void disconnect() {
if (_pClient && _pClient->isConnected()) {
_pClient->disconnect();
}
_printerConnected = false;
}
/**
* Send raw bytes to the printer's write characteristic.
* Automatically chunks data if > MTU-3.
*/
bool sendData(const uint8_t *data, size_t length) {
if (!_printerConnected || !_pWriteChar) return false;
const size_t CHUNK = 244; // MTU-3
size_t offset = 0;
int chunks = 0;
while (offset < length) {
size_t n = min(CHUNK, length - offset);
if (!_pWriteChar->writeValue(const_cast<uint8_t*>(data + offset), n, true)) {
Serial.printf("[BLE] FAIL chunk %d at %d/%d\n", chunks, (int)offset, (int)length);
return false;
}
offset += n;
chunks++;
delay(12);
}
Serial.printf("[BLE] OK %d bytes in %d chunks, heap=%d\n", (int)length, chunks, ESP.getFreeHeap());
return true;
}
// ---- Status ----------------------------------------------------------
Status getStatus() const {
Status s;
s.bleReady = _bleReady;
s.printerFound = _deviceFound;
s.printerConnected = _printerConnected;
s.printerName = _deviceName;
s.printerAddress = _deviceAddress.toString().c_str();
s.rssi = _rssi;
return s;
}
bool isConnected() const { return _printerConnected; }
private:
static constexpr uint32_t SCAN_INTERVAL_MS = 15000;
static constexpr uint32_t SCAN_DURATION_SEC = 5;
static constexpr uint32_t RECONNECT_MS = 5000;
// ---- BLE callbacks (NimBLE 2.x API) ----------------------------------
class ScanCallbacks : public NimBLEScanCallbacks {
public:
ScanCallbacks(BLEPrinterClient *parent) : _p(parent) {}
void onResult(const NimBLEAdvertisedDevice *device) override {
if (device->getName() == PRINTER_BLE_NAME) {
Serial.printf("[BLE] Found printer: %s (%s) RSSI=%d\n",
device->getName().c_str(),
device->getAddress().toString().c_str(),
device->getRSSI());
_p->_deviceName = device->getName().c_str();
_p->_deviceAddress = device->getAddress();
_p->_rssi = device->getRSSI();
_p->_deviceFound = true;
NimBLEDevice::getScan()->stop();
}
}
private:
BLEPrinterClient *_p;
};
class ClientCallbacks : public NimBLEClientCallbacks {
public:
ClientCallbacks(BLEPrinterClient *parent) : _p(parent) {}
void onConnect(NimBLEClient *client) override { (void)client; }
void onDisconnect(NimBLEClient *client, int reason) override {
(void)client;
Serial.printf("[BLE] Disconnected (reason=%d)\n", reason);
_p->_printerConnected = false;
}
private:
BLEPrinterClient *_p;
};
// ---- State -----------------------------------------------------------
NimBLEScan *_pScan = nullptr;
NimBLEClient *_pClient = nullptr;
NimBLERemoteCharacteristic *_pWriteChar = nullptr;
bool _bleReady = false;
bool _deviceFound = false;
bool _printerConnected = false;
String _deviceName;
NimBLEAddress _deviceAddress;
int _rssi = 0;
uint32_t _lastScan = 0;
uint32_t _lastConnectAttempt = 0;
};
+35
View File
@@ -0,0 +1,35 @@
#pragma once
// ---------------------------------------------------------------------------
// WiFi — set via build_flags in platformio.ini or private_config.ini
// ---------------------------------------------------------------------------
#ifndef WIFI_SSID
#define WIFI_SSID "your-wifi-ssid"
#endif
#ifndef WIFI_PASS
#define WIFI_PASS "your-wifi-password"
#endif
// ---------------------------------------------------------------------------
// Printer — BLE advertised name of the M110 (e.g. "Q002E0CP0670069")
// ---------------------------------------------------------------------------
#ifndef PRINTER_BLE_NAME
#define PRINTER_BLE_NAME "M110"
#endif
// ---------------------------------------------------------------------------
// M110 BLE GATT UUIDs (Phomemo standard — do not change)
// ---------------------------------------------------------------------------
#define PHOMEMO_SERVICE_UUID "0000FF00-0000-1000-8000-00805F9B34FB"
#define PHOMEMO_CHARACTERISTIC_UUID "0000FF02-0000-1000-8000-00805F9B34FB"
// ---------------------------------------------------------------------------
// Print head — M110 = 48mm @ 203dpi → 384 dots → 48 bytes per row
// ---------------------------------------------------------------------------
#define HEAD_WIDTH_DOTS 384
#define HEAD_WIDTH_BYTES (HEAD_WIDTH_DOTS / 8) // 48
// ---------------------------------------------------------------------------
// Web server port
// ---------------------------------------------------------------------------
#define WEB_PORT 80
+318
View File
@@ -0,0 +1,318 @@
/**
* ESP32-C3 WiFi → BLE Bridge for Phomemo M110 Label Printer
*
* Connects to the M110 via BLE and exposes a HTTP API for printing
* text, QR codes, and raw ESC/POS data.
*
* Build: pio run -t upload
* Monitor: pio device monitor
*/
#include <Arduino.h>
#include <HWCDC.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <ArduinoJson.h>
#include <LittleFS.h>
// ESP32-C3 Arduino 2.0.x: Serial0 = USB-serial-JTAG (CDC), Serial = UART0 (GPIO20/21).
// Use Serial0 for all debug output so it appears on the USB port.
#include "config.h"
#include "printer.h"
#include "ble_client.h"
#include "qr_renderer.h"
// ---------------------------------------------------------------------------
// Globals
// ---------------------------------------------------------------------------
AsyncWebServer server(WEB_PORT);
BLEPrinterClient blePrinter;
String wifiIP;
#define STR_HELPER(x) #x
#define STRINGIFY(x) STR_HELPER(x)
// ---------------------------------------------------------------------------
// WiFi
// ---------------------------------------------------------------------------
void connectWiFi() {
Serial.printf("[WiFi] Connecting to %s ...\n", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASS);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 40) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
wifiIP = WiFi.localIP().toString();
Serial.printf("\n[WiFi] Connected! IP: %s\n", wifiIP.c_str());
} else {
Serial.println("\n[WiFi] FAILED — will retry");
}
}
/**
* Minimal Print subclass that accumulates bytes into a std::vector.
* Used as a buffer target for M110Printer before sending over BLE.
*/
class PrintBuffer : public Print {
public:
size_t write(uint8_t c) override {
_buf.push_back(c);
return 1;
}
size_t write(const uint8_t *data, size_t len) override {
_buf.insert(_buf.end(), data, data + len);
return len;
}
const uint8_t* data() const { return _buf.data(); }
size_t size() const { return _buf.size(); }
void clear() { _buf.clear(); }
private:
std::vector<uint8_t> _buf;
};
// ---------------------------------------------------------------------------
String buildStatusJson() {
auto s = blePrinter.getStatus();
JsonDocument doc;
doc["wifi"] = (WiFi.status() == WL_CONNECTED) ? "connected" : "disconnected";
doc["ip"] = wifiIP;
doc["bleReady"] = s.bleReady;
doc["printerFound"] = s.printerFound;
doc["printerConnected"] = s.printerConnected;
doc["printerName"] = s.printerName;
doc["printerAddress"] = s.printerAddress;
doc["rssi"] = s.rssi;
doc["uptime"] = millis() / 1000;
doc["freeHeap"] = ESP.getFreeHeap();
String json;
serializeJson(doc, json);
return json;
}
// ---------------------------------------------------------------------------
// API: POST /api/print
// Body: JSON
// { "type": "qr", "data": "https://example.com", "scale": 4 }
// { "type": "raw", "data": "<base64-encoded ESC/POS bytes>" }
// ---------------------------------------------------------------------------
void handlePrint(AsyncWebServerRequest *request, uint8_t *data, size_t len,
size_t index, size_t total) {
// accumulate body (AsyncWebServer may deliver in chunks for large bodies,
// but our JSON bodies are small)
static String body;
if (index == 0) body.clear();
body.concat((const char *)data, len);
if (index + len < total) return; // wait for complete body
if (!blePrinter.isConnected()) {
request->send(503, "application/json", "{\"error\":\"printer not connected\"}");
return;
}
// Use a dynamically-sized document — bitmap base64 payloads can exceed
// the default 256-byte JsonDocument limit.
DynamicJsonDocument doc(32768);
DeserializationError err = deserializeJson(doc, body);
if (err) {
request->send(400, "application/json", "{\"error\":\"invalid JSON\"}");
return;
}
String type = doc["type"] | "qr";
PrintBuffer buf;
M110Printer printer(buf);
// Shared base64 decoding table
static const char b64table[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
if (type == "qr") {
// --- QR Code --------------------------------------------------
const char *text = doc["data"] | "https://techahead.de";
uint8_t scale = doc["scale"] | 4;
QRBitmap qr;
if (!qr.render(text, scale)) {
request->send(400, "application/json", "{\"error\":\"QR encode failed\"}");
return;
}
int16_t offX = doc["offsetX"] | 0;
printer.printLabel(qr.data(), qr.width(), qr.height(), MEDIA_LABEL_WITH_GAPS, offX);
} else if (type == "bitmap") {
// --- 1-bit bitmap (from web UI image upload) ------------------
const char *b64 = doc["data"] | "";
uint16_t imgW = doc["width"] | HEAD_WIDTH_DOTS;
uint16_t imgH = doc["height"] | 100;
// Decode base64 into a temporary pixel buffer
PrintBuffer pixels;
size_t b64Len = strlen(b64);
int val = 0, valb = -8;
for (size_t i = 0; i < b64Len; i++) {
if (b64[i] == '=') break;
const char *p = strchr(b64table, b64[i]);
if (!p) continue;
val = (val << 6) | (int)(p - b64table);
valb += 6;
if (valb >= 0) {
pixels.write((uint8_t)((val >> valb) & 0xFF));
valb -= 8;
}
}
if (pixels.size() == 0) {
request->send(400, "application/json", "{\"error\":\"empty bitmap\"}");
return;
}
// Send complete image as one ESC/POS stream.
// sendBitmap handles chunking internally if needed.
uint8_t density = doc["density"] | 15;
if (density < 1) density = 1;
if (density > 15) density = 15;
int16_t offX = doc["offsetX"] | 0;
printer.beginLabel(MEDIA_LABEL_WITH_GAPS, 5, density);
printer.sendBitmap(pixels.data(), imgW, imgH, offX);
printer.endLabel();
if (blePrinter.sendData(buf.data(), buf.size())) {
request->send(200, "application/json",
"{\"ok\":true,\"bytes\":" + String(buf.size()) + "}");
} else {
request->send(500, "application/json", "{\"error\":\"BLE send failed\"}");
}
return;
} else if (type == "raw") {
// --- Raw ESC/POS ----------------------------------------------
// Raw data is sent directly — no M110Printer wrapper needed.
const char *b64raw = doc["data"] | "";
size_t b64RLen = strlen(b64raw);
int val = 0, valb = -8;
for (size_t i = 0; i < b64RLen; i++) {
if (b64raw[i] == '=') break;
const char *p = strchr(b64table, b64raw[i]);
if (!p) continue;
val = (val << 6) | (int)(p - b64table);
valb += 6;
if (valb >= 0) {
buf.write((uint8_t)((val >> valb) & 0xFF));
valb -= 8;
}
}
// Send raw data directly — it already contains ESC/POS commands.
if (!buf.size()) {
request->send(400, "application/json", "{\"error\":\"empty payload\"}");
return;
}
if (blePrinter.sendData(buf.data(), buf.size())) {
request->send(200, "application/json",
"{\"ok\":true,\"bytes\":" + String(buf.size()) + "}");
} else {
request->send(500, "application/json", "{\"error\":\"BLE send failed\"}");
}
return;
} else {
request->send(400, "application/json", "{\"error\":\"unknown type\"}");
return;
}
// Send to printer
if (blePrinter.sendData(buf.data(), buf.size())) {
request->send(200, "application/json",
"{\"ok\":true,\"bytes\":" + String(buf.size()) + "}");
} else {
request->send(500, "application/json", "{\"error\":\"BLE send failed\"}");
}
}
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=== ESP32 M110 Label Printer Bridge ===");
// LittleFS (for static web UI)
if (!LittleFS.begin(true)) {
Serial.println("[FS] LittleFS mount failed");
}
// WiFi
connectWiFi();
// BLE
blePrinter.begin();
// ---- Web routes ------------------------------------------------------
// CORS header for all responses
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*");
DefaultHeaders::Instance().addHeader("Access-Control-Allow-Headers", "Content-Type");
// Status
server.on("/api/status", HTTP_GET, [](AsyncWebServerRequest *r) {
r->send(200, "application/json", buildStatusJson());
});
// Connect (trigger scan + connect now)
server.on("/api/connect", HTTP_POST, [](AsyncWebServerRequest *r) {
blePrinter.startScan();
r->send(200, "application/json", "{\"ok\":true,\"msg\":\"scan started\"}");
});
// Print (accepts JSON body)
server.on("/api/print", HTTP_POST,
[](AsyncWebServerRequest *r) {}, // no-op on request
nullptr,
handlePrint);
// Static files from LittleFS (web UI)
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
// 404
server.onNotFound([](AsyncWebServerRequest *r) {
r->send(404, "application/json", "{\"error\":\"not found\"}");
});
// OPTIONS for CORS preflight
server.on("/api/print", HTTP_OPTIONS, [](AsyncWebServerRequest *r) {
r->send(204);
});
server.on("/api/connect", HTTP_OPTIONS, [](AsyncWebServerRequest *r) {
r->send(204);
});
server.begin();
Serial.println("[HTTP] Server started on port " STRINGIFY(WEB_PORT));
}
// ---------------------------------------------------------------------------
// Loop
// ---------------------------------------------------------------------------
void loop() {
// Reconnect WiFi if needed
static uint32_t lastWifiCheck = 0;
if (millis() - lastWifiCheck > 30000) {
lastWifiCheck = millis();
if (WiFi.status() != WL_CONNECTED) {
connectWiFi();
}
}
// BLE maintenance (scan, reconnect)
blePrinter.tick();
delay(100);
}
+104
View File
@@ -0,0 +1,104 @@
#pragma once
#include <Arduino.h>
/**
* ESC/POS command encoder for Phomemo M110/M120/M220.
*
* All output is appended to the provided Print stream (e.g. a buffer or
* directly to the BLE characteristic). The caller is responsible for
* flushing to the printer as a single write.
*
* IMPORTANT: the Phomemo M110 is known to _only_ accept GS v 0 (raster
* bit-image) and a few header/footer commands. Text and barcode ESC/POS
* commands (ESC @, ESC a, GS k, etc.) are sent to the printer's serial port
* but may be ignored or misinterpreted by the M110 firmware. The safest
* production path is to render everything (text, QR codes, barcodes) to a
* bitmap and send it via sendBitmap().
*/
enum MediaType {
MEDIA_LABEL_WITH_GAPS = 0x0A,
MEDIA_CONTINUOUS = 0x0B,
MEDIA_LABEL_WITH_MARKS = 0x26
};
class M110Printer {
public:
/**
* @param out Target stream (e.g. a String or a BLE characteristic wrapper).
* Must remain valid for the lifetime of this object.
*/
M110Printer(Print &out) : _out(out) {}
// ---- Header / footer -------------------------------------------------
void beginLabel(MediaType media = MEDIA_LABEL_WITH_GAPS,
uint8_t speed = 5, uint8_t density = 15) {
// ESC @ — reset printer state before each label
_out.write((const uint8_t*)"\x1B\x40", 2);
// Speed ESC N 0x0D <1-5>
_out.write((const uint8_t*)"\x1B\x4E\x0D", 3);
_out.write(speed);
// Density ESC N 0x04 <1-15>
_out.write((const uint8_t*)"\x1B\x4E\x04", 3);
_out.write(density);
// Media GS DC1 <type>
_out.write((const uint8_t*)"\x1F\x11", 2);
_out.write((uint8_t)media);
}
void endLabel() {
_out.write((const uint8_t*)"\x1F\xF0\x05\x00\x1F\xF0\x03\x00", 8);
}
// ---- Raster bitmap (GS v 0) ------------------------------------------
/**
* Send a 1-bit-per-pixel bitmap.
* @param data Packed bitmap: MSB = leftmost pixel, 1 = black.
* @param widthPx Width in pixels (≤ HEAD_WIDTH_DOTS).
* @param heightPx Height in pixels.
* @param offsetXPx Horizontal offset in pixels: 0=centered, negative=left, positive=right.
*/
void sendBitmap(const uint8_t *data, uint16_t widthPx, uint16_t heightPx,
int16_t offsetXPx = 0) {
uint16_t srcBytesPerLine = (widthPx + 7) / 8;
uint16_t dstBytesPerLine = HEAD_WIDTH_BYTES;
int16_t basePadLeft = (dstBytesPerLine - srcBytesPerLine) / 2;
int16_t totalPadLeft = basePadLeft + offsetXPx / 8;
int16_t totalPadRight = dstBytesPerLine - srcBytesPerLine - totalPadLeft;
if (totalPadLeft < 0) { totalPadRight += totalPadLeft; totalPadLeft = 0; }
if (totalPadRight < 0) { totalPadLeft += totalPadRight; totalPadRight = 0; }
if (totalPadLeft + srcBytesPerLine + totalPadRight > dstBytesPerLine) {
totalPadRight = dstBytesPerLine - srcBytesPerLine - totalPadLeft;
}
// Single GS v 0 covering the full image height.
// The printer will print what fits on the physical label.
_out.write((const uint8_t*)"\x1D\x76\x30\x00", 4);
_out.write((uint8_t)(dstBytesPerLine & 0xFF));
_out.write((uint8_t)(dstBytesPerLine >> 8));
_out.write((uint8_t)(heightPx & 0xFF));
_out.write((uint8_t)(heightPx >> 8));
for (uint16_t y = 0; y < heightPx; y++) {
for (uint16_t p = 0; p < (uint16_t)totalPadLeft; p++) _out.write((uint8_t)0);
_out.write(data + (y * srcBytesPerLine), srcBytesPerLine);
for (uint16_t p = 0; p < (uint16_t)totalPadRight; p++) _out.write((uint8_t)0);
}
}
// ---- Convenience: full-label print -----------------------------------
void printLabel(const uint8_t *bitmap, uint16_t w, uint16_t h,
MediaType media = MEDIA_LABEL_WITH_GAPS,
int16_t offsetX = 0) {
beginLabel(media);
sendBitmap(bitmap, w, h, offsetX);
endLabel();
}
private:
Print &_out;
};
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include <Arduino.h>
#include <qrcode.h>
/**
* Render a QR code with auto-version selection.
* Tries version 4 first, then increases up to version 10 until the text fits.
*/
class QRBitmap {
public:
QRBitmap() {}
bool render(const char *text, uint8_t scale = 4, uint8_t ecc = 2) {
// Try versions 4-10 until text fits
uint8_t version = 4;
uint8_t qrcodeData[qrcode_getBufferSize(10)]; // enough for v10
QRCode qr;
int8_t err;
while (version <= 10) {
err = qrcode_initText(&qr, qrcodeData, version, ecc, text);
if (err == 0) break; // success
version++;
}
if (err != 0) return false;
// Step 2: allocate bitmap
uint16_t qrPx = qr.size * scale;
// Must be ≤ HEAD_WIDTH_DOTS
if (qrPx > HEAD_WIDTH_DOTS) {
scale = HEAD_WIDTH_DOTS / qr.size;
qrPx = qr.size * scale;
}
// Pad width to next multiple of 8 so every row is byte-aligned.
_bytesPerLine = (qrPx + 7) / 8;
_width = _bytesPerLine * 8;
_height = qrPx;
size_t bufSize = (size_t)_bytesPerLine * _height;
_buf.resize(bufSize);
memset(_buf.data(), 0, bufSize);
// Step 3: scale modules into bitmap
for (uint8_t my = 0; my < qr.size; my++) {
for (uint8_t mx = 0; mx < qr.size; mx++) {
if (qrcode_getModule(&qr, mx, my)) {
fillBlock(mx * scale, my * scale, scale, scale);
}
}
}
return true;
}
const uint8_t *data() const { return _buf.data(); }
size_t size() const { return _buf.size(); }
uint16_t width() const { return _width; }
uint16_t height() const { return _height; }
private:
void fillBlock(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h) {
for (uint16_t y = y0; y < y0 + h && y < _height; y++) {
for (uint16_t x = x0; x < x0 + w && x < _width; x++) {
uint16_t byteIdx = y * _bytesPerLine + (x / 8);
uint8_t bitPos = 7 - (x % 8); // MSB = left
if (byteIdx < _buf.size()) {
_buf[byteIdx] |= (1 << bitPos);
}
}
}
}
std::vector<uint8_t> _buf;
uint16_t _width = 0;
uint16_t _height = 0;
uint16_t _bytesPerLine = 0;
};