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>
14 KiB
Business Rules Reference
All business rules extracted from the decompiled legacy WPF source code. Each rule includes the source file and line reference for verification.
1. CrossSwitch Rules
Source: SegmentViewModel.cs lines 962-977
Execution Flow
- Stop any active playback on target viewer
- Cancel any running sequence on target viewer
- Call
CentralServerDriver.CrossSwitch(viewerId, cameraNumber) - Log with
UserAction.CrossSwitchfor audit trail - Clear camera number edit state
Preconditions
- Camera number must be valid (> 0)
- Screen must be selected
- No active alarm blocking the monitor (configurable)
- Network must be available for coordinated operations
Camera Number Composition
Full Camera Number = Prefix + Entered Digits
Example: 500 (prefix) + 123 (digits) = 500123
- Prefix cycles through configured values (e.g., 500, 501, 502)
- Each prefix maps to a different camera server
- Prefix key (
VirtualKey.Prefix) cycles to next prefix
Edit Timeout
- Source:
SegmentViewModel.cslines 829-847 - Camera number entry has a configurable timeout (
CancelEditTimeoutfrom config) DispatcherTimerstarts on first digit entry- Timer resets on each subsequent digit
- On timeout: edit is cancelled, partial number discarded
- On Enter: edit is confirmed, CrossSwitch executed
2. Camera Lock Rules
Source: CameraLockService.cs, SegmentViewModel.cs lines 631-676
Lock Lifecycle
stateDiagram-v2
[*] --> Unlocked
Unlocked --> LockRequested: User selects PTZ camera
LockRequested --> Locked: Lock acquired
LockRequested --> TakeoverDialog: Locked by another keyboard
TakeoverDialog --> TakeoverRequested: User confirms takeover
TakeoverDialog --> Unlocked: User cancels
TakeoverRequested --> Locked: Other keyboard confirms
TakeoverRequested --> Unlocked: Other keyboard rejects
Locked --> Unlocked: Manual unlock (Enter key)
Locked --> ExpiringSoon: 4 minutes elapsed
ExpiringSoon --> Locked: PTZ movement resets timer
ExpiringSoon --> Unlocked: 5 minutes elapsed (timeout)
Lock Rules
| Rule | Detail | Source |
|---|---|---|
| Lock timeout | 5 minutes from acquisition | AppServer config |
| Expiry warning | 1 minute before timeout | CameraLockNotificationType.ExpireSoon |
| Reset on PTZ | Each joystick movement resets expiry timer | SegmentViewModel:754, 941 |
| Priority levels | Low (default), High (override) |
CameraLockPriority enum |
| Auto-unlock on CrossSwitch | Entering new camera number + Enter unlocks current | SegmentViewModel:906-916 |
| Telemetry activation | IsCameraTelemetryActive = true only when locked |
SegmentViewModel:659 |
| Multiple locks | A keyboard can lock multiple cameras (HashSet) | SegmentViewModel:631 |
| Restore on navigate | When entering segment, restore locks via GetLockedCameraIds |
Startup logic |
Takeover Protocol
- Keyboard A holds lock on camera X
- Keyboard B requests lock → gets
CameraLockResult.Acquired = false - Keyboard B shows dialog: "Camera locked by [A]. Request takeover?"
- If Yes →
RequestLock()sends notification to Keyboard A - Keyboard A receives
ConfirmTakeOvernotification → shows dialog - If Keyboard A confirms → lock transferred to B
- If Keyboard A rejects → B receives rejection, lock stays with A
New Implementation (No AppServer)
- PRIMARY keyboard manages lock state in memory:
Map<int, CameraLock> - Lock operations via WebSocket messages to PRIMARY
- PRIMARY broadcasts lock changes to all connected keyboards
- Degraded mode (no PRIMARY): local-only lock tracking, no coordination
3. PTZ Control Rules
Source: SegmentViewModel.cs lines 739-769
Joystick → PTZ Mapping
Joystick X axis → Pan (negative = left, positive = right)
Joystick Y axis → Tilt (negative = up, positive = down)
Joystick Z axis → Zoom (negative = out, positive = in)
Speed Processing
| Step | Detail | Source |
|---|---|---|
| Raw HID value | Scaled to -255..+255 by HID driver | CopilotDevice.HidDataProvider_DataReceived |
| Deduplication | Only send if value changed from last sent | ptzSpeeds dictionary in DoPtzAction |
| Direction split | Negative = Left/Up/Out, Positive = Right/Down/In | Driver layer |
| Zero = Stop | Speed 0 sent as explicit stop command | Driver layer |
| No scaling | Raw value passed directly to SDK (0-255 absolute) | Confirmed in all drivers |
Critical Finding: No Zoom-Proportional Speed
- The app does NOT scale pan/tilt speed based on zoom level
- Raw joystick value → directly to SDK → camera server handles proportional behavior
- This is a hardware/server feature, not an app feature
- Flutter app should pass raw speed values unchanged
PTZ Preconditions
IsCameraTelemetryActivemust be true (camera must be locked)- Camera must be PTZ-capable (checked via driver)
- Lock expiry resets on every joystick movement
Bridge API for PTZ
POST /camera/pan {Camera: int, Direction: "left"|"right", Speed: 0-255}
POST /camera/tilt {Camera: int, Direction: "up"|"down", Speed: 0-255}
POST /camera/zoom {Camera: int, Direction: "in"|"out", Speed: 0-255}
POST /camera/stop {Camera: int}
4. Function Button Rules
Source: FunctionButtonsService.cs (78 lines)
Configuration Structure
{
"walls": {
"1": {
"F1": [
{"actionType": "CrossSwitch", "viewerId": 1001, "sourceId": 500001},
{"actionType": "CrossSwitch", "viewerId": 1002, "sourceId": 500002}
],
"F2": [
{"actionType": "SequenceStart", "viewerId": 1001, "sourceId": 5}
]
}
}
}
Execution Rules
- Buttons F1-F7 mapped per wall ID
- Each button can trigger multiple actions (executed sequentially)
- Action types:
CrossSwitch→ callsdriver.CrossSwitch(viewerId, sourceId)SequenceStart→ callssequenceService.Start(viewerId, sourceId)
- After CrossSwitch action: viewer is un-maximized
- Returns
falseif no actions configured for button+wall combination
Key Input Mapping
VirtualKey.F1 through VirtualKey.F7 → ExecuteFunctionButtonActions(wallId, "F1"..."F7")
5. Preposition Rules
Source: PrepositionService.cs (114 lines)
Operations
| Operation | Method | Requires AppServer? | Bridge Endpoint |
|---|---|---|---|
| List prepositions | GetPrepositionNames(mediaChannelId) |
No (local config) | — |
| Call up (move to) | CallUpPreposition(mediaChannelId, prepositionId) |
No | POST /camera/preset |
| Save new | SavePrepositionToAppServer(...) |
Yes (legacy) | Direct via bridge (new) |
| Delete | DeletePrepositionToAppServer(...) |
Yes (legacy) | Direct via bridge (new) |
| Sync from server | UpdatePrepositionsFromAppServer() |
Yes (legacy) | Not needed (local config) |
Preconditions
- Camera must be PTZ-capable
- Camera must be locked (
IsCameraTelemetryActive == true) - Preposition IDs are per media channel, not per camera
New Implementation
- Preposition names stored in
prepositions.jsonlocally - Call-up via bridge:
POST /camera/preset {Camera, Preset} - Save/delete: PRIMARY keyboard coordinates name updates and broadcasts to other keyboards
- No AppServer dependency for basic call-up
6. Sequence Rules
Source: SequenceService.cs (77 lines)
Sequence Model
SequenceCategory → contains multiple Sequences
Sequence → runs on a specific viewer, cycling through cameras
Execution Rules
- Start:
sequenceService.Start(viewerId, sequenceId) - Stop:
sequenceService.Stop(viewerId) - Only one sequence per viewer at a time
- CrossSwitch on a viewer with active sequence → stops sequence first
- Sequences disabled during telemetry mode (
IsCameraTelemetryActive) - Requires AppServer availability (legacy) → PRIMARY availability (new)
State Events
ViewerSequenceStateChangedevent updates viewer stateGetRunningSequences()called on startup to restore state
New Implementation
- PRIMARY keyboard runs sequence timer logic
- Sequence definitions stored in local config
- Start/stop via WebSocket messages to PRIMARY
- STANDBY can resume sequences after promotion (query bridge for current viewer state)
7. Playback Rules
Source: PlaybackStateService.cs, SegmentViewModel.cs
Playback States (PlayMode enum)
| Value | Mode | Description |
|---|---|---|
| 0 | Unknown | — |
| 1 | PlayStop | Paused |
| 2 | PlayForward | Normal speed forward |
| 3 | PlayBackward | Normal speed backward |
| 4 | FastForward | Fast forward |
| 5 | FastBackward | Fast backward |
| 6 | StepForward | Single frame forward |
| 7 | StepBackward | Single frame backward |
| 8 | PlayBOD | Jump to beginning of day |
| 9 | PlayEOD | Jump to end of day |
| 10 | QuasiLive | Near-live with delay |
| 11 | Live | Real-time live view |
Jog/Shuttle Mapping
Source: CopilotDevice.SerialPortDataProvider
Jog wheel: Single-step events (j+1 or j-1)
→ StepForward / StepBackward
Shuttle wheel: Speed values -7 to +7
-7..-1 → FastBackward (increasing speed)
0 → PlayStop (pause)
+1..+7 → FastForward (increasing speed)
Shuttle speed mapping (from legacy ShuttleConverter):
±1 → speed 0.5x
±2 → speed 1x
±3 → speed 2x
±4 → speed 4x
±5 → speed 8x
±6 → speed 16x
±7 → speed 32x
Playback Bridge Endpoints
POST /viewer/set-play-mode {Viewer, PlayMode, PlaySpeed}
POST /viewer/play-from-time {Viewer, Channel, PlayMode, Time}
POST /viewer/jump-by-time {Viewer, Channel, PlayMode, TimeInSec}
Playback Rules
- CrossSwitch stops active playback before switching
- Playback state tracked per viewer in
PlaybackStateService - Entering playback mode: navigate to
PlaybackPage - Plus/Minus keys in playback: not camera navigation but focus control (speed ±128)
8. Keyboard Input Rules
Source: MainWindow.cs (217 lines), SegmentViewModel.cs (771-944)
Input Routing Architecture
Hardware Input → CopilotDevice → MainWindow → CurrentViewModel (if implements handler)
↓
Keyboard Emulation → PreviewKeyDown → VirtualKeyboard.Emulate → Same routing
Key Mapping (SegmentViewModel)
| VirtualKey | Action | Condition |
|---|---|---|
Digit0-9 |
Append to camera number | Begins edit mode |
Enter |
Confirm camera number → CrossSwitch | If editing: execute. If telemetry: unlock + execute |
Backspace |
Remove last digit | If editing |
Escape |
Cancel edit / deselect | If editing: cancel. Else: navigate back |
Prefix |
Cycle camera prefix | Cycles through configured prefixes |
Plus |
Next camera / Focus+ | If editing: ignored. If playback: focus speed +128 |
Minus |
Previous camera / Focus- | Similar to Plus |
Home |
Go to preposition / PlayLive | Context-dependent |
F1-F7 |
Execute function button | Wall-specific actions |
Lock / JoyButton2 |
Toggle camera lock | If locked: unlock. If unlocked: try lock |
Search |
Navigate to camera search | If no alarm on screen |
Prepositions |
Navigate to prepositions | If camera is PTZ + telemetry active |
Playback |
Navigate to playback page | If valid media channel |
Sequence |
Navigate to sequence categories | If screen selected + AppServer available |
AlarmHistory |
Navigate to alarm history | — |
FullScreen |
Toggle viewer maximize | If camera > 0 |
Service Hotkey
- Backspace held for 3 seconds → Navigate to Service Menu
- Implemented via
DispatcherTimerwith 3000ms interval - Timer starts on Backspace KeyDown, cancelled on KeyUp
Keyboard Emulation Mode
- Activated when serial port not available (development)
- Uses
PreviewKeyDown/PreviewKeyUpwindow events VirtualKeyboard.Emulate()convertsSystem.Windows.Input.KeytoVirtualKey- Ignores key repeat (
e.IsRepeatcheck)
9. Alarm Rules
Source: CameraAlarmService.cs (47 lines), AlarmService in Flutter
Alarm States
vasNewAlarm → vasPresented → vasStacked → vasConfirmed → vasRemoved
Alarm Blocking
- Active alarm on a camera → blocks CrossSwitch to that camera's monitor
HasCameraAlarms(cameraId)returns true if unresolved alarm exists- Alarm state displayed as red highlight on monitor tile
Alarm Sync Strategy
- Startup: Query all active alarms from bridges (
GET /alarms/active) - Real-time: WebSocket events (
EventStarted,EventStopped) - Periodic: Re-sync every 30 seconds (configurable)
- Hourly: Full alarm mapping refresh (
CameraAlarmsUpdateWorker)
Alarm History
GetAlarmForCamera(cameraId, from, to)→ list ofCameraAlarmrecords- Times converted from UTC to local time for display
- Ordered by
StartTimedescending (newest first)
10. Monitor Wall Configuration Rules
Source: MonitorWallConfiguration, SegmentModel, ScreenModel
Wall Structure
Wall → Segments → Monitors → Viewers
| Level | Example | Description |
|---|---|---|
| Wall | Wall 1 | The physical monitor wall installation |
| Segment | "Top Row" | A logical grouping of monitors |
| Monitor | Physical screen #1 | One physical display (can have 1 or 4 viewers) |
| Viewer | Viewer 1001 | One video stream slot on a monitor |
Quad View
- A physical monitor can show 1 camera (single) or 4 cameras (quad)
PhysicalMonitor.IsQuadViewdetermines layout- Quad view has 4
viewerIds, single has 1
Screen Selection Rules
- Only one screen/viewer selected at a time
- Selection clears camera number edit state
- Selected viewer highlighted with cyan border
- CrossSwitch operates on selected viewer
11. Network Availability Rules
Source: NetworkAvailabilityState, NetworkAvailabilityWorker
Availability States
- Camera server connection: tracked per server
- AppServer/PRIMARY connection: single state
- Combined state determines feature availability
Feature Availability Matrix
| Feature | Camera Server Required | PRIMARY Required |
|---|---|---|
| CrossSwitch | Yes | No |
| PTZ control | Yes | No |
| Camera lock (coordinated) | No | Yes |
| Camera lock (local-only) | No | No |
| Preposition call-up | Yes | No |
| Preposition save/delete | Yes | Yes |
| Sequence start/stop | No | Yes |
| Alarm display | Yes | No |
| Config sync | No | Yes |
Degraded Mode
When PRIMARY is unavailable:
- CrossSwitch and PTZ always work (direct to bridge)
- Lock shown as "local only" (no coordination between keyboards)
- Sequences cannot be started/stopped
- Config changes not synchronized