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:
+400
@@ -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>
|
||||
Reference in New Issue
Block a user