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:
@@ -0,0 +1,7 @@
|
||||
.pio/
|
||||
.pioenvs/
|
||||
.vscode/
|
||||
*.o
|
||||
*.elf
|
||||
*.bin
|
||||
private_config.ini
|
||||
@@ -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 (1–5)
|
||||
- `ESC N 0x04 <density>` — Druckdichte (1–15)
|
||||
- `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
@@ -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>
|
||||
Vendored
+8
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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"'
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user