printerConnection.ts 12.9 KB
/**
 * 打印机连接与下发:蓝牙(BLE) / 一体机(TCP localhost)
 */
import type { ActiveBtDeviceType, PrinterType } from './types/printer'
import classicBluetooth from './bluetoothTool.js'
import { getDeviceFingerprint } from '../deviceInfo'

const STORAGE_PRINTER_TYPE = 'printerType'
const STORAGE_BT_DEVICE_ID = 'btDeviceId'
const STORAGE_BT_DEVICE_NAME = 'btDeviceName'
const STORAGE_BT_SERVICE_ID = 'btServiceId'
const STORAGE_BT_CHARACTERISTIC_ID = 'btCharacteristicId'
const STORAGE_BT_DEVICE_TYPE = 'btDeviceType' // 'ble' | 'classic'
const STORAGE_BLE_MTU = 'bleMTU'
const STORAGE_BUILTIN_PORT = 'builtinPort'
const STORAGE_PRINTER_DRIVER_KEY = 'printerDriverKey'

const BUILTIN_PROBE_PORTS = [9100, 4000, 9000, 6000]
const BUILTIN_PRINTER_DEVICE_KEYWORDS: string[] = [
  // 在这里补充需要走 Built-in 的设备型号关键字(小写匹配)
  // 例如:'desktop-aio', 'pos-terminal-x1'
]
export type BtDeviceType = ActiveBtDeviceType

export const PrinterStorageKeys = {
  type: STORAGE_PRINTER_TYPE,
  btDeviceId: STORAGE_BT_DEVICE_ID,
  btDeviceName: STORAGE_BT_DEVICE_NAME,
  btServiceId: STORAGE_BT_SERVICE_ID,
  btCharacteristicId: STORAGE_BT_CHARACTERISTIC_ID,
  btDeviceType: STORAGE_BT_DEVICE_TYPE,
  bleMTU: STORAGE_BLE_MTU,
  driverKey: STORAGE_PRINTER_DRIVER_KEY,
} as const

export function setPrinterType (type: PrinterType) {
  uni.setStorageSync(STORAGE_PRINTER_TYPE, type)
}

export function setBluetoothConnection (info: {
  deviceId: string
  deviceName: string
  serviceId?: string
  characteristicId?: string
  deviceType?: BtDeviceType
  mtu?: number
  driverKey?: string
}) {
  uni.setStorageSync(STORAGE_PRINTER_TYPE, 'bluetooth')
  uni.setStorageSync(STORAGE_BT_DEVICE_ID, info.deviceId)
  uni.setStorageSync(STORAGE_BT_DEVICE_NAME, info.deviceName)
  uni.setStorageSync(STORAGE_BT_SERVICE_ID, info.serviceId || '')
  uni.setStorageSync(STORAGE_BT_CHARACTERISTIC_ID, info.characteristicId || '')
  uni.setStorageSync(STORAGE_BT_DEVICE_TYPE, info.deviceType || 'ble')
  uni.setStorageSync(STORAGE_BLE_MTU, info.mtu != null ? info.mtu : BLE_MTU_DEFAULT)
  uni.setStorageSync(STORAGE_PRINTER_DRIVER_KEY, info.driverKey || '')
}

export function setBuiltinPrinter (driverKey = 'generic-tsc') {
  uni.setStorageSync(STORAGE_PRINTER_TYPE, 'builtin')
  uni.setStorageSync(STORAGE_PRINTER_DRIVER_KEY, driverKey)
}

export function clearPrinter () {
  uni.removeStorageSync(STORAGE_PRINTER_TYPE)
  uni.removeStorageSync(STORAGE_BT_DEVICE_ID)
  uni.removeStorageSync(STORAGE_BT_DEVICE_NAME)
  uni.removeStorageSync(STORAGE_BT_SERVICE_ID)
  uni.removeStorageSync(STORAGE_BT_CHARACTERISTIC_ID)
  uni.removeStorageSync(STORAGE_BT_DEVICE_TYPE)
  uni.removeStorageSync(STORAGE_BLE_MTU)
  uni.removeStorageSync(STORAGE_BUILTIN_PORT)
  uni.removeStorageSync(STORAGE_PRINTER_DRIVER_KEY)
}

