Initial commit: COPILOT D6 Flutter keyboard controller
Flutter web app replacing legacy WPF CCTV surveillance keyboard controller. Includes wall overview, section view with monitor grid, camera input, PTZ control, alarm/lock/sequence BLoCs, and legacy-matching UI styling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
64
copilot_keyboard/lib/data/models/bridge_event.dart
Normal file
64
copilot_keyboard/lib/data/models/bridge_event.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
/// Event received from bridge WebSocket
|
||||
class BridgeEvent {
|
||||
final String serverId;
|
||||
final DateTime timestamp;
|
||||
final String action;
|
||||
final Map<String, dynamic> params;
|
||||
|
||||
BridgeEvent({
|
||||
required this.serverId,
|
||||
required this.timestamp,
|
||||
required this.action,
|
||||
required this.params,
|
||||
});
|
||||
|
||||
factory BridgeEvent.fromJson(Map<String, dynamic> json, String serverId) {
|
||||
return BridgeEvent(
|
||||
serverId: serverId,
|
||||
timestamp: json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'] as String)
|
||||
: DateTime.now(),
|
||||
action: json['action'] as String? ?? '',
|
||||
params: json['params'] as Map<String, dynamic>? ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
// Specific event type checks
|
||||
bool get isViewerConnected => action == 'ViewerConnected';
|
||||
bool get isViewerCleared => action == 'ViewerCleared';
|
||||
bool get isViewerSelectionChanged => action == 'ViewerSelectionChanged';
|
||||
bool get isEventStarted => action == 'EventStarted';
|
||||
bool get isEventStopped => action == 'EventStopped';
|
||||
bool get isDigitalInput => action == 'DigitalInput';
|
||||
bool get isAlarmQueueNotification => action == 'VCAlarmQueueNotification';
|
||||
bool get isConnectionLost => action == 'ConnectionLost';
|
||||
|
||||
// Viewer events
|
||||
int? get viewer => params['Viewer'] as int?;
|
||||
int? get channel => params['Channel'] as int?;
|
||||
int? get playMode => params['PlayMode'] as int?;
|
||||
|
||||
// Alarm events
|
||||
int? get eventId => params['EventID'] as int?;
|
||||
String? get eventName => params['EventName'] as String?;
|
||||
int? get typeId => params['TypeID'] as int?;
|
||||
int? get foreignKey {
|
||||
final fk = params['ForeignKey'];
|
||||
if (fk is int) return fk;
|
||||
if (fk is String) return int.tryParse(fk);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Digital input
|
||||
int? get contact => params['Contact'] as int?;
|
||||
int? get state => params['State'] as int?;
|
||||
|
||||
// Alarm queue notification
|
||||
int? get notification => params['Notification'] as int?;
|
||||
int? get alarmId => params['AlarmID'] as int?;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'BridgeEvent($action from $serverId at $timestamp)';
|
||||
}
|
||||
}
|
||||
265
copilot_keyboard/lib/data/services/alarm_service.dart
Normal file
265
copilot_keyboard/lib/data/services/alarm_service.dart
Normal file
@@ -0,0 +1,265 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../../domain/entities/alarm_state.dart';
|
||||
import '../../domain/entities/server_config.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
|
||||
/// Service for querying and tracking alarm state from GeViServer
|
||||
class AlarmService {
|
||||
final Logger _logger = Logger();
|
||||
final Map<String, Dio> _httpClients = {};
|
||||
final Map<String, ServerConfig> _servers = {};
|
||||
|
||||
// Alarm state
|
||||
final _alarmsController = BehaviorSubject<List<AlarmState>>.seeded([]);
|
||||
Timer? _syncTimer;
|
||||
|
||||
/// Stream of active alarms
|
||||
Stream<List<AlarmState>> get alarms => _alarmsController.stream;
|
||||
|
||||
/// Current active alarms
|
||||
List<AlarmState> get currentAlarms => _alarmsController.value;
|
||||
|
||||
/// Initialize with GeViServer configurations
|
||||
Future<void> initialize(List<ServerConfig> servers) async {
|
||||
// Only use GeViServer type servers for alarm queries
|
||||
final geviServers =
|
||||
servers.where((s) => s.type == ServerType.geviserver && s.enabled);
|
||||
|
||||
for (final server in geviServers) {
|
||||
_servers[server.id] = server;
|
||||
_httpClients[server.id] = Dio(BaseOptions(
|
||||
baseUrl: server.bridgeUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
}
|
||||
|
||||
_logger.i('AlarmService initialized with ${_servers.length} GeViServers');
|
||||
}
|
||||
|
||||
/// Query all active alarms from all GeViServers
|
||||
Future<List<AlarmState>> queryAllAlarms() async {
|
||||
final allAlarms = <AlarmState>[];
|
||||
|
||||
for (final entry in _servers.entries) {
|
||||
final serverId = entry.key;
|
||||
try {
|
||||
final alarms = await _queryAlarmsFromServer(serverId);
|
||||
allAlarms.addAll(alarms);
|
||||
_logger.i('Queried ${alarms.length} alarms from $serverId');
|
||||
} catch (e) {
|
||||
_logger.e('Failed to query alarms from $serverId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_alarmsController.add(allAlarms);
|
||||
return allAlarms;
|
||||
}
|
||||
|
||||
/// Query alarms from a specific server using GetFirstAlarm/GetNextAlarm pattern
|
||||
Future<List<AlarmState>> _queryAlarmsFromServer(String serverId) async {
|
||||
final client = _httpClients[serverId];
|
||||
if (client == null) return [];
|
||||
|
||||
final alarms = <AlarmState>[];
|
||||
|
||||
try {
|
||||
// Try the bulk endpoint first (preferred)
|
||||
final response = await client.get('/alarms/active');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final alarmList = data['alarms'] as List<dynamic>? ?? [];
|
||||
|
||||
for (final alarmJson in alarmList) {
|
||||
final alarm = _parseAlarmFromJson(
|
||||
alarmJson as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) {
|
||||
alarms.add(alarm);
|
||||
}
|
||||
}
|
||||
return alarms;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.d('Bulk alarm query failed, trying iteration: $e');
|
||||
}
|
||||
|
||||
// Fallback to GetFirstAlarm/GetNextAlarm iteration
|
||||
try {
|
||||
// Get first alarm
|
||||
var response = await client.get('/alarms/first');
|
||||
if (response.statusCode != 200) return alarms;
|
||||
|
||||
var data = response.data as Map<String, dynamic>;
|
||||
if (data['alarm'] == null) return alarms; // No alarms
|
||||
|
||||
var alarm = _parseAlarmFromJson(
|
||||
data['alarm'] as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) alarms.add(alarm);
|
||||
|
||||
// Iterate through remaining alarms
|
||||
while (true) {
|
||||
response = await client.get('/alarms/next');
|
||||
if (response.statusCode != 200) break;
|
||||
|
||||
data = response.data as Map<String, dynamic>;
|
||||
if (data['alarm'] == null) break; // No more alarms
|
||||
|
||||
alarm = _parseAlarmFromJson(
|
||||
data['alarm'] as Map<String, dynamic>,
|
||||
serverId,
|
||||
);
|
||||
if (alarm != null) alarms.add(alarm);
|
||||
|
||||
// Safety limit
|
||||
if (alarms.length > 1000) {
|
||||
_logger.w('Alarm query hit safety limit of 1000');
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Alarm iteration failed: $e');
|
||||
}
|
||||
|
||||
return alarms;
|
||||
}
|
||||
|
||||
/// Parse alarm from JSON response
|
||||
AlarmState? _parseAlarmFromJson(Map<String, dynamic> json, String serverId) {
|
||||
try {
|
||||
return AlarmState(
|
||||
eventId: json['EventID'] as int? ?? json['event_id'] as int? ?? 0,
|
||||
eventName:
|
||||
json['EventName'] as String? ?? json['event_name'] as String? ?? '',
|
||||
typeId: json['TypeID'] as int? ?? json['type_id'] as int? ?? 0,
|
||||
foreignKey: _parseForeignKey(json['ForeignKey'] ?? json['foreign_key']),
|
||||
serverId: serverId,
|
||||
startedAt: _parseDateTime(json['StartedAt'] ?? json['started_at']),
|
||||
stoppedAt: _parseNullableDateTime(json['StoppedAt'] ?? json['stopped_at']),
|
||||
isActive: json['IsActive'] as bool? ?? json['is_active'] as bool? ?? true,
|
||||
status: AlarmStatus.fromValue(
|
||||
json['Status'] as int? ?? json['status'] as int? ?? 0,
|
||||
),
|
||||
associatedMonitor: json['AssociatedMonitor'] as int? ??
|
||||
json['associated_monitor'] as int?,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse alarm: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int _parseForeignKey(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
DateTime _parseDateTime(dynamic value) {
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||
return DateTime.now();
|
||||
}
|
||||
|
||||
DateTime? _parseNullableDateTime(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is DateTime) return value;
|
||||
if (value is String) return DateTime.tryParse(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Start periodic alarm sync
|
||||
void startPeriodicSync(Duration interval) {
|
||||
stopPeriodicSync();
|
||||
_syncTimer = Timer.periodic(interval, (_) => queryAllAlarms());
|
||||
_logger.i('Started periodic alarm sync every ${interval.inSeconds}s');
|
||||
}
|
||||
|
||||
/// Stop periodic alarm sync
|
||||
void stopPeriodicSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = null;
|
||||
}
|
||||
|
||||
/// Handle alarm event from WebSocket
|
||||
void handleAlarmEvent(BridgeEvent event) {
|
||||
final currentAlarms = List<AlarmState>.from(_alarmsController.value);
|
||||
|
||||
if (event.isEventStarted) {
|
||||
// New alarm started
|
||||
final newAlarm = AlarmState(
|
||||
eventId: event.eventId ?? 0,
|
||||
eventName: event.eventName ?? '',
|
||||
typeId: event.typeId ?? 0,
|
||||
foreignKey: event.foreignKey ?? 0,
|
||||
serverId: event.serverId,
|
||||
startedAt: event.timestamp,
|
||||
isActive: true,
|
||||
status: AlarmStatus.newAlarm,
|
||||
);
|
||||
|
||||
// Remove existing alarm with same ID if any
|
||||
currentAlarms.removeWhere((a) =>
|
||||
a.eventId == newAlarm.eventId && a.serverId == newAlarm.serverId);
|
||||
currentAlarms.add(newAlarm);
|
||||
|
||||
_logger.d('Alarm started: ${newAlarm.eventName} (ID: ${newAlarm.eventId})');
|
||||
} else if (event.isEventStopped) {
|
||||
// Alarm stopped
|
||||
final index = currentAlarms.indexWhere((a) =>
|
||||
a.eventId == event.eventId && a.serverId == event.serverId);
|
||||
|
||||
if (index >= 0) {
|
||||
currentAlarms[index] = currentAlarms[index].stopped();
|
||||
_logger.d('Alarm stopped: ${currentAlarms[index].eventName}');
|
||||
}
|
||||
} else if (event.isAlarmQueueNotification) {
|
||||
// Alarm status changed (presented, confirmed, etc.)
|
||||
final alarmId = event.alarmId;
|
||||
final notification = event.notification;
|
||||
|
||||
if (alarmId != null && notification != null) {
|
||||
final index = currentAlarms.indexWhere((a) =>
|
||||
a.eventId == alarmId && a.serverId == event.serverId);
|
||||
|
||||
if (index >= 0) {
|
||||
final newStatus = AlarmStatus.fromValue(notification);
|
||||
currentAlarms[index] = currentAlarms[index].withStatus(newStatus);
|
||||
_logger.d(
|
||||
'Alarm status changed: ${currentAlarms[index].eventName} -> ${newStatus.name}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_alarmsController.add(currentAlarms);
|
||||
}
|
||||
|
||||
/// Get alarms blocking a specific monitor
|
||||
List<AlarmState> getAlarmsForMonitor(int monitorId) {
|
||||
return _alarmsController.value
|
||||
.where((a) => a.associatedMonitor == monitorId && a.blocksMonitor)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Check if any alarm blocks a monitor
|
||||
bool isMonitorBlocked(int monitorId) {
|
||||
return _alarmsController.value
|
||||
.any((a) => a.associatedMonitor == monitorId && a.blocksMonitor);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
stopPeriodicSync();
|
||||
_alarmsController.close();
|
||||
_httpClients.clear();
|
||||
_servers.clear();
|
||||
}
|
||||
}
|
||||
487
copilot_keyboard/lib/data/services/bridge_service.dart
Normal file
487
copilot_keyboard/lib/data/services/bridge_service.dart
Normal file
@@ -0,0 +1,487 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../domain/entities/server_config.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
|
||||
/// Service for communicating with all bridges.
|
||||
/// Includes auto-reconnection with exponential backoff and health polling.
|
||||
class BridgeService {
|
||||
final Logger _logger = Logger();
|
||||
final Map<String, Dio> _httpClients = {};
|
||||
final Map<String, WebSocketChannel> _wsChannels = {};
|
||||
final Map<String, StreamSubscription> _wsSubscriptions = {};
|
||||
final Map<String, ServerConfig> _servers = {};
|
||||
final Map<String, int> _reconnectAttempts = {};
|
||||
final Map<String, Timer> _reconnectTimers = {};
|
||||
Timer? _healthCheckTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
// Reconnection config
|
||||
static const _initialReconnectDelay = Duration(seconds: 1);
|
||||
static const _maxReconnectDelay = Duration(seconds: 30);
|
||||
static const _healthCheckInterval = Duration(seconds: 10);
|
||||
static const _commandRetryCount = 3;
|
||||
static const _commandRetryDelay = Duration(milliseconds: 200);
|
||||
|
||||
// Event streams
|
||||
final _eventController = BehaviorSubject<BridgeEvent>();
|
||||
final _connectionStatusController =
|
||||
BehaviorSubject<Map<String, bool>>.seeded({});
|
||||
|
||||
// Callback for state resync after reconnection
|
||||
void Function(String serverId)? onReconnected;
|
||||
|
||||
/// Stream of all events from all bridges
|
||||
Stream<BridgeEvent> get eventStream => _eventController.stream;
|
||||
|
||||
/// Stream of connection status per server
|
||||
Stream<Map<String, bool>> get connectionStatus =>
|
||||
_connectionStatusController.stream;
|
||||
|
||||
/// Get current connection status
|
||||
Map<String, bool> get currentConnectionStatus =>
|
||||
_connectionStatusController.value;
|
||||
|
||||
/// Initialize the service with server configurations
|
||||
Future<void> initialize(List<ServerConfig> servers) async {
|
||||
_logger.i('Initializing BridgeService with ${servers.length} servers');
|
||||
|
||||
for (final server in servers) {
|
||||
if (!server.enabled) {
|
||||
_logger.d('Skipping disabled server: ${server.id}');
|
||||
continue;
|
||||
}
|
||||
|
||||
_servers[server.id] = server;
|
||||
|
||||
// Create HTTP client
|
||||
_httpClients[server.id] = Dio(BaseOptions(
|
||||
baseUrl: server.bridgeUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
_updateConnectionStatus(server.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to a specific server's bridge
|
||||
Future<bool> connect(String serverId) async {
|
||||
final server = _servers[serverId];
|
||||
if (server == null) {
|
||||
_logger.w('Unknown server: $serverId');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check bridge health
|
||||
final response =
|
||||
await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_updateConnectionStatus(serverId, true);
|
||||
_logger.i('Connected to bridge: $serverId');
|
||||
|
||||
// Connect WebSocket for events if available
|
||||
if (server.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server.websocketUrl!);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect to $serverId: $e');
|
||||
}
|
||||
|
||||
_updateConnectionStatus(serverId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Connect to all configured servers and start health monitoring
|
||||
Future<void> connectAll() async {
|
||||
for (final serverId in _servers.keys) {
|
||||
await connect(serverId);
|
||||
}
|
||||
_startHealthChecks();
|
||||
}
|
||||
|
||||
/// Disconnect from a specific server
|
||||
Future<void> disconnect(String serverId) async {
|
||||
await _disconnectWebSocket(serverId);
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_logger.i('Disconnected from bridge: $serverId');
|
||||
}
|
||||
|
||||
/// Disconnect from all servers
|
||||
Future<void> disconnectAll() async {
|
||||
for (final serverId in _servers.keys) {
|
||||
await disconnect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Command Methods
|
||||
// ============================================================
|
||||
|
||||
/// Switch camera to monitor (ViewerConnectLive).
|
||||
/// Critical command — retries up to 3 times with backoff.
|
||||
Future<bool> viewerConnectLive(int viewer, int channel) async {
|
||||
final serverId = _findServerForMonitor(viewer);
|
||||
if (serverId == null) {
|
||||
_logger.w('No server found for monitor $viewer');
|
||||
return false;
|
||||
}
|
||||
|
||||
return _retryCommand('viewerConnectLive', () async {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/viewer/connect-live',
|
||||
data: {'Viewer': viewer, 'Channel': channel},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear monitor (ViewerClear)
|
||||
Future<bool> viewerClear(int viewer) async {
|
||||
final serverId = _findServerForMonitor(viewer);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/viewer/clear',
|
||||
data: {'Viewer': viewer},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('viewerClear failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Pan control
|
||||
Future<bool> ptzPan(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/pan',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzPan failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Tilt control
|
||||
Future<bool> ptzTilt(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/tilt',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzTilt failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Zoom control
|
||||
Future<bool> ptzZoom(int camera, String direction, int speed) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/zoom',
|
||||
data: {'Camera': camera, 'Direction': direction, 'Speed': speed},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzZoom failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Stop all movement
|
||||
Future<bool> ptzStop(int camera) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/stop',
|
||||
data: {'Camera': camera},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzStop failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// PTZ Go to preset
|
||||
Future<bool> ptzPreset(int camera, int preset) async {
|
||||
final serverId = _findServerForCamera(camera);
|
||||
if (serverId == null) return false;
|
||||
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.post(
|
||||
'/camera/preset',
|
||||
data: {'Camera': camera, 'Preset': preset},
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('ptzPreset failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Query Methods
|
||||
// ============================================================
|
||||
|
||||
/// Get current monitor states from a bridge
|
||||
Future<List<Map<String, dynamic>>> getMonitorStates(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/monitors');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final monitors = data['monitors'] as List<dynamic>? ?? [];
|
||||
return monitors.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getMonitorStates failed for $serverId: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get active alarms from a bridge
|
||||
Future<List<Map<String, dynamic>>> getActiveAlarms(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/alarms/active');
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final alarms = data['alarms'] as List<dynamic>? ?? [];
|
||||
return alarms.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getActiveAlarms failed for $serverId: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get bridge status
|
||||
Future<Map<String, dynamic>?> getBridgeStatus(String serverId) async {
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/status');
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getBridgeStatus failed for $serverId: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Methods
|
||||
// ============================================================
|
||||
|
||||
String? _findServerForCamera(int cameraId) {
|
||||
for (final entry in _servers.entries) {
|
||||
if (entry.value.ownsCamera(cameraId)) {
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _findServerForMonitor(int monitorId) {
|
||||
for (final entry in _servers.entries) {
|
||||
if (entry.value.ownsMonitor(monitorId)) {
|
||||
return entry.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _updateConnectionStatus(String serverId, bool connected) {
|
||||
final current = Map<String, bool>.from(_connectionStatusController.value);
|
||||
current[serverId] = connected;
|
||||
_connectionStatusController.add(current);
|
||||
}
|
||||
|
||||
Future<void> _connectWebSocket(String serverId, String url) async {
|
||||
try {
|
||||
await _disconnectWebSocket(serverId);
|
||||
|
||||
_logger.d('Connecting WebSocket to $url');
|
||||
final channel = WebSocketChannel.connect(Uri.parse(url));
|
||||
_wsChannels[serverId] = channel;
|
||||
_reconnectAttempts[serverId] = 0; // Reset on successful connection
|
||||
|
||||
_wsSubscriptions[serverId] = channel.stream.listen(
|
||||
(message) {
|
||||
try {
|
||||
final json = jsonDecode(message as String) as Map<String, dynamic>;
|
||||
if (json['type'] == 'connected') {
|
||||
_logger.d('WebSocket connected to $serverId');
|
||||
return;
|
||||
}
|
||||
final event = BridgeEvent.fromJson(json, serverId);
|
||||
_eventController.add(event);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse WebSocket message: $e');
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
_logger.e('WebSocket error for $serverId: $error');
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_scheduleReconnect(serverId);
|
||||
},
|
||||
onDone: () {
|
||||
_logger.w('WebSocket closed for $serverId');
|
||||
_updateConnectionStatus(serverId, false);
|
||||
_scheduleReconnect(serverId);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect WebSocket to $serverId: $e');
|
||||
_scheduleReconnect(serverId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectWebSocket(String serverId) async {
|
||||
await _wsSubscriptions[serverId]?.cancel();
|
||||
_wsSubscriptions.remove(serverId);
|
||||
|
||||
await _wsChannels[serverId]?.sink.close();
|
||||
_wsChannels.remove(serverId);
|
||||
}
|
||||
|
||||
/// Retry a critical command with exponential backoff.
|
||||
/// Used for CrossSwitch and other critical operations.
|
||||
Future<bool> _retryCommand(String name, Future<bool> Function() command) async {
|
||||
for (int attempt = 1; attempt <= _commandRetryCount; attempt++) {
|
||||
try {
|
||||
final result = await command();
|
||||
if (result) return true;
|
||||
} catch (e) {
|
||||
if (attempt == _commandRetryCount) {
|
||||
_logger.e('$name failed after $attempt attempts: $e');
|
||||
return false;
|
||||
}
|
||||
_logger.w('$name attempt $attempt failed, retrying: $e');
|
||||
await Future.delayed(_commandRetryDelay * attempt);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Schedule WebSocket reconnection with exponential backoff.
|
||||
void _scheduleReconnect(String serverId) {
|
||||
if (_disposed) return;
|
||||
_reconnectTimers[serverId]?.cancel();
|
||||
|
||||
final attempts = _reconnectAttempts[serverId] ?? 0;
|
||||
final delay = Duration(
|
||||
milliseconds: (_initialReconnectDelay.inMilliseconds *
|
||||
(1 << attempts.clamp(0, 5))) // 1s, 2s, 4s, 8s, 16s, 32s
|
||||
.clamp(0, _maxReconnectDelay.inMilliseconds),
|
||||
);
|
||||
|
||||
_logger.i('Scheduling reconnect for $serverId in ${delay.inSeconds}s (attempt ${attempts + 1})');
|
||||
|
||||
_reconnectTimers[serverId] = Timer(delay, () async {
|
||||
if (_disposed) return;
|
||||
_reconnectAttempts[serverId] = attempts + 1;
|
||||
|
||||
final server = _servers[serverId];
|
||||
if (server == null) return;
|
||||
|
||||
// Check if bridge is healthy before reconnecting WebSocket
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_updateConnectionStatus(serverId, true);
|
||||
if (server.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server.websocketUrl!);
|
||||
}
|
||||
_logger.i('Reconnected to $serverId');
|
||||
onReconnected?.call(serverId);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.d('Reconnect health check failed for $serverId: $e');
|
||||
_scheduleReconnect(serverId); // Try again
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Start periodic health checks for all bridges.
|
||||
/// Detects when a bridge comes back online after failure.
|
||||
void _startHealthChecks() {
|
||||
_healthCheckTimer?.cancel();
|
||||
_healthCheckTimer = Timer.periodic(_healthCheckInterval, (_) async {
|
||||
if (_disposed) return;
|
||||
for (final serverId in _servers.keys) {
|
||||
final isConnected = currentConnectionStatus[serverId] ?? false;
|
||||
if (!isConnected) {
|
||||
// Bridge is down — try to reconnect
|
||||
try {
|
||||
final response = await _httpClients[serverId]!.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_logger.i('Bridge $serverId is back online');
|
||||
_updateConnectionStatus(serverId, true);
|
||||
_reconnectAttempts[serverId] = 0;
|
||||
|
||||
final server = _servers[serverId];
|
||||
if (server?.websocketUrl != null) {
|
||||
await _connectWebSocket(serverId, server!.websocketUrl!);
|
||||
}
|
||||
onReconnected?.call(serverId);
|
||||
}
|
||||
} catch (_) {
|
||||
// Still down, will check again next cycle
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose of all resources
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_healthCheckTimer?.cancel();
|
||||
for (final timer in _reconnectTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_reconnectTimers.clear();
|
||||
_eventController.close();
|
||||
_connectionStatusController.close();
|
||||
|
||||
for (final sub in _wsSubscriptions.values) {
|
||||
sub.cancel();
|
||||
}
|
||||
_wsSubscriptions.clear();
|
||||
|
||||
for (final channel in _wsChannels.values) {
|
||||
channel.sink.close();
|
||||
}
|
||||
_wsChannels.clear();
|
||||
|
||||
_httpClients.clear();
|
||||
_servers.clear();
|
||||
}
|
||||
}
|
||||
464
copilot_keyboard/lib/data/services/coordination_service.dart
Normal file
464
copilot_keyboard/lib/data/services/coordination_service.dart
Normal file
@@ -0,0 +1,464 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
import '../../domain/entities/camera_lock.dart';
|
||||
|
||||
/// Client for the COPILOT Coordinator service (:8090).
|
||||
/// Handles camera locks, sequences, and keyboard coordination.
|
||||
class CoordinationService {
|
||||
final Logger _logger = Logger();
|
||||
late final Dio _http;
|
||||
WebSocketChannel? _ws;
|
||||
StreamSubscription? _wsSubscription;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _lockResetTimer;
|
||||
bool _disposed = false;
|
||||
|
||||
String _coordinatorUrl = '';
|
||||
String _keyboardId = '';
|
||||
|
||||
// Reconnection config
|
||||
static const _initialReconnectDelay = Duration(seconds: 1);
|
||||
static const _maxReconnectDelay = Duration(seconds: 30);
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
// Connection status
|
||||
final _connectedController = BehaviorSubject<bool>.seeded(false);
|
||||
|
||||
// Lock state
|
||||
final _locksController = BehaviorSubject<Map<int, CameraLock>>.seeded({});
|
||||
final _notificationController =
|
||||
BehaviorSubject<CameraLockNotification?>.seeded(null);
|
||||
|
||||
/// Whether connected to the coordinator
|
||||
Stream<bool> get connected => _connectedController.stream;
|
||||
bool get isConnected => _connectedController.value;
|
||||
|
||||
/// Current camera locks (all keyboards)
|
||||
Stream<Map<int, CameraLock>> get locks => _locksController.stream;
|
||||
Map<int, CameraLock> get currentLocks => _locksController.value;
|
||||
|
||||
/// Lock notifications targeted at this keyboard
|
||||
Stream<CameraLockNotification?> get notifications =>
|
||||
_notificationController.stream;
|
||||
|
||||
/// Initialize with coordinator URL and keyboard identity
|
||||
Future<void> initialize(String coordinatorUrl, String keyboardId) async {
|
||||
_coordinatorUrl = coordinatorUrl;
|
||||
_keyboardId = keyboardId;
|
||||
|
||||
_http = Dio(BaseOptions(
|
||||
baseUrl: coordinatorUrl,
|
||||
connectTimeout: const Duration(seconds: 5),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
_logger.i(
|
||||
'CoordinationService initialized: $coordinatorUrl (keyboard: $keyboardId)');
|
||||
}
|
||||
|
||||
/// Connect to the coordinator (HTTP health check + WebSocket)
|
||||
Future<bool> connect() async {
|
||||
try {
|
||||
final response = await _http.get('/health');
|
||||
if (response.statusCode == 200) {
|
||||
_connectedController.add(true);
|
||||
_reconnectAttempts = 0;
|
||||
await _connectWebSocket();
|
||||
|
||||
// Sync current lock state
|
||||
await syncLocks();
|
||||
_logger.i('Connected to coordinator');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect to coordinator: $e');
|
||||
}
|
||||
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Disconnect from the coordinator
|
||||
Future<void> disconnect() async {
|
||||
_reconnectTimer?.cancel();
|
||||
_lockResetTimer?.cancel();
|
||||
await _disconnectWebSocket();
|
||||
_connectedController.add(false);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Lock Operations
|
||||
// ============================================================
|
||||
|
||||
/// Try to acquire a lock on a camera
|
||||
Future<({bool acquired, CameraLock? lock})> tryLock(
|
||||
int cameraId, {
|
||||
CameraLockPriority priority = CameraLockPriority.low,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/try', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Priority': priority.name,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
final acquired = data['acquired'] as bool? ?? false;
|
||||
final lockJson = data['lock'] as Map<String, dynamic>?;
|
||||
final lock = lockJson != null ? CameraLock.fromJson(lockJson) : null;
|
||||
|
||||
if (acquired && lock != null) {
|
||||
_updateLockState(lock);
|
||||
_startLockResetTimer(cameraId);
|
||||
}
|
||||
|
||||
return (acquired: acquired, lock: lock);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('tryLock failed for camera $cameraId: $e');
|
||||
}
|
||||
return (acquired: false, lock: null);
|
||||
}
|
||||
|
||||
/// Release a camera lock
|
||||
Future<bool> releaseLock(int cameraId) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/release', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
});
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
_removeLockState(cameraId);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('releaseLock failed for camera $cameraId: $e');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Request takeover of a camera locked by another keyboard
|
||||
Future<bool> requestTakeover(
|
||||
int cameraId, {
|
||||
CameraLockPriority priority = CameraLockPriority.low,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/takeover', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Priority': priority.name,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('requestTakeover failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Confirm or reject a takeover request
|
||||
Future<bool> confirmTakeover(int cameraId, bool confirm) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/confirm', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
'Confirm': confirm,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('confirmTakeover failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset lock expiration timer (called periodically while PTZ is active)
|
||||
Future<bool> resetExpiration(int cameraId) async {
|
||||
try {
|
||||
final response = await _http.post('/locks/reset', data: {
|
||||
'CameraId': cameraId,
|
||||
'KeyboardId': _keyboardId,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('resetExpiration failed for camera $cameraId: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all current locks from the coordinator
|
||||
Future<void> syncLocks() async {
|
||||
try {
|
||||
final response = await _http.get('/locks');
|
||||
if (response.statusCode == 200) {
|
||||
final lockList = response.data as List<dynamic>;
|
||||
final locks = <int, CameraLock>{};
|
||||
for (final lockJson in lockList) {
|
||||
final lock = CameraLock.fromJson(lockJson as Map<String, dynamic>);
|
||||
locks[lock.cameraId] = lock;
|
||||
}
|
||||
_locksController.add(locks);
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('syncLocks failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get camera IDs locked by this keyboard
|
||||
Future<List<int>> getMyLockedCameras() async {
|
||||
try {
|
||||
final response = await _http.get('/locks/$_keyboardId');
|
||||
if (response.statusCode == 200) {
|
||||
final ids = response.data as List<dynamic>;
|
||||
return ids.cast<int>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getMyLockedCameras failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by this keyboard
|
||||
bool isCameraLockedByMe(int cameraId) {
|
||||
final lock = currentLocks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() == _keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
/// Check if a camera is locked by another keyboard
|
||||
bool isCameraLockedByOther(int cameraId) {
|
||||
final lock = currentLocks[cameraId];
|
||||
return lock != null &&
|
||||
lock.ownerName.toLowerCase() != _keyboardId.toLowerCase();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Sequence Operations
|
||||
// ============================================================
|
||||
|
||||
/// Start a sequence on a viewer
|
||||
Future<Map<String, dynamic>?> startSequence(
|
||||
int viewerId, int sequenceId) async {
|
||||
try {
|
||||
final response = await _http.post('/sequences/start', data: {
|
||||
'ViewerId': viewerId,
|
||||
'SequenceId': sequenceId,
|
||||
});
|
||||
if (response.statusCode == 200) {
|
||||
return response.data as Map<String, dynamic>;
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('startSequence failed: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Stop a sequence on a viewer
|
||||
Future<bool> stopSequence(int viewerId) async {
|
||||
try {
|
||||
final response = await _http.post('/sequences/stop', data: {
|
||||
'ViewerId': viewerId,
|
||||
});
|
||||
return response.statusCode == 200;
|
||||
} catch (e) {
|
||||
_logger.e('stopSequence failed: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get running sequences
|
||||
Future<List<Map<String, dynamic>>> getRunningSequences() async {
|
||||
try {
|
||||
final response = await _http.get('/sequences/running');
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getRunningSequences failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get available sequences, optionally filtered by category
|
||||
Future<List<Map<String, dynamic>>> getSequences({int? categoryId}) async {
|
||||
try {
|
||||
final queryParams = categoryId != null ? {'categoryId': categoryId} : null;
|
||||
final response =
|
||||
await _http.get('/sequences', queryParameters: queryParams);
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getSequences failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Get sequence categories
|
||||
Future<List<Map<String, dynamic>>> getSequenceCategories() async {
|
||||
try {
|
||||
final response = await _http.get('/sequences/categories');
|
||||
if (response.statusCode == 200) {
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('getSequenceCategories failed: $e');
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Private Methods
|
||||
// ============================================================
|
||||
|
||||
Future<void> _connectWebSocket() async {
|
||||
await _disconnectWebSocket();
|
||||
|
||||
try {
|
||||
final wsUrl =
|
||||
'${_coordinatorUrl.replaceFirst('http', 'ws')}/ws?keyboard=$_keyboardId';
|
||||
_logger.d('Connecting coordinator WebSocket: $wsUrl');
|
||||
|
||||
_ws = WebSocketChannel.connect(Uri.parse(wsUrl));
|
||||
|
||||
_wsSubscription = _ws!.stream.listen(
|
||||
(message) => _handleWsMessage(message as String),
|
||||
onError: (error) {
|
||||
_logger.e('Coordinator WebSocket error: $error');
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
onDone: () {
|
||||
_logger.w('Coordinator WebSocket closed');
|
||||
_connectedController.add(false);
|
||||
_scheduleReconnect();
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.e('Failed to connect coordinator WebSocket: $e');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectWebSocket() async {
|
||||
await _wsSubscription?.cancel();
|
||||
_wsSubscription = null;
|
||||
await _ws?.sink.close();
|
||||
_ws = null;
|
||||
}
|
||||
|
||||
void _handleWsMessage(String message) {
|
||||
try {
|
||||
final json = jsonDecode(message) as Map<String, dynamic>;
|
||||
final type = json['type'] as String?;
|
||||
final data = json['data'];
|
||||
|
||||
switch (type) {
|
||||
case 'lock_acquired':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final lock = CameraLock.fromJson(data);
|
||||
_updateLockState(lock);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lock_released':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final cameraId =
|
||||
data['cameraId'] as int? ?? data['CameraId'] as int? ?? 0;
|
||||
_removeLockState(cameraId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'lock_notification':
|
||||
if (data is Map<String, dynamic>) {
|
||||
final notification = CameraLockNotification.fromJson(data);
|
||||
_notificationController.add(notification);
|
||||
_logger.i(
|
||||
'Lock notification: ${notification.type.name} camera ${notification.cameraId}');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sequence_started':
|
||||
case 'sequence_stopped':
|
||||
_logger.d('Sequence event: $type');
|
||||
break;
|
||||
|
||||
case 'keyboard_online':
|
||||
case 'keyboard_offline':
|
||||
_logger.d('Keyboard event: $type');
|
||||
break;
|
||||
|
||||
default:
|
||||
_logger.d('Unknown coordinator event: $type');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('Failed to parse coordinator message: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateLockState(CameraLock lock) {
|
||||
final locks = Map<int, CameraLock>.from(_locksController.value);
|
||||
locks[lock.cameraId] = lock;
|
||||
_locksController.add(locks);
|
||||
}
|
||||
|
||||
void _removeLockState(int cameraId) {
|
||||
final locks = Map<int, CameraLock>.from(_locksController.value);
|
||||
locks.remove(cameraId);
|
||||
_locksController.add(locks);
|
||||
}
|
||||
|
||||
/// Auto-reset lock expiration every 2 minutes while a lock is held
|
||||
void _startLockResetTimer(int cameraId) {
|
||||
_lockResetTimer?.cancel();
|
||||
_lockResetTimer = Timer.periodic(const Duration(minutes: 2), (_) {
|
||||
if (isCameraLockedByMe(cameraId)) {
|
||||
resetExpiration(cameraId);
|
||||
} else {
|
||||
_lockResetTimer?.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (_disposed) return;
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
final delay = Duration(
|
||||
milliseconds: (_initialReconnectDelay.inMilliseconds *
|
||||
(1 << _reconnectAttempts.clamp(0, 5)))
|
||||
.clamp(0, _maxReconnectDelay.inMilliseconds),
|
||||
);
|
||||
|
||||
_logger.i(
|
||||
'Scheduling coordinator reconnect in ${delay.inSeconds}s (attempt ${_reconnectAttempts + 1})');
|
||||
|
||||
_reconnectTimer = Timer(delay, () async {
|
||||
if (_disposed) return;
|
||||
_reconnectAttempts++;
|
||||
await connect();
|
||||
});
|
||||
}
|
||||
|
||||
/// Dispose all resources
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_reconnectTimer?.cancel();
|
||||
_lockResetTimer?.cancel();
|
||||
_wsSubscription?.cancel();
|
||||
_ws?.sink.close();
|
||||
_connectedController.close();
|
||||
_locksController.close();
|
||||
_notificationController.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../../domain/entities/function_button_config.dart';
|
||||
import 'bridge_service.dart';
|
||||
import 'coordination_service.dart';
|
||||
|
||||
/// Executes function button actions (F1-F7).
|
||||
/// Ported from legacy FunctionButtonsService.cs.
|
||||
class FunctionButtonService {
|
||||
final BridgeService _bridgeService;
|
||||
final CoordinationService _coordinationService;
|
||||
final Logger _logger = Logger();
|
||||
|
||||
FunctionButtonConfig _config = const FunctionButtonConfig();
|
||||
|
||||
FunctionButtonService({
|
||||
required BridgeService bridgeService,
|
||||
required CoordinationService coordinationService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_coordinationService = coordinationService;
|
||||
|
||||
/// Load function button configuration.
|
||||
void loadConfig(FunctionButtonConfig config) {
|
||||
_config = config;
|
||||
_logger.i('Loaded function button config: ${config.walls.length} walls');
|
||||
}
|
||||
|
||||
/// Execute all actions for a button on a specific wall.
|
||||
/// Actions are executed sequentially (like legacy).
|
||||
Future<bool> execute(String wallId, String buttonKey) async {
|
||||
final actions = _config.getActions(wallId, buttonKey);
|
||||
if (actions.isEmpty) {
|
||||
_logger.d('No actions for $buttonKey on wall $wallId');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.i('Executing $buttonKey on wall $wallId (${actions.length} actions)');
|
||||
|
||||
for (final action in actions) {
|
||||
try {
|
||||
switch (action.type) {
|
||||
case FunctionButtonActionType.crossSwitch:
|
||||
await _bridgeService.viewerConnectLive(
|
||||
action.viewerId, action.sourceId);
|
||||
_logger.d(
|
||||
'CrossSwitch: viewer ${action.viewerId} -> camera ${action.sourceId}');
|
||||
|
||||
case FunctionButtonActionType.sequenceStart:
|
||||
await _coordinationService.startSequence(
|
||||
action.viewerId, action.sourceId);
|
||||
_logger.d(
|
||||
'SequenceStart: viewer ${action.viewerId} -> sequence ${action.sourceId}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e(
|
||||
'Function button action failed: ${action.type.name} - $e');
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Check if a button has actions for a given wall.
|
||||
bool hasActions(String wallId, String buttonKey) {
|
||||
return _config.hasActions(wallId, buttonKey);
|
||||
}
|
||||
}
|
||||
189
copilot_keyboard/lib/data/services/state_service.dart
Normal file
189
copilot_keyboard/lib/data/services/state_service.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:rxdart/rxdart.dart';
|
||||
|
||||
import '../../domain/entities/monitor_state.dart';
|
||||
import '../../domain/entities/alarm_state.dart';
|
||||
import '../models/bridge_event.dart';
|
||||
import 'bridge_service.dart';
|
||||
import 'alarm_service.dart';
|
||||
|
||||
/// Service for tracking overall system state (monitors + alarms)
|
||||
class StateService {
|
||||
final BridgeService _bridgeService;
|
||||
final AlarmService _alarmService;
|
||||
final Logger _logger = Logger();
|
||||
|
||||
StreamSubscription? _eventSubscription;
|
||||
|
||||
// Monitor state
|
||||
final _monitorStatesController =
|
||||
BehaviorSubject<Map<int, MonitorState>>.seeded({});
|
||||
|
||||
// Combined state stream (monitors with alarm flags)
|
||||
final _combinedMonitorStatesController =
|
||||
BehaviorSubject<Map<int, MonitorState>>.seeded({});
|
||||
|
||||
StateService({
|
||||
required BridgeService bridgeService,
|
||||
required AlarmService alarmService,
|
||||
}) : _bridgeService = bridgeService,
|
||||
_alarmService = alarmService;
|
||||
|
||||
/// Stream of monitor states (without alarm info)
|
||||
Stream<Map<int, MonitorState>> get monitorStates =>
|
||||
_monitorStatesController.stream;
|
||||
|
||||
/// Stream of monitor states with alarm flags
|
||||
Stream<Map<int, MonitorState>> get combinedMonitorStates =>
|
||||
_combinedMonitorStatesController.stream;
|
||||
|
||||
/// Stream of active alarms (delegated to AlarmService)
|
||||
Stream<List<AlarmState>> get activeAlarms => _alarmService.alarms;
|
||||
|
||||
/// Current monitor states
|
||||
Map<int, MonitorState> get currentMonitorStates =>
|
||||
_monitorStatesController.value;
|
||||
|
||||
/// Get state for a specific monitor
|
||||
MonitorState? getMonitorState(int viewerId) {
|
||||
return _combinedMonitorStatesController.value[viewerId];
|
||||
}
|
||||
|
||||
/// Initialize state tracking
|
||||
Future<void> initialize() async {
|
||||
// Subscribe to bridge events
|
||||
_eventSubscription = _bridgeService.eventStream.listen(_handleBridgeEvent);
|
||||
|
||||
// Subscribe to alarm changes to update monitor flags
|
||||
_alarmService.alarms.listen((_) => _updateCombinedStates());
|
||||
|
||||
_logger.i('StateService initialized');
|
||||
}
|
||||
|
||||
/// Sync initial state from all bridges
|
||||
Future<void> syncFromBridges() async {
|
||||
_logger.i('Syncing state from bridges...');
|
||||
|
||||
final connectionStatus = _bridgeService.currentConnectionStatus;
|
||||
final monitors = <int, MonitorState>{};
|
||||
|
||||
for (final entry in connectionStatus.entries) {
|
||||
if (!entry.value) continue; // Skip disconnected servers
|
||||
|
||||
try {
|
||||
final serverMonitors =
|
||||
await _bridgeService.getMonitorStates(entry.key);
|
||||
|
||||
for (final monitorJson in serverMonitors) {
|
||||
final state = MonitorState.fromJson(monitorJson);
|
||||
monitors[state.viewerId] = state;
|
||||
}
|
||||
|
||||
_logger.d(
|
||||
'Synced ${serverMonitors.length} monitors from ${entry.key}');
|
||||
} catch (e) {
|
||||
_logger.e('Failed to sync monitors from ${entry.key}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
// Also sync alarms
|
||||
await _alarmService.queryAllAlarms();
|
||||
|
||||
_logger.i('State sync complete: ${monitors.length} monitors');
|
||||
}
|
||||
|
||||
/// Handle incoming bridge event
|
||||
void _handleBridgeEvent(BridgeEvent event) {
|
||||
if (event.isViewerConnected || event.isViewerSelectionChanged) {
|
||||
_handleViewerConnected(event);
|
||||
} else if (event.isViewerCleared) {
|
||||
_handleViewerCleared(event);
|
||||
} else if (event.isEventStarted ||
|
||||
event.isEventStopped ||
|
||||
event.isAlarmQueueNotification) {
|
||||
// Delegate alarm events to AlarmService
|
||||
_alarmService.handleAlarmEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle viewer connected event
|
||||
void _handleViewerConnected(BridgeEvent event) {
|
||||
final viewer = event.viewer;
|
||||
final channel = event.channel;
|
||||
final playMode = event.playMode;
|
||||
|
||||
if (viewer == null) return;
|
||||
|
||||
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
|
||||
final existing = monitors[viewer];
|
||||
|
||||
monitors[viewer] = MonitorState(
|
||||
viewerId: viewer,
|
||||
currentChannel: channel ?? existing?.currentChannel ?? 0,
|
||||
playMode: PlayMode.fromValue(playMode ?? existing?.playMode.value ?? 0),
|
||||
serverId: event.serverId,
|
||||
lastUpdated: event.timestamp,
|
||||
);
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
_logger.d('Monitor $viewer connected to channel $channel');
|
||||
}
|
||||
|
||||
/// Handle viewer cleared event
|
||||
void _handleViewerCleared(BridgeEvent event) {
|
||||
final viewer = event.viewer;
|
||||
if (viewer == null) return;
|
||||
|
||||
final monitors = Map<int, MonitorState>.from(_monitorStatesController.value);
|
||||
final existing = monitors[viewer];
|
||||
|
||||
if (existing != null) {
|
||||
monitors[viewer] = existing.cleared();
|
||||
} else {
|
||||
monitors[viewer] = MonitorState(
|
||||
viewerId: viewer,
|
||||
currentChannel: 0,
|
||||
playMode: PlayMode.unknown,
|
||||
serverId: event.serverId,
|
||||
lastUpdated: event.timestamp,
|
||||
);
|
||||
}
|
||||
|
||||
_monitorStatesController.add(monitors);
|
||||
_updateCombinedStates();
|
||||
|
||||
_logger.d('Monitor $viewer cleared');
|
||||
}
|
||||
|
||||
/// Update combined states with alarm flags
|
||||
void _updateCombinedStates() {
|
||||
final monitors = _monitorStatesController.value;
|
||||
final combined = <int, MonitorState>{};
|
||||
|
||||
for (final entry in monitors.entries) {
|
||||
final hasAlarm = _alarmService.isMonitorBlocked(entry.key);
|
||||
combined[entry.key] = entry.value.withAlarm(hasAlarm);
|
||||
}
|
||||
|
||||
_combinedMonitorStatesController.add(combined);
|
||||
}
|
||||
|
||||
/// Check if a monitor is blocked by an alarm
|
||||
bool isMonitorBlocked(int viewerId) {
|
||||
return _alarmService.isMonitorBlocked(viewerId);
|
||||
}
|
||||
|
||||
/// Dispose resources
|
||||
void dispose() {
|
||||
_eventSubscription?.cancel();
|
||||
_monitorStatesController.close();
|
||||
_combinedMonitorStatesController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user