import 'dart:async'; import 'dart:convert'; import 'dart:io'; import '../../core/config/sync_config.dart'; import '../../domain/repositories/sync_repository.dart'; import '../local/terminal_identity.dart'; import '../remote/mqtt_order_transport.dart'; import 'sync_engine.dart'; /// What a till reports about itself, every 30 seconds. /// /// The Last Will already answers *is it dead* — the broker publishes `offline` /// on a terminal's behalf when it stops responding. That is not enough to run a /// hundred shops on, because the failure that actually costs money looks /// completely healthy from outside: a till that is connected, selling, and /// quietly accumulating two hundred bills it has never managed to upload. /// /// So this carries the numbers that separate *reachable* from *well*: how deep /// the queue is, how long the oldest thing in it has been waiting, whether the /// till has rung anything today, and whether the hardware is in the way. /// /// Not retained, and never acknowledged. A heartbeat is a fact with an expiry /// date — the back office holds it in Redis under a TTL, so a terminal that /// loses power ages off the board by itself. Retaining it would leave a dead /// till looking alive until something overwrote it. class HealthReporter { HealthReporter({ required MqttOrderTransport transport, required TerminalIdentity terminal, required SyncConfig config, required SyncEngine engine, required SyncRepository repository, required this.appVersion, this.deviceState, this.printerEndpoint, Duration interval = const Duration(seconds: 30), DateTime Function()? clock, Timer Function(Duration, void Function())? scheduleTimer, }) : _transport = transport, _terminal = terminal, _config = config, _engine = engine, _repository = repository, _interval = interval, _now = clock ?? DateTime.now, _schedule = scheduleTimer ?? Timer.new; final MqttOrderTransport _transport; final TerminalIdentity _terminal; final SyncConfig _config; final SyncEngine _engine; final SyncRepository _repository; final Duration _interval; final DateTime Function() _now; final Timer Function(Duration, void Function()) _schedule; final String appVersion; /// Hardware readings, if this build collects any. /// /// A hook rather than a hard dependency: battery level and free storage need /// platform packages that a desktop build has no use for, and a health board /// is not a good enough reason to make the whole app depend on them. What is /// not collected is *omitted* rather than sent as zero — a dashboard showing /// every till at 0% battery is worse than one showing nothing. final Future> Function()? deviceState; /// Where the receipt printer lives, if one is configured. /// /// Read fresh on each beat rather than captured once, because a shop can /// re-point its printer in Settings without restarting the till. final ({String host, int port})? Function()? printerEndpoint; Timer? _timer; bool _stopped = false; Future start() async { if (_stopped) throw StateError('This HealthReporter has been disposed.'); await publish(); _tick(); } void _tick() { _timer = _schedule(_interval, () { if (_stopped) return; unawaited(publish()); _tick(); }); } /// One heartbeat. /// /// Every failure is swallowed. A terminal that cannot say how it is must /// still sell — losing a heartbeat is a monitoring gap, and stopping a till /// because a dashboard is unreachable would be a self-inflicted outage. Future publish() async { if (!_transport.isConnected) return; try { final state = _engine.state; final pendingBills = state.pending; final pendingRegistrations = await _repository.unsyncedCustomerCount(); // Terminal-wide rather than scoped to whoever is signed in: the board // watches a till, not a shift. final today = await _repository.todayReport( terminalId: _terminal.code, cashierName: '', ); final payload = { 'schema': 1, 'status': 'online', 'terminal_id': _terminal.code, 'device_id': _terminal.deviceId, 'terminal_name': _terminal.name, 'location_id': _config.storeId, 'store_name': _terminal.name, 'app_version': appVersion, 'transport': _config.transport.name, // Queue depth — the number that makes a silent failure visible. 'pending_bills': pendingBills, 'pending_registrations': pendingRegistrations, 'oldest_pending_at': (await _oldestPendingAt())?.toIso8601String(), 'sync_halted': state.isHalted, 'sync_error': state.lastError, 'last_upload_at': state.lastSuccessAt?.toIso8601String(), // Today's trading. A till that is connected but has rung nothing in // three hours is usually a jammed printer or an absent cashier, and // neither shows up on an online/offline board. 'today_bills': today.billCount, 'today_amount': today.grossSales, 'last_bill_at': today.lastBillAt?.toIso8601String(), 'reported_at': _now().toIso8601String(), }; final device = await _collectDeviceState(); payload.addAll(device); await _transport.publishHealth(jsonEncode(payload)); } on Object { // Deliberately silent — see above. } } /// When the oldest unsent bill was rung. /// /// More useful than the count on its own: fifty bills queued in the last ten /// minutes is a broker hiccup, while three queued since Tuesday is a till /// nobody has looked at. Future _oldestPendingAt() async { try { final rows = await _repository.orderSyncRows(limit: 500); DateTime? oldest; for (final row in rows) { if (row.isSynced) continue; if (oldest == null || row.createdAt.isBefore(oldest)) { oldest = row.createdAt; } } return oldest; } on Object { return null; } } /// Hardware readings, plus whatever this build can work out for itself. Future> _collectDeviceState() async { final out = {}; if (deviceState != null) { try { out.addAll(await deviceState!()); } on Object { // A missing battery reading must not cost the rest of the heartbeat. } } final printer = printerEndpoint?.call(); if (printer != null && printer.host.isNotEmpty) { out['printer_reachable'] = await _canReach(printer.host, printer.port); } return out; } /// Opens and immediately closes a socket to the till's printer. /// /// Cheap enough to run every 30 seconds, and it answers the question a shop /// actually phones about — a printer that is switched off looks identical to /// a working one until someone tries to print a bill. Future _canReach(String host, int port) async { try { final socket = await Socket.connect( host, port, timeout: const Duration(seconds: 2), ); socket.destroy(); return true; } on Object { return false; } } Future dispose() async { _stopped = true; _timer?.cancel(); } }