42efd83bc0
- 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
401 lines
17 KiB
HTML
401 lines
17 KiB
HTML
<!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>
|