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:
klas
2026-02-12 14:57:38 +01:00
commit 40143734fc
125 changed files with 65073 additions and 0 deletions

View 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)';
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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);
}
}

View 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();
}
}