Mobile Development 46 min read

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.

Goodme Frontend Team
Goodme Frontend Team
Goodme Frontend Team
Exploring BLE Printing in DingTalk Mini Programs: Implementation and Best Practices

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaScriptMiniProgramImageProcessingBLEESC/POSDingTalkGATTThermalPrinting
Goodme Frontend Team
Written by

Goodme Frontend Team

Regularly sharing the team's insights and expertise in the frontend field

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.