Files
esp32-m110-label/data/index.html
T
dcp4ever 42efd83bc0 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
2026-06-13 16:38:54 +02:00

401 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>