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:
101
lib/core/services/cash_drawer_service.dart
Normal file
101
lib/core/services/cash_drawer_service.dart
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user