const BLE_MTU_DEFAULT = 20

export function getPrinterType (): PrinterType | '' {
  const type = (uni.getStorageSync(STORAGE_PRINTER_TYPE) as PrinterType) || ''
  if (!type) return ''
  if (getAvailablePrinterTypes().includes(type)) return type
  clearPrinter()
  return ''
}

export function getCurrentPrinterDriverKey (): string {
  return String(uni.getStorageSync(STORAGE_PRINTER_DRIVER_KEY) || '')
}

export function getBluetoothConnection (): {
  deviceId: string
  deviceName: string
  serviceId: string
  characteristicId: string
  deviceType: BtDeviceType
  mtu: number
} | null {
  const deviceId = uni.getStorageSync(STORAGE_BT_DEVICE_ID)
  const deviceType = (uni.getStorageSync(STORAGE_BT_DEVICE_TYPE) as BtDeviceType) || 'ble'
  if (!deviceId) return null
  if (deviceType === 'classic') {
    return {
      deviceId,
      deviceName: uni.getStorageSync(STORAGE_BT_DEVICE_NAME) || 'Printer',
      serviceId: '',
      characteristicId: '',
      deviceType: 'classic',
      mtu: BLE_MTU_DEFAULT,
    }
  }
  const serviceId = uni.getStorageSync(STORAGE_BT_SERVICE_ID)
  const characteristicId = uni.getStorageSync(STORAGE_BT_CHARACTERISTIC_ID)
  if (!serviceId || !characteristicId) return null
  return {
    deviceId,
    deviceName: uni.getStorageSync(STORAGE_BT_DEVICE_NAME) || 'Printer',
    serviceId,
    characteristicId,
    deviceType: 'ble',
    mtu: Number(uni.getStorageSync(STORAGE_BLE_MTU)) || BLE_MTU_DEFAULT,
  }
}

export function isBuiltinConnected (): boolean {
  return getPrinterType() === 'builtin'
}

export function isBuiltinPrinterAvailable (): boolean {
  // #ifdef APP-PLUS
  try {
    const plugin = (uni as any)?.requireNativePlugin
      ? (uni as any).requireNativePlugin('moe-tcp-client')
      : null
    return !!plugin
  } catch (_) {
    return false
  }
  // #endif
  // #ifndef APP-PLUS
  return false
  // #endif
}

export function isBuiltinPrinterEnabledByDeviceModel (): boolean {
  const fingerprint = getDeviceFingerprint()
  if (!fingerprint) return false
  return BUILTIN_PRINTER_DEVICE_KEYWORDS.some(keyword => fingerprint.includes(String(keyword || '').toLowerCase()))
}

export function getAvailablePrinterTypes (): PrinterType[] {
  if (isBuiltinPrinterAvailable() && isBuiltinPrinterEnabledByDeviceModel()) {
    return ['builtin']
  }
  return ['bluetooth']
}

