From 42efd83bc0874522b6c8b6365e7c0f2168a1a705 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lars=20Schr=C3=B6der?= Date: Sat, 13 Jun 2026 16:38:54 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20ESP32-C3=20WiFi=E2=86=92BLE?= =?UTF-8?q?=20bridge=20for=20Phomemo=20M110?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 7 + README.md | 146 ++++++++++++++ data/index.html | 400 +++++++++++++++++++++++++++++++++++++ data/qrcode.min.js | 8 + platformio.ini | 20 ++ private_config.ini.example | 8 + src/ble_client.h | 208 +++++++++++++++++++ src/config.h | 35 ++++ src/main.cpp | 318 +++++++++++++++++++++++++++++ src/printer.h | 104 ++++++++++ src/qr_renderer.h | 76 +++++++ 11 files changed, 1330 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 data/index.html create mode 100644 data/qrcode.min.js create mode 100644 platformio.ini create mode 100644 private_config.ini.example create mode 100644 src/ble_client.h create mode 100644 src/config.h create mode 100644 src/main.cpp create mode 100644 src/printer.h create mode 100644 src/qr_renderer.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0fc14b --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.pio/ +.pioenvs/ +.vscode/ +*.o +*.elf +*.bin +private_config.ini diff --git a/README.md b/README.md new file mode 100644 index 0000000..1300817 --- /dev/null +++ b/README.md @@ -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:///` ö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": "", + "width": 384, + "height": 200, + "density": 15, + "offsetX": 0 +} +``` + +```json +{ + "type": "raw", + "data": "" +} +``` + +## 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 ` — Druckgeschwindigkeit (1–5) + - `ESC N 0x04 ` — Druckdichte (1–15) + - `GS DC1 ` — Medientyp (0x0A=LabelWithGaps, 0x0B=Continuous) + - `GS v 0 ` — 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 diff --git a/data/index.html b/data/index.html new file mode 100644 index 0000000..253dfb8 --- /dev/null +++ b/data/index.html @@ -0,0 +1,400 @@ + + + + + +M110 Label Printer + + + +

🖨️ Phomemo M110

+ +
+

Status

+
+ WiFi: + Drucker: + IP: + Signal: +
+ + +
+ +
+
+
+
+ + +
+ + +
+
+

📂 Bild hier ablegen oder klicken

+ +
+ + +
+ + + 15 +
+
+ + + 384 +
+
+ + + 0 +
+
+ + + 128 +
+
+ + +
+ + + +
+ + + +
+
+
+ +
+

Log

