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>
This commit is contained in:
klas
2026-02-12 14:57:38 +01:00
commit 40143734fc
125 changed files with 65073 additions and 0 deletions

View File

@@ -0,0 +1,393 @@
# 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
1. Stop any active playback on target viewer
2. Cancel any running sequence on target viewer
3. Call `CentralServerDriver.CrossSwitch(viewerId, cameraNumber)`
4. Log with `UserAction.CrossSwitch` for audit trail
5. 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.cs` lines 829-847
- Camera number entry has a configurable timeout (`CancelEditTimeout` from config)
- `DispatcherTimer` starts 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
```mermaid
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
1. Keyboard A holds lock on camera X
2. Keyboard B requests lock → gets `CameraLockResult.Acquired = false`
3. Keyboard B shows dialog: "Camera locked by [A]. Request takeover?"
4. If Yes → `RequestLock()` sends notification to Keyboard A
5. Keyboard A receives `ConfirmTakeOver` notification → shows dialog
6. If Keyboard A confirms → lock transferred to B
7. 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
- `IsCameraTelemetryActive` must 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
```json
{
"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` → calls `driver.CrossSwitch(viewerId, sourceId)`
- `SequenceStart` → calls `sequenceService.Start(viewerId, sourceId)`
- After CrossSwitch action: viewer is un-maximized
- Returns `false` if 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.json` locally
- 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
- `ViewerSequenceStateChanged` event updates viewer state
- `GetRunningSequences()` 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 `DispatcherTimer` with 3000ms interval
- Timer starts on Backspace KeyDown, cancelled on KeyUp
### Keyboard Emulation Mode
- Activated when serial port not available (development)
- Uses `PreviewKeyDown` / `PreviewKeyUp` window events
- `VirtualKeyboard.Emulate()` converts `System.Windows.Input.Key` to `VirtualKey`
- Ignores key repeat (`e.IsRepeat` check)
## 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
1. **Startup:** Query all active alarms from bridges (`GET /alarms/active`)
2. **Real-time:** WebSocket events (`EventStarted`, `EventStopped`)
3. **Periodic:** Re-sync every 30 seconds (configurable)
4. **Hourly:** Full alarm mapping refresh (`CameraAlarmsUpdateWorker`)
### Alarm History
- `GetAlarmForCamera(cameraId, from, to)` → list of `CameraAlarm` records
- Times converted from UTC to local time for display
- Ordered by `StartTime` descending (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.IsQuadView` determines 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