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

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 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:

// 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:

  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

// 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