Flutter Bluetooth Thermal Printer Setup: Panda 58mm POS

Use one package for transport and one for content. print_bluetooth_thermal handles enabling Bluetooth, listing paired devices, connecting by MAC address and writing bytes, while esc_pos_utils_plus builds the ESC/POS byte list. Add permission_handler for Android 12 and later. Avoid stacking two all-in-one printing plugins, because they will compete for the adapter.
In Android settings. Bluetooth Classic serial devices must be bonded at the operating-system level first; your app then connects to an already-paired device by its MAC address. Apps that try to bond programmatically tend to work on one device model and fail confusingly on the next.
Often not directly. Apple does not expose Bluetooth Classic serial port profile to third-party apps — accessories need MFi certification or the External Accessory framework — so a printer that pairs instantly with Android may be unreachable from iOS unless it also offers a Bluetooth Low Energy interface. Confirm this before promising an iOS release.
The published figures for the range are conventional: the desktop PRJ-58D prints up to 100 mm per second on 57.5 mm paper over USB and Bluetooth with an RJ-11 cash drawer port, while the portable PRJ-R58B-II is a 230 dpi unit printing at roughly 50 to 90 mm per second on battery. Both accept ESC/POS commands.
Three usual causes: the printer went to sleep after several idle minutes, the battery sagged under the current draw of printing, or another Bluetooth device took priority on the tablet. Treat connect as an operation that can fail, reconnect and retry once automatically, and show a tappable connection status in the app bar.

Key Takeaway
A Panda 58 mm Bluetooth printer speaks ESC/POS over Bluetooth Classic serial port profile. In Flutter the reliable pattern is one package for transport and one for content: print_bluetooth_thermal to connect and write bytes, esc_pos_utils_plus to build them, with pairing done in Android settings first.
Panda printers are everywhere in Indonesian retail. They sit on warung counters, in delivery bags, and behind PPOB windows, they cost a fraction of a counter-top Epson, and they are the printer most Indonesian POS apps are actually asked to support. They are also the reason a lot of Flutter developers discover that Bluetooth printing is not one problem but three: permissions, pairing and protocol.
This is the setup that has held up for me across several devices and Android versions, written in the order you should do it rather than the order the packages document it.
Panda is a brand, not a protocol, and the useful thing about the range is how conventional it is. Reading the published specifications for the common models tells you almost everything you need before writing code.
That last point is worth dwelling on. You are not writing Panda code; you are writing ESC/POS code and choosing a transport. If your abstraction reflects that, swapping to a different brand later costs you a configuration value rather than a rewrite.

Bluetooth is not one thing, and the difference decides which Flutter packages can talk to your printer at all.
| Transport | Where it works | What it means for your app |
|---|---|---|
| Bluetooth Classic, serial port profile | Android, Windows, and most cheap 58 mm printers including Panda | Pair once in system settings, then connect by MAC address and stream bytes |
| Bluetooth Low Energy | iOS and newer printers that expose a write characteristic | No pairing dialog, but you must discover services and write in small chunks |
| USB | Android with OTG, Windows, Linux | Fastest and most reliable, but ties the printer physically to one device |
| Wi-Fi or Ethernet | Counter-top models, rarely portable ones | A TCP socket to the raw print port, with no Bluetooth stack involved at all |
The important consequence is the iOS one. Apple has never opened Bluetooth Classic serial port profile to third-party apps — accessories need MFi certification or the External Accessory framework — so a printer that works instantly on Android may be unreachable from an iPhone unless it also offers a low-energy interface. Decide this before promising an iOS release.
Package choice in one line: use print_bluetooth_thermal for the connection and esc_pos_utils_plus for the content. The split exists because they solve unrelated problems, and mixing an all-in-one package with a second byte generator is how apps end up sending two different code page settings in the same receipt.
Three packages cover the whole job. Two of them are the transport and content split above; the third is Android's runtime permission model, which you cannot avoid on Android 12 and later.
# pubspec.yaml — the two-package split most Flutter POS apps end up with
dependencies:
print_bluetooth_thermal: ^1.2.2 # transport: discovery, connect, writeBytes
esc_pos_utils_plus: ^2.0.4 # content: builds the ESC/POS byte list
permission_handler: ^11.3.1 # Android 12+ runtime permissionsResist adding a fourth. Every extra plugin in this space brings its own opinion about scanning, its own platform channel and its own idea of what a connection is, and two of them in one app will fight over the adapter.
This is deliberately linear: enable, connect, build, write, disconnect. Almost every intermittent Bluetooth printing bug I have chased came from doing these in a different order, or from skipping the disconnect.
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
/// Discover, connect, print, disconnect. Deliberately linear: the failure
/// modes of a 58 mm Bluetooth printer are all about ordering.
Future<void> printTestReceipt(String macAddress) async {
if (!await PrintBluetoothThermal.bluetoothEnabled) {
throw StateError('Bluetooth is off');
}
final connected = await PrintBluetoothThermal.connect(
macPrinterAddress: macAddress,
);
if (!connected) throw StateError('Could not connect to $macAddress');
try {
final profile = await CapabilityProfile.load();
final generator = Generator(PaperSize.mm58, profile);
final bytes = <int>[
...generator.text(
'TOKO SUMBER REJEKI',
styles: const PosStyles(
align: PosAlign.center,
bold: true,
height: PosTextSize.size2,
width: PosTextSize.size2,
),
),
...generator.text('Jl. Diponegoro 12',
styles: const PosStyles(align: PosAlign.center)),
...generator.hr(),
...generator.row([
PosColumn(text: 'Kopi Susu', width: 8),
PosColumn(
text: '25.000',
width: 4,
styles: const PosStyles(align: PosAlign.right),
),
]),
...generator.feed(2),
...generator.cut(),
];
await PrintBluetoothThermal.writeBytes(bytes);
} finally {
await PrintBluetoothThermal.disconnect;
}
}Two details are load-bearing. The capability profile is what maps the generator's intentions onto a specific printer's command dialect, so loading it once and reusing it keeps a receipt internally consistent. And the disconnect belongs in a finally block: a connection left open after an exception blocks the next sale, and the cashier's fix will be to restart the app.
Do not try to pair the printer from inside your app. On Android, classic serial devices must be paired in system settings first; your app then connects to an already-bonded device by MAC address. Apps that attempt to bond programmatically work on one device and fail confusingly on the next.

Field installs go wrong at pairing far more often than in code. This sequence is what I now write into the shop's setup sheet.
Storing the MAC per device matters in multi-till shops. Two tablets syncing the same account will otherwise fight over one printer address, and each will silently print to the other's printer.
Put a visible printer status chip in the app bar — connected, disconnected, printing — and make it tappable to reconnect. It turns the most common support call into something the cashier resolves in two seconds without phoning anyone.
A Bluetooth printer that works on your desk fails in three predictable ways in a shop.
None of these are exotic, and all three are invisible during development because a desk has a charger, no competing devices and a developer who taps print every thirty seconds.
Getting a Flutter app printing to a Panda 58 mm printer is mostly about respecting boundaries: system settings own pairing, one package owns the connection, another owns the bytes, and your code owns the retry. Build it in that order and the printer stops being the flaky part of the product.