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