DEV Community

Trí Đặng
Trí Đặng

Posted on

I Dropped Zebra's SDK From My Flutter Printer Plugin. Here's What Shipping Taught Me

How to print to Zebra label printers from Flutter over Bluetooth LE and Wi-Fi on iOS, Android, macOS, and Windows, without the Link-OS SDK or Apple's MFi approval, and the printer quirks that cost me the most time.


Short answer, if you came here from a search: to print to a Zebra printer from Flutter, connect over Bluetooth LE or Wi-Fi (TCP port 9100), send ZPL, and check the printer's ~HS status before each job. The open-source flutter_zpl_printer package does this in pure Dart, with no Zebra SDK, on iOS, Android, macOS, and Windows.


In March I published Stop Fighting Raw ZPL. It showed a two-package pipeline: flutter_zpl_generator designs the label in typed Dart, and flutter_zpl_printer sends it to the printer.

The design half held up. The printer half didn't.

That first version of flutter_zpl_printer wrapped Zebra's Link-OS SDK, like most Flutter Zebra packages on pub.dev do. In practice, wrapping the SDK meant three walls:

  1. You can't install it with pub get. Zebra's SDK ships as libZSDK_API.a for iOS and ZSDK_ANDROID_API.jar for Android. A package can't redistribute them, so every developer downloads the binaries and copies them into the plugin by hand.
  2. iOS Bluetooth needs Apple's MFi approval. The SDK talks to printers over Classic Bluetooth through the com.zebra.rawport accessory protocol. Before the App Store accepts your app, you send its details to Zebra, Zebra submits them to Apple, and you paste the approved MFi Product Plan IDs into your review notes. Skip that and Apple rejects the build.
  3. Phones only. No macOS, no Windows, which hurts when the packing station runs on a laptop.

So for 0.1.0 I deleted the SDK and rebuilt the plugin around the protocols Zebra printers already speak:

  • ZPL for labels.
  • SGD (Set/Get/Do), Zebra's text protocol for reading and changing settings: ! U1 getvar "device.product_name".
  • ~HS, the ZPL host status command, which reports paper out, head open, paused, and more.
  • Zebra's Bluetooth LE GATT service, which every Link-OS printer with Bluetooth 4.0 or later exposes.

All of it runs in Dart. Native code appears only where the OS insists, for USB enumeration and permissions.

The rebuild powers zPrint, a shipping label app, and it prints over Bluetooth LE and Wi-Fi on iOS, Android, macOS, and Windows against real Zebra printers. Getting there taught me more about Zebra printers than any manual did. The rest of this article covers those lessons, each one a problem you'll probably hit yourself.

The whole flow in 15 lines

import 'package:flutter_zpl_printer/flutter_zpl_printer.dart';

Future<void> printHello() async {
  // Bluetooth LE + UDP broadcast/multicast + USB, merged into one stream.
  final found = await DiscoveryService.discoverAll(
    timeout: const Duration(seconds: 15),
  ).first; // throws if nothing turns up before the timeout

  final printer = await ZebraPrinter.connect(found.createConnection());
  try {
    final status = await printer.getStatus();
    if (status.isReadyToPrint) {
      await printer.printZpl('^XA^FO50,50^A0N,40,40^FDHello Zebra^FS^XZ');
    }
  } finally {
    await printer.disconnect();
  }
}
Enter fullscreen mode Exit fullscreen mode

flutter pub add flutter_zpl_printer installs everything. The README lists the Bluetooth and local-network permission strings for each platform.

Now the lessons.

Lesson 1: Bluetooth LE gets you out of MFi approval on iOS

If Apple rejected your Flutter app with a message about MFi accessory authorization, you were using Classic Bluetooth. Zebra's own developer blog states the way out: "If your app uses Bluetooth Low Energy and does not use Bluetooth Classic to communicate with Zebra printers, you don't need to follow the MFi whitelisting procedures because Bluetooth Low Energy is outside MFi."

flutter_zpl_printer uses Bluetooth LE only. You add NSBluetoothAlwaysUsageDescription to Info.plist, and you never touch UISupportedExternalAccessoryProtocols.

The trade-off: your printer needs Bluetooth 4.0 or later. The ZQ620 we test with qualifies; check your model's spec sheet for Bluetooth Low Energy. Older models that speak only Classic Bluetooth won't connect.

Lesson 2: The same printer shows up twice, under two names

Scan over Bluetooth LE and Wi-Fi at the same time and one ZQ620 appears twice:

Transport Name the printer reports
Bluetooth LE XXZKN210306204
UDP broadcast :,.ZBRXXZKN210306204ZTC ZQ620-203dpi CPCLV85.20.24

Both names contain the serial number. Group discovery results by serial and show one row per printer, with every route you know to reach it:

final routes = <String, List<DiscoveredPrinter>>{};

DiscoveryService.discoverAll().listen((p) {
  final key = p.serial ?? p.name ?? p.address; // serial parsed from either name
  (routes[key] ??= []).add(p);
});
Enter fullscreen mode Exit fullscreen mode