function buildClassicBluetoothError (message: string, deviceId?: string): Error {
  const baseMessage = String(message || 'Classic Bluetooth error')
  if (!classicBluetooth || typeof classicBluetooth.getDebugState !== 'function') {
    return new Error(baseMessage)
  }
  try {
    const debugState = classicBluetooth.getDebugState() || {}
    const details: string[] = []
    if (deviceId) details.push(`device=${deviceId}`)
    if (debugState.lastSocketStrategy) details.push(`socket=${debugState.lastSocketStrategy}`)
    if (debugState.connectionState) details.push(`state=${debugState.connectionState}`)
    if (typeof debugState.socketConnected === 'boolean') details.push(`connected=${debugState.socketConnected}`)
    if (typeof debugState.outputReady === 'boolean') details.push(`outputReady=${debugState.outputReady}`)
    if (debugState.lastSendMode) details.push(`sendMode=${debugState.lastSendMode}`)
    if (debugState.lastSendError) details.push(`sendError=${debugState.lastSendError}`)
    else if (debugState.lastError) details.push(`lastError=${debugState.lastError}`)
    if (details.length === 0) return new Error(baseMessage)
    return new Error(`${baseMessage}\n${details.join('\n')}`)
  } catch (_) {
    return new Error(baseMessage)
  }
}

/**
 * 发送打印数据到当前已选打印机
 * @param data 字节数组(TSC 指令)
 * @param onProgress 可选进度回调 0~100
 */
export function sendToPrinter (
  data: number[],
  onProgress?: (percent: number) => void
): Promise<void> {
  const type = getPrinterType()
  if (type === 'bluetooth') {
    const conn = getBluetoothConnection()
    if (conn && conn.deviceType === 'classic') {
      return sendViaClassic(data, onProgress)
    }
    return sendViaBle(data, onProgress)
  }
  if (type === 'builtin') {
    return sendViaBuiltin(data)
  }
  return Promise.reject(new Error('No printer connected. Please connect a Bluetooth or built-in printer first.'))
}

function sendViaBle (
  data: number[],
  onProgress?: (percent: number) => void
): Promise<void> {
  const conn = getBluetoothConnection()
  if (!conn) {
    return Promise.reject(new Error('Bluetooth printer not connected.'))
  }
  const { deviceId, serviceId, characteristicId, mtu } = conn
  const chunks: number[][] = []
  for (let i = 0; i < data.length; i += mtu) {
    chunks.push(data.slice(i, i + mtu))
  }
  const total = chunks.length
  let sent = 0

  function sendNext (): Promise<void> {
    if (sent >= total) {
      if (onProgress) onProgress(100)
      return Promise.resolve()
    }
    const chunk = chunks[sent]
    const buffer = new ArrayBuffer(chunk.length)
    const view = new DataView(buffer)
    for (let j = 0; j < chunk.length; j++) {
      view.setUint8(j, chunk[j] & 0xff)
    }
    return new Promise((resolve, reject) => {
      uni.writeBLECharacteristicValue({
        deviceId,
        serviceId,
        characteristicId,
        value: buffer,
        success: () => {
          sent++
          if (onProgress) onProgress(Math.round((sent / total) * 100))
          setTimeout(() => sendNext().then(resolve).catch(reject), 10)
        },
        fail: (err: any) => reject(new Error(err.errMsg || 'BLE write failed')),
      })
    })
  }

  return sendNext()
}

