# 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 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 tryLock(int cameraId, CameraLockPriority priority); Future unlock(int cameraId); Future requestTakeover(int cameraId); Future confirmTakeover(int cameraId, bool confirm); Future resetExpiration(int cameraId); // Called on every PTZ movement // State streams Stream> get lockStates; // All locks Stream 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` — cameras locked by this keyboard - `allLocks: Map` — 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 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 locks; final Map 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 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 → Key pressed (e.g., "p48" = digit 0) r → Key released (e.g., "r48" = digit 0) j → Jog wheel position (-1 or +1 per detent) s → Shuttle wheel position (-7 to +7) v → 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>> _config; Future 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 getNames(int cameraId) => _config.getPrepositionNames(cameraId); Future callUp(int cameraId, int presetId) => _bridge.ptzPreset(cameraId, presetId); Future save(int cameraId, int presetId, String name) async { _config.setPrepositionName(cameraId, presetId, name); await _config.persist(); // If PRIMARY available: broadcast to other keyboards } Future 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 emit) { _editTimeoutTimer?.cancel(); _editTimeoutTimer = Timer(editTimeout, () { add(ClearCameraInput()); // Auto-cancel on timeout }); // ... existing digit logic } void _onExecuteCrossSwitch(ExecuteCrossSwitch event, Emitter 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 setPlayMode(int viewer, PlayMode mode, double speed) => _bridge.post('/viewer/set-play-mode', { 'Viewer': viewer, 'PlayMode': mode.name, 'PlaySpeed': speed }); Future 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 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 _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** | |