+
+
+ + + + + diff --git a/data/qrcode.min.js b/data/qrcode.min.js new file mode 100644 index 0000000..c1215c7 --- /dev/null +++ b/data/qrcode.min.js @@ -0,0 +1,8 @@ +/** + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/qrcode-generator@1.4.4/qrcode.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +var qrcode=function(){var t=function(t,r){var e=t,n=g[r],o=null,i=0,a=null,u=[],f={},c=function(t,r){o=function(t){for(var r=new Array(t),e=0;e=7&&v(t),null==a&&(a=p(e,n,u)),w(a,r)},l=function(t,r){for(var e=-1;e<=7;e+=1)if(!(t+e<=-1||i<=t+e))for(var n=-1;n<=7;n+=1)r+n<=-1||i<=r+n||(o[t+e][r+n]=0<=e&&e<=6&&(0==n||6==n)||0<=n&&n<=6&&(0==e||6==e)||2<=e&&e<=4&&2<=n&&n<=4)},h=function(){for(var t=8;t>n&1);o[Math.floor(n/3)][n%3+i-8-3]=a}for(n=0;n<18;n+=1){a=!t&&1==(r>>n&1);o[n%3+i-8-3][Math.floor(n/3)]=a}},d=function(t,r){for(var e=n<<3|r,a=B.getBCHTypeInfo(e),u=0;u<15;u+=1){var f=!t&&1==(a>>u&1);u<6?o[u][8]=f:u<8?o[u+1][8]=f:o[i-15+u][8]=f}for(u=0;u<15;u+=1){f=!t&&1==(a>>u&1);u<8?o[8][i-u-1]=f:u<9?o[8][15-u-1+1]=f:o[8][15-u-1]=f}o[i-8][8]=!t},w=function(t,r){for(var e=-1,n=i-1,a=7,u=0,f=B.getMaskFunction(r),c=i-1;c>0;c-=2)for(6==c&&(c-=1);;){for(var g=0;g<2;g+=1)if(null==o[n][c-g]){var l=!1;u>>a&1)),f(n,c-g)&&(l=!l),o[n][c-g]=l,-1==(a-=1)&&(u+=1,a=7)}if((n+=e)<0||i<=n){n-=e,e=-e;break}}},p=function(t,r,e){for(var n=A.getRSBlocks(t,r),o=b(),i=0;i8*u)throw"code length overflow. ("+o.getLengthInBits()+">"+8*u+")";for(o.getLengthInBits()+4<=8*u&&o.put(0,4);o.getLengthInBits()%8!=0;)o.putBit(!1);for(;!(o.getLengthInBits()>=8*u||(o.put(236,8),o.getLengthInBits()>=8*u));)o.put(17,8);return function(t,r){for(var e=0,n=0,o=0,i=new Array(r.length),a=new Array(r.length),u=0;u=0?h.getAt(s):0}}var v=0;for(g=0;gn)&&(t=n,r=e)}return r}())},f.createTableTag=function(t,r){t=t||2;var e="";e+='',e+="";for(var n=0;n";for(var o=0;o';e+=""}return e+="",e+="
"},f.createSvgTag=function(t,r,e,n){var o={};"object"==typeof arguments[0]&&(t=(o=arguments[0]).cellSize,r=o.margin,e=o.alt,n=o.title),t=t||2,r=void 0===r?4*t:r,(e="string"==typeof e?{text:e}:e||{}).text=e.text||null,e.id=e.text?e.id||"qrcode-description":null,(n="string"==typeof n?{text:n}:n||{}).text=n.text||null,n.id=n.text?n.id||"qrcode-title":null;var i,a,u,c,g=f.getModuleCount()*t+2*r,l="";for(c="l"+t+",0 0,"+t+" -"+t+",0 0,-"+t+"z ",l+=''+y(n.text)+"":"",l+=e.text?''+y(e.text)+"":"",l+='',l+='":r+=">";break;case"&":r+="&";break;case'"':r+=""";break;default:r+=n}}return r};return f.createASCII=function(t,r){if((t=t||1)<2)return function(t){t=void 0===t?2:t;var r,e,n,o,i,a=1*f.getModuleCount()+2*t,u=t,c=a-t,g={"██":"█","█ ":"▀"," █":"▄"," ":" "},l={"██":"▀","█ ":"▀"," █":" "," ":" "},h="";for(r=0;r=c?l[i]:g[i];h+="\n"}return a%2&&t>0?h.substring(0,h.length-a-1)+Array(a+1).join("▀"):h.substring(0,h.length-1)}(r);t-=1,r=void 0===r?2*t:r;var e,n,o,i,a=f.getModuleCount()*t+2*r,u=r,c=a-r,g=Array(t+1).join("██"),l=Array(t+1).join(" "),h="",s="";for(e=0;e>>8),r.push(255&a)):r.push(n)}}return r}};var r,e,n,o,i,a=1,u=2,f=4,c=8,g={L:1,M:0,Q:3,H:2},l=0,h=1,s=2,v=3,d=4,w=5,p=6,y=7,B=(r=[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],e=1335,n=7973,i=function(t){for(var r=0;0!=t;)r+=1,t>>>=1;return r},(o={}).getBCHTypeInfo=function(t){for(var r=t<<10;i(r)-i(e)>=0;)r^=e<=0;)r^=n<5&&(e+=3+i-5)}for(n=0;n=256;)r-=255;return t[r]}};return n}();function k(t,r){if(void 0===t.length)throw t.length+"/"+r;var e=function(){for(var e=0;e>>7-r%8&1)},put:function(t,r){for(var n=0;n>>r-n-1&1))},getLengthInBits:function(){return r},putBit:function(e){var n=Math.floor(r/8);t.length<=n&&t.push(0),e&&(t[n]|=128>>>r%8),r+=1}};return e},M=function(t){var r=a,e=t,n={getMode:function(){return r},getLength:function(t){return e.length},write:function(t){for(var r=e,n=0;n+2>>8&255)+(255&n),t.put(n,13),e+=2}if(e>>8)},writeBytes:function(t,e,n){e=e||0,n=n||t.length;for(var o=0;o0&&(r+=","),r+=t[e];return r+="]"}};return r},S=function(t){var r=t,e=0,n=0,o=0,i={read:function(){for(;o<8;){if(e>=r.length){if(0==o)return-1;throw"unexpected end of file./"+o}var t=r.charAt(e);if(e+=1,"="==t)return o=0,-1;t.match(/^\s$/)||(n=n<<6|a(t.charCodeAt(0)),o+=6)}var i=n>>>o-8&255;return o-=8,i}},a=function(t){if(65<=t&&t<=90)return t-65;if(97<=t&&t<=122)return t-97+26;if(48<=t&&t<=57)return t-48+52;if(43==t)return 62;if(47==t)return 63;throw"c:"+t};return i},I=function(t,r,e){for(var n=function(t,r){var e=t,n=r,o=new Array(t*r),i={setPixel:function(t,r,n){o[r*e+t]=n},write:function(t){t.writeString("GIF87a"),t.writeShort(e),t.writeShort(n),t.writeByte(128),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(0),t.writeByte(255),t.writeByte(255),t.writeByte(255),t.writeString(","),t.writeShort(0),t.writeShort(0),t.writeShort(e),t.writeShort(n),t.writeByte(0);var r=a(2);t.writeByte(2);for(var o=0;r.length-o>255;)t.writeByte(255),t.writeBytes(r,o,255),o+=255;t.writeByte(r.length-o),t.writeBytes(r,o,r.length-o),t.writeByte(0),t.writeString(";")}},a=function(t){for(var r=1<>>r!=0)throw"length over";for(;c+r>=8;)f.writeByte(255&(t<>>=8-c,g=0,c=0;g|=t<0&&f.writeByte(g)}});h.write(r,n);var s=0,v=String.fromCharCode(o[s]);for(s+=1;s=6;)i(t>>>r-6),r-=6},o.flush=function(){if(r>0&&(i(t<<6-r),t=0,r=0),e%3!=0)for(var o=3-e%3,a=0;a>6,128|63&n):n<55296||n>=57344?r.push(224|n>>12,128|n>>6&63,128|63&n):(e++,n=65536+((1023&n)<<10|1023&t.charCodeAt(e)),r.push(240|n>>18,128|n>>12&63,128|n>>6&63,128|63&n))}return r}(t)},function(t){"function"==typeof define&&define.amd?define([],t):"object"==typeof exports&&(module.exports=t())}((function(){return qrcode})); +//# sourceMappingURL=/sm/26b4b0d0b1e283d6b3ec9857ac597d7a60c76ac17be1ef4c965f03086de426bb.map \ No newline at end of file diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..f8db486 --- /dev/null +++ b/platformio.ini @@ -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 diff --git a/private_config.ini.example b/private_config.ini.example new file mode 100644 index 0000000..fc8ac12 --- /dev/null +++ b/private_config.ini.example @@ -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"' diff --git a/src/ble_client.h b/src/ble_client.h new file mode 100644 index 0000000..43646e5 --- /dev/null +++ b/src/ble_client.h @@ -0,0 +1,208 @@ +#pragma once +#include +#include +#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(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; +}; diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..2e39695 --- /dev/null +++ b/src/config.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..e4c30e4 --- /dev/null +++ b/src/main.cpp @@ -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 +#include +#include +#include +#include +#include + +// 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 _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": "" } +// --------------------------------------------------------------------------- +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); +} diff --git a/src/printer.h b/src/printer.h new file mode 100644 index 0000000..6e13be7 --- /dev/null +++ b/src/printer.h @@ -0,0 +1,104 @@ +#pragma once +#include + +/** + * 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 + _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; +}; diff --git a/src/qr_renderer.h b/src/qr_renderer.h new file mode 100644 index 0000000..84818df --- /dev/null +++ b/src/qr_renderer.h @@ -0,0 +1,76 @@ +#pragma once +#include +#include + +/** + * 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 _buf; + uint16_t _width = 0; + uint16_t _height = 0; + uint16_t _bytesPerLine = 0; +};