Article
Programmable Order Receipts with the Sunmi V2
Ever wondered how you can manage a restaurant's order flow using an Android tablet and React Native?
I've shortened the code for readability and used fictional order data in the illustrations. The snippets use the original stack; adaptations and newer suggestions are labelled. I haven't run these examples on a Sunmi for this article.
First, make Android print something
I used
Janus Lo's react-native-sunmi-inner-printer
to talk to the built-in printer. Janus wrote that bridge; I built the receipt logic and added my own application-control modules alongside it.
The package's JavaScript entry point is wonderfully short:
import { NativeModules } from 'react-native';const SunmiInnerPrinter = NativeModules.SunmiInnerPrinter;
On the Java side, the library binds to Sunmi's printer service. Android supplies an
IBinder
, which the generated AIDL interface turns into something the module can call:
// Excerpt from the third-party printer bridge.@Overridepublic void onServiceConnected(ComponentName name, IBinder service) {woyouService = IWoyouService.Stub.asInterface(service);}
That service connection takes time. A native module can exist before its printer service connects, so a real app needs a readiness check and a useful failure state.
Once the service is available, printing a heading looks like this:
import SunmiInnerPrinter from 'react-native-sunmi-inner-printer';await SunmiInnerPrinter.setAlignment(1); // 0 left, 1 centre, 2 rightawait SunmiInnerPrinter.setFontSize(50);await SunmiInnerPrinter.printOriginalText('ecoeats\n');await SunmiInnerPrinter.setFontSize(80);await SunmiInnerPrinter.printOriginalText('#042\n');
The newline matters. The printer can hold a partial line until it receives enough text or a line break. Forget it and you get to stare at the machine wondering which layer has decided to ignore you.
Formatting persists between calls too. Set centre alignment and subsequent text stays centred until you change it. I explicitly switched back to left alignment before the customer details and food.
Already quite a different experience from adding another
<Text>
element.
Three bytes for bold
For bold text, I sent raw command bytes around the text. The command was
ESC E n
:
0x1b
,
0x45
, then
1
to enable emphasis or
0
to disable it.
The bridge accepts raw data as Base64. My original helper converted the bytes to a binary string and encoded it with the project's
btoa
utility.
Here's a cleaned-up version, with
finally
added to attempt to restore normal formatting after a text error:
import { btoa } from '../utils/base64';function encodeBytes(bytes: number[]) {return btoa(String.fromCharCode(...bytes));}async function setBold(enabled: boolean) {await SunmiInnerPrinter.sendRAWData(encodeBytes([0x1b, 0x45, enabled ? 1 : 0]),);}async function printBold(text: string) {await setBold(true);try {await SunmiInnerPrinter.printOriginalText(text);} finally {await setBold(false);}}
Keep that helper for byte payloads. Unicode text needs the printer's text API or an explicitly chosen encoding; feeding arbitrary strings through a binary-string Base64 helper invites a separate afternoon of character-set misery.
The library decodes the payload in Java:
// Excerpt from the third-party printer bridge.final byte[] data = Base64.decode(base64EncriptedData, Base64.DEFAULT);// The actual implementation dispatches work through its thread pool// and maps service callbacks to the React Native Promise.printerService.sendRAWData(data, callback);
That is the path from a TypeScript array to the printer's formatting state. I find it disproportionately pleasing that
[0x1b, 0x45, 0x01]
can make an allergy heading stand out on a piece of paper.
The shared formatting state also explains why print jobs need serialisation. Interleave two receipts and one job can change the other's font size or alignment halfway through. I used a local lock around order printing; a stronger implementation would funnel every print entry point, including shipment tickets, through one queue.
Keep each whole receipt together. A queue around individual lines would still allow an impressively confusing collaboration between two orders.
Designing for a very narrow piece of paper
The receipt had a clear reading order: brand, restaurant, enormous order number, pickup time, customer details, warnings, food, modifiers, and total.
I varied the font size aggressively. The original renderer used 80 for the order number, 40 for category headings and items, 30 for modifier-group labels, and 34 for the actual choices. Smaller metadata sat below the header.
Those values belong to the printer API's font sizing. Don't assume they produce the same physical result as a React Native screen font with the same number.
The header code followed the hierarchy directly:
// Condensed from the receipt renderer.await SunmiInnerPrinter.setAlignment(1);await SunmiInnerPrinter.setFontSize(50);await printBold('ecoeats\n');await SunmiInnerPrinter.setFontSize(30);await SunmiInnerPrinter.printOriginalText(`${restaurant.name}\n`);await SunmiInnerPrinter.setFontSize(80);await SunmiInnerPrinter.printOriginalText(`#${order.number}\n`);await SunmiInnerPrinter.setFontSize(30);await printBold(`Pickup Time: ${pickupLabel}\n`);await SunmiInnerPrinter.setAlignment(0);
Here
pickupLabel
represents a formatted time. For a new implementation I'd pass the restaurant's time zone explicitly to the formatter rather than rely on the device's locale and clock settings.
The order number earns the largest type because people need to match food, tickets, and collections quickly. Long restaurant names can wrap. A tiny order number would make every handoff slightly more annoying.
I gave allergy notes their own section and kept packaging instructions close to the relevant food. Reusable orders could also trigger a prominent warning near the top of the ticket. Paper doesn't have the screen's coloured badges, so font size, emphasis, spacing, and placement had to do the work.
The printer bridge documents a maximum bitmap width of 384 pixels for the interface I used. I sent square QR images at 380 × 380. That narrow width also makes typography a balancing act: increase the font size and a previously neat item name suddenly occupies two lines.
Avoid designing a receipt around short demo strings. Try a long menu item, several modifier groups, a substantial allergy note, and a multi-digit quantity. Keep critical instructions intact when they wrap.
For aligned totals, the library also offers a column API:
// Optional extension using the bridge API; my order renderer used text lines.await SunmiInnerPrinter.setFontSize(24);await SunmiInnerPrinter.printColumnsText(['Subtotal', '£18.50'],[22, 10],[0, 2], // left label, right amount);
The widths are character-based allocations, not CSS pixels. All three arrays must have matching lengths. Font size and glyph width affect the result, so print a test on the target device before treating these example widths as a layout guarantee.
My old file still has unused column-width variables near the top. The actual receipt path printed lines. A familiar archaeological layer: the approach you considered is still sitting beside the one you shipped.
Give the modifiers somewhere sensible to live
A basket arrives as data with relationships. The kitchen needs a readable hierarchy:
MAINS2x Vegetable curryRicePilauExtras2x Chutney
I wrote
computeCategoriesWithModifierGroups
to join basket items back to the menu, group them into categories, collect their modifiers, and sort the groups and choices by the menu's display order.
Then both the order screen and the receipt consumed that organised view. I could change how I displayed a modifier without inventing another interpretation of the basket.
The shape looked roughly like this:
type KitchenCategory = {title: string;items: {id: string;name: string;quantity: number;modifierItems: {id: string;name: string;items: { id: string; name: string; quantity: number }[];}[];}[];
I used repeated lookups in the original transformation. I'd pre-index larger menus now:
// A small improvement to the lookup step.const itemsById = new Map(menu.items.map(item => [item.id, item]));const groupsById = new Map(menu.modifierGroups.map(group => [group.id, group]),);const menuItem = itemsById.get(basketItem.menuItemId);if (!menuItem) {throw new Error('Refresh the menu before rendering this order');}
The original screen could refetch the menu when the transformation reported a missing item. For durable order records, I'd also keep the purchased names and choices with the order itself. A menu edit shouldn't rewrite yesterday's dinner.
Once the grouping was ready, the printer renderer could concentrate on indentation and type:
// Condensed from the item/modifier renderer.for (const category of categories) {await SunmiInnerPrinter.setFontSize(40);await printBold(`${category.title}\n`);for (const item of category.items) {await SunmiInnerPrinter.setFontSize(40);await SunmiInnerPrinter.printOriginalText(`${item.quantity}x ${item.name}\n`,);for (const group of item.modifierItems) {await SunmiInnerPrinter.setFontSize(30);await SunmiInnerPrinter.printOriginalText(` ${group.name}\n`);await SunmiInnerPrinter.setFontSize(34);for (const choice of group.items) {const quantity = choice.quantity > 1 ? `${choice.quantity}x ` : '';await SunmiInnerPrinter.printOriginalText(` ${quantity}${choice.name}\n`,);}}}}
I printed a quantity for a modifier only when it exceeded one. Small choice, useful result: the unusual quantity stands out while ordinary selections stay compact.
For reusable orders, I inserted the named container instruction immediately after the item. That gave the person packing the order the information beside the thing they were packing.
Then I printed the subtotal using integer pennies and fed a few blank lines so the last text cleared the tear edge. Physical margins. Finally, a CSS problem I could solve with
\n
.
Sending a QR code from React to paper
For the shipment-related order view, I rendered a QR code with
react-native-qrcode-svg
, kept a ref, and exported the image when the user requested a print.
The route looked like this, with a fictional identifier:
// Adapted to show the ref/export path without private order URLs.type QRHandle = {toDataURL: (callback: (base64: string) => void) => void;};const qrRef = useRef<QRHandle | null>(null);<QRCodevalue="DEMO-ORDER-042"size={256}getRef={ref => { qrRef.current = ref; }}/>async function printQr() {const handle = qrRef.current;if (!handle) throw new Error('QR code has not rendered yet');const base64 = await new Promise<string>(resolve => {handle.toDataURL(resolve);});await SunmiInnerPrinter.setAlignment(1);await SunmiInnerPrinter.printBitmap(base64, 380, 380);}
The bridge decodes the Base64 image, constructs an Android
Bitmap
, and passes it to the printer service. React draws the graphic; Android gives it a form the printer understands.
For a production helper, add a timeout around export and make sure an unmounted component can't leave a print request waiting forever.
I'd also generate at the intended raster size where possible, use a white background, and preserve the QR quiet zone. Resizing a code can soften its edges or distort its modules. Test a physical print with the scanners you expect people to use.
The library exposes a direct
printQRCode
method too. That is worth trying when you only need a code and want the printer to render its modules. Exporting from React was convenient for the visual I already had.
There is a cut-paper command helper in the old file as well, but the order-print path doesn't call it. Check the device's actual cutter capability before wiring one in. A byte sequence cannot persuade a tear bar to grow a motor.
Fitting the same order onto the screen
On the device, I had colour, touch, and scrolling to play with. I also had a small display and potentially a very long order.
I kept the order contents in a flexible
ScrollView
and placed the action footer beside it in the component tree. The food could scroll while Accept, Reject, or Ready remained reachable.
The structure was straightforward:
// Simplified from OrderView.<View style={{ flex: 1 }}><ScrollView style={{ flex: 1 }}><OrderSummary /><AllergyNotes /><KitchenItems categories={categories} /></ScrollView><View style={{ padding: 16 }}><OrderActions order={order} /></View></View>
A footer inside the scrolling content would disappear beneath the modifiers. An absolutely positioned footer could cover the final item unless I carefully reserved its height. Keeping it as a sibling let the layout engine allocate the space.
Within an item, I gave the quantity a compact column and let the name take the remaining width:
// Adapted from the order item layout.<View style={{ flexDirection: 'row', alignItems: 'flex-start' }}><Text style={{ color: accent, fontSize: 18 }}>{item.quantity}x</Text><View style={{ flex: 1, marginLeft: 8 }}><Text style={{ fontSize: 18 }}>{item.name}</Text><ModifierGroups groups={item.modifierItems} /></View></View>
That
flex: 1
gives long names a width to wrap within. The modifiers sit underneath the name, so the quantity remains attached to the main item instead of competing with every extra.
For reusable packaging, I added an illustrated container panel. People could tap it for more detail. The printed receipt repeated the container name in bold.
The home screen used a
SectionList
for current work and orders to prepare later. Individual rows used a fixed height in that version, with order information on the left and a new-order badge or time on the right. Compact rows let the kitchen scan several orders without immediately scrolling.
There are trade-offs in that fixed height. Long names, translations, and larger accessibility text need deliberate testing. I'd let the row grow where necessary rather than clip something operationally important.
I also delayed the order query until the navigation interaction had a chance to finish. The historical screen used
InteractionManager.runAfterInteractions
with a timer fallback. That kept a pile of work from starting at the same moment as the screen transition.
For the old React Native stack, this is a tidier version of the cleanup:
useEffect(() => {const interaction = InteractionManager.runAfterInteractions(() => setReady(true),);const fallback = setTimeout(() => setReady(true), 500);return () => {interaction.cancel();clearTimeout(fallback);};}, []);
The screen also measured viewport height, scroll offset, and content height for a scroll affordance. The relationship to use is:
const remainingBelow = contentHeight - (scrollY + viewportHeight);const showScrollHint = remainingBelow > threshold;
I'd use that explicit calculation when revisiting the old condition. A scroll hint should point to actual hidden content; it shouldn't require an interpretive dance around three height variables.
Writing my own Java modules
The printer package covered the printer. I wrote
PhoenixModule
and
ToucherModule
for other device-level experiments and controls, then registered them through an
AppPackage
.
This was the classic React Native Android bridge. The module returned a JavaScript-visible name from
getName()
, and
@ReactMethod
exposed methods to JavaScript.
My package registration looked like this:
@Overridepublic List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {List<NativeModule> modules = new ArrayList<>();modules.add(new ToucherModule(reactContext));modules.add(new PhoenixModule(reactContext));return modules;}
Then I added the package to the application's package list:
List<ReactPackage> packages = new PackageList(this).getPackages();packages.add(new AppPackage());return packages;
After compiling those native modules into the APK, JavaScript could reach them through
NativeModules
. A CodePush update could call existing native methods, but adding a new method still required a native build.
I also added one very useful line to
MainActivity
:
@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);}
The flag keeps the screen awake while that activity's window is visible. The kitchen could leave the order screen in view without poking the device just to keep it lit. Background work still needed its own lifecycle.
Phoenix: arrange the return before making the exit
PhoenixModule
exposed a restart method that scheduled the splash activity and then exited the process. The relevant code looked like this:
// Historical implementation, abbreviated.Intent start = new Intent(getCurrentActivity(), SplashActivity.class);PendingIntent pending = PendingIntent.getActivity(mContext,123456,start,PendingIntent.FLAG_CANCEL_CURRENT);AlarmManager alarms =(AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE);alarms.set(AlarmManager.RTC, System.currentTimeMillis() + 1000, pending);System.exit(0);
The idea made me smile: book your return trip before killing the process.
JavaScript stopped the background service before asking Phoenix to restart:
const { PhoenixModule } = NativeModules;async function restartTerminalApp() {await BackgroundService.stop();PhoenixModule.restart();}
This excerpt needs updating for current Android targets: check pending-intent mutability requirements, activity availability, alarm behaviour, and background activity-launch restrictions. A scheduled alarm doesn't guarantee an exact restart one second later.
The module also contained whole-device reboot and app-data-reset methods. Reboot requires privileged permission or appropriate device provisioning; declaring an ordinary app permission won't grant it. Clearing app data removes local application state, so keep that behind an explicit maintenance action with a recovery plan.
I limited automatic reboot attempts to a quiet-time window. I'd check active orders, pending prints, and device readiness as well if I were rebuilding the policy today.
Seeing what the restaurant saw
Even though eceoeats wasn't operating over an enormous area, we still covered a good chunk of Scotland. When something went wrong with our tech in a Kitchen, we had to be able to provide genuinely useful support to the Restaurant without sending somebody down there. Kitchens are an intense environment, and we didn't want to bog down the staff at our partner locations with debugging tablets.
Sunmi exposed a 'remote control' feature from their Web interface, but it was incredibly unreliable. I ended up having to build our own little WebRTC streaming system that allowed connecting to the tablets from our admin dashboard directly, so our operators could take control of the device, issue commands, clicks, and see exactly what the restaurant was seeing.
I wrote an Android screen-sharing helper with
react-native-webrtc
. It requested a display stream, attached it to a peer connection, created an SDP offer, and handed that offer to a callback for signalling.
The core sequence used the library API available at the time:
// Shortened from the historical screen-sharing helper.const pc = new RTCPeerConnection(configuration);const stream = await mediaDevices.getDisplayMedia({ video: true });pc.addStream(stream);pc.onicecandidate = event => {if (event.candidate) onCandidate(event.candidate);};const description = await pc.createOffer();await pc.setLocalDescription(description);onOffer(description, { pc, stream });
The other end still needed to send back its description and ICE candidates. STUN helps peers discover connectivity; some networks require a TURN relay too - but I just ignored this part and hoped for the best.
For an actual support session, authenticate both ends, ask the operator to start sharing, make the session visible, and stop every capture track when it ends. Restaurant screens can contain customer information. Keep that out of recordings and demonstrations unless you have a clear reason and permission.
That helper explains why I enjoyed programming these devices. I could move from React layout to a paper bitmap, then into Android lifecycle code and WebRTC, all in the same project.
Testing without feeding the printer all afternoon
The receipt renderer included a console fallback for development. It replaced printer methods with stubs, remembered the current font size, and logged text like
[SIZE=40] 2x Vegetable curry
.
That let me inspect category ordering, quantity formatting, and the sequence of font changes without burning through another roll - although I did, of course, burn through a lot of thermal paper!
The real hardware still gets the final say on wrapping, image quality, glyph support, and paper feed. I'd keep a small set of deliberately awkward sample orders: long names, deep modifiers, allergy notes, repeated quantities, and reusable packaging.
The bridge exposes
PrinterStatus
events for conditions such as paper out and cover open. A small listener can make those failures understandable:
// Suggested UI integration using the printer library's event API.useEffect(() => {const listener = DeviceEventEmitter.addListener('PrinterStatus',action => {if (action === SunmiInnerPrinter.Constants.OUT_OF_PAPER_ACTION) {setPrinterMessage('Load a new paper roll');} else if (action === SunmiInnerPrinter.Constants.COVER_OPEN_ACTION) {setPrinterMessage('Close the printer cover');} else if (action === SunmiInnerPrinter.Constants.NORMAL_ACTION) {setPrinterMessage(null);}},);return () => listener.remove();}, []);
It really was very funny of me to be writing all of these as effects. I was still going through a phase where I stick quite rigidly to all logic being in React lifecycle, when in reality if I'd picked up something like RxJS, a service arch, or really any other event coordination mechanism for business logic I would have had fewer bugs to deal with!
Inspect callback behaviour in the version you ship. Some of the older buffer methods return
void
, and putting
await
in front of them doesn't create a completion signal. Surface uncertain print outcomes instead of blindly firing off another copy.
I spent a lot of time on details like these: keeping the action button reachable, putting the modifier beneath the right item, getting a QR image through the bridge, and recovering the native application when a JavaScript reload wouldn't do.
Vintage Video of me testing out an early version of the Restaurant app at home
That order ping sound drove me (and the kitchen staff too) absolutely nuts!
Code references: