Files
FEDEO/mobile/src/lib/nimbot.ts
2026-03-16 20:46:26 +01:00

629 lines
20 KiB
TypeScript

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<typeof setTimeout>;
};
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<void> {
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<string, number>)[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<NimbotPacket> {
return new Promise<NimbotPacket>((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<NimbotConnectionInfo> {
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<boolean> {
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<DiscoveredNimbotDevice[]> {
const hasPermission = await requestBluetoothPermissions();
if (!hasPermission) throw new Error('Bluetooth-Berechtigungen fehlen.');
const ble = getManager();
const found = new Map<string, DiscoveredNimbotDevice>();
await ble.stopDeviceScan();
await new Promise<void>((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout> | 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<NimbotConnectionInfo> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 });
}