function sendViaClassic (
  data: number[],
  onProgress?: (percent: number) => void
): Promise<void> {
  // #ifdef APP-PLUS
  const conn = getBluetoothConnection()
  if (!conn || conn.deviceType !== 'classic') {
    return Promise.reject(new Error('Classic Bluetooth printer not connected.'))
  }
  return new Promise((resolve, reject) => {
    try {
      if (!classicBluetooth) {
        reject(new Error('Classic Bluetooth not available'))
        return
      }
      const debugState = typeof classicBluetooth.getDebugState === 'function'
        ? classicBluetooth.getDebugState()
        : null
      const connectionState = String(debugState?.connectionState || '').trim().toLowerCase()
      const ready = debugState
        ? (!!debugState.outputReady && (!!debugState.socketConnected || connectionState === 'connected'))
        : true
      if (!ready) {
        const errorMessage = typeof classicBluetooth.getLastError === 'function'
          ? classicBluetooth.getLastError()
          : ''
        reject(buildClassicBluetoothError(errorMessage || 'Classic Bluetooth connection is not ready', conn.deviceId))
        return
      }

      const sendData = data.map((byte) => {
        const value = byte & 0xff
        return value >= 128 ? value - 256 : value
      })

      if (typeof classicBluetooth.sendByteDataAsync === 'function') {
        classicBluetooth.sendByteDataAsync(sendData, (ok: boolean, errorMessage?: string) => {
          if (onProgress) onProgress(100)
          if (ok) {
            resolve()
            return
          }
          reject(buildClassicBluetoothError(
            errorMessage || classicBluetooth.getLastError?.() || 'Classic Bluetooth send failed',
            conn.deviceId
          ))
        })
        return
      }

      const ok = classicBluetooth.sendByteData(sendData)
      if (onProgress) onProgress(100)
      if (ok) {
        resolve()
        return
      }
      const errorMessage = typeof classicBluetooth.getLastError === 'function'
        ? classicBluetooth.getLastError()
        : ''
      reject(buildClassicBluetoothError(errorMessage || 'Classic Bluetooth send failed', conn.deviceId))
    } catch (e: any) {
      reject(buildClassicBluetoothError(e?.message || String(e || 'Classic Bluetooth send exception'), conn.deviceId))
    }
  })
  // #endif
  // #ifndef APP-PLUS
  return Promise.reject(new Error('Classic Bluetooth is only available in the app.'))
  // #endif
}

function sendViaBuiltin (data: number[]): Promise<void> {
  // #ifdef APP-PLUS
  try {
    const u = uni as any
    const moeTcp = u.requireNativePlugin ? u.requireNativePlugin('moe-tcp-client') : null
    if (!moeTcp) {
      return Promise.reject(new Error('BUILTIN_PLUGIN_NOT_FOUND'))
    }
    const uint8 = new Uint8Array(data.length)
    for (let i = 0; i < data.length; i++) uint8[i] = data[i] & 0xff
    const hexStr = Array.from(uint8)
      .map(b => ('0' + (b & 0xff).toString(16)).slice(-2))
      .join('')

    const savedPort = parseInt(uni.getStorageSync(STORAGE_BUILTIN_PORT)) || 0
    const ports = savedPort > 0
      ? [savedPort, ...BUILTIN_PROBE_PORTS.filter(p => p !== savedPort)]
      : [...BUILTIN_PROBE_PORTS]

    function tryPort (idx: number): Promise<void> {
      if (idx >= ports.length) {
        return Promise.reject(new Error(
          'Built-in printer: all ports failed (tried ' + BUILTIN_PROBE_PORTS.join(', ') +
          '). Check if the printer service is running on this device.'
        ))
      }
      const port = ports[idx]
      return new Promise((resolve, reject) => {
        console.log('[builtin] trying 127.0.0.1:' + port)
        moeTcp.connect({ ip: '127.0.0.1', port }, (res: string) => {
          try {
            const r = typeof res === 'string' ? JSON.parse(res) : res
            if (r.code !== 1) {
              console.log('[builtin] port ' + port + ' failed: ' + (r.msg || ''))
              try { moeTcp.disconnect() } catch (_) {}
              tryPort(idx + 1).then(resolve).catch(reject)
              return
            }
            console.log('[builtin] connected on port ' + port)
            uni.setStorageSync(STORAGE_BUILTIN_PORT, String(port))
            moeTcp.sendHexStr({ message: hexStr })
            setTimeout(() => {
              try { moeTcp.disconnect() } catch (_) {}
              resolve()
            }, 300)
          } catch (e) {
            try { moeTcp.disconnect() } catch (_) {}
            tryPort(idx + 1).then(resolve).catch(reject)
          }
        })
      })
    }
    return tryPort(0)
  } catch (e) {
    return Promise.reject(e)
  }
  // #endif
  // #ifndef APP-PLUS
  return Promise.reject(new Error('Built-in printer is only available in the app. Use Bluetooth printer on this device.'))
  // #endif
}