Files
COPILOT/Docs/legacy-architecture/migration-implementation.md
klas 40143734fc 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>
2026-02-12 14:57:38 +01:00

702 lines
19 KiB
Markdown

# 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** | |