Exploring BLE Printing in DingTalk Mini Programs: Implementation and Best Practices
This article provides a detailed engineering analysis of using Bluetooth Low Energy (BLE) within DingTalk mini programs to drive portable thermal printers, covering why BLE is chosen over classic Bluetooth, the GATT communication model, connection lifecycle management, ESC/POS command construction, packet splitting for the 20‑byte BLE limit, and practical image‑printing techniques, all illustrated with code examples and step‑by‑step guidance.
Introduction
In logistics delivery, electronic signatures cannot fully replace paper receipts in some scenarios. Delivery personnel often need to print delivery orders on site for item‑by‑item verification and signing. Paper receipts serve as proof of delivery, accounting evidence, and responsibility traceability.
Typical delivery environments lack fixed networks, have unstable or offline connectivity, and are highly distributed, making traditional network‑dependent printing solutions infeasible. The solution explored is a combination of DingTalk mini programs, portable thermal printers, and Bluetooth Low Energy (BLE).
Why Choose BLE Over Classic Bluetooth?
Classic Bluetooth (BR/EDR) was designed for continuous high‑throughput data streams, supporting profiles such as A2DP, HFP, and SPP. BLE, introduced with Bluetooth 4.0, targets low‑power, intermittent, small‑packet communication. Key differences:
Classic Bluetooth provides a streaming model (SPP) with higher power consumption and longer connection times (2‑5 s).
BLE uses the GATT model, exposing services and characteristics, allowing short connections (100‑300 ms) and low power consumption.
iOS fully supports BLE via CoreBluetooth, while classic Bluetooth SPP requires MFi certification and is unavailable to third‑party apps.
DingTalk mini programs expose only BLE APIs; classic Bluetooth APIs are not available.
From an engineering perspective, BLE is a better fit for the "connect‑use‑disconnect" workflow of on‑site printing.
BLE Communication Model
BLE does not provide a continuous byte stream. Instead, it uses the Generic Attribute Profile (GATT) where a device’s capabilities are modeled as services, each containing characteristics. Communication is performed by reading, writing, or receiving notifications on these characteristics.
Typical workflow:
Initialize the Bluetooth adapter.
Check permissions (Bluetooth and location on Android).
Start device discovery and filter for printers.
Connect to the selected device.
Discover services and characteristics.
Identify a writable characteristic for sending ESC/POS commands.
Enable Notify on a characteristic to receive printer status (paper‑out, overheating, etc.).
Send data, then clean up resources.
Permission and Adapter Initialization
async function checkBluetoothAuthorization() {
const bluetooth = await this.checkAuthorization('BLUETOOTH');
const sys = dd.getSystemInfoSync();
if (sys.platform === 'android') {
const lbs = await this.checkAuthorization('LBS');
return bluetooth && lbs;
}
return bluetooth;
}
function openBluetoothAdapter() {
return new Promise((resolve, reject) => {
dd.openBluetoothAdapter({
success: resolve,
fail: err => {
if (err.errorCode === 10001) {
dd.showToast({ content: '系统蓝牙未开启', type: 'fail' });
}
reject(err);
}
});
});
}Device Scanning
dd.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false,
interval: 0,
success() {
// optional timeout handling
}
});
dd.onBluetoothDeviceFound(res => {
res.devices.forEach(device => {
if (device.name && device.name.includes('Printer')) {
console.log('发现打印机:', device.name, device.deviceId);
storeDeviceInfo(device);
}
});
});Connecting and Service Discovery
dd.connectBLEDevice({
deviceId,
success() {
console.log('设备连接成功');
dd.stopBluetoothDevicesDiscovery();
getDeviceServices(deviceId);
},
fail(err) {
console.error('连接失败', err);
}
});
function getDeviceServices(deviceId) {
dd.getBLEDeviceServices({
deviceId,
success(res) {
const printService = res.services.find(s => s.uuid.includes('FF00') || s.isPrimary);
if (printService) {
getServiceCharacteristics(deviceId, printService.uuid);
}
},
fail(err) {
console.error('获取服务失败', err);
}
});
}Characteristic Handling
function getServiceCharacteristics(deviceId, serviceId) {
dd.getBLEDeviceCharacteristics({
deviceId,
serviceId,
success(res) {
let writeCharId = null;
let notifyCharId = null;
res.characteristics.forEach(c => {
if (c.properties.write || c.properties.writeWithoutResponse) {
writeCharId = c.characteristicId;
}
if (c.properties.notify || c.properties.indicate) {
notifyCharId = c.characteristicId;
}
});
enableNotify(deviceId, serviceId, notifyCharId);
},
fail(err) {
console.error('获取特征值失败', err);
}
});
}
function enableNotify(deviceId, serviceId, characteristicId) {
dd.notifyBLECharacteristicValueChange({
deviceId,
serviceId,
characteristicId,
state: true,
success() {
console.log('Notify 已启用');
dd.onBLECharacteristicValueChange(res => {
const hexStr = res.value; // hex string from DingTalk
parsePrinterStatus(hexStr);
});
onPrinterReady();
},
fail(err) {
console.error('启用 Notify 失败', err);
}
});
}Connection Teardown
function releaseBluetoothResources() {
stopBluetoothDevicesDiscovery();
if (this.isConnected) {
dd.disconnectBLEDevice({
deviceId: this.data.deviceId,
success() { console.log('成功断开设备连接'); },
fail(err) { console.error('断开设备连接失败', err); }
});
}
dd.offBLEConnectionStateChanged();
dd.offBLECharacteristicValueChange();
dd.closeBluetoothAdapter({
success() { console.log('蓝牙适配器已关闭,资源已释放'); },
fail(err) { console.error('关闭蓝牙适配器失败', err); }
});
}ESC/POS Command Model
ESC/POS is the de‑facto standard for thermal receipt printers. Commands start with control characters (ESC = 0x1B, GS = 0x1D) followed by parameters. A typical print job consists of initialization, formatting, content, and cut commands.
const ESC_POS_COMMANDS = {
INIT: [0x1B, 0x40],
LF: [0x0A],
CR: [0x0D],
CRLF: [0x0D, 0x0A],
CUT_FULL: [0x1D, 0x56, 0x41, 0x00],
CUT_PARTIAL: [0x1D, 0x56, 0x42, 0x00],
ALIGN_LEFT: [0x1B, 0x61, 0x00],
ALIGN_CENTER: [0x1B, 0x61, 0x01],
ALIGN_RIGHT: [0x1B, 0x61, 0x02],
BOLD_ON: [0x1B, 0x45, 0x01],
BOLD_OFF: [0x1B, 0x45, 0x00],
FONT_NORMAL: [0x1D, 0x21, 0x00],
FONT_DOUBLE_HEIGHT: [0x1D, 0x21, 0x01],
FONT_DOUBLE_WIDTH: [0x1D, 0x21, 0x10],
FONT_DOUBLE: [0x1D, 0x21, 0x11],
LINE_SPACING_DEFAULT: [0x1B, 0x32],
LINE_SPACING: [0x1B, 0x33]
};PrintJob Class (Engineered Wrapper)
class ESCPOSGenerator {
constructor(encoding = 'gb2312', pageWidth = 48) {
this.currentEncoding = encoding;
this.pageWidth = pageWidth;
this.commands = [];
this.currentState = { bold: false, align: 'left', lineSpacing: 64, size: 1 };
this.encoder = iconv;
}
init() { this.pushCommand(ESC_POS_COMMANDS.INIT); return this; }
text(content, options = {}) {
const next = { ...this.currentState, ...options };
this.align(next.align);
this.applyDiffStyle(next);
const encoded = this.encoder.encode(content, this.currentEncoding);
this.commands.push(...encoded);
if (Object.keys(options).length) this.applyDiffStyle(this.currentState);
return this;
}
lineText(content, options = {}) { return this.text(content, options).newline(); }
newline(lines = 1) { for (let i = 0; i < lines; i++) this.pushCommand(ESC_POS_COMMANDS.LF); return this; }
separator(char = '-') {
const repeat = Math.floor(this.pageWidth / char.length);
if (!repeat) return this;
const line = char.repeat(repeat);
const prev = this.currentState.align;
this.align('center');
this.text(line).newline();
if (prev !== 'center') this.align(prev);
return this;
}
cut(type = 'full') {
this.pushCommand(type === 'partial' ? ESC_POS_COMMANDS.CUT_PARTIAL : ESC_POS_COMMANDS.CUT_FULL);
return this;
}
build() { return new Uint8Array(this.commands); }
// internal helpers omitted for brevity
pushCommand(cmd) { this.commands.push(...cmd); }
align(a) { const map = { left: ESC_POS_COMMANDS.ALIGN_LEFT, center: ESC_POS_COMMANDS.ALIGN_CENTER, right: ESC_POS_COMMANDS.ALIGN_RIGHT }; this.pushCommand(map[a]); }
// ... bold, size, lineSpacing, applyDiffStyle ...
}Usage example:
const job = new ESCPOSGenerator();
const data = job.init()
.text('配送回单', { bold: true, size: 2, align: 'center' })
.newline(2)
.cut()
.build();
console.log(`打印数据大小: ${data.length} 字节`);BLE Data Transmission Mechanism
BLE packets are limited to 20 bytes of payload (23‑byte ATT packet minus 3‑byte header). DingTalk’s dd.writeBLECharacteristicValue enforces this limit. The platform does not support MTU negotiation, so the 20‑byte limit is fixed.
Packet Splitting
function splitIntoPackets(hexString) {
const packets = [];
for (let i = 0; i < hexString.length; i += 40) { // 40 hex chars = 20 bytes
packets.push(hexString.slice(i, i + 40));
}
return packets;
}Send Queue with Flow Control
class BLEPacketQueue {
constructor(deviceId, serviceId, characteristicId) {
this.deviceId = deviceId;
this.serviceId = serviceId;
this.characteristicId = characteristicId;
this.queue = [];
this.isSending = false;
}
addPackets(packets) {
this.queue.push(...packets);
if (!this.isSending) this.sendNext();
}
sendNext() {
if (this.queue.length === 0) {
this.isSending = false;
console.log('所有分包发送完成');
return;
}
this.isSending = true;
const packet = this.queue.shift();
dd.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.characteristicId,
value: packet,
success: () => {
setTimeout(() => this.sendNext(), 20); // 15‑20 ms interval works well
},
fail: err => {
console.error('分包发送失败', err);
this.queue.unshift(packet);
setTimeout(() => this.sendNext(), 50);
}
});
}
}Practical experience shows that a 15‑20 ms interval balances printer buffer capacity and overall latency.
BLE Image Printing
Thermal printers render only black or white dots. An image must be converted to a binary raster where each pixel is 1 (print) or 0 (skip). The printer’s width is typically 384 dots (48 bytes per line).
Getting Pixel Data
async function getImagePixelData(imagePath, targetWidth = 384) {
return new Promise((resolve, reject) => {
dd.getImageInfo({
src: imagePath,
success(info) {
const scale = targetWidth / info.width;
const targetHeight = Math.floor(info.height * scale);
const ctx = dd.createCanvasContext('printCanvas');
ctx.clearRect(0, 0, targetWidth, targetHeight);
ctx.drawImage(imagePath, 0, 0, targetWidth, targetHeight);
ctx.draw(false, () => {
dd.canvasGetImageData({
canvasId: 'printCanvas',
x: 0, y: 0,
width: targetWidth,
height: targetHeight,
success(res) { resolve({ data: res.data, width: targetWidth, height: targetHeight }); },
fail: reject
});
});
},
fail: reject
});
});
}Grayscale and Binarization
Convert RGBA data to grayscale using the weighted formula 0.299 R + 0.587 G + 0.114 B, then apply a threshold (fixed or Otsu) to obtain a binary matrix.
function rgbToGray(r, g, b) {
return Math.round(r * 0.299 + g * 0.587 + b * 0.114);
}
function otsuThreshold(grayArray) {
// simplified Otsu implementation omitted for brevity; returns a value 0‑255
}Raster Conversion (8 pixels → 1 byte)
function convertImageToRaster(imageData, width, height) {
const { data } = imageData;
const bytesPerLine = Math.ceil(width / 8);
const raster = new Uint8Array(bytesPerLine * height);
const grayData = new Array(width * height);
// Grayscale
for (let i = 0, j = 0; i < data.length; i += 4, j++) {
grayData[j] = rgbToGray(data[i], data[i + 1], data[i + 2]);
}
const threshold = otsuThreshold(grayData);
// Binarize and pack bits
for (let y = 0; y < height; y++) {
for (let x = 0; x < bytesPerLine; x++) {
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const px = x * 8 + bit;
if (px < width) {
const idx = y * width + px;
if (grayData[idx] < threshold) {
byte |= (0x80 >> bit);
}
}
}
raster[y * bytesPerLine + x] = byte; // pads with 0 automatically for non‑multiple‑8 widths
}
}
return raster;
}ESC/POS Image Command (GS v 0)
function buildImageCommand(raster, width, height) {
const bytesPerLine = Math.ceil(width / 8);
const header = [
0x1D, 0x76, 0x30, 0x00,
bytesPerLine & 0xFF,
(bytesPerLine >> 8) & 0xFF,
height & 0xFF,
(height >> 8) & 0xFF
];
const result = new Uint8Array(header.length + raster.length);
result.set(header);
result.set(raster, header.length);
return result;
}Because the printer expects each line to be a whole number of bytes, widths must be a multiple of 8. The conversion routine automatically pads the last byte of a line with zeros when the width is not divisible by 8.
Practical Tips for Image Printing
Keep images ≤ 384 px wide (or the printer’s native width).
Prefer simple black‑and‑white graphics (logos, QR codes, signatures).
Use the Otsu algorithm for automatic thresholding on varied images.
Split the final command into 20‑byte packets and send with a 30 ms or larger interval to avoid the printer’s RX buffer overflow.
Cache pre‑converted raster data for static assets (e.g., company logo) to avoid repeated processing.
Full Sending Flow Example
async function printOrder(orderInfo) {
// 1. Build ESC/POS command (text + optional image)
const job = new ESCPOSGenerator();
const cmd = job.init()
.text('订单号: ' + orderInfo.id, { align: 'center' })
.newline()
.text('收货人: ' + orderInfo.receiver)
.newline(2)
.cut()
.build();
// 2. Convert to hex string for DingTalk API
const hex = Array.from(cmd).map(b => b.toString(16).padStart(2, '0')).join('');
// 3. Split into 20‑byte packets
const packets = splitIntoPackets(hex);
// 4. Queue and send
const queue = new BLEPacketQueue(deviceId, serviceId, writeCharId);
queue.addPackets(packets);
// 5. Wait for printer’s completion notification (implementation omitted)
await waitForPrintComplete();
}Conclusion
The article walks through the complete engineering pipeline for printing receipts from a DingTalk mini program using BLE: selecting BLE for its low‑power, short‑connection model; mastering the GATT service/characteristic structure; managing permissions, scanning, connection, and lifecycle; constructing ESC/POS commands with a fluent PrintJob wrapper; handling the 20‑byte BLE packet limit via splitting and paced queueing; and finally converting images to black‑white raster data suitable for thermal printers. These practices enable reliable, on‑site receipt printing in logistics scenarios while respecting the constraints of the DingTalk platform.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Goodme Frontend Team
Regularly sharing the team's insights and expertise in the frontend field
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
