Open the cash drawer for real, and persist the back-office route

Cash drawer
- openCashDrawer was a debugPrint. The drawer never opened.
- It cannot go through the PDF pipeline: a PDF is rendered by the platform
  driver, which will not pass raw ESC/POS bytes to the device. So it goes over
  a socket instead — nearly every network thermal printer listens on 9100 and
  forwards whatever arrives straight to the print head, which makes the whole
  protocol five bytes.
- Printer IP and port are configurable in Settings with a Test button that
  saves and fires immediately, because a drawer that does not open is
  indistinguishable from one that is not wired up.
- Every failure explains itself: unreachable, refused, or simply not
  configured — which is the honest state for a USB printer, since there is no
  raw path to one from Flutter.
- Now fires only on a cash tender. A card-only sale that pops the drawer is a
  shrinkage risk, and it is the first thing a shop notices.

Back-office route
- Host, port, TLS and transport persist to the database; username, password
  and API key go to the platform keystore (Keychain / Credential Manager /
  Android Keystore). Writing credentials into SQLite would put them in the
  same file as the bills, on a machine behind a shop counter.
- Loaded at startup. Previously the dialog wrote settings that were silently
  ignored on the next launch, which reads exactly like they never saved — and
  credentials retyped every morning end up on a sticky note instead.
- A saved route never overwrites the terminal's store or terminal id. Those
  belong to the device, and re-pointing a till at a different broker must not
  change who it is, or its bills and presence records stop lining up.

Tests: 168 -> 176. The drawer test stands up a real socket server and asserts
the exact bytes arrive. The config test asserts no credential appears anywhere
in the meta table while the non-secret settings do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Suriya
2026-08-01 13:18:56 +05:30
parent 3513281a11
commit fdd90f28d9
12 changed files with 575 additions and 36 deletions

View File

@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
/// What happened when the till drawer was asked to open.
enum DrawerResult {
opened,
/// No drawer address configured. Not an error — plenty of shops take card
/// only, or open the drawer by hand.
notConfigured,
unreachable,
refused,
}
/// Opens the cash drawer.
///
/// The drawer is wired to the receipt printer's RJ11 port and fires when the
/// printer receives `ESC p m t1 t2`. That is a raw byte sequence, and it cannot
/// go through the PDF pipeline — a PDF is rendered by the platform driver,
/// which will not pass arbitrary bytes to the device. This used to be a
/// `debugPrint`, so the drawer never opened at all.
///
/// So it goes over the wire instead: nearly every thermal receipt printer with
/// a network port listens on 9100 (JetDirect) and forwards whatever arrives
/// straight to the print head. Sending five bytes to that socket is the whole
/// protocol.
///
/// A USB-only printer has no such path from Flutter and reports
/// [DrawerResult.notConfigured] rather than pretending.
class CashDrawerService {
CashDrawerService({
Future<Socket> Function(String host, int port, {Duration? timeout})? connect,
}) : _connect = connect ?? _defaultConnect;
final Future<Socket> Function(String host, int port, {Duration? timeout})
_connect;
static Future<Socket> _defaultConnect(
String host,
int port, {
Duration? timeout,
}) =>
Socket.connect(host, port, timeout: timeout ?? const Duration(seconds: 3));
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
///
/// Pin 2 is the near-universal wiring. A drawer on pin 5 wants `27 112 1 …`,
/// which is the one thing worth checking if the printer clicks and nothing
/// opens.
static const List<int> kickCommand = [27, 112, 0, 25, 250];
/// Short on purpose. This runs while the cashier is taking cash, and a drawer
/// that opens three seconds late has already been opened by hand.
static const Duration timeout = Duration(seconds: 3);
Future<DrawerResult> open({String? host, int port = 9100}) async {
if (host == null || host.trim().isEmpty) return DrawerResult.notConfigured;
Socket? socket;
try {
socket = await _connect(host.trim(), port, timeout: timeout);
socket.add(kickCommand);
await socket.flush().timeout(timeout);
return DrawerResult.opened;
} on SocketException catch (e) {
debugPrint('Cash drawer at $host:$port unreachable: ${e.message}');
return DrawerResult.unreachable;
} on TimeoutException {
debugPrint('Cash drawer at $host:$port did not accept the kick in time');
return DrawerResult.refused;
} on Object catch (e) {
debugPrint('Cash drawer kick failed: $e');
return DrawerResult.refused;
} finally {
// Never awaited: a printer that accepted the bytes but will not close the
// socket must not hold up the sale.
unawaited(socket?.close().catchError((_) {}));
}
}
}
/// Human-readable outcome, for the Settings test button.
extension DrawerResultMessage on DrawerResult {
String get message => switch (this) {
DrawerResult.opened => 'Drawer opened.',
DrawerResult.notConfigured =>
'No drawer address set. Enter the receipt printer\'s IP address to '
'kick the drawer automatically after a cash sale.',
DrawerResult.unreachable =>
'Could not reach the printer. Check it is powered on and on the same '
'network as this terminal.',
DrawerResult.refused =>
'The printer accepted the connection but not the command. Check the '
'drawer is wired to its RJ11 port.',
};
bool get isSuccess => this == DrawerResult.opened;
}

View File

@@ -1,5 +1,7 @@
import 'package:flutter/foundation.dart';
import 'cash_drawer_service.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
@@ -597,26 +599,17 @@ class ReceiptService {
return b.toString();
}
/// ESC/POS drawer kick: `ESC p m t1 t2` on pin 2.
/// Opens the till drawer, if one is configured.
///
/// Most drawers are wired to the printer's RJ11 port and open when the
/// printer receives this. It cannot be sent through the PDF pipeline — a
/// PDF is rendered by the driver, not passed through as bytes — so this
/// needs a raw channel to the printer.
///
/// On desktop with a driver-installed printer there is no raw path from
/// Flutter, so this stays a no-op. Wire it up when you move to ESC/POS:
/// send [drawerKickCommand] over the same socket or Bluetooth link that
/// carries the receipt.
Future<void> openCashDrawer() async {
debugPrint(
'Cash drawer kick requested — needs a raw ESC/POS channel, '
'not the PDF driver. Command: ${drawerKickCommand.join(' ')}',
);
}
/// `ESC p 0 25 250` — pin 2, 50ms on, 500ms off.
static const List<int> drawerKickCommand = [27, 112, 0, 25, 250];
/// Delegates to [CashDrawerService], which talks raw ESC/POS over a socket.
/// The PDF pipeline cannot carry the command — a PDF is rendered by the
/// platform driver, which will not pass arbitrary bytes through to the
/// device.
Future<DrawerResult> openCashDrawer({
String? host,
int port = 9100,
}) =>
CashDrawerService().open(host: host, port: port);
}
/// One GST rate's slice of a bill.