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>
19 KiB
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
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
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
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 deniedUnlockCamera(cameraId)→ release lockResetLockExpiration(cameraId)→ on PTZ movementTakeoverRequested(cameraId, requestedBy)→ show confirmation dialogLockStateUpdated(locks)→ from PRIMARY broadcast
Key state:
lockedCameras: Set<int>— cameras locked by this keyboardallLocks: Map<int, CameraLock>— all locks across keyboardspendingTakeover: int?— camera with pending takeover dialogisTelemetryActive: bool— true when any camera locked
Integration with Existing BLoCs
The WallBloc needs to check lock state before PTZ. Modify PtzBloc:
// 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)
// 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
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
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:
- Desktop build on LattePanda → full support
- Web build → can only be STANDBY (connects to PRIMARY via WebSocket client)
- 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
// 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):
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
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
// 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
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:
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
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
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
// 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
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
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)
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
// 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 |