When the user taps a printer, try the fastest route first: Wi-Fi, then Bluetooth. A TCP socket can open even when the printer behind it can't take a job, so send one getStatus() after connecting over Wi-Fi to prove it answers.

Lesson 3: "My app can't find the printer over Bluetooth"

Check these three first:

  • Android permissions. Request BLUETOOTH_SCAN and BLUETOOTH_CONNECT at runtime before you scan, plus location on Android 11 and older. iOS, macOS, and Windows prompt on first use.
  • The printer is asleep. Mobile printers power down their radio after idle time. Press a button on the printer and scan again.
  • The scan was too short. Bluetooth LE advertising comes in bursts, and a scan right after the app starts often misses printers. zPrint scans for 30 seconds and retries up to three times, with a Stop button on screen.

For Wi-Fi, expect office networks to block UDP broadcast. Always offer manual IP entry. TcpConnection.zpl('192.168.1.50') connects without any discovery.

Lesson 4: A print job sent blind disappears

If the print head is open or the roll is empty, the printer accepts your ZPL and prints nothing. Your user taps Print fifteen more times, then files a bug.

Ask the printer first. getStatus() sends ~HS and parses the reply:

final s = await printer.getStatus();
if (!s.isReadyToPrint) {
  final reason = s.isHeadOpen ? 'Close the print head'
      : s.isPaperOut ? 'Load labels'
      : s.isRibbonOut ? 'Replace the ribbon'
      : s.isPaused ? 'Printer is paused'
      : s.isHeadTooHot ? 'Print head is too hot. Wait a moment'
      : 'Printer is not ready';
  showMessage(reason);
  return;
}
await printer.printZpl(label);
Enter fullscreen mode Exit fullscreen mode

One round trip turns a silent failure into a sentence the user can act on.

Lesson 5: Images fail in four different ways

Printing a logo or a photo took more debugging than any other feature. These are the failures we hit on a ZQ620 and what fixed each one:

Symptom Cause Fix
Image never prints on a mobile printer Graphic sent inline inside ^XA…^XZ Store it with ~DG before ^XA, recall it with ^XG
Print head overheats on dark images Floyd-Steinberg dithering covers too many dots and trips the head's thermal protection Use threshold dithering
Printer holds the label instead of feeding it Printer left in cutter or applicator mode Set tear-off mode in the label
Image silently dropped Wrong Z64 checksum Send uncompressed hex

flutter_zpl_generator handles all four:

import 'package:flutter_zpl_generator/flutter_zpl_generator.dart' as zpl;

final width = int.tryParse(
        (await printer.getSetting(PrinterSgdKey.ezplPrintWidth.value)).trim()) ??
    384; // 2-inch, 203 dpi fallback

final label = await zpl.ZplGenerator(
  config: zpl.ZplConfiguration(printWidth: width, printMode: zpl.ZplPrintMode.tearOff),
  autoLabelLengthFromFirstImage: true,
  commands: [
    zpl.ZplImageDownload(           // ~DG: store the graphic first
      image: pngBytes,
      targetWidth: width,
      ditheringAlgorithm: zpl.ZplDitheringAlgorithm.threshold,
    ),
    const zpl.ZplImageRecall(),     // ^XG: place it
  ],
).build();

await printer.printZpl(label);
Enter fullscreen mode Exit fullscreen mode

The prefix on the import matters: both packages export a ZplPrintMode.

The checksum row is my own bug. Z64 compresses image data and protects it with a CRC, and Zebra's ZPL II Programming Guide says the CRC is "calculated over the :encoded_data field", meaning the Base64 text. Versions 0.1.0 and 0.1.1 of the plugin computed it over the raw bitmap, and a printer that verifies the CRC treats a mismatch as an aborted download. Version 0.1.2 fixes the checksum and makes the plugin's own printImage() send uncompressed hex by default. The generator path above is still the one we've tested on hardware, so start there.

Lesson 6: Over Bluetooth, replies can answer the wrong question

zPrint reads a few settings after connecting: model, firmware, print width, IP address. One day the IP address came back as the printer's serial number.

The culprit was device.host_status. On a ZQ620 running firmware V85.20 it replies with three lines. Over Bluetooth the extra two stay in the receive buffer, and the next read picks them up as its answer.

Two rules fixed it for good: read single-line settings only, and send one command at a time. PrinterSgdKey lists keys we've verified on real printers, so you get autocomplete instead of a manual search:

final model    = await printer.getSetting(PrinterSgdKey.deviceProductName.value); // "ZQ620"
final firmware = await printer.getSetting(PrinterSgdKey.applName.value);          // "V85.20.24"
final dpi      = await printer.getSetting(PrinterSgdKey.devicePrintheadResolution.value);
final ip       = await printer.getSetting(PrinterSgdKey.ipAddr.value);
Enter fullscreen mode Exit fullscreen mode

Printers answer ? for settings they don't have. Treat ? and an empty string as "unknown".

Lesson 7: Wi-Fi connections to phone hotspots need eight seconds

Bluetooth suits first contact but runs slow for big jobs, so zPrint reads the printer's IP over Bluetooth and moves the session to Wi-Fi in the background.

On a phone hotspot, a 3-second connect timeout failed every time. The hotspot's NAT and DHCP add latency to the first connection. Eight seconds with one retry works. If both attempts fail, stay on Bluetooth: the printer is probably on a guest network that isolates clients.

Lesson 8: Never reprint automatically

Bluetooth drops for a second when someone walks between the phone and the printer. ReconnectableConnection recovers the link with exponential backoff:

final conn = ReconnectableConnection(BleConnection(deviceId), maxRetries: 3);
final printer = await ZebraPrinter.connect(conn);

try {
  await printer.printZpl(label);
} on ReconnectSuccessException {
  // The link is back. This label may or may not have printed.
  showRetryPrompt();
}
Enter fullscreen mode Exit fullscreen mode

The library reconnects, then throws ReconnectSuccessException and refuses to resend the job. A retry loop that resends prints mid-dropout will one day print a shipping label twice, and a duplicate tracking number costs more than a tap on Retry. The warehouse worker can see whether the label came out. Your code can't.

How it compares to other Flutter Zebra packages

Pick the package that fits your printers. As of October 2026, from each package's pub.dev page:

Package How it talks to the printer iOS Bluetooth Desktop
flutter_zpl_printer Pure Dart (ZPL, SGD, BLE GATT) Bluetooth LE, no MFi approval macOS, Windows
zsdk Link-OS Multiplatform SDK Classic Bluetooth via MFi No
zebra_printer Link-OS SDK Not supported (Android only) No
zebrautil Native plugin Classic Bluetooth via com.zebra.rawport (MFi) No

Choose an SDK-based package if you need Classic Bluetooth for printers older than Bluetooth 4.0, or PDF printing, which zsdk supports. Choose flutter_zpl_printer if you want pub get installs, an iOS release without MFi paperwork, or desktop support.

What doesn't work yet

The README marks a platform ✅ only after it printed on real hardware:

Transport iOS macOS Windows Android
Bluetooth LE ✅ ✅ ✅ ✅
Wi-Fi / TCP ✅ ✅ ✅ ✅
USB not possible not tested fails in testing not tested

USB code exists for macOS, Windows, and Android, built on libusb, but nobody has confirmed it working yet. Windows failed in our tests and we haven't found the cause. If you try USB, the issue tracker needs your printer model, OS, and the exception text.

Status parsing targets ZPL printers. CPCL printers connect and print, but their status replies come back in ZPL's shape.

FAQ

Can I print to a Zebra printer from Flutter without the Link-OS SDK?
Yes. Zebra printers accept ZPL over TCP port 9100 and over their Bluetooth LE GATT service, and they answer settings queries in the SGD text protocol. flutter_zpl_printer implements all three in Dart, so you install it with flutter pub add and copy no Zebra binaries into your project.

Does my iOS app need MFi approval to print to a Zebra printer?
Only if it uses Classic Bluetooth. Zebra's developer documentation says apps that use Bluetooth Low Energy alone are outside the MFi program and skip the whitelisting process. Wi-Fi printing over TCP doesn't need MFi either. Your printer must support Bluetooth 4.0 or later for the BLE route.

How do I print an image to a Zebra printer from Flutter?
Convert the image to a monochrome graphic, store it on the printer with ~DG, and place it with ^XG. flutter_zpl_generator does this with ZplImageDownload and ZplImageRecall. Use threshold dithering and uncompressed hex, then send the result with printZpl.

Why does my Zebra printer print nothing?
Check the status first. The print head may be open, the roll empty, or the printer paused, and the printer accepts your ZPL without printing it. Send ~HS (or call getStatus()) before each job and show the user the reason. A printer in cutter or applicator mode can also hold labels.

Does flutter_zpl_printer work on macOS and Windows?
Yes, over Bluetooth LE and Wi-Fi, tested with Zebra printers. USB on desktop is experimental: untested on macOS, and failing on Windows in our tests. Use Bluetooth LE or Wi-Fi for production desktop printing.

Get started

Coming from 0.0.1? The API changed completely. The migration table maps each old call to the new one.

If you print to a Zebra model I haven't listed, tell me how it went on GitHub. Every confirmed printer makes the next developer's choice easier.

flutter_zpl_printer is not affiliated with or endorsed by Zebra Technologies. Zebra, Link-OS, and ZPL are trademarks of Zebra Technologies Corp.

Top comments (1)

Collapse
 
aikotanaka profile image
Aiko Tanaka •

Handling the hotspot connection latency with an 8-second timeout and a retry makes a lot of sense. Mobile hotspot DHCP and NAT negotiation often cause the first connection to stall, so short 3-second timeouts fail constantly in the field. Also good to know about storing the graphic with ~DG before ^XA on mobile printers to prevent dropped jobs.