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:
701
Docs/legacy-architecture/migration-implementation.md
Normal file
701
Docs/legacy-architecture/migration-implementation.md
Normal file
@@ -0,0 +1,701 @@
|
||||
# Feature Implementation Guide
|
||||
|
||||
> Step-by-step guide for implementing each missing feature in the Flutter app. Each section references the legacy source code and provides the Flutter implementation approach.
|
||||
|
||||
## Implementation Priority Order
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title Migration Implementation Timeline
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %b %d
|
||||
|
||||
section Phase 2 - Critical
|
||||
Camera Lock System :p2a, 2026-02-15, 10d
|
||||
PRIMARY/STANDBY Hub :p2b, 2026-02-15, 14d
|
||||
Hardware Keyboard (Serial) :p2c, after p2a, 7d
|
||||
Hardware Joystick (HID) :p2d, after p2a, 7d
|
||||
|
||||
section Phase 3 - High
|
||||
Function Buttons F1-F7 :p3a, after p2c, 3d
|
||||
Prepositions :p3b, after p2c, 5d
|
||||
Camera Edit Timeout :p3c, after p2c, 2d
|
||||
CrossSwitch Rules Engine :p3d, after p2c, 5d
|
||||
|
||||
section Phase 4 - Medium
|
||||
Playback Mode :p4a, after p3b, 7d
|
||||
Sequences :p4b, after p3b, 10d
|
||||
Alarm History :p4c, after p3b, 3d
|
||||
Service Menu :p4d, after p3b, 3d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Camera Lock System
|
||||
|
||||
**Legacy:** `CameraLockService.cs` + `SegmentViewModel.cs:631-676`
|
||||
**Complexity:** High | **Priority:** Critical
|
||||
|
||||
### New Files to Create
|
||||
|
||||
```
|
||||
lib/
|
||||
├── data/services/
|
||||
│ └── lock_service.dart # Lock operations via PRIMARY WebSocket
|
||||
├── domain/entities/
|
||||
│ └── camera_lock.dart # Lock state entity
|
||||
└── presentation/blocs/lock/
|
||||
├── lock_bloc.dart # Lock state management
|
||||
├── lock_event.dart
|
||||
└── lock_state.dart
|
||||
```
|
||||
|
||||
### Entity: CameraLock
|
||||
|
||||
```dart
|
||||
class CameraLock extends Equatable {
|
||||
final int cameraId;
|
||||
final String heldBy; // keyboard name
|
||||
final CameraLockPriority priority;
|
||||
final DateTime acquiredAt;
|
||||
final DateTime expiresAt;
|
||||
|
||||
bool get isExpiringSoon =>
|
||||
expiresAt.difference(DateTime.now()) < Duration(minutes: 1);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [cameraId, heldBy, priority, acquiredAt, expiresAt];
|
||||
}
|
||||
|
||||
enum CameraLockPriority { low, high }
|
||||
enum CameraLockResult { acquired, denied, takeoverRequested }
|
||||
```
|
||||
|
||||
### Service: LockService
|
||||
|
||||
```dart
|
||||
class LockService {
|
||||
final CoordinationService _coordination;
|
||||
|
||||
// Lock operations (send to PRIMARY via WebSocket)
|
||||
Future<CameraLockResult> tryLock(int cameraId, CameraLockPriority priority);
|
||||
Future<void> unlock(int cameraId);
|
||||
Future<void> requestTakeover(int cameraId);
|
||||
Future<void> confirmTakeover(int cameraId, bool confirm);
|
||||
Future<void> resetExpiration(int cameraId); // Called on every PTZ movement
|
||||
|
||||
// State streams
|
||||
Stream<Map<int, CameraLock>> get lockStates; // All locks
|
||||
Stream<LockNotification> get notifications; // Lock events for this keyboard
|
||||
}
|
||||
```
|
||||
|
||||
### BLoC: LockBloc
|
||||
|
||||
Key events:
|
||||
- `TryLockCamera(cameraId)` → attempt lock, show dialog if denied
|
||||
- `UnlockCamera(cameraId)` → release lock
|
||||
- `ResetLockExpiration(cameraId)` → on PTZ movement
|
||||
- `TakeoverRequested(cameraId, requestedBy)` → show confirmation dialog
|
||||
- `LockStateUpdated(locks)` → from PRIMARY broadcast
|
||||
|
||||
Key state:
|
||||
- `lockedCameras: Set<int>` — cameras locked by this keyboard
|
||||
- `allLocks: Map<int, CameraLock>` — all locks across keyboards
|
||||
- `pendingTakeover: int?` — camera with pending takeover dialog
|
||||
- `isTelemetryActive: bool` — true when any camera locked
|
||||
|
||||
### Integration with Existing BLoCs
|
||||
|
||||
The `WallBloc` needs to check lock state before PTZ. Modify `PtzBloc`:
|
||||
```dart
|
||||
// Before sending PTZ command:
|
||||
if (!lockBloc.state.lockedCameras.contains(cameraId)) {
|
||||
// Camera not locked, try to lock first
|
||||
lockBloc.add(TryLockCamera(cameraId));
|
||||
return;
|
||||
}
|
||||
// Camera locked, proceed with PTZ
|
||||
bridgeService.ptzPan(camera, direction, speed);
|
||||
lockBloc.add(ResetLockExpiration(cameraId)); // Reset expiry timer
|
||||
```
|
||||
|
||||
### Lock Expiry Timer (Client-Side)
|
||||
|
||||
```dart
|
||||
// In LockBloc: start timer when lock acquired
|
||||
Timer? _expiryWarningTimer;
|
||||
|
||||
void _onLockAcquired(int cameraId) {
|
||||
_expiryWarningTimer?.cancel();
|
||||
_expiryWarningTimer = Timer(Duration(minutes: 4), () {
|
||||
emit(state.copyWith(expiryWarning: cameraId));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. PRIMARY/STANDBY Coordination Hub
|
||||
|
||||
**Legacy:** Replaces `Copilot.AppServer.dll` + `Copilot.AppServer.Client.dll`
|
||||
**Complexity:** High | **Priority:** Critical
|
||||
|
||||
### New Files to Create
|
||||
|
||||
```
|
||||
lib/
|
||||
├── data/services/
|
||||
│ └── coordination_service.dart # WebSocket client to PRIMARY
|
||||
├── coordination/
|
||||
│ ├── primary_hub.dart # WebSocket server (when this keyboard is PRIMARY)
|
||||
│ ├── election_service.dart # PRIMARY election logic
|
||||
│ ├── heartbeat_service.dart # 2-second heartbeat
|
||||
│ └── messages.dart # Shared message types
|
||||
└── presentation/blocs/coordination/
|
||||
├── coordination_bloc.dart
|
||||
├── coordination_event.dart
|
||||
└── coordination_state.dart
|
||||
```
|
||||
|
||||
### Message Protocol
|
||||
|
||||
```dart
|
||||
abstract class CoordinationMessage {
|
||||
String get type;
|
||||
Map<String, dynamic> toJson();
|
||||
}
|
||||
|
||||
// Heartbeat
|
||||
class HeartbeatMessage extends CoordinationMessage {
|
||||
final String keyboardId;
|
||||
final DateTime timestamp;
|
||||
final bool isPrimary;
|
||||
}
|
||||
|
||||
// Lock messages
|
||||
class LockRequestMessage extends CoordinationMessage { ... }
|
||||
class LockResponseMessage extends CoordinationMessage { ... }
|
||||
class LockBroadcastMessage extends CoordinationMessage { ... }
|
||||
|
||||
// Sequence messages
|
||||
class SequenceStartMessage extends CoordinationMessage { ... }
|
||||
class SequenceStopMessage extends CoordinationMessage { ... }
|
||||
class SequenceStateBroadcast extends CoordinationMessage { ... }
|
||||
|
||||
// State sync
|
||||
class FullStateSyncMessage extends CoordinationMessage {
|
||||
final Map<int, CameraLock> locks;
|
||||
final Map<int, SequenceState> sequences;
|
||||
}
|
||||
```
|
||||
|
||||
### Election Protocol
|
||||
|
||||
```dart
|
||||
class ElectionService {
|
||||
// 1. On startup, try to connect to configured PRIMARY
|
||||
// 2. If PRIMARY responds → become STANDBY
|
||||
// 3. If PRIMARY doesn't respond within 6s → self-promote to PRIMARY
|
||||
// 4. As PRIMARY: start WebSocket server on :8090
|
||||
// 5. As STANDBY: send heartbeat checks every 2s
|
||||
// 6. If 3 consecutive heartbeats missed (6s) → promote to PRIMARY
|
||||
|
||||
Stream<KeyboardRole> get role; // PRIMARY or STANDBY
|
||||
}
|
||||
|
||||
enum KeyboardRole { primary, standby, standalone }
|
||||
```
|
||||
|
||||
### Web Platform Consideration
|
||||
|
||||
For Flutter web, the WebSocket server (PRIMARY hub) cannot run in the browser. Options:
|
||||
1. **Desktop build** on LattePanda → full support
|
||||
2. **Web build** → can only be STANDBY (connects to PRIMARY via WebSocket client)
|
||||
3. **Hybrid** → separate Dart CLI process for PRIMARY hub
|
||||
|
||||
---
|
||||
|
||||
## 3. Hardware Keyboard (Serial Port)
|
||||
|
||||
**Legacy:** `CopilotDevice.cs`, `SerialPortDataProvider.cs`
|
||||
**Complexity:** Medium | **Priority:** Critical
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
lib/
|
||||
├── data/services/
|
||||
│ └── keyboard_service.dart # Serial port + HID abstraction
|
||||
├── domain/entities/
|
||||
│ └── virtual_key.dart # Key enum (matches legacy VirtualKey)
|
||||
```
|
||||
|
||||
### Serial Protocol
|
||||
|
||||
```
|
||||
Baud rate: 115200
|
||||
Data bits: 8, Stop bits: 1, Parity: None
|
||||
|
||||
Messages (ASCII, line-terminated):
|
||||
p<keycode> → Key pressed (e.g., "p48" = digit 0)
|
||||
r<keycode> → Key released (e.g., "r48" = digit 0)
|
||||
j<value> → Jog wheel position (-1 or +1 per detent)
|
||||
s<value> → Shuttle wheel position (-7 to +7)
|
||||
v<version> → Firmware version response
|
||||
h → Heartbeat from Arduino
|
||||
```
|
||||
|
||||
### Web Serial API Implementation
|
||||
|
||||
```dart
|
||||
// For Flutter Web:
|
||||
class WebSerialKeyboardService implements KeyboardService {
|
||||
// Uses dart:js_interop to call Web Serial API
|
||||
// navigator.serial.requestPort()
|
||||
// port.open({ baudRate: 115200 })
|
||||
// port.readable.getReader() → parse messages
|
||||
}
|
||||
|
||||
// For Flutter Desktop:
|
||||
class NativeSerialKeyboardService implements KeyboardService {
|
||||
// Uses flutter_libserialport package
|
||||
// SerialPort(portName).open(read: true)
|
||||
// Read stream → parse messages
|
||||
}
|
||||
```
|
||||
|
||||
### Key Enum (VirtualKey)
|
||||
|
||||
Map Arduino keycodes to VirtualKey enum (matches legacy):
|
||||
```dart
|
||||
enum VirtualKey {
|
||||
digit0, digit1, digit2, digit3, digit4,
|
||||
digit5, digit6, digit7, digit8, digit9,
|
||||
enter, backspace, escape, prefix,
|
||||
plus, minus, home,
|
||||
f1, f2, f3, f4, f5, f6, f7,
|
||||
lock, search, prepositions, playback,
|
||||
sequence, alarmHistory, fullScreen,
|
||||
joyButton1, joyButton2,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Hardware Joystick (HID)
|
||||
|
||||
**Legacy:** `JoystickHidDataProvider.cs`
|
||||
**Complexity:** Medium | **Priority:** Critical
|
||||
|
||||
### HID Device Identification
|
||||
```
|
||||
VendorId: 0x2ACF (10959)
|
||||
ProductId: 0x0101 (257)
|
||||
```
|
||||
|
||||
### Web HID API Implementation
|
||||
|
||||
```dart
|
||||
class WebHidJoystickService implements JoystickService {
|
||||
// navigator.hid.requestDevice([{vendorId: 0x2ACF, productId: 0x0101}])
|
||||
// device.open()
|
||||
// device.addEventListener('inputreport', callback)
|
||||
// Parse input report → extract X, Y, Z axes → scale to -255..+255
|
||||
}
|
||||
```
|
||||
|
||||
### Input Report Parsing
|
||||
|
||||
The Arduino Leonardo sends HID reports with 3 axes:
|
||||
```
|
||||
Report format (depends on HID descriptor):
|
||||
Byte 0-1: X axis (16-bit signed) → Pan
|
||||
Byte 2-3: Y axis (16-bit signed) → Tilt
|
||||
Byte 4-5: Z axis (16-bit signed) → Zoom
|
||||
Byte 6: Buttons (bitmask)
|
||||
```
|
||||
|
||||
Scale raw values to -255..+255 range (same as legacy `GetScaledValue`).
|
||||
|
||||
### Deduplication
|
||||
|
||||
```dart
|
||||
// Only emit if value changed (matches legacy DoPtzAction logic)
|
||||
int _lastX = 0, _lastY = 0, _lastZ = 0;
|
||||
|
||||
void _onHidReport(int x, int y, int z) {
|
||||
if (x != _lastX) { _lastX = x; _ptzBloc.add(PtzPanStart(x)); }
|
||||
if (y != _lastY) { _lastY = y; _ptzBloc.add(PtzTiltStart(y)); }
|
||||
if (z != _lastZ) { _lastZ = z; _ptzBloc.add(PtzZoomStart(z)); }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Function Buttons (F1-F7)
|
||||
|
||||
**Legacy:** `FunctionButtonsService.cs` (78 lines)
|
||||
**Complexity:** Low | **Priority:** High
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
lib/
|
||||
├── config/
|
||||
│ └── function_buttons_config.dart # Load function-buttons.json
|
||||
├── data/services/
|
||||
│ └── function_button_service.dart # Execute button actions
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```dart
|
||||
class FunctionButtonService {
|
||||
final BridgeService _bridge;
|
||||
final Map<String, Map<String, List<FunctionButtonAction>>> _config;
|
||||
|
||||
Future<bool> execute(String wallId, String buttonKey) async {
|
||||
final actions = _config[wallId]?[buttonKey];
|
||||
if (actions == null || actions.isEmpty) return false;
|
||||
|
||||
for (final action in actions) {
|
||||
switch (action.type) {
|
||||
case ActionType.crossSwitch:
|
||||
await _bridge.viewerConnectLive(action.viewerId, action.sourceId);
|
||||
case ActionType.sequenceStart:
|
||||
await _sequenceService.start(action.viewerId, action.sourceId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with WallBloc
|
||||
|
||||
Add to keyboard handler in `MainScreen`:
|
||||
```dart
|
||||
case VirtualKey.f1: functionButtonService.execute(wallId, 'F1');
|
||||
case VirtualKey.f2: functionButtonService.execute(wallId, 'F2');
|
||||
// ... through F7
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Prepositions (Camera Presets)
|
||||
|
||||
**Legacy:** `PrepositionService.cs` (114 lines)
|
||||
**Complexity:** Medium | **Priority:** High
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
lib/
|
||||
├── config/
|
||||
│ └── prepositions_config.dart
|
||||
├── data/services/
|
||||
│ └── preposition_service.dart
|
||||
└── presentation/
|
||||
├── blocs/preposition/
|
||||
│ ├── preposition_bloc.dart
|
||||
│ ├── preposition_event.dart
|
||||
│ └── preposition_state.dart
|
||||
└── screens/
|
||||
├── prepositions_screen.dart
|
||||
└── preposition_add_screen.dart
|
||||
```
|
||||
|
||||
### Service Implementation
|
||||
|
||||
```dart
|
||||
class PrepositionService {
|
||||
final BridgeService _bridge;
|
||||
final PrepositionsConfig _config;
|
||||
|
||||
Map<int, String> getNames(int cameraId) =>
|
||||
_config.getPrepositionNames(cameraId);
|
||||
|
||||
Future<void> callUp(int cameraId, int presetId) =>
|
||||
_bridge.ptzPreset(cameraId, presetId);
|
||||
|
||||
Future<void> save(int cameraId, int presetId, String name) async {
|
||||
_config.setPrepositionName(cameraId, presetId, name);
|
||||
await _config.persist();
|
||||
// If PRIMARY available: broadcast to other keyboards
|
||||
}
|
||||
|
||||
Future<void> delete(int cameraId, int presetId) async {
|
||||
_config.removePreposition(cameraId, presetId);
|
||||
await _config.persist();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### UI Flow
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> PrepositionList: User presses Prepositions key
|
||||
PrepositionList --> CallUp: Select preset
|
||||
CallUp --> PrepositionList: Camera moves to position
|
||||
PrepositionList --> AddPreposition: Press Add button
|
||||
AddPreposition --> EnterName: Move camera to position
|
||||
EnterName --> PrepositionList: Save with name
|
||||
PrepositionList --> [*]: Press Back
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Camera Number Edit Timeout
|
||||
|
||||
**Legacy:** `SegmentViewModel.cs:829-847`
|
||||
**Complexity:** Low | **Priority:** High
|
||||
|
||||
### Add to WallBloc
|
||||
|
||||
```dart
|
||||
// In WallBloc
|
||||
Timer? _editTimeoutTimer;
|
||||
static const editTimeout = Duration(seconds: 5); // from config
|
||||
|
||||
void _onAddCameraDigit(AddCameraDigit event, Emitter<WallState> emit) {
|
||||
_editTimeoutTimer?.cancel();
|
||||
_editTimeoutTimer = Timer(editTimeout, () {
|
||||
add(ClearCameraInput()); // Auto-cancel on timeout
|
||||
});
|
||||
// ... existing digit logic
|
||||
}
|
||||
|
||||
void _onExecuteCrossSwitch(ExecuteCrossSwitch event, Emitter<WallState> emit) {
|
||||
_editTimeoutTimer?.cancel();
|
||||
// ... existing CrossSwitch logic
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Playback Mode
|
||||
|
||||
**Legacy:** `PlaybackStateService.cs`, viewer controller
|
||||
**Complexity:** Medium | **Priority:** Medium
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
lib/
|
||||
├── data/services/
|
||||
│ └── playback_service.dart
|
||||
├── presentation/
|
||||
│ ├── blocs/playback/
|
||||
│ │ ├── playback_bloc.dart
|
||||
│ │ ├── playback_event.dart
|
||||
│ │ └── playback_state.dart
|
||||
│ └── screens/
|
||||
│ └── playback_screen.dart
|
||||
```
|
||||
|
||||
### Shuttle Speed Mapping
|
||||
|
||||
```dart
|
||||
const shuttleSpeedMap = {
|
||||
-7: -32.0, -6: -16.0, -5: -8.0, -4: -4.0,
|
||||
-3: -2.0, -2: -1.0, -1: -0.5, 0: 0.0,
|
||||
1: 0.5, 2: 1.0, 3: 2.0, 4: 4.0,
|
||||
5: 8.0, 6: 16.0, 7: 32.0,
|
||||
};
|
||||
```
|
||||
|
||||
### Bridge Endpoints
|
||||
|
||||
```dart
|
||||
class PlaybackService {
|
||||
Future<void> setPlayMode(int viewer, PlayMode mode, double speed) =>
|
||||
_bridge.post('/viewer/set-play-mode', {
|
||||
'Viewer': viewer, 'PlayMode': mode.name, 'PlaySpeed': speed
|
||||
});
|
||||
|
||||
Future<void> playFromTime(int viewer, int channel, PlayMode mode, DateTime time) =>
|
||||
_bridge.post('/viewer/play-from-time', {
|
||||
'Viewer': viewer, 'Channel': channel,
|
||||
'PlayMode': mode.name, 'Time': time.toIso8601String()
|
||||
});
|
||||
|
||||
Future<void> jumpByTime(int viewer, int channel, PlayMode mode, int seconds) =>
|
||||
_bridge.post('/viewer/jump-by-time', {
|
||||
'Viewer': viewer, 'Channel': channel,
|
||||
'PlayMode': mode.name, 'TimeInSec': seconds
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Sequence Execution
|
||||
|
||||
**Legacy:** `SequenceService.cs` (77 lines)
|
||||
**Complexity:** High | **Priority:** Medium
|
||||
|
||||
### Architecture Decision
|
||||
|
||||
Sequences run on the PRIMARY keyboard (replaces AppServer):
|
||||
|
||||
```
|
||||
Flutter App (any keyboard)
|
||||
→ WebSocket message to PRIMARY
|
||||
→ PRIMARY runs sequence timer
|
||||
→ PRIMARY sends CrossSwitch commands to bridge
|
||||
→ PRIMARY broadcasts state to all keyboards
|
||||
```
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
lib/
|
||||
├── data/services/
|
||||
│ └── sequence_service.dart # Client (sends to PRIMARY)
|
||||
├── coordination/
|
||||
│ └── sequence_runner.dart # Server (runs on PRIMARY)
|
||||
├── presentation/
|
||||
│ ├── blocs/sequence/
|
||||
│ │ ├── sequence_bloc.dart
|
||||
│ │ ├── sequence_event.dart
|
||||
│ │ └── sequence_state.dart
|
||||
│ └── screens/
|
||||
│ ├── sequence_categories_screen.dart
|
||||
│ └── sequences_screen.dart
|
||||
```
|
||||
|
||||
### Sequence Runner (PRIMARY only)
|
||||
|
||||
```dart
|
||||
class SequenceRunner {
|
||||
final Map<int, Timer> _activeSequences = {}; // viewerId → timer
|
||||
|
||||
void start(int viewerId, Sequence sequence) {
|
||||
stop(viewerId); // Stop existing sequence on this viewer
|
||||
int stepIndex = 0;
|
||||
|
||||
_activeSequences[viewerId] = Timer.periodic(
|
||||
Duration(seconds: sequence.dwellTime),
|
||||
(_) {
|
||||
final camera = sequence.cameras[stepIndex % sequence.cameras.length];
|
||||
bridgeService.viewerConnectLive(viewerId, camera);
|
||||
stepIndex++;
|
||||
_broadcastState();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void stop(int viewerId) {
|
||||
_activeSequences[viewerId]?.cancel();
|
||||
_activeSequences.remove(viewerId);
|
||||
_broadcastState();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Service Menu
|
||||
|
||||
**Legacy:** `MainWindow.cs` — Backspace held 3 seconds
|
||||
**Complexity:** Low | **Priority:** Low
|
||||
|
||||
### Implementation
|
||||
|
||||
```dart
|
||||
// In MainScreen or KeyboardService
|
||||
Timer? _serviceKeyTimer;
|
||||
|
||||
void _onKeyDown(VirtualKey key) {
|
||||
if (key == VirtualKey.backspace) {
|
||||
_serviceKeyTimer = Timer(Duration(seconds: 3), () {
|
||||
GoRouter.of(context).push('/service-menu');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onKeyUp(VirtualKey key) {
|
||||
if (key == VirtualKey.backspace) {
|
||||
_serviceKeyTimer?.cancel();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Menu Options
|
||||
- Restart application
|
||||
- Restart hardware (LattePanda reboot)
|
||||
- Shutdown hardware
|
||||
- Show firmware version
|
||||
- Show app version
|
||||
- Network diagnostics
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests (per feature)
|
||||
|
||||
| Feature | Test Focus |
|
||||
|---------|-----------|
|
||||
| Lock system | Lock/unlock/takeover/expiry logic |
|
||||
| Coordination | Election, heartbeat, failover |
|
||||
| Keyboard input | Key mapping, edit timeout, service hotkey |
|
||||
| Function buttons | Config loading, action execution |
|
||||
| Prepositions | CRUD operations, name mapping |
|
||||
| Playback | Shuttle speed mapping, mode transitions |
|
||||
| Sequences | Start/stop/timer/failover |
|
||||
|
||||
### Integration Tests
|
||||
|
||||
| Test | Description |
|
||||
|------|------------|
|
||||
| CrossSwitch + Lock | Lock camera → PTZ → CrossSwitch → auto-unlock |
|
||||
| Failover | PRIMARY stops → STANDBY promotes → locks preserved |
|
||||
| Alarm blocking | Active alarm → CrossSwitch blocked → alarm resolved → CrossSwitch works |
|
||||
| Sequence + CrossSwitch | Running sequence → CrossSwitch → sequence stopped |
|
||||
| Playback + PTZ | Enter playback → switch to PTZ → playback stopped |
|
||||
|
||||
### Hardware Tests (Manual)
|
||||
|
||||
| Test | Verify |
|
||||
|------|--------|
|
||||
| Serial keyboard | All keys mapped correctly, no missed events |
|
||||
| HID joystick | Axes correct direction, full range, smooth PTZ |
|
||||
| Shuttle wheel | Speed mapping matches legacy behavior |
|
||||
| Jog wheel | Single-step forward/backward |
|
||||
| Long-press Backspace | Service menu opens after 3 seconds |
|
||||
|
||||
---
|
||||
|
||||
## File Summary: What to Create
|
||||
|
||||
| File | Type | Lines (est.) | Phase |
|
||||
|------|------|-------------|-------|
|
||||
| `lock_service.dart` | Service | 120 | 2A |
|
||||
| `camera_lock.dart` | Entity | 40 | 2A |
|
||||
| `lock_bloc.dart` + events/state | BLoC | 200 | 2A |
|
||||
| `coordination_service.dart` | Service | 150 | 2B |
|
||||
| `primary_hub.dart` | Server | 200 | 2B |
|
||||
| `election_service.dart` | Service | 100 | 2B |
|
||||
| `heartbeat_service.dart` | Service | 60 | 2B |
|
||||
| `messages.dart` | Models | 120 | 2B |
|
||||
| `coordination_bloc.dart` + events/state | BLoC | 150 | 2B |
|
||||
| `keyboard_service.dart` | Service | 150 | 3 |
|
||||
| `virtual_key.dart` | Enum | 50 | 3 |
|
||||
| `joystick_service.dart` | Service | 100 | 3 |
|
||||
| `function_button_service.dart` | Service | 60 | 3 |
|
||||
| `function_buttons_config.dart` | Config | 40 | 3 |
|
||||
| `preposition_service.dart` | Service | 80 | 3 |
|
||||
| `prepositions_config.dart` | Config | 40 | 3 |
|
||||
| `preposition_bloc.dart` + events/state | BLoC | 120 | 3 |
|
||||
| `prepositions_screen.dart` | Screen | 100 | 3 |
|
||||
| `playback_service.dart` | Service | 80 | 4 |
|
||||
| `playback_bloc.dart` + events/state | BLoC | 120 | 4 |
|
||||
| `playback_screen.dart` | Screen | 150 | 4 |
|
||||
| `sequence_service.dart` | Service | 60 | 4 |
|
||||
| `sequence_runner.dart` | Server | 100 | 4 |
|
||||
| `sequence_bloc.dart` + events/state | BLoC | 120 | 4 |
|
||||
| `sequence_screens.dart` | Screens | 150 | 4 |
|
||||
| **Total** | | **~2,560** | |
|
||||
Reference in New Issue
Block a user