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
+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