import { PermissionsAndroid, Platform } from 'react-native'; import { BleManager, Device, Subscription } from 'react-native-ble-plx'; import { EncodedLabelImage, EncodedLabelRow } from '@/src/lib/api'; const NIMBOT_NAME_MARKERS = ['NIIMBOT', 'NIMBOT', 'M2', 'D11', 'D110', 'B21']; const SCAN_TIMEOUT_MS = 12_000; const SCAN_AFTER_FIRST_FOUND_MS = 2_200; type DiscoveredNimbotDevice = { id: string; name: string; rssi: number | null; }; type NimbotConnectionInfo = { device: DiscoveredNimbotDevice; writeServiceId: string | null; writeCharacteristicId: string | null; writeWithoutResponse: boolean; writeWithResponse: boolean; notifyServiceId: string | null; notifyCharacteristicId: string | null; }; type NimbotPrintOptions = { density?: number; labelType?: number; copies?: number; totalPages?: number; packetIntervalMs?: number; printheadPixels?: number; useIndexedRows?: boolean; }; type NimbotPacket = { command: number; data: Uint8Array; }; type PacketWaiter = { ids: number[]; resolve: (packet: NimbotPacket) => void; reject: (error: Error) => void; timeoutId: ReturnType; }; let manager: BleManager | null = null; let disconnectSubscription: Subscription | null = null; let notifySubscription: Subscription | null = null; let activeConnection: NimbotConnectionInfo | null = null; let packetBuffer = new Uint8Array(); const packetWaiters: PacketWaiter[] = []; function getManager(): BleManager { if (!manager) manager = new BleManager(); return manager; } function normalizeName(device: Device): string { return String(device.localName || device.name || '').trim(); } function isNimbotDevice(device: Device): boolean { const name = normalizeName(device).toUpperCase(); if (!name) return false; return NIMBOT_NAME_MARKERS.some((marker) => name.includes(marker)); } function toDiscoveredDevice(device: Device): DiscoveredNimbotDevice { return { id: device.id, name: normalizeName(device) || device.id, rssi: typeof device.rssi === 'number' ? device.rssi : null, }; } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function toU16BE(value: number): [number, number] { const v = clamp(Math.floor(value), 0, 0xffff); return [(v >> 8) & 0xff, v & 0xff]; } function toU16Parts(value: number): [number, number] { return toU16BE(value); } function countPixelsForBitmapPacketWithMode( buf: Uint8Array, printheadPixels: number, mode: 'auto' | 'split' | 'total' = 'auto' ): { total: number; parts: [number, number, number] } { let total = 0; const parts: [number, number, number] = [0, 0, 0]; const chunkSize = Math.floor(printheadPixels / 8 / 3); let split = buf.byteLength <= chunkSize * 3; if (mode === 'total') { split = false; } else if (mode === 'split') { split = buf.byteLength <= chunkSize * 3; } buf.forEach((value, byteN) => { const chunkIdx = chunkSize > 0 ? Math.floor(byteN / chunkSize) : 0; for (let bitN = 0; bitN < 8; bitN += 1) { if ((value & (1 << bitN)) === 0) continue; total += 1; if (!split) continue; if (chunkIdx > 2) continue; parts[chunkIdx] = (parts[chunkIdx] + 1) & 0xff; } }); if (split) { return { total, parts }; } const [hi, lo] = toU16BE(total); return { total, parts: [0x00, lo, hi] }; } function indexPixels(buf: Uint8Array): Uint8Array { const result: number[] = []; for (let bytePos = 0; bytePos < buf.byteLength; bytePos += 1) { const b = buf[bytePos]; for (let bitPos = 0; bitPos < 8; bitPos += 1) { if (b & (1 << (7 - bitPos))) { const pixelIndex = bytePos * 8 + bitPos; const [hi, lo] = toU16BE(pixelIndex); result.push(hi, lo); } } } return Uint8Array.from(result); } function normalizeRowData(rowData: EncodedLabelRow['rowData']): Uint8Array { if (!rowData) return new Uint8Array(); if (rowData instanceof Uint8Array) return rowData; if (Array.isArray(rowData)) return Uint8Array.from(rowData.map((v) => Number(v) & 0xff)); return Uint8Array.from( Object.keys(rowData) .sort((a, b) => Number(a) - Number(b)) .map((key) => Number((rowData as Record)[key]) & 0xff) ); } function buildNimbotPacket(command: number, payload: number[]): Uint8Array { const cmd = command & 0xff; const length = payload.length & 0xff; let checksum = cmd ^ length; for (const b of payload) checksum ^= b & 0xff; return Uint8Array.from([0x55, 0x55, cmd, length, ...payload, checksum & 0xff, 0xaa, 0xaa]); } function encodeBase64(bytes: Uint8Array): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; let output = ''; for (let i = 0; i < bytes.length; i += 3) { const b0 = bytes[i] ?? 0; const b1 = bytes[i + 1] ?? 0; const b2 = bytes[i + 2] ?? 0; const chunk = (b0 << 16) | (b1 << 8) | b2; output += chars[(chunk >> 18) & 63]; output += chars[(chunk >> 12) & 63]; output += i + 1 < bytes.length ? chars[(chunk >> 6) & 63] : '='; output += i + 2 < bytes.length ? chars[chunk & 63] : '='; } return output; } function decodeBase64(input: string): Uint8Array { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const clean = input.replace(/[^A-Za-z0-9+/=]/g, ''); const out: number[] = []; let i = 0; while (i < clean.length) { const c0 = chars.indexOf(clean[i] || 'A'); const c1 = chars.indexOf(clean[i + 1] || 'A'); const c2 = chars.indexOf(clean[i + 2] || 'A'); const c3 = chars.indexOf(clean[i + 3] || 'A'); const n = (Math.max(c0, 0) << 18) | (Math.max(c1, 0) << 12) | ((Math.max(c2, 0) & 63) << 6) | (Math.max(c3, 0) & 63); out.push((n >> 16) & 0xff); if (clean[i + 2] !== '=') out.push((n >> 8) & 0xff); if (clean[i + 3] !== '=') out.push(n & 0xff); i += 4; } return Uint8Array.from(out); } function clearPacketWaiters(message: string): void { while (packetWaiters.length > 0) { const waiter = packetWaiters.shift(); if (!waiter) continue; clearTimeout(waiter.timeoutId); waiter.reject(new Error(message)); } } function parseNimbotPackets(chunk: Uint8Array): NimbotPacket[] { packetBuffer = Uint8Array.from([...packetBuffer, ...chunk]); const packets: NimbotPacket[] = []; let cursor = 0; while (cursor + 8 <= packetBuffer.length) { if (packetBuffer[cursor] !== 0x55 || packetBuffer[cursor + 1] !== 0x55) { cursor += 1; continue; } const command = packetBuffer[cursor + 2] & 0xff; const len = packetBuffer[cursor + 3] & 0xff; const end = cursor + 8 + len; if (end > packetBuffer.length) break; if (packetBuffer[end - 2] !== 0xaa || packetBuffer[end - 1] !== 0xaa) { cursor += 1; continue; } const dataStart = cursor + 4; const dataEnd = dataStart + len; const data = packetBuffer.slice(dataStart, dataEnd); const checksum = packetBuffer[dataEnd] & 0xff; let calculated = command ^ len; for (let i = 0; i < data.length; i += 1) { calculated ^= data[i] & 0xff; } if ((calculated & 0xff) === checksum) { packets.push({ command, data }); cursor = end; continue; } cursor += 1; } packetBuffer = packetBuffer.slice(cursor); return packets; } function onRawNotification(base64Value: string): void { if (!base64Value) return; const packets = parseNimbotPackets(decodeBase64(base64Value)); if (packets.length === 0) return; for (const packet of packets) { if (packet.command === 0xdb || packet.command === 0x00) { clearPacketWaiters(`Drucker meldet Fehler (0x${packet.command.toString(16).padStart(2, '0')}).`); continue; } const waiterIndex = packetWaiters.findIndex((waiter) => waiter.ids.length === 0 || waiter.ids.includes(packet.command)); if (waiterIndex < 0) continue; const waiter = packetWaiters.splice(waiterIndex, 1)[0]; clearTimeout(waiter.timeoutId); waiter.resolve(packet); } } function waitForPacket(ids: number[] = [], timeoutMs = 1800): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { const idx = packetWaiters.findIndex((waiter) => waiter.timeoutId === timeoutId); if (idx >= 0) packetWaiters.splice(idx, 1); reject(new Error(`Timeout auf Druckerantwort (${ids.map((id) => `0x${id.toString(16)}`).join(', ') || 'any'})`)); }, timeoutMs); packetWaiters.push({ ids, resolve, reject, timeoutId }); }); } async function findCharacteristics(device: Device): Promise { const services = await device.services(); let writeServiceId: string | null = null; let writeCharacteristicId: string | null = null; let writeWithoutResponse = false; let writeWithResponse = false; let notifyServiceId: string | null = null; let notifyCharacteristicId: string | null = null; for (const service of services) { if (!service.uuid || service.uuid.length < 5) continue; const characteristics = await service.characteristics(); for (const characteristic of characteristics) { if (characteristic.isNotifiable && characteristic.isWritableWithoutResponse) { return { device: toDiscoveredDevice(device), writeServiceId: service.uuid, writeCharacteristicId: characteristic.uuid, writeWithoutResponse: true, writeWithResponse: Boolean(characteristic.isWritableWithResponse), notifyServiceId: service.uuid, notifyCharacteristicId: characteristic.uuid, }; } } for (const characteristic of characteristics) { if (!writeCharacteristicId && (characteristic.isWritableWithResponse || characteristic.isWritableWithoutResponse)) { writeServiceId = service.uuid; writeCharacteristicId = characteristic.uuid; writeWithoutResponse = Boolean(characteristic.isWritableWithoutResponse); writeWithResponse = Boolean(characteristic.isWritableWithResponse); } if (!notifyCharacteristicId && characteristic.isNotifiable) { notifyServiceId = service.uuid; notifyCharacteristicId = characteristic.uuid; } if (writeCharacteristicId && notifyCharacteristicId) { return { device: toDiscoveredDevice(device), writeServiceId, writeCharacteristicId, writeWithoutResponse, writeWithResponse, notifyServiceId, notifyCharacteristicId, }; } } } return { device: toDiscoveredDevice(device), writeServiceId, writeCharacteristicId, writeWithoutResponse, writeWithResponse, notifyServiceId, notifyCharacteristicId, }; } export async function requestBluetoothPermissions(): Promise { if (Platform.OS !== 'android') return true; if (Platform.Version >= 31) { const result = await PermissionsAndroid.requestMultiple([ PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN, PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT, PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, ]); return ( result[PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN] === PermissionsAndroid.RESULTS.GRANTED && result[PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT] === PermissionsAndroid.RESULTS.GRANTED && result[PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION] === PermissionsAndroid.RESULTS.GRANTED ); } const location = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION); return location === PermissionsAndroid.RESULTS.GRANTED; } export async function scanNimbotDevices(): Promise { const hasPermission = await requestBluetoothPermissions(); if (!hasPermission) throw new Error('Bluetooth-Berechtigungen fehlen.'); const ble = getManager(); const found = new Map(); await ble.stopDeviceScan(); await new Promise((resolve, reject) => { let timeoutId: ReturnType | null = setTimeout(() => { ble.stopDeviceScan(); resolve(); }, SCAN_TIMEOUT_MS); const finalizeSoon = () => { if (timeoutId) clearTimeout(timeoutId); timeoutId = setTimeout(() => { ble.stopDeviceScan(); resolve(); }, SCAN_AFTER_FIRST_FOUND_MS); }; ble.startDeviceScan(null, null, (error, device) => { if (error) { if (timeoutId) clearTimeout(timeoutId); ble.stopDeviceScan(); reject(error); return; } if (!device || !isNimbotDevice(device)) return; found.set(device.id, toDiscoveredDevice(device)); finalizeSoon(); }); }); return Array.from(found.values()).sort((a, b) => { const rssiA = typeof a.rssi === 'number' ? a.rssi : -200; const rssiB = typeof b.rssi === 'number' ? b.rssi : -200; return rssiB - rssiA; }); } export async function connectNimbotDevice(deviceId: string): Promise { const ble = getManager(); await ble.stopDeviceScan(); if (activeConnection?.device.id && activeConnection.device.id !== deviceId) { try { await ble.cancelDeviceConnection(activeConnection.device.id); } catch {} } const connected = await ble.connectToDevice(deviceId, { autoConnect: false }); await connected.discoverAllServicesAndCharacteristics(); const metadata = await findCharacteristics(connected); activeConnection = metadata; if (metadata.notifyServiceId && metadata.notifyCharacteristicId) { notifySubscription?.remove(); notifySubscription = ble.monitorCharacteristicForDevice( connected.id, metadata.notifyServiceId, metadata.notifyCharacteristicId, (error, characteristic) => { if (error) return; if (!characteristic?.value) return; onRawNotification(characteristic.value); } ); } disconnectSubscription?.remove(); disconnectSubscription = ble.onDeviceDisconnected(connected.id, () => { clearPacketWaiters('Bluetooth-Verbindung getrennt.'); notifySubscription?.remove(); notifySubscription = null; packetBuffer = new Uint8Array(); activeConnection = null; }); return activeConnection; } export function getActiveNimbotConnection(): NimbotConnectionInfo | null { return activeConnection; } export async function disconnectNimbotDevice(): Promise { if (!activeConnection?.device.id) return; const ble = getManager(); try { await ble.cancelDeviceConnection(activeConnection.device.id); } finally { clearPacketWaiters('Verbindung beendet.'); notifySubscription?.remove(); notifySubscription = null; packetBuffer = new Uint8Array(); activeConnection = null; disconnectSubscription?.remove(); disconnectSubscription = null; } } export async function sendNimbotRawCommand(payloadBase64: string): Promise { if (!activeConnection) throw new Error('Kein Nimbot verbunden.'); const ble = getManager(); const writeServiceId = activeConnection.writeServiceId; const writeCharId = activeConnection.writeCharacteristicId; if (!writeServiceId || !writeCharId) throw new Error('Kein schreibbares Characteristic gefunden.'); if (activeConnection.writeWithoutResponse) { await ble.writeCharacteristicWithoutResponseForDevice(activeConnection.device.id, writeServiceId, writeCharId, payloadBase64); return; } if (activeConnection.writeWithResponse) { await ble.writeCharacteristicWithResponseForDevice(activeConnection.device.id, writeServiceId, writeCharId, payloadBase64); return; } throw new Error('Characteristic ist nicht schreibbar.'); } async function sendNimbotPacket( command: number, payload: number[], options: { expectedResponseIds?: number[]; timeoutMs?: number; connectPrefix03?: boolean; allowResponseTimeout?: boolean } = {} ): Promise { const packet = buildNimbotPacket(command, payload); const payloadBytes = options.connectPrefix03 ? Uint8Array.from([0x03, ...Array.from(packet)]) : packet; const waiter = options.expectedResponseIds ? waitForPacket(options.expectedResponseIds, options.timeoutMs ?? 1800) : null; await sendNimbotRawCommand(encodeBase64(payloadBytes)); if (waiter) { try { await waiter; } catch (error) { if (!options.allowResponseTimeout) { throw error; } } } } async function sendNimbotConnectHandshake(): Promise { await sendNimbotPacket(0xc1, [0x01], { expectedResponseIds: [0xc2], timeoutMs: 2500, connectPrefix03: true, allowResponseTimeout: true, }); } function buildImagePackets( encoded: EncodedLabelImage, options: { printheadPixels?: number; useIndexedRows?: boolean } = {} ): { cmd: number; payload: number[] }[] { const packets: { cmd: number; payload: number[] }[] = []; const printheadPixels = clamp(Math.floor(options.printheadPixels ?? 567), 32, 2048); const useIndexedRows = Boolean(options.useIndexedRows); for (const row of encoded.rowsData || []) { if (row.dataType === 'void') { const [rowHi, rowLo] = toU16Parts(row.rowNumber); const repeat = clamp(row.repeat || 1, 1, 255); packets.push({ cmd: 0x84, payload: [rowHi, rowLo, repeat] }); continue; } if (row.dataType !== 'pixels') continue; const rowData = normalizeRowData(row.rowData); const [rowHi, rowLo] = toU16Parts(row.rowNumber); const repeat = clamp(row.repeat || 1, 1, 255); const counts = countPixelsForBitmapPacketWithMode(rowData, printheadPixels, 'auto'); if (useIndexedRows && (row.blackPixelsCount || 0) <= 6) { const indexes = Array.from(indexPixels(rowData)); packets.push({ cmd: 0x83, payload: [rowHi, rowLo, ...counts.parts, repeat, ...indexes], }); continue; } packets.push({ cmd: 0x85, payload: [rowHi, rowLo, ...counts.parts, repeat, ...Array.from(rowData)], }); } return packets; } export async function printNimbotEncodedLabel(encoded: EncodedLabelImage, options: NimbotPrintOptions = {}): Promise { if (!activeConnection) throw new Error('Kein Nimbot verbunden.'); const density = clamp(Math.floor(options.density ?? 5), 1, 5); const labelType = clamp(Math.floor(options.labelType ?? 1), 1, 11); const copies = clamp(Math.floor(options.copies ?? 1), 1, 20); const totalPages = clamp(Math.floor(options.totalPages ?? 1), 1, 20); const packetIntervalMs = clamp(Math.floor(options.packetIntervalMs ?? 10), 2, 80); const printheadPixels = clamp(Math.floor(options.printheadPixels ?? 567), 32, 2048); const useIndexedRows = Boolean(options.useIndexedRows ?? false); if (!encoded?.rows || !encoded?.cols || !Array.isArray(encoded.rowsData)) { throw new Error('Ungültiges Label-Format.'); } const rows = Number(encoded.rows); const cols = Number(encoded.cols); const [rowsHi, rowsLo] = toU16Parts(rows); const [colsHi, colsLo] = toU16Parts(cols); const [copiesHi, copiesLo] = toU16Parts(copies); await sendNimbotConnectHandshake(); await sleep(30); await sendNimbotPacket(0x21, [density], { expectedResponseIds: [0x31], allowResponseTimeout: true }); await sleep(packetIntervalMs); await sendNimbotPacket(0x23, [labelType], { expectedResponseIds: [0x33], allowResponseTimeout: true }); await sleep(packetIntervalMs); await sendNimbotPacket(0x01, [0x00, totalPages, 0x00, 0x00, 0x00, 0x00, 0x00], { expectedResponseIds: [0x02], allowResponseTimeout: true, }); await sleep(packetIntervalMs); await sendNimbotPacket(0x03, [0x01], { expectedResponseIds: [0x04], allowResponseTimeout: true }); await sleep(packetIntervalMs); await sendNimbotPacket(0x13, [rowsHi, rowsLo, colsHi, colsLo, copiesHi, copiesLo], { expectedResponseIds: [0x14], allowResponseTimeout: true, }); await sleep(packetIntervalMs); const imagePackets = buildImagePackets(encoded, { printheadPixels, useIndexedRows }); for (let i = 0; i < imagePackets.length; i += 1) { const p = imagePackets[i]; await sendNimbotPacket(p.cmd, p.payload); await sleep(packetIntervalMs); } await sendNimbotPacket(0xe3, [0x01], { expectedResponseIds: [0xe4], allowResponseTimeout: true }); await sleep(25); await sendNimbotPacket(0xf3, [0x01], { expectedResponseIds: [0xf4], timeoutMs: 3000, allowResponseTimeout: true }); }