import 'dart:async'; import 'dart:convert'; import '../../core/config/sync_config.dart'; import '../local/terminal_identity.dart'; import '../remote/mqtt_order_transport.dart'; import 'sync_engine.dart'; /// Publishes what this till is doing, so a fleet of them can be watched. /// /// The Last Will already answers "is it dead" — the broker publishes `offline` /// when a terminal stops responding. That is not enough to run 100 shops on: a /// till can be connected and still be broken, holding 200 unsent bills or /// running last month's catalogue. This publishes the state that distinguishes /// *reachable* from *healthy*. /// /// Retained on purpose. A dashboard that connects at noon gets every terminal's /// last report immediately, instead of a blank board until each one happens to /// tick. class PresenceReporter { PresenceReporter({ required MqttOrderTransport transport, required TerminalIdentity terminal, required SyncConfig config, required SyncEngine engine, required this.appVersion, required Future Function() catalogueRevision, Duration interval = const Duration(minutes: 1), DateTime Function()? clock, Timer Function(Duration, void Function())? scheduleTimer, }) : _transport = transport, _terminal = terminal, _config = config, _engine = engine, _catalogueRevision = catalogueRevision, _interval = interval, _now = clock ?? DateTime.now, _schedule = scheduleTimer ?? Timer.new; final MqttOrderTransport _transport; final TerminalIdentity _terminal; final SyncConfig _config; final SyncEngine _engine; final Future Function() _catalogueRevision; final Duration _interval; final DateTime Function() _now; final Timer Function(Duration, void Function()) _schedule; final String appVersion; Timer? _timer; bool _stopped = false; Future start() async { if (_stopped) throw StateError('This PresenceReporter has been disposed.'); await publish(); _tick(); } void _tick() { _timer = _schedule(_interval, () { if (_stopped) return; unawaited(publish()); _tick(); }); } /// One presence record. /// /// Failures are swallowed. A terminal that cannot tell head office how it is /// must still sell — losing a heartbeat is a monitoring gap, not a reason to /// stop trading. Future publish() async { if (!_transport.isConnected) return; try { final state = _engine.state; await _transport.publishStatus( jsonEncode({ 'schema': 1, 'state': 'online', 'device_id': _terminal.deviceId, 'terminal_code': _terminal.code, 'terminal_name': _terminal.name, 'store_id': _terminal.storeId, 'app_version': appVersion, 'reported_at': _now().toIso8601String(), // The three numbers that separate a healthy till from a broken one. 'pending_bills': state.pending, 'last_upload_at': state.lastSuccessAt?.toIso8601String(), 'catalogue_revision': await _catalogueRevision(), 'sync_halted': state.isHalted, 'sync_error': state.lastError, 'consecutive_failures': state.consecutiveFailures, 'transport': _config.transport.name, }), ); } on Object { // Deliberately silent — see above. } } Future dispose() async { _stopped = true; _timer?.cancel